chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# Database (Required)
|
||||
DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio"
|
||||
# Optional read-replica connection string for offloading heavy read paths
|
||||
# (logs listing, audit logs, dashboard aggregations). Reads fall back to
|
||||
# DATABASE_URL when unset.
|
||||
# DATABASE_REPLICA_URL=""
|
||||
|
||||
# Authentication (Required unless DISABLE_AUTH=true)
|
||||
BETTER_AUTH_SECRET=your_secret_key # Use `openssl rand -hex 32` to generate, or visit https://www.better-auth.com/docs/installation
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
# Authentication Bypass (Optional - for self-hosted deployments behind private networks)
|
||||
# DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests.
|
||||
|
||||
# Private Database Hosts (Optional - for self-hosted deployments only)
|
||||
# ALLOW_PRIVATE_DATABASE_HOSTS=true # Uncomment to let database/connector tools reach private/reserved/loopback hosts (e.g. Docker/K8s service names, localhost). Loosens the SSRF boundary; only enable on a trusted private network.
|
||||
|
||||
# NextJS (Required)
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
# INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL
|
||||
# TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins.
|
||||
|
||||
# Security (Required)
|
||||
ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables
|
||||
INTERNAL_API_SECRET=your_internal_api_secret # Use `openssl rand -hex 32` to generate, used to encrypt internal api routes
|
||||
API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt api keys
|
||||
|
||||
# Email Provider (Optional)
|
||||
# Configure ONE provider — the mailer auto-detects in priority order:
|
||||
# Resend → AWS SES → SMTP → Azure Communication Services. If none are
|
||||
# configured, emails are logged to console instead.
|
||||
#
|
||||
# Resend
|
||||
# RESEND_API_KEY= # API key from https://resend.com
|
||||
#
|
||||
# AWS SES (credentials resolved via the standard AWS provider chain:
|
||||
# env vars, shared config, ECS/EKS task role, EC2 instance profile, SSO)
|
||||
# AWS_SES_REGION=us-east-1
|
||||
#
|
||||
# SMTP (works with MailHog locally: host=localhost port=1025, no auth)
|
||||
# SMTP_HOST=smtp.example.com
|
||||
# SMTP_PORT=587 # 465 = implicit TLS, 587 = STARTTLS, 25 = plain
|
||||
# SMTP_USER= # Optional — omit for unauthenticated relays
|
||||
# SMTP_PASS= # Optional — omit for unauthenticated relays
|
||||
# SMTP_SECURE= # Set "true" to force TLS on connect; auto-true on port 465
|
||||
#
|
||||
# Azure Communication Services
|
||||
# AZURE_ACS_CONNECTION_STRING=
|
||||
#
|
||||
# Shared sender configuration
|
||||
# FROM_EMAIL_ADDRESS="Sim <noreply@example.com>"
|
||||
# EMAIL_DOMAIN=example.com # Fallback when FROM_EMAIL_ADDRESS is unset
|
||||
|
||||
# Local AI Models (Optional)
|
||||
# OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models
|
||||
# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible)
|
||||
# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth
|
||||
# LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible)
|
||||
# LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth
|
||||
# FIREWORKS_API_KEY= # Optional Fireworks AI API key for model listing
|
||||
# NEXT_PUBLIC_BEDROCK_DEFAULT_CREDENTIALS=true # Set when using AWS default credential chain (IAM roles, ECS task roles, IRSA). Hides credential fields in Agent block UI.
|
||||
# AZURE_OPENAI_ENDPOINT= # Azure OpenAI endpoint (hides field in UI when set alongside NEXT_PUBLIC_AZURE_CONFIGURED)
|
||||
# AZURE_OPENAI_API_KEY= # Azure OpenAI API key
|
||||
# AZURE_OPENAI_API_VERSION= # Azure OpenAI API version
|
||||
# AZURE_ANTHROPIC_ENDPOINT= # Azure Anthropic endpoint (AI Foundry)
|
||||
# AZURE_ANTHROPIC_API_KEY= # Azure Anthropic API key
|
||||
# AZURE_ANTHROPIC_API_VERSION= # Azure Anthropic API version (e.g., 2023-06-01)
|
||||
# NEXT_PUBLIC_AZURE_CONFIGURED=true # Set when Azure credentials are pre-configured above. Hides endpoint/key/version fields in Agent block UI.
|
||||
# COHERE_API_KEY= # Cohere API key for the Knowledge block reranker (rerank-v4.0-pro/-fast, rerank-v3.5). Alternatively set COHERE_API_KEY_1/2/3 for rotation.
|
||||
# NEXT_PUBLIC_COHERE_CONFIGURED=true # Set when COHERE_API_KEY (or rotation keys) are pre-configured above. Hides the Cohere API Key field on the Knowledge block UI.
|
||||
|
||||
# Hosted tool API keys (Optional - lets Sim supply the key so users don't have to bring their own).
|
||||
# Each provider reads `{PREFIX}_COUNT` then `{PREFIX}_1..N`, distributing requests round-robin across the keys.
|
||||
# HUNTER_API_KEY_COUNT=2 # Number of Hunter.io keys for hosted Hunter blocks
|
||||
# HUNTER_API_KEY_1= # Hunter.io API key #1
|
||||
# HUNTER_API_KEY_2= # Hunter.io API key #2
|
||||
# PEOPLEDATALABS_API_KEY_COUNT=2 # Number of People Data Labs keys for hosted PDL blocks
|
||||
# PEOPLEDATALABS_API_KEY_1= # People Data Labs API key #1
|
||||
# PEOPLEDATALABS_API_KEY_2= # People Data Labs API key #2
|
||||
# CONTEXT_DEV_API_KEY_COUNT=2 # Number of Context.dev keys for hosted Context.dev blocks
|
||||
# CONTEXT_DEV_API_KEY_1= # Context.dev API key #1
|
||||
# CONTEXT_DEV_API_KEY_2= # Context.dev API key #2
|
||||
|
||||
# File Storage (Optional - defaults to local disk; use S3 or Azure Blob for production)
|
||||
# AWS_REGION=us-east-1 # Required with S3_BUCKET_NAME to enable S3. Use "auto" for Cloudflare R2
|
||||
# AWS_ACCESS_KEY_ID= # Omit to use the instance/IRSA credential chain
|
||||
# AWS_SECRET_ACCESS_KEY= # Omit to use the instance/IRSA credential chain
|
||||
# S3_BUCKET_NAME= # General workspace files bucket (required with AWS_REGION to enable S3)
|
||||
# S3_KB_BUCKET_NAME= # Knowledge base documents
|
||||
# S3_EXECUTION_FILES_BUCKET_NAME= # Workflow execution files
|
||||
# S3_CHAT_BUCKET_NAME= # Deployed chat assets
|
||||
# S3_COPILOT_BUCKET_NAME= # Copilot attachments
|
||||
# S3_PROFILE_PICTURES_BUCKET_NAME= # User profile pictures
|
||||
# S3_OG_IMAGES_BUCKET_NAME= # OpenGraph preview images (falls back to S3_BUCKET_NAME)
|
||||
# S3_WORKSPACE_LOGOS_BUCKET_NAME= # Workspace logos (falls back to S3_BUCKET_NAME)
|
||||
# S3_ENDPOINT= # Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3
|
||||
# S3_FORCE_PATH_STYLE=true # Required for MinIO/Ceph RGW. Leave unset for AWS S3 and R2
|
||||
|
||||
# Azure Blob Storage takes precedence over S3 if both are configured
|
||||
# AZURE_ACCOUNT_NAME= # Azure storage account name
|
||||
# AZURE_ACCOUNT_KEY= # Azure storage account key
|
||||
# AZURE_CONNECTION_STRING= # Alternative to account name/key
|
||||
# AZURE_STORAGE_CONTAINER_NAME= # General workspace files container
|
||||
# AZURE_STORAGE_KB_CONTAINER_NAME= # Knowledge base documents
|
||||
# AZURE_STORAGE_EXECUTION_FILES_CONTAINER_NAME= # Workflow execution files
|
||||
# AZURE_STORAGE_CHAT_CONTAINER_NAME= # Deployed chat assets
|
||||
# AZURE_STORAGE_COPILOT_CONTAINER_NAME= # Copilot attachments
|
||||
# AZURE_STORAGE_PROFILE_PICTURES_CONTAINER_NAME= # User profile pictures
|
||||
# AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME= # OpenGraph preview images (falls back to AZURE_STORAGE_CONTAINER_NAME)
|
||||
# AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME= # Workspace logos (falls back to AZURE_STORAGE_CONTAINER_NAME)
|
||||
|
||||
# Admin API (Optional - for self-hosted GitOps)
|
||||
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
|
||||
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
|
||||
@@ -0,0 +1,48 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/packages/**/node_modules
|
||||
|
||||
# bun specific
|
||||
.bun
|
||||
bun.lockb
|
||||
bun-debug.log*
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/dist
|
||||
**/dist/
|
||||
**/standalone/
|
||||
sim-standalone.tar.gz
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# env files
|
||||
.env
|
||||
*.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.test
|
||||
.env.production
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# Uploads
|
||||
/uploads
|
||||
|
||||
.trigger
|
||||
@@ -0,0 +1,231 @@
|
||||
# Sim App Scope
|
||||
|
||||
These rules apply to files under `apps/sim/` in addition to the repository root [AGENTS.md](/AGENTS.md).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Principles
|
||||
|
||||
1. **Single Responsibility**: Each component, hook, store has one clear purpose
|
||||
2. **Composition Over Complexity**: Break down complex logic into smaller pieces
|
||||
3. **Type Safety First**: TypeScript interfaces for all props, state, return types
|
||||
4. **Predictable State**: Zustand for global state, useState for UI-only concerns
|
||||
|
||||
### Root-Level Structure
|
||||
|
||||
```
|
||||
apps/
|
||||
├── sim/ # this app (Next.js: UI + API routes + workflow editor)
|
||||
│ ├── app/ # Next.js app router (pages, API routes)
|
||||
│ ├── blocks/ # Block definitions and registry
|
||||
│ ├── components/ # Shared UI (emcn/, ui/)
|
||||
│ ├── executor/ # Workflow execution engine
|
||||
│ ├── hooks/ # Shared hooks (queries/, selectors/)
|
||||
│ ├── lib/ # App-wide utilities
|
||||
│ ├── providers/ # LLM provider integrations
|
||||
│ ├── stores/ # Zustand stores
|
||||
│ ├── tools/ # Tool definitions
|
||||
│ └── triggers/ # Trigger definitions
|
||||
└── realtime/ # Bun Socket.IO server (collaborative canvas)
|
||||
|
||||
packages/ # @sim/* — audit, auth, db, logger, realtime-protocol,
|
||||
# security, tsconfig, utils, platform-authz,
|
||||
# workflow-persistence, workflow-types
|
||||
```
|
||||
|
||||
The Socket.IO collaborative-canvas server lives in a separate workspace at
|
||||
`apps/realtime/`. It shares DB + auth with `apps/sim` via the `@sim/*`
|
||||
packages. `apps/* → packages/*` only — packages never import from `apps/*`.
|
||||
Do not add imports from `@/lib/webhooks/providers/*`, `@/executor/*`,
|
||||
`@/blocks/*`, or `@/tools/*` to any package consumed by `apps/realtime` —
|
||||
those heavyweight registries stay in this app. `apps/realtime` calls back
|
||||
into this app only over internal HTTP with `INTERNAL_API_SECRET`. CI enforces
|
||||
these boundaries via `scripts/check-monorepo-boundaries.ts` and
|
||||
`scripts/check-realtime-prune-graph.ts`.
|
||||
|
||||
### Feature Organization
|
||||
|
||||
Features live under `app/workspace/[workspaceId]/`:
|
||||
|
||||
```
|
||||
feature/
|
||||
├── components/ # Feature components
|
||||
├── hooks/ # Feature-scoped hooks
|
||||
├── utils/ # Feature-scoped utilities (2+ consumers)
|
||||
├── feature.tsx # Main component
|
||||
└── page.tsx # Next.js page entry
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Components**: PascalCase (`WorkflowList`)
|
||||
- **Hooks**: `use` prefix (`useWorkflowOperations`)
|
||||
- **Files**: kebab-case (`workflow-list.tsx`)
|
||||
- **Stores**: `stores/feature/store.ts`
|
||||
- **Constants**: SCREAMING_SNAKE_CASE
|
||||
- **Interfaces**: PascalCase with suffix (`WorkflowListProps`)
|
||||
|
||||
## Imports And Types
|
||||
|
||||
- Always use absolute imports from `@/...`; do not add relative imports.
|
||||
- Use barrel exports only when a folder has 3+ exports; do not re-export through non-barrel files.
|
||||
- Use `import type` for type-only imports.
|
||||
- Do not use `any`; prefer precise types or `unknown` with guards.
|
||||
|
||||
## Components And Styling
|
||||
|
||||
- Use `'use client'` only when hooks or browser-only APIs are required.
|
||||
- Define a props interface for every component.
|
||||
- Extract constants with `as const` where appropriate.
|
||||
- Use Tailwind classes and `cn()` for conditional classes; avoid inline styles unless CSS variables are the intended mechanism.
|
||||
- Keep styling local to the component; do not modify global styles for feature work.
|
||||
|
||||
## API Contracts
|
||||
|
||||
Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.
|
||||
|
||||
- Each contract is built with `defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } })` from `@/lib/api/contracts`.
|
||||
- Contracts export named schemas AND named TypeScript type aliases (e.g., `export type CreateFolderBody = z.input<typeof createFolderBodySchema>`). Clients import the named aliases — never `z.input<...>` / `z.output<...>` in hooks.
|
||||
- Shared identifier schemas live in `apps/sim/lib/api/contracts/primitives.ts` (e.g., `workspaceIdSchema`, `workflowIdSchema`).
|
||||
- Audit script: `bun run check:api-validation` enforces boundary policy and prints ratchet metrics for route Zod imports, route-local schema constructors, route `ZodError` references, client hook Zod imports, and related counters. It must pass on PRs. `bun run check:api-validation:strict` is the strict CI gate and additionally fails on annotations with empty reasons.
|
||||
- Domain validators that are not HTTP boundaries — tools, blocks, triggers, connectors, realtime handlers, and internal helpers — may still use Zod directly. The contract rule is boundary-only.
|
||||
|
||||
### Boundary annotations
|
||||
|
||||
A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes four annotation forms:
|
||||
|
||||
- `// boundary-raw-fetch: <reason>` — placed on the line directly above a raw `fetch(` call inside `apps/sim/hooks/queries/**`, `apps/sim/hooks/selectors/**`, or any other client/UI source under `apps/sim/**` that targets a same-origin `/api/...` URL. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests.
|
||||
- `// double-cast-allowed: <reason>` — placed on the line directly above an `as unknown as X` cast outside test files.
|
||||
- `// boundary-raw-json: <reason>` — placed on the line directly above a raw `await request.json()` / `await req.json()` read (or the multi-line `await request.clone().json()` shim variant) in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant `.catch(() => ({}))` parse, or otherwise cannot go through `parseRequest`.
|
||||
- `// untyped-response: <reason>` — placed on the line directly above a `schema: z.unknown()` / `schema: z.object({}).passthrough()` / `schema: z.record(z.string(), z.unknown())` response declaration (or a simple alias to one of those) in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough).
|
||||
|
||||
Placement rule: the annotation must immediately precede the call or cast. Up to three non-empty preceding comment lines are tolerated, so additional context comments above the annotation are fine. The reason must be non-empty after trimming — annotations with empty reasons fail strict mode (`annotationsMissingReason`).
|
||||
|
||||
Whole-file allowlists for routes (legitimate non-boundary or auth-handled routes that legitimately import Zod for non-boundary reasons) go through `INDIRECT_ZOD_ROUTES` in `scripts/check-api-validation-contracts.ts`, not per-line annotations.
|
||||
|
||||
Examples:
|
||||
|
||||
```ts
|
||||
// boundary-raw-fetch: streaming SSE chunks must be processed as they arrive
|
||||
const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { signal })
|
||||
```
|
||||
|
||||
```ts
|
||||
// double-cast-allowed: legacy provider type lacks the discriminator field we need
|
||||
const provider = config as unknown as LegacyProvider
|
||||
```
|
||||
|
||||
```ts
|
||||
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
|
||||
const body = await request
|
||||
.clone()
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
```
|
||||
|
||||
```ts
|
||||
// untyped-response: forwards firecrawl /v2/parse response unchanged for downstream tool consumers
|
||||
output: z.unknown(),
|
||||
```
|
||||
|
||||
## API Route Pattern
|
||||
|
||||
Routes never `import { z } from 'zod'` and never define route-local boundary schemas. They consume the contract from `@/lib/api/contracts/**` and validate with canonical helpers from `@/lib/api/server`:
|
||||
|
||||
- `parseRequest(contract, request, context, options?)` — fully contract-bound routes; parses params, query, body, and headers in one call. Pass `{}` for `context` on routes without route params, or the route's `context` argument when route params exist. Returns a discriminated union; check `parsed.success` and return `parsed.response` on failure.
|
||||
- `validationErrorResponse(error)` and `getValidationErrorMessage(error, fallback)` — produce 400 responses from a `ZodError`.
|
||||
- `validationErrorResponseFromError(error)` — when handling unknown caught errors that may or may not be a `ZodError`.
|
||||
- `isZodError(error)` — type guard. Routes never use `instanceof z.ZodError`.
|
||||
|
||||
### Fully contract-bound route (`parseRequest`)
|
||||
|
||||
```typescript
|
||||
import { createLogger } from '@sim/logger'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createFolderContract } from '@/lib/api/contracts/folders'
|
||||
import { parseRequest } from '@/lib/api/server'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('FoldersAPI')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
const parsed = await parseRequest(createFolderContract, request, {})
|
||||
if (!parsed.success) return parsed.response
|
||||
const { body } = parsed.data
|
||||
logger.info('Creating folder', { workspaceId: body.workspaceId })
|
||||
return NextResponse.json({ ok: true })
|
||||
})
|
||||
```
|
||||
|
||||
Routes under `apps/sim/app/api/v1/**` use the shared middleware in `apps/sim/app/api/v1/middleware.ts` for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route.
|
||||
|
||||
### Adding a new boundary feature end-to-end
|
||||
|
||||
When adding a new route + client surface, follow this order. Each step has one place it lives.
|
||||
|
||||
1. **Author the contract first** in `apps/sim/lib/api/contracts/<domain>.ts` (or a subdirectory for large domains: `knowledge/`, `selectors/`, `tools/`). Define one schema per request slice (`params`, `query`, `body`, `headers`) and one for the response, then wrap with `defineRouteContract`. Export named type aliases (`z.input` for inputs, `z.output` for outputs).
|
||||
2. **Implement the route** in `apps/sim/app/api/<path>/route.ts`. Auth always runs **before** `parseRequest` — never validate untrusted input before authenticating the caller. The route returns exactly the shape declared in `contract.response.schema`.
|
||||
3. **Add the React Query hook** in `apps/sim/hooks/queries/<domain>.ts`. Use `requestJson(contract, input)` for the call. Build a hierarchical query-key factory (`all` → `lists()` → `list(workspaceId)` → `details()` → `detail(id)`) so invalidations can target prefixes.
|
||||
4. **Use the hook in the component**. The mutation's `data` and `error` are fully typed from the contract; surface `error.message` (already extracted from the response body's `error` or `message` field by `requestJson`).
|
||||
|
||||
### Schema review checklist (read the contract diff like a DB migration)
|
||||
|
||||
LLMs will write contracts that compile but are sloppy. The human reviewer should optimize attention on:
|
||||
|
||||
- **`required` vs `optional` vs `nullable` is correct**. `optional()` allows omission; `nullable()` allows `null`; chaining both creates a tri-state that's almost never what you want.
|
||||
- **Response schema matches the route's actual JSON output**. The most common drift bug — route emits a field the schema doesn't declare, or omits a required field. Walk every `NextResponse.json(...)` callsite against the schema.
|
||||
- **Error messages are descriptive**. `'fileName cannot be empty'` beats `'Required'`. Use the second arg of `min(1, '...')`, `nonempty('...')`, etc. For cross-field refines, use `superRefine` with a `path` and a message that names the failing field.
|
||||
- **Bounds are set** on arrays (`.min(1)`, `.max(N)`), strings (`.min(1).max(N)` for IDs/names), and numbers (`.min().max()` for limits/sizes).
|
||||
- **`z.unknown()` is a smell** unless the data is genuinely arbitrary (provider passthrough, user-defined tool result, JSON-RPC envelope). When kept, must be annotated `// untyped-response: <specific reason>` in a `schema:` slot.
|
||||
- **Discriminated unions over plain unions** when the wire has a discriminant field — gives clients exhaustive narrowing.
|
||||
|
||||
CI (`bun run check:api-validation:strict`) catches structural violations (Zod imports in routes, raw `request.json()`, double casts, missing annotations). It does **not** catch these schema-quality judgments — that's the human's job in PR review.
|
||||
|
||||
## React Query Client Boundary
|
||||
|
||||
Hooks in `apps/sim/hooks/queries/**` consume contracts the same way routes do. Every same-origin JSON call must go through `requestJson(contract, ...)` from `@/lib/api/client/request` instead of raw `fetch`:
|
||||
|
||||
- Hooks import named type aliases from `@/lib/api/contracts/**`. Never write `z.input<...>` / `z.output<...>` in hooks, and never `import { z } from 'zod'` in client code.
|
||||
- `requestJson` parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward `signal` for cancellation.
|
||||
- Documented exceptions for raw `fetch`: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each raw `fetch` with a TSDoc comment explaining which exception applies.
|
||||
|
||||
```typescript
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'
|
||||
|
||||
async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise<EntityList> {
|
||||
const data = await requestJson(listEntitiesContract, {
|
||||
query: { workspaceId },
|
||||
signal,
|
||||
})
|
||||
return data.entities
|
||||
}
|
||||
|
||||
export function useEntityList(workspaceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: entityKeys.list(workspaceId),
|
||||
queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
|
||||
enabled: Boolean(workspaceId),
|
||||
staleTime: 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
- Use Vitest.
|
||||
- Prefer `@vitest-environment node` unless DOM APIs are required.
|
||||
- Use `vi.hoisted()` + `vi.mock()` + static imports; do not use `vi.resetModules()` + `vi.doMock()` + dynamic imports except for true module-scope singletons.
|
||||
- Do not use `vi.importActual()`.
|
||||
- Prefer mocks and factories from `@sim/testing`.
|
||||
|
||||
## Utils Rules
|
||||
|
||||
- **Never create `utils.ts` for single consumer** - inline it
|
||||
- **Create `utils.ts` when** 2+ files need the same helper
|
||||
- **Check existing sources** before duplicating (`lib/` has many utilities)
|
||||
- **Location**: `lib/` (app-wide) → `feature/utils/` (feature-scoped) → inline (single-use)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AuthShell } from '@/app/(auth)/components'
|
||||
|
||||
export default function AuthLayoutClient({ children }: { children: React.ReactNode }) {
|
||||
return <AuthShell>{children}</AuthShell>
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/** Shared className for inline auth action links. */
|
||||
export const AUTH_TEXT_LINK =
|
||||
'font-medium text-[var(--brand-accent)] underline-offset-4 transition hover:text-[var(--brand-accent-hover)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' as const
|
||||
@@ -0,0 +1,21 @@
|
||||
interface AuthDividerProps {
|
||||
label: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Or continue with" rule separating the email/password form from the
|
||||
* social/SSO options. Light tokens only: a `--border` hairline with the label
|
||||
* knocked out over the `--bg` canvas in `--text-muted`.
|
||||
*/
|
||||
export function AuthDivider({ label }: AuthDividerProps) {
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='absolute inset-0 flex items-center'>
|
||||
<div className='w-full border-[var(--border)] border-t' />
|
||||
</div>
|
||||
<div className='relative flex justify-center'>
|
||||
<span className='bg-[var(--bg)] px-4 text-[var(--text-muted)] text-sm'>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Label } from '@sim/emcn'
|
||||
|
||||
interface AuthFieldProps {
|
||||
/** Matches the `id` set on the control rendered as {@link children}. */
|
||||
htmlFor: string
|
||||
label: string
|
||||
/** Validation messages to render beneath the control. */
|
||||
errors?: string[]
|
||||
/** Optional right-aligned action shown next to the label (e.g. Forgot password). */
|
||||
action?: ReactNode
|
||||
/** The field control — a {@link ChipInput}/{@link PasswordInput}. */
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A labeled form field row: canonical {@link Label}, an optional inline label
|
||||
* action, the control, and a validation-message list in the error token. The
|
||||
* control drives its own invalid chrome through its `error` prop — this wrapper
|
||||
* only owns the label row and the message list, so every auth field reads and
|
||||
* spaces identically.
|
||||
*/
|
||||
export function AuthField({ htmlFor, label, errors, action, children }: AuthFieldProps) {
|
||||
const hasErrors = Boolean(errors && errors.length > 0)
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor={htmlFor}>{label}</Label>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
{hasErrors && (
|
||||
<div className='space-y-1 text-[var(--text-error)] text-caption' aria-live='polite'>
|
||||
{errors?.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
|
||||
interface AuthFormMessageProps {
|
||||
type: 'error' | 'success'
|
||||
align?: 'left' | 'center'
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Form-level status copy (not tied to a single field) in the canonical tokens:
|
||||
* errors in `--text-error`, success in `--brand-accent`. One place owns the
|
||||
* auth message chrome so success/error states never drift to ad-hoc hex or
|
||||
* `text-red-*`/`#4CAF50` colors.
|
||||
*/
|
||||
export function AuthFormMessage({ type, align = 'left', children }: AuthFormMessageProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'space-y-1 text-caption',
|
||||
align === 'center' && 'text-center',
|
||||
type === 'error' ? 'text-[var(--text-error)]' : 'text-[var(--brand-accent)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface AuthHeaderProps {
|
||||
title: string
|
||||
description: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The centered heading + subcopy block shared by every auth page and status
|
||||
* page. One source of truth for auth heading typography (light tokens, normal
|
||||
* weight, no bespoke tracking — aligned with the landing scale, sized down for
|
||||
* the single-column form).
|
||||
*/
|
||||
export function AuthHeader({ title, description }: AuthHeaderProps) {
|
||||
return (
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='text-balance text-[32px] text-[var(--text-primary)] leading-[1.2]'>{title}</h1>
|
||||
<p className='text-[var(--text-muted)] text-base leading-[1.5]'>{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ChipInput, type ChipInputProps, cn } from '@sim/emcn'
|
||||
import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants'
|
||||
|
||||
/**
|
||||
* The auth text field — a {@link ChipInput} raised to the auth control height
|
||||
* ({@link AUTH_CONTROL_HEIGHT}) so every labeled field on the auth and invite
|
||||
* surfaces shares one slightly-taller geometry. All chip props pass through
|
||||
* (`error`, `endAdornment`, `icon`, …); only the height is owned here, and a
|
||||
* caller's `className` (layout only) still composes on top.
|
||||
*/
|
||||
export const AuthInput = React.forwardRef<HTMLInputElement, ChipInputProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<ChipInput ref={ref} className={cn(AUTH_CONTROL_HEIGHT, className)} {...props} />
|
||||
)
|
||||
)
|
||||
|
||||
AuthInput.displayName = 'AuthInput'
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AuthTextLink } from '@/app/(auth)/components/auth-text-link'
|
||||
|
||||
interface AuthLegalFooterProps {
|
||||
/** The gerund describing the consent action, e.g. "signing in". */
|
||||
action: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The "By {action}, you agree to our Terms / Privacy" fine print shared by the
|
||||
* login and signup pages. Restyled to muted light tokens with the legal links
|
||||
* routed through {@link AuthTextLink}, so the consent copy has one source.
|
||||
*/
|
||||
export function AuthLegalFooter({ action }: AuthLegalFooterProps) {
|
||||
return (
|
||||
<p className='text-center text-[var(--text-muted)] text-caption leading-relaxed'>
|
||||
By {action}, you agree to our{' '}
|
||||
<AuthTextLink href='/terms' external>
|
||||
Terms of Service
|
||||
</AuthTextLink>{' '}
|
||||
and{' '}
|
||||
<AuthTextLink href='/privacy' external>
|
||||
Privacy Policy
|
||||
</AuthTextLink>
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
|
||||
interface AuthNavPromptProps {
|
||||
/** Muted lead text before the link (e.g. "Don't have an account?"). */
|
||||
prompt?: string
|
||||
href: string
|
||||
linkLabel: string
|
||||
/** Side effect to run before navigation (e.g. clearing verification state). */
|
||||
onNavigate?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The cross-page navigation row (Sign up / Sign in / Back to login) — an
|
||||
* optional muted prompt followed by an outline {@link ChipLink} pill, matching
|
||||
* the landing's secondary chip CTAs. Centralizes the auth nav affordance so the
|
||||
* pill chrome is described by props, never restyled per page.
|
||||
*/
|
||||
export function AuthNavPrompt({ prompt, href, linkLabel, onNavigate }: AuthNavPromptProps) {
|
||||
return (
|
||||
<div className='flex items-center justify-center gap-1 text-sm'>
|
||||
{prompt && <span className='text-[var(--text-muted)]'>{prompt}</span>}
|
||||
<ChipLink href={href} onClick={onNavigate} className='border border-[var(--border-1)]'>
|
||||
{linkLabel}
|
||||
</ChipLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { LogoMark, SimWordmark } from '@/app/(landing)/components/navbar/components'
|
||||
|
||||
interface AuthShellProps {
|
||||
/** Centered content column (the form, status copy, etc.). */
|
||||
children: ReactNode
|
||||
/** Optional element pinned to the bottom of the shell (e.g. the support footer). */
|
||||
footer?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The light auth/status page frame — the single source of truth for the shell
|
||||
* every auth page and standalone status page wears.
|
||||
*
|
||||
* Mirrors the landing chrome: it pins the `light` token layer (so the platform's
|
||||
* light-mode `var(--*)` tokens resolve regardless of the visitor's theme), uses
|
||||
* the canvas/`--text-primary` surface, and renders a logo-only header that reuses
|
||||
* the landing {@link LogoMark} + {@link SimWordmark} at the same nav gutters. The
|
||||
* single content column is centered and capped for a calm single-form layout.
|
||||
*/
|
||||
export function AuthShell({ children, footer }: AuthShellProps) {
|
||||
return (
|
||||
<div className='light relative flex min-h-screen flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<header>
|
||||
<nav className='mx-auto flex w-full max-w-[1446px] items-center px-12 py-4 max-sm:px-5 max-lg:px-8'>
|
||||
<Link href='/' aria-label='Sim home' className='flex h-[30px] items-center'>
|
||||
<LogoMark>
|
||||
<SimWordmark />
|
||||
</LogoMark>
|
||||
</Link>
|
||||
</nav>
|
||||
</header>
|
||||
<div className='flex flex-1 items-center justify-center px-4 pb-16'>
|
||||
<div className='w-full max-w-[400px]'>{children}</div>
|
||||
</div>
|
||||
{footer}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Chip, Loader } from '@sim/emcn'
|
||||
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
|
||||
|
||||
interface AuthSubmitButtonProps {
|
||||
children: ReactNode
|
||||
/** Label shown beside the spinner while the action is in flight. */
|
||||
loadingLabel: string
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
type?: 'submit' | 'button'
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical full-width primary auth action — a `primary`-variant {@link Chip}
|
||||
* with the shared in-flight spinner, so the primary CTA chrome lives in
|
||||
* exactly one place.
|
||||
*/
|
||||
export function AuthSubmitButton({
|
||||
children,
|
||||
loadingLabel,
|
||||
loading = false,
|
||||
disabled = false,
|
||||
type = 'submit',
|
||||
onClick,
|
||||
}: AuthSubmitButtonProps) {
|
||||
return (
|
||||
<Chip
|
||||
variant='primary'
|
||||
type={type}
|
||||
onClick={onClick}
|
||||
disabled={disabled || loading}
|
||||
fullWidth
|
||||
flush
|
||||
className={AUTH_BUTTON_CLASS}
|
||||
>
|
||||
{loading ? (
|
||||
<span className='flex items-center gap-2'>
|
||||
<Loader className='size-4' animate />
|
||||
{loadingLabel}
|
||||
</span>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import Link from 'next/link'
|
||||
|
||||
const AUTH_TEXT_LINK_CLASS =
|
||||
'text-[var(--text-secondary)] underline-offset-4 transition-colors hover:text-[var(--text-primary)] hover:underline disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
interface AuthTextLinkProps {
|
||||
children: ReactNode
|
||||
/** Renders a navigation link when set; otherwise renders an action button. */
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
/** Opens the link in a new tab with safe `rel` (e.g. Terms/Privacy). */
|
||||
external?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical inline text affordance for the auth pages — forgot-password,
|
||||
* resend, and the legal links. Renders a {@link Link} when `href` is set and a
|
||||
* `<button>` otherwise, both in one light-token style. Replaces the legacy dark
|
||||
* `AUTH_TEXT_LINK` class string with a single props-driven source of truth.
|
||||
*/
|
||||
export function AuthTextLink({
|
||||
children,
|
||||
href,
|
||||
onClick,
|
||||
external = false,
|
||||
disabled = false,
|
||||
className,
|
||||
}: AuthTextLinkProps) {
|
||||
if (href) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
onClick={onClick}
|
||||
className={cn(AUTH_TEXT_LINK_CLASS, className)}
|
||||
{...(external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={cn(AUTH_TEXT_LINK_CLASS, className)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Auth and invite surfaces use a slightly taller control than the 30px chip
|
||||
* default, matching the landing `HeroCta` field family (the landing's own
|
||||
* auth-adjacent CTA renders taller fields than in-app chips). Applied as the
|
||||
* single source of truth for every auth field and button height so the inputs,
|
||||
* submit, social, SSO, and invite action buttons stay on one line.
|
||||
*/
|
||||
export const AUTH_CONTROL_HEIGHT = 'h-9'
|
||||
|
||||
/**
|
||||
* Shared layout for full-width auth/invite chip buttons (submit, social, SSO,
|
||||
* invite actions). `[&>span]:flex-none` collapses the chip's stretching label
|
||||
* span — which carries `flex-1` — so the icon + label cluster truly centers
|
||||
* under `justify-center` (the landing `HeroCta` idiom). Height-only inputs use
|
||||
* {@link AUTH_CONTROL_HEIGHT}; buttons compose this on top of it.
|
||||
*/
|
||||
export const AUTH_BUTTON_CLASS = `${AUTH_CONTROL_HEIGHT} w-full justify-center [&>span]:flex-none`
|
||||
@@ -0,0 +1,14 @@
|
||||
export { AuthDivider } from './auth-divider'
|
||||
export { AuthField } from './auth-field'
|
||||
export { AuthFormMessage } from './auth-form-message'
|
||||
export { AuthHeader } from './auth-header'
|
||||
export { AuthInput } from './auth-input'
|
||||
export { AuthLegalFooter } from './auth-legal-footer'
|
||||
export { AuthNavPrompt } from './auth-nav-prompt'
|
||||
export { AuthShell } from './auth-shell'
|
||||
export { AuthSubmitButton } from './auth-submit-button'
|
||||
export { AuthTextLink } from './auth-text-link'
|
||||
export { PasswordInput } from './password-input'
|
||||
export { SocialLoginButtons } from './social-login-buttons'
|
||||
export { SSOLoginButton } from './sso-login-button'
|
||||
export { SupportFooter } from './support-footer'
|
||||
@@ -0,0 +1,20 @@
|
||||
import { env } from '@/lib/core/config/env'
|
||||
import {
|
||||
isGithubAuthDisabled,
|
||||
isGoogleAuthDisabled,
|
||||
isMicrosoftAuthDisabled,
|
||||
isProd,
|
||||
} from '@/lib/core/config/env-flags'
|
||||
|
||||
export async function getOAuthProviderStatus() {
|
||||
const githubAvailable =
|
||||
!!(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) && !isGithubAuthDisabled
|
||||
|
||||
const googleAvailable =
|
||||
!!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) && !isGoogleAuthDisabled
|
||||
|
||||
const microsoftAvailable =
|
||||
!!(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET) && !isMicrosoftAuthDisabled
|
||||
|
||||
return { githubAvailable, googleAvailable, microsoftAvailable, isProduction: isProd }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChipInput, type ChipInputProps, cn } from '@sim/emcn'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { AUTH_CONTROL_HEIGHT } from '@/app/(auth)/components/constants'
|
||||
|
||||
type PasswordInputProps = Omit<ChipInputProps, 'type' | 'icon' | 'endAdornment'>
|
||||
|
||||
/**
|
||||
* A {@link ChipInput} that owns the password reveal toggle — the eye button is
|
||||
* driven through the canonical `endAdornment` slot and the field's invalid state
|
||||
* through the `error` prop, so no consumer hand-rolls the relative wrapper +
|
||||
* absolutely positioned button the auth forms previously duplicated four times.
|
||||
*/
|
||||
export function PasswordInput({ error, className, ...props }: PasswordInputProps) {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<ChipInput
|
||||
{...props}
|
||||
className={cn(AUTH_CONTROL_HEIGHT, className)}
|
||||
type={visible ? 'text' : 'password'}
|
||||
error={error}
|
||||
endAdornment={
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setVisible((v) => !v)}
|
||||
aria-label={visible ? 'Hide password' : 'Show password'}
|
||||
className='flex shrink-0 text-[var(--text-icon)] transition-colors hover:text-[var(--text-primary)]'
|
||||
>
|
||||
{visible ? <EyeOff className='size-[14px]' /> : <Eye className='size-[14px]' />}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { Chip, cn } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { GithubIcon, GoogleIcon, MicrosoftIcon } from '@/components/icons'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
|
||||
|
||||
const logger = createLogger('SocialLoginButtons')
|
||||
|
||||
interface SocialLoginButtonsProps {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
microsoftAvailable: boolean
|
||||
callbackURL?: string
|
||||
isProduction: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function SocialLoginButtons({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
callbackURL = '/workspace',
|
||||
isProduction,
|
||||
children,
|
||||
}: SocialLoginButtonsProps) {
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false)
|
||||
const [isGoogleLoading, setIsGoogleLoading] = useState(false)
|
||||
const [isMicrosoftLoading, setIsMicrosoftLoading] = useState(false)
|
||||
|
||||
async function signInWithGithub() {
|
||||
if (!githubAvailable) return
|
||||
|
||||
setIsGithubLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'github', callbackURL })
|
||||
} catch (err) {
|
||||
logger.error('GitHub sign-in failed', { error: getErrorMessage(err) })
|
||||
} finally {
|
||||
setIsGithubLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithGoogle() {
|
||||
if (!googleAvailable) return
|
||||
|
||||
setIsGoogleLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'google', callbackURL })
|
||||
} catch (err) {
|
||||
logger.error('Google sign-in failed', { error: getErrorMessage(err) })
|
||||
} finally {
|
||||
setIsGoogleLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function signInWithMicrosoft() {
|
||||
if (!microsoftAvailable) return
|
||||
|
||||
setIsMicrosoftLoading(true)
|
||||
try {
|
||||
await client.signIn.social({ provider: 'microsoft', callbackURL })
|
||||
} catch (err) {
|
||||
logger.error('Microsoft sign-in failed', { error: getErrorMessage(err) })
|
||||
} finally {
|
||||
setIsMicrosoftLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const githubButton = (
|
||||
<Chip
|
||||
fullWidth
|
||||
flush
|
||||
leftIcon={GithubIcon}
|
||||
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
|
||||
disabled={!githubAvailable || isGithubLoading}
|
||||
onClick={signInWithGithub}
|
||||
>
|
||||
{isGithubLoading ? 'Connecting…' : 'GitHub'}
|
||||
</Chip>
|
||||
)
|
||||
|
||||
const googleButton = (
|
||||
<Chip
|
||||
fullWidth
|
||||
flush
|
||||
leftIcon={GoogleIcon}
|
||||
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
|
||||
disabled={!googleAvailable || isGoogleLoading}
|
||||
onClick={signInWithGoogle}
|
||||
>
|
||||
{isGoogleLoading ? 'Connecting…' : 'Google'}
|
||||
</Chip>
|
||||
)
|
||||
|
||||
const microsoftButton = (
|
||||
<Chip
|
||||
fullWidth
|
||||
flush
|
||||
leftIcon={MicrosoftIcon}
|
||||
className={cn(AUTH_BUTTON_CLASS, 'border border-[var(--border-1)]')}
|
||||
disabled={!microsoftAvailable || isMicrosoftLoading}
|
||||
onClick={signInWithMicrosoft}
|
||||
>
|
||||
{isMicrosoftLoading ? 'Connecting…' : 'Microsoft'}
|
||||
</Chip>
|
||||
)
|
||||
|
||||
const hasAnyOAuthProvider = githubAvailable || googleAvailable || microsoftAvailable
|
||||
|
||||
if (!hasAnyOAuthProvider && !children) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='grid gap-3'>
|
||||
{googleAvailable && googleButton}
|
||||
{microsoftAvailable && microsoftButton}
|
||||
{githubAvailable && githubButton}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
import { Chip, cn } from '@sim/emcn'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import { AUTH_BUTTON_CLASS } from '@/app/(auth)/components/constants'
|
||||
|
||||
interface SSOLoginButtonProps {
|
||||
callbackURL?: string
|
||||
className?: string
|
||||
variant?: 'primary' | 'outline'
|
||||
}
|
||||
|
||||
export function SSOLoginButton({
|
||||
callbackURL,
|
||||
className,
|
||||
variant = 'outline',
|
||||
}: SSOLoginButtonProps) {
|
||||
const router = useRouter()
|
||||
|
||||
if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSSOClick = () => {
|
||||
const ssoUrl = `/sso${callbackURL ? `?callbackUrl=${encodeURIComponent(callbackURL)}` : ''}`
|
||||
router.push(ssoUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
variant={variant === 'primary' ? 'primary' : undefined}
|
||||
fullWidth
|
||||
flush
|
||||
onClick={handleSSOClick}
|
||||
className={cn(
|
||||
AUTH_BUTTON_CLASS,
|
||||
variant === 'outline' && 'border border-[var(--border-1)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
Sign in with SSO
|
||||
</Chip>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { cn } from '@sim/emcn'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
export interface SupportFooterProps {
|
||||
/**
|
||||
* `fixed`/`absolute` pin the footer over the page (short, centered forms
|
||||
* only — content must never render underneath it). `static` renders it in
|
||||
* normal document flow after the content, which is required for pages with
|
||||
* unbounded content height (e.g. the resume gate's HITL form): an
|
||||
* absolutely-positioned footer with no reserved space is not pushed down by
|
||||
* flow content, so it silently overlaps and eats clicks on whatever content
|
||||
* ends up in its footprint.
|
||||
*/
|
||||
position?: 'fixed' | 'absolute' | 'static'
|
||||
}
|
||||
|
||||
export function SupportFooter({ position = 'fixed' }: SupportFooterProps) {
|
||||
const brandConfig = useBrandConfig()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pb-8 text-center text-[var(--text-muted)] text-caption leading-relaxed',
|
||||
position !== 'static' && 'right-0 bottom-0 left-0 z-50',
|
||||
position
|
||||
)}
|
||||
>
|
||||
Need help?{' '}
|
||||
<a
|
||||
href={`mailto:${brandConfig.supportEmail}`}
|
||||
className='text-[var(--text-muted)] underline-offset-4 transition-colors hover:text-[var(--text-body)] hover:underline'
|
||||
>
|
||||
Contact support
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
import AuthLayoutClient from '@/app/(auth)/auth-layout-client'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
robots: { index: false, follow: false },
|
||||
}
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <AuthLayoutClient>{children}</AuthLayoutClient>
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function LoginLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[80px] rounded-[4px]' />
|
||||
<div className='mt-8 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-4 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[64px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-6 h-[1px] w-full rounded-[1px]' />
|
||||
<div className='mt-6 flex w-full gap-3'>
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-6 h-[14px] w-[200px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
ChipModal,
|
||||
ChipModalBody,
|
||||
ChipModalError,
|
||||
ChipModalField,
|
||||
ChipModalFooter,
|
||||
ChipModalHeader,
|
||||
} from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { forgetPasswordContract } from '@/lib/api/contracts'
|
||||
import { client } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { captureClientEvent } from '@/lib/posthog/client'
|
||||
import {
|
||||
AuthDivider,
|
||||
AuthField,
|
||||
AuthFormMessage,
|
||||
AuthHeader,
|
||||
AuthInput,
|
||||
AuthLegalFooter,
|
||||
AuthNavPrompt,
|
||||
AuthSubmitButton,
|
||||
AuthTextLink,
|
||||
PasswordInput,
|
||||
SocialLoginButtons,
|
||||
SSOLoginButton,
|
||||
} from '@/app/(auth)/components'
|
||||
|
||||
const logger = createLogger('LoginForm')
|
||||
|
||||
const validateEmailField = (emailValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!emailValue || !emailValue.trim()) {
|
||||
errors.push('Email is required.')
|
||||
return errors
|
||||
}
|
||||
|
||||
const validation = quickValidateEmail(normalizeEmail(emailValue))
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.reason || 'Please enter a valid email address.')
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const PASSWORD_VALIDATIONS = {
|
||||
required: {
|
||||
test: (value: string) => Boolean(value && typeof value === 'string'),
|
||||
message: 'Password is required.',
|
||||
},
|
||||
notEmpty: {
|
||||
test: (value: string) => value.trim().length > 0,
|
||||
message: 'Password cannot be empty.',
|
||||
},
|
||||
}
|
||||
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.required.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.required.message)
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.notEmpty.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.notEmpty.message)
|
||||
return errors
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export default function LoginPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
isProduction,
|
||||
}: {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
microsoftAvailable: boolean
|
||||
isProduction: boolean
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const callbackUrlParam = searchParams?.get('callbackUrl')
|
||||
const isValidCallbackUrl = callbackUrlParam ? validateCallbackUrl(callbackUrlParam) : false
|
||||
const invalidCallbackRef = useRef(false)
|
||||
if (callbackUrlParam && !isValidCallbackUrl && !invalidCallbackRef.current) {
|
||||
invalidCallbackRef.current = true
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: callbackUrlParam })
|
||||
}
|
||||
const callbackUrl = isValidCallbackUrl ? callbackUrlParam! : '/workspace'
|
||||
const isInviteFlow = searchParams?.get('invite_flow') === 'true'
|
||||
|
||||
const [forgotPasswordOpen, setForgotPasswordOpen] = useState(false)
|
||||
const [forgotPasswordEmail, setForgotPasswordEmail] = useState('')
|
||||
const [isSubmittingReset, setIsSubmittingReset] = useState(false)
|
||||
const [resetStatus, setResetStatus] = useState<{
|
||||
type: 'success' | 'error' | null
|
||||
message: string
|
||||
}>({ type: null, message: '' })
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [resetSuccessMessage, setResetSuccessMessage] = useState<string | null>(() =>
|
||||
searchParams?.get('resetSuccess') === 'true'
|
||||
? 'Password reset successful. Please sign in with your new password.'
|
||||
: null
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
captureClientEvent('login_page_viewed', {})
|
||||
}, [])
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
|
||||
const errors = validateEmailField(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
}
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
|
||||
const errors = validatePassword(newPassword)
|
||||
setPasswordErrors(errors)
|
||||
setShowValidationError(false)
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const redirectToVerify = (emailToVerify: string) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', emailToVerify)
|
||||
}
|
||||
router.push('/verify')
|
||||
}
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailRaw = formData.get('email') as string
|
||||
const email = normalizeEmail(emailRaw)
|
||||
|
||||
const emailValidationErrors = validateEmailField(email)
|
||||
setEmailErrors(emailValidationErrors)
|
||||
setShowEmailValidationError(emailValidationErrors.length > 0)
|
||||
|
||||
const passwordValidationErrors = validatePassword(password)
|
||||
setPasswordErrors(passwordValidationErrors)
|
||||
setShowValidationError(passwordValidationErrors.length > 0)
|
||||
|
||||
if (emailValidationErrors.length > 0 || passwordValidationErrors.length > 0) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const safeCallbackUrl = callbackUrl
|
||||
let errorHandled = false
|
||||
|
||||
const result = await client.signIn.email(
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: safeCallbackUrl,
|
||||
},
|
||||
{
|
||||
onError: (ctx: any) => {
|
||||
logger.error('Login error:', ctx.error)
|
||||
|
||||
if (ctx.error.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
errorHandled = true
|
||||
redirectToVerify(email)
|
||||
return
|
||||
}
|
||||
|
||||
errorHandled = true
|
||||
const errorMessage: string[] = ['Invalid email or password']
|
||||
|
||||
if (
|
||||
ctx.error.code?.includes('BAD_REQUEST') ||
|
||||
ctx.error.message?.includes('Email and password sign in is not enabled')
|
||||
) {
|
||||
errorMessage.push('Email sign in is currently disabled.')
|
||||
} else if (
|
||||
ctx.error.code?.includes('INVALID_CREDENTIALS') ||
|
||||
ctx.error.message?.includes('invalid password')
|
||||
) {
|
||||
errorMessage.push('Invalid email or password. Please try again.')
|
||||
} else if (
|
||||
ctx.error.code?.includes('USER_NOT_FOUND') ||
|
||||
ctx.error.message?.includes('not found')
|
||||
) {
|
||||
errorMessage.push('No account found with this email. Please sign up first.')
|
||||
} else if (ctx.error.code?.includes('MISSING_CREDENTIALS')) {
|
||||
errorMessage.push('Please enter both email and password.')
|
||||
} else if (ctx.error.code?.includes('EMAIL_PASSWORD_DISABLED')) {
|
||||
errorMessage.push('Email and password login is disabled.')
|
||||
} else if (ctx.error.code?.includes('FAILED_TO_CREATE_SESSION')) {
|
||||
errorMessage.push('Failed to create session. Please try again later.')
|
||||
} else if (ctx.error.code?.includes('too many attempts')) {
|
||||
errorMessage.push(
|
||||
'Too many login attempts. Please try again later or reset your password.'
|
||||
)
|
||||
} else if (ctx.error.code?.includes('account locked')) {
|
||||
errorMessage.push(
|
||||
'Your account has been locked for security. Please reset your password.'
|
||||
)
|
||||
} else if (ctx.error.code?.includes('network')) {
|
||||
errorMessage.push('Network error. Please check your connection and try again.')
|
||||
} else if (ctx.error.message?.includes('rate limit')) {
|
||||
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
||||
}
|
||||
|
||||
setResetSuccessMessage(null)
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!result || result.error) {
|
||||
// Show error if not already handled by onError callback
|
||||
if (!errorHandled) {
|
||||
setResetSuccessMessage(null)
|
||||
const errorMessage = result?.error?.message || 'Login failed. Please try again.'
|
||||
setPasswordErrors([errorMessage])
|
||||
setShowValidationError(true)
|
||||
}
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Clear reset success message on successful login
|
||||
setResetSuccessMessage(null)
|
||||
|
||||
// Explicit redirect fallback if better-auth doesn't redirect
|
||||
router.push(safeCallbackUrl)
|
||||
} catch (err: any) {
|
||||
if (err.message?.includes('not verified') || err.code?.includes('EMAIL_NOT_VERIFIED')) {
|
||||
redirectToVerify(email)
|
||||
return
|
||||
}
|
||||
|
||||
logger.error('Uncaught login error:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleForgotPassword = async () => {
|
||||
if (!forgotPasswordEmail) {
|
||||
setResetStatus({
|
||||
type: 'error',
|
||||
message: 'Please enter your email address',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const emailValidation = quickValidateEmail(normalizeEmail(forgotPasswordEmail))
|
||||
if (!emailValidation.isValid) {
|
||||
setResetStatus({
|
||||
type: 'error',
|
||||
message: 'Please enter a valid email address',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmittingReset(true)
|
||||
setResetStatus({ type: null, message: '' })
|
||||
|
||||
try {
|
||||
await requestJson(forgetPasswordContract, {
|
||||
body: {
|
||||
email: forgotPasswordEmail,
|
||||
redirectTo: `${getBaseUrl()}/reset-password`,
|
||||
},
|
||||
})
|
||||
} catch (requestError) {
|
||||
let errorMessage = getErrorMessage(requestError, 'Failed to request password reset')
|
||||
|
||||
if (
|
||||
errorMessage.includes('Invalid body parameters') ||
|
||||
errorMessage.includes('invalid email')
|
||||
) {
|
||||
errorMessage = 'Please enter a valid email address'
|
||||
} else if (errorMessage.includes('Email is required')) {
|
||||
errorMessage = 'Please enter your email address'
|
||||
} else if (
|
||||
errorMessage.includes('user not found') ||
|
||||
errorMessage.includes('User not found')
|
||||
) {
|
||||
errorMessage = 'No account found with this email address'
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
setResetStatus({
|
||||
type: 'success',
|
||||
message: 'Password reset link sent to your email',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
setForgotPasswordOpen(false)
|
||||
setResetStatus({ type: null, message: '' })
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
logger.error('Error requesting password reset:', { error })
|
||||
setResetStatus({
|
||||
type: 'error',
|
||||
message: getErrorMessage(error, 'Failed to request password reset'),
|
||||
})
|
||||
} finally {
|
||||
setIsSubmittingReset(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
|
||||
const emailEnabled = !isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED'))
|
||||
const hasSocial = githubAvailable || googleAvailable || microsoftAvailable
|
||||
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
|
||||
const showTopSSO = hasOnlySSO
|
||||
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
|
||||
const showDivider = (emailEnabled || showTopSSO) && showBottomSection
|
||||
|
||||
const emailFieldErrors = showEmailValidationError && emailErrors.length > 0 ? emailErrors : []
|
||||
const passwordFieldErrors = showValidationError && passwordErrors.length > 0 ? passwordErrors : []
|
||||
const canSubmit = email.trim().length > 0 && password.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='space-y-6'>
|
||||
<AuthHeader title='Sign in' description='Enter your details' />
|
||||
|
||||
{showTopSSO && <SSOLoginButton callbackURL={callbackUrl} variant='primary' />}
|
||||
|
||||
{emailEnabled && (
|
||||
<form onSubmit={onSubmit} className='space-y-6'>
|
||||
<div className='space-y-5'>
|
||||
<AuthField htmlFor='email' label='Email' errors={emailFieldErrors}>
|
||||
<AuthInput
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
error={emailFieldErrors.length > 0}
|
||||
/>
|
||||
</AuthField>
|
||||
<AuthField
|
||||
htmlFor='password'
|
||||
label='Password'
|
||||
errors={passwordFieldErrors}
|
||||
action={
|
||||
<AuthTextLink
|
||||
onClick={() => setForgotPasswordOpen(true)}
|
||||
className='text-caption'
|
||||
>
|
||||
Forgot password?
|
||||
</AuthTextLink>
|
||||
}
|
||||
>
|
||||
<PasswordInput
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='current-password'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter your password'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
error={passwordFieldErrors.length > 0}
|
||||
/>
|
||||
</AuthField>
|
||||
</div>
|
||||
|
||||
{resetSuccessMessage && (
|
||||
<AuthFormMessage type='success'>
|
||||
<p>{resetSuccessMessage}</p>
|
||||
</AuthFormMessage>
|
||||
)}
|
||||
|
||||
<AuthSubmitButton loading={isLoading} loadingLabel='Signing in…' disabled={!canSubmit}>
|
||||
Sign in
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showDivider && <AuthDivider label='Or continue with' />}
|
||||
|
||||
{showBottomSection && (
|
||||
<SocialLoginButtons
|
||||
googleAvailable={googleAvailable}
|
||||
githubAvailable={githubAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
isProduction={isProduction}
|
||||
callbackURL={callbackUrl}
|
||||
>
|
||||
{ssoEnabled && !hasOnlySSO && (
|
||||
<SSOLoginButton callbackURL={callbackUrl} variant='outline' />
|
||||
)}
|
||||
</SocialLoginButtons>
|
||||
)}
|
||||
|
||||
{emailEnabled && (
|
||||
<AuthNavPrompt
|
||||
prompt="Don't have an account?"
|
||||
href={isInviteFlow ? `/signup?invite_flow=true&callbackUrl=${callbackUrl}` : '/signup'}
|
||||
linkLabel='Sign up'
|
||||
/>
|
||||
)}
|
||||
|
||||
<AuthLegalFooter action='signing in' />
|
||||
</div>
|
||||
|
||||
<ChipModal
|
||||
open={forgotPasswordOpen}
|
||||
onOpenChange={setForgotPasswordOpen}
|
||||
srTitle='Reset Password'
|
||||
>
|
||||
<ChipModalHeader onClose={() => setForgotPasswordOpen(false)}>
|
||||
Reset Password
|
||||
</ChipModalHeader>
|
||||
<ChipModalBody>
|
||||
<p className='px-2 text-[var(--text-secondary)] text-sm'>
|
||||
Enter your email address and we'll send you a link to reset your password if your
|
||||
account exists.
|
||||
</p>
|
||||
<ChipModalField
|
||||
type='email'
|
||||
title='Email'
|
||||
value={forgotPasswordEmail}
|
||||
onChange={(value) => setForgotPasswordEmail(value)}
|
||||
onSubmit={() => {
|
||||
if (!isSubmittingReset) void handleForgotPassword()
|
||||
}}
|
||||
required
|
||||
placeholder='you@example.com'
|
||||
/>
|
||||
{resetStatus.type === 'success' && (
|
||||
<p className='px-2 text-[var(--text-secondary)] text-sm'>{resetStatus.message}</p>
|
||||
)}
|
||||
<ChipModalError>
|
||||
{resetStatus.type === 'error' ? resetStatus.message : null}
|
||||
</ChipModalError>
|
||||
</ChipModalBody>
|
||||
<ChipModalFooter
|
||||
onCancel={() => setForgotPasswordOpen(false)}
|
||||
cancelDisabled={isSubmittingReset}
|
||||
primaryAction={{
|
||||
label: isSubmittingReset ? 'Sending…' : 'Send Reset Link',
|
||||
onClick: handleForgotPassword,
|
||||
disabled: !forgotPasswordEmail || isSubmittingReset,
|
||||
}}
|
||||
/>
|
||||
</ChipModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import LoginForm from '@/app/(auth)/login/login-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Log In',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function LoginPage() {
|
||||
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
|
||||
await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginForm
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
isProduction={isProduction}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function ResetPasswordLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[160px] rounded-[4px]' />
|
||||
<Skeleton className='mt-3 h-[14px] w-[280px] rounded-[4px]' />
|
||||
<div className='mt-8 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-6 h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
import ResetPasswordPage from '@/app/(auth)/reset-password/reset-password-content'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Reset Password',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default ResetPasswordPage
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { resetPasswordContract } from '@/lib/api/contracts'
|
||||
import { AuthHeader, AuthNavPrompt } from '@/app/(auth)/components'
|
||||
import { SetNewPasswordForm } from '@/app/(auth)/reset-password/reset-password-form'
|
||||
|
||||
const logger = createLogger('ResetPasswordPage')
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [statusMessage, setStatusMessage] = useState<{
|
||||
type: 'success' | 'error' | null
|
||||
text: string
|
||||
}>({
|
||||
type: null,
|
||||
text: '',
|
||||
})
|
||||
|
||||
const tokenError = !token
|
||||
? 'Invalid or missing reset token. Please request a new password reset link.'
|
||||
: null
|
||||
|
||||
const handleResetPassword = async (password: string) => {
|
||||
if (!token) return
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
setStatusMessage({ type: null, text: '' })
|
||||
|
||||
await requestJson(resetPasswordContract, {
|
||||
body: {
|
||||
token,
|
||||
newPassword: password,
|
||||
},
|
||||
})
|
||||
|
||||
setStatusMessage({
|
||||
type: 'success',
|
||||
text: 'Password reset successful! Redirecting to login...',
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
router.push('/login?resetSuccess=true')
|
||||
}, 1500)
|
||||
} catch (error) {
|
||||
logger.error('Error resetting password:', { error })
|
||||
setStatusMessage({
|
||||
type: 'error',
|
||||
text: getErrorMessage(error, 'Failed to reset password'),
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<AuthHeader title='Reset your password' description='Enter a new password for your account' />
|
||||
|
||||
<SetNewPasswordForm
|
||||
token={token}
|
||||
onSubmit={handleResetPassword}
|
||||
isSubmitting={isSubmitting}
|
||||
statusType={tokenError ? 'error' : statusMessage.type}
|
||||
statusMessage={tokenError ?? statusMessage.text}
|
||||
/>
|
||||
|
||||
<AuthNavPrompt href='/login' linkLabel='Back to login' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading…</div>}>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn } from '@sim/emcn'
|
||||
import {
|
||||
AuthField,
|
||||
AuthFormMessage,
|
||||
AuthSubmitButton,
|
||||
PasswordInput,
|
||||
} from '@/app/(auth)/components'
|
||||
|
||||
interface SetNewPasswordFormProps {
|
||||
token: string | null
|
||||
onSubmit: (password: string) => Promise<void>
|
||||
isSubmitting: boolean
|
||||
statusType: 'success' | 'error' | null
|
||||
statusMessage: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SetNewPasswordForm({
|
||||
token,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
statusType,
|
||||
statusMessage,
|
||||
className,
|
||||
}: SetNewPasswordFormProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPassword, setConfirmPassword] = useState('')
|
||||
const [validationMessages, setValidationMessages] = useState<string[]>([])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const errors: string[] = []
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long')
|
||||
}
|
||||
|
||||
if (password.length > 100) {
|
||||
errors.push('Password must not exceed 100 characters')
|
||||
}
|
||||
|
||||
if (!/[A-Z]/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter')
|
||||
}
|
||||
|
||||
if (!/[a-z]/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter')
|
||||
}
|
||||
|
||||
if (!/[0-9]/.test(password)) {
|
||||
errors.push('Password must contain at least one number')
|
||||
}
|
||||
|
||||
if (!/[^A-Za-z0-9]/.test(password)) {
|
||||
errors.push('Password must contain at least one special character')
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errors.push('Passwords do not match')
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setValidationMessages(errors)
|
||||
return
|
||||
}
|
||||
|
||||
setValidationMessages([])
|
||||
onSubmit(password)
|
||||
}
|
||||
|
||||
const hasValidationErrors = validationMessages.length > 0
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
|
||||
<div className='space-y-5'>
|
||||
<AuthField htmlFor='password' label='New Password'>
|
||||
<PasswordInput
|
||||
id='password'
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Enter new password'
|
||||
error={hasValidationErrors}
|
||||
/>
|
||||
</AuthField>
|
||||
<AuthField htmlFor='confirmPassword' label='Confirm Password'>
|
||||
<PasswordInput
|
||||
id='confirmPassword'
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
disabled={isSubmitting || !token}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
placeholder='Confirm new password'
|
||||
error={hasValidationErrors}
|
||||
/>
|
||||
</AuthField>
|
||||
|
||||
{hasValidationErrors && (
|
||||
<AuthFormMessage type='error'>
|
||||
{validationMessages.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</AuthFormMessage>
|
||||
)}
|
||||
|
||||
{statusType && statusMessage && (
|
||||
<AuthFormMessage type={statusType === 'success' ? 'success' : 'error'}>
|
||||
<p>{statusMessage}</p>
|
||||
</AuthFormMessage>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthSubmitButton
|
||||
loading={isSubmitting}
|
||||
loadingLabel='Resetting…'
|
||||
disabled={!token || password.length === 0 || confirmPassword.length === 0}
|
||||
>
|
||||
Reset Password
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function SignupLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[100px] rounded-[4px]' />
|
||||
<div className='mt-8 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-4 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[40px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<div className='mt-4 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[64px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-6 h-[1px] w-full rounded-[1px]' />
|
||||
<div className='mt-6 flex w-full gap-3'>
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
<Skeleton className='h-[44px] flex-1 rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-6 h-[14px] w-[220px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { isEmailSignupDisabled, isRegistrationDisabled } from '@/lib/core/config/env-flags'
|
||||
import { getOAuthProviderStatus } from '@/app/(auth)/components/oauth-provider-checker'
|
||||
import SignupForm from '@/app/(auth)/signup/signup-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Sign Up',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SignupPage() {
|
||||
if (isRegistrationDisabled) {
|
||||
return <div>Registration is disabled, please contact your admin.</div>
|
||||
}
|
||||
|
||||
const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } =
|
||||
await getOAuthProviderStatus()
|
||||
|
||||
return (
|
||||
<SignupForm
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
isProduction={isProduction}
|
||||
emailSignupEnabled={!isEmailSignupDisabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { getEnv, isFalsy, isTruthy } from '@/lib/core/config/env'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { captureClientEvent, captureEvent } from '@/lib/posthog/client'
|
||||
import {
|
||||
AuthDivider,
|
||||
AuthField,
|
||||
AuthFormMessage,
|
||||
AuthHeader,
|
||||
AuthInput,
|
||||
AuthLegalFooter,
|
||||
AuthNavPrompt,
|
||||
AuthSubmitButton,
|
||||
PasswordInput,
|
||||
SocialLoginButtons,
|
||||
SSOLoginButton,
|
||||
} from '@/app/(auth)/components'
|
||||
|
||||
const logger = createLogger('SignupForm')
|
||||
|
||||
const PASSWORD_VALIDATIONS = {
|
||||
minLength: { regex: /.{8,}/, message: 'Password must be at least 8 characters long.' },
|
||||
uppercase: {
|
||||
regex: /(?=.*?[A-Z])/,
|
||||
message: 'Password must include at least one uppercase letter.',
|
||||
},
|
||||
lowercase: {
|
||||
regex: /(?=.*?[a-z])/,
|
||||
message: 'Password must include at least one lowercase letter.',
|
||||
},
|
||||
number: { regex: /(?=.*?[0-9])/, message: 'Password must include at least one number.' },
|
||||
special: {
|
||||
regex: /(?=.*?[#?!@$%^&*-])/,
|
||||
message: 'Password must include at least one special character.',
|
||||
},
|
||||
}
|
||||
|
||||
const NAME_VALIDATIONS = {
|
||||
required: {
|
||||
test: (value: string) => Boolean(value && typeof value === 'string'),
|
||||
message: 'Name is required.',
|
||||
},
|
||||
notEmpty: {
|
||||
test: (value: string) => value.trim().length > 0,
|
||||
message: 'Name cannot be empty.',
|
||||
},
|
||||
validCharacters: {
|
||||
regex: /^[\p{L}\s\-']+$/u,
|
||||
message: 'Name can only contain letters, spaces, hyphens, and apostrophes.',
|
||||
},
|
||||
noConsecutiveSpaces: {
|
||||
regex: /^(?!.*\s\s).*$/,
|
||||
message: 'Name cannot contain consecutive spaces.',
|
||||
},
|
||||
}
|
||||
|
||||
const validateEmailField = (emailValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!emailValue || !emailValue.trim()) {
|
||||
errors.push('Email is required.')
|
||||
return errors
|
||||
}
|
||||
|
||||
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.reason || 'Please enter a valid email address.')
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
interface SignupFormProps {
|
||||
githubAvailable: boolean
|
||||
googleAvailable: boolean
|
||||
microsoftAvailable: boolean
|
||||
isProduction: boolean
|
||||
emailSignupEnabled: boolean
|
||||
}
|
||||
|
||||
function SignupFormContent({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
isProduction,
|
||||
emailSignupEnabled,
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refetch: refetchSession } = useSession()
|
||||
const posthog = usePostHog()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
captureClientEvent('signup_page_viewed', {})
|
||||
}, [])
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [email, setEmail] = useState(() => searchParams.get('email') ?? '')
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
const turnstileRef = useRef<TurnstileInstance>(null)
|
||||
const [turnstileSiteKey] = useState(() => getEnv('NEXT_PUBLIC_TURNSTILE_SITE_KEY'))
|
||||
const rawRedirectUrl = searchParams.get('redirect') || searchParams.get('callbackUrl') || ''
|
||||
const isValidRedirectUrl = rawRedirectUrl ? validateCallbackUrl(rawRedirectUrl) : false
|
||||
const invalidCallbackRef = useRef(false)
|
||||
if (rawRedirectUrl && !isValidRedirectUrl && !invalidCallbackRef.current) {
|
||||
invalidCallbackRef.current = true
|
||||
logger.warn('Invalid callback URL detected and blocked:', { url: rawRedirectUrl })
|
||||
}
|
||||
const redirectUrl = isValidRedirectUrl ? rawRedirectUrl : ''
|
||||
const isInviteFlow = useMemo(
|
||||
() => searchParams.get('invite_flow') === 'true' || redirectUrl.startsWith('/invite/'),
|
||||
[searchParams, redirectUrl]
|
||||
)
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [nameErrors, setNameErrors] = useState<string[]>([])
|
||||
const [showNameValidationError, setShowNameValidationError] = useState(false)
|
||||
|
||||
const validatePassword = (passwordValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.minLength.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.minLength.message)
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.uppercase.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.uppercase.message)
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.lowercase.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.lowercase.message)
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.number.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.number.message)
|
||||
}
|
||||
|
||||
if (!PASSWORD_VALIDATIONS.special.regex.test(passwordValue)) {
|
||||
errors.push(PASSWORD_VALIDATIONS.special.message)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const validateName = (nameValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!NAME_VALIDATIONS.required.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.required.message)
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.notEmpty.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.notEmpty.message)
|
||||
return errors
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.validCharacters.regex.test(nameValue.trim())) {
|
||||
errors.push(NAME_VALIDATIONS.validCharacters.message)
|
||||
}
|
||||
|
||||
if (!NAME_VALIDATIONS.noConsecutiveSpaces.regex.test(nameValue)) {
|
||||
errors.push(NAME_VALIDATIONS.noConsecutiveSpaces.message)
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
|
||||
const errors = validatePassword(newPassword)
|
||||
setPasswordErrors(errors)
|
||||
setShowValidationError(false)
|
||||
}
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const rawValue = e.target.value
|
||||
setName(rawValue)
|
||||
|
||||
const errors = validateName(rawValue)
|
||||
setNameErrors(errors)
|
||||
setShowNameValidationError(false)
|
||||
}
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
|
||||
const errors = validateEmailField(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
|
||||
if (emailError) {
|
||||
setEmailError('')
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsLoading(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const emailValueRaw = formData.get('email') as string
|
||||
const emailValue = emailValueRaw.trim().toLowerCase()
|
||||
const passwordValue = formData.get('password') as string
|
||||
const nameValue = formData.get('name') as string
|
||||
|
||||
const trimmedName = nameValue.trim()
|
||||
|
||||
const nameValidationErrors = validateName(trimmedName)
|
||||
setNameErrors(nameValidationErrors)
|
||||
setShowNameValidationError(nameValidationErrors.length > 0)
|
||||
|
||||
const emailValidationErrors = validateEmailField(emailValue)
|
||||
setEmailErrors(emailValidationErrors)
|
||||
setShowEmailValidationError(emailValidationErrors.length > 0)
|
||||
|
||||
const errors = validatePassword(passwordValue)
|
||||
setPasswordErrors(errors)
|
||||
|
||||
setShowValidationError(errors.length > 0)
|
||||
|
||||
try {
|
||||
if (
|
||||
nameValidationErrors.length > 0 ||
|
||||
emailValidationErrors.length > 0 ||
|
||||
errors.length > 0
|
||||
) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (trimmedName.length > 100) {
|
||||
setNameErrors(['Name will be truncated to 100 characters. Please shorten your name.'])
|
||||
setShowNameValidationError(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let token: string | undefined
|
||||
const widget = turnstileRef.current
|
||||
if (turnstileSiteKey && widget) {
|
||||
try {
|
||||
widget.reset()
|
||||
widget.execute()
|
||||
token = await widget.getResponsePromise()
|
||||
} catch {
|
||||
captureEvent(posthog, 'signup_failed', {
|
||||
error_code: 'captcha_client_failure',
|
||||
})
|
||||
setFormError('Captcha verification failed. Please try again.')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setFormError(null)
|
||||
const response = await client.signUp.email(
|
||||
{
|
||||
email: emailValue,
|
||||
password: passwordValue,
|
||||
name: trimmedName,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
...(token ? { 'x-captcha-response': token } : {}),
|
||||
},
|
||||
onError: (ctx) => {
|
||||
logger.warn('Signup error:', ctx.error)
|
||||
const errorMessage: string[] = ['Failed to create account']
|
||||
|
||||
let errorCode = 'unknown'
|
||||
if (ctx.error.code?.includes('USER_ALREADY_EXISTS')) {
|
||||
errorCode = 'user_already_exists'
|
||||
setEmailError('An account with this email already exists. Please sign in instead.')
|
||||
} else if (
|
||||
ctx.error.code?.includes('BAD_REQUEST') ||
|
||||
ctx.error.message?.includes('Email and password sign up is not enabled')
|
||||
) {
|
||||
errorCode = 'signup_disabled'
|
||||
errorMessage.push('Email signup is currently disabled.')
|
||||
setEmailError(errorMessage[0])
|
||||
} else if (ctx.error.code?.includes('INVALID_EMAIL')) {
|
||||
errorCode = 'invalid_email'
|
||||
errorMessage.push('Please enter a valid email address.')
|
||||
setEmailError(errorMessage[0])
|
||||
} else if (ctx.error.code?.includes('PASSWORD_TOO_SHORT')) {
|
||||
errorCode = 'password_too_short'
|
||||
errorMessage.push('Password must be at least 8 characters long.')
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
} else if (ctx.error.code?.includes('PASSWORD_TOO_LONG')) {
|
||||
errorCode = 'password_too_long'
|
||||
errorMessage.push('Password must be less than 128 characters long.')
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
} else if (ctx.error.code?.includes('network')) {
|
||||
errorCode = 'network_error'
|
||||
errorMessage.push('Network error. Please check your connection and try again.')
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
} else if (ctx.error.code?.includes('rate limit')) {
|
||||
errorCode = 'rate_limited'
|
||||
errorMessage.push('Too many requests. Please wait a moment before trying again.')
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
} else {
|
||||
setPasswordErrors(errorMessage)
|
||||
setShowValidationError(true)
|
||||
}
|
||||
|
||||
captureEvent(posthog, 'signup_failed', { error_code: errorCode })
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
if (!response || response.error) {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await refetchSession()
|
||||
logger.info('Session refreshed after successful signup')
|
||||
} catch (sessionError) {
|
||||
logger.error('Failed to refresh session after signup:', sessionError)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem('verificationEmail', emailValue)
|
||||
if (isInviteFlow && redirectUrl) {
|
||||
sessionStorage.setItem('inviteRedirectUrl', redirectUrl)
|
||||
sessionStorage.setItem('isInviteFlow', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
router.push('/verify?fromSignup=true')
|
||||
} catch (error) {
|
||||
logger.error('Signup error:', error)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const ssoEnabled = isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))
|
||||
const emailEnabled =
|
||||
!isFalsy(getEnv('NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED')) && emailSignupEnabled
|
||||
const hasSocial = githubAvailable || googleAvailable || microsoftAvailable
|
||||
const hasOnlySSO = ssoEnabled && !emailEnabled && !hasSocial
|
||||
const showBottomSection = hasSocial || (ssoEnabled && !hasOnlySSO)
|
||||
const showDivider = (emailEnabled || hasOnlySSO) && showBottomSection
|
||||
|
||||
const nameFieldErrors = showNameValidationError && nameErrors.length > 0 ? nameErrors : []
|
||||
const emailHasError = Boolean(emailError) || (showEmailValidationError && emailErrors.length > 0)
|
||||
const emailFieldErrors =
|
||||
showEmailValidationError && emailErrors.length > 0
|
||||
? emailErrors
|
||||
: emailError && !showEmailValidationError
|
||||
? [emailError]
|
||||
: []
|
||||
const passwordFieldErrors = showValidationError && passwordErrors.length > 0 ? passwordErrors : []
|
||||
const canSubmit = name.trim().length > 0 && email.trim().length > 0 && password.length > 0
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<AuthHeader title='Create an account' description='Create an account or log in' />
|
||||
|
||||
{hasOnlySSO && <SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='primary' />}
|
||||
|
||||
{emailEnabled && (
|
||||
<form onSubmit={onSubmit} className='space-y-6'>
|
||||
<div className='space-y-5'>
|
||||
<AuthField htmlFor='name' label='Full name' errors={nameFieldErrors}>
|
||||
<AuthInput
|
||||
id='name'
|
||||
name='name'
|
||||
placeholder='Enter your name'
|
||||
type='text'
|
||||
autoCapitalize='words'
|
||||
autoComplete='name'
|
||||
title='Name can only contain letters, spaces, hyphens, and apostrophes'
|
||||
value={name}
|
||||
onChange={handleNameChange}
|
||||
error={nameFieldErrors.length > 0}
|
||||
/>
|
||||
</AuthField>
|
||||
<AuthField htmlFor='email' label='Email' errors={emailFieldErrors}>
|
||||
<AuthInput
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
error={emailHasError}
|
||||
/>
|
||||
</AuthField>
|
||||
<AuthField htmlFor='password' label='Password' errors={passwordFieldErrors}>
|
||||
<PasswordInput
|
||||
id='password'
|
||||
name='password'
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
placeholder='Enter your password'
|
||||
autoCorrect='off'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
error={passwordFieldErrors.length > 0}
|
||||
/>
|
||||
</AuthField>
|
||||
</div>
|
||||
|
||||
{turnstileSiteKey && (
|
||||
<Turnstile
|
||||
ref={turnstileRef}
|
||||
siteKey={turnstileSiteKey}
|
||||
options={{ execution: 'execute', appearance: 'execute' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<AuthFormMessage type='error'>
|
||||
<p>{formError}</p>
|
||||
</AuthFormMessage>
|
||||
)}
|
||||
|
||||
<AuthSubmitButton
|
||||
loading={isLoading}
|
||||
loadingLabel='Creating account…'
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
Create account
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{showDivider && <AuthDivider label='Or continue with' />}
|
||||
|
||||
{showBottomSection && (
|
||||
<SocialLoginButtons
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
callbackURL={redirectUrl || '/workspace'}
|
||||
isProduction={isProduction}
|
||||
>
|
||||
{ssoEnabled && !hasOnlySSO && (
|
||||
<SSOLoginButton callbackURL={redirectUrl || '/workspace'} variant='outline' />
|
||||
)}
|
||||
</SocialLoginButtons>
|
||||
)}
|
||||
|
||||
<AuthNavPrompt
|
||||
prompt='Already have an account?'
|
||||
href={isInviteFlow ? `/login?invite_flow=true&callbackUrl=${redirectUrl}` : '/login'}
|
||||
linkLabel='Sign in'
|
||||
/>
|
||||
|
||||
<AuthLegalFooter action='creating an account' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SignupPage({
|
||||
githubAvailable,
|
||||
googleAvailable,
|
||||
microsoftAvailable,
|
||||
isProduction,
|
||||
emailSignupEnabled,
|
||||
}: SignupFormProps) {
|
||||
return (
|
||||
<Suspense fallback={<div className='flex h-screen items-center justify-center'>Loading…</div>}>
|
||||
<SignupFormContent
|
||||
githubAvailable={githubAvailable}
|
||||
googleAvailable={googleAvailable}
|
||||
microsoftAvailable={microsoftAvailable}
|
||||
isProduction={isProduction}
|
||||
emailSignupEnabled={emailSignupEnabled}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function SSOLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[120px] rounded-[4px]' />
|
||||
<Skeleton className='mt-3 h-[14px] w-[260px] rounded-[4px]' />
|
||||
<div className='mt-8 w-full space-y-2'>
|
||||
<Skeleton className='h-[14px] w-[80px] rounded-[4px]' />
|
||||
<Skeleton className='h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='mt-6 h-[44px] w-full rounded-[10px]' />
|
||||
<Skeleton className='mt-6 h-[14px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { Metadata } from 'next'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { getEnv, isTruthy } from '@/lib/core/config/env'
|
||||
import SSOForm from '@/ee/sso/components/sso-form'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Single Sign-On',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function SSOPage() {
|
||||
if (!isTruthy(getEnv('NEXT_PUBLIC_SSO_ENABLED'))) {
|
||||
redirect('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<SSOForm />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function VerifyLoading() {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<Skeleton className='h-[38px] w-[180px] rounded-[4px]' />
|
||||
<Skeleton className='mt-3 h-[14px] w-[300px] rounded-[4px]' />
|
||||
<Skeleton className='mt-1 h-[14px] w-[240px] rounded-[4px]' />
|
||||
<Skeleton className='mt-8 h-[44px] w-full rounded-[10px]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { isEmailVerificationEnabled, isProd } from '@/lib/core/config/env-flags'
|
||||
import { hasEmailService } from '@/lib/messaging/email/mailer'
|
||||
import { VerifyContent } from '@/app/(auth)/verify/verify-content'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Verify Email',
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function VerifyPage() {
|
||||
const emailServiceConfigured = hasEmailService()
|
||||
|
||||
return (
|
||||
<VerifyContent
|
||||
hasEmailService={emailServiceConfigured}
|
||||
isProduction={isProd}
|
||||
isEmailVerificationEnabled={isEmailVerificationEnabled}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { normalizeEmail } from '@sim/utils/string'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { client, useSession } from '@/lib/auth/auth-client'
|
||||
import { validateCallbackUrl } from '@/lib/core/security/input-validation'
|
||||
|
||||
const logger = createLogger('useVerification')
|
||||
|
||||
/**
|
||||
* Mutually-exclusive phases of the email-OTP verification machine.
|
||||
* - `idle`: awaiting input
|
||||
* - `verifying`: a verify request is in flight
|
||||
* - `verified`: code accepted, redirecting
|
||||
* - `error`: last verify attempt failed (paired with `errorMessage`)
|
||||
*/
|
||||
type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error'
|
||||
|
||||
interface UseVerificationParams {
|
||||
hasEmailService: boolean
|
||||
isProduction: boolean
|
||||
isEmailVerificationEnabled: boolean
|
||||
}
|
||||
|
||||
interface UseVerificationReturn {
|
||||
otp: string
|
||||
email: string
|
||||
status: VerificationStatus
|
||||
isResending: boolean
|
||||
errorMessage: string
|
||||
isOtpComplete: boolean
|
||||
hasEmailService: boolean
|
||||
isProduction: boolean
|
||||
isEmailVerificationEnabled: boolean
|
||||
verifyCode: () => Promise<void>
|
||||
resendCode: () => void
|
||||
handleOtpChange: (value: string) => void
|
||||
}
|
||||
|
||||
export function useVerification({
|
||||
hasEmailService,
|
||||
isProduction,
|
||||
isEmailVerificationEnabled,
|
||||
}: UseVerificationParams): UseVerificationReturn {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { refetch: refetchSession } = useSession()
|
||||
const [otp, setOtp] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [status, setStatus] = useState<VerificationStatus>('idle')
|
||||
const [isResending, setIsResending] = useState(false)
|
||||
const [isSendingInitialOtp, setIsSendingInitialOtp] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [redirectUrl, setRedirectUrl] = useState<string | null>(null)
|
||||
const [isInviteFlow, setIsInviteFlow] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedEmail = sessionStorage.getItem('verificationEmail')
|
||||
if (storedEmail) {
|
||||
setEmail(storedEmail)
|
||||
}
|
||||
|
||||
const storedRedirectUrl = sessionStorage.getItem('inviteRedirectUrl')
|
||||
if (storedRedirectUrl && validateCallbackUrl(storedRedirectUrl)) {
|
||||
setRedirectUrl(storedRedirectUrl)
|
||||
} else if (storedRedirectUrl) {
|
||||
logger.warn('Ignoring unsafe stored invite redirect URL', { url: storedRedirectUrl })
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
}
|
||||
|
||||
const storedIsInviteFlow = sessionStorage.getItem('isInviteFlow')
|
||||
if (storedIsInviteFlow === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}
|
||||
|
||||
const redirectParam = searchParams.get('redirectAfter')
|
||||
if (redirectParam) {
|
||||
if (validateCallbackUrl(redirectParam)) {
|
||||
setRedirectUrl(redirectParam)
|
||||
} else {
|
||||
logger.warn('Ignoring unsafe redirectAfter parameter', { url: redirectParam })
|
||||
}
|
||||
}
|
||||
|
||||
const inviteFlowParam = searchParams.get('invite_flow')
|
||||
if (inviteFlowParam === 'true') {
|
||||
setIsInviteFlow(true)
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
useEffect(() => {
|
||||
if (email && !isSendingInitialOtp && hasEmailService) {
|
||||
setIsSendingInitialOtp(true)
|
||||
}
|
||||
}, [email, isSendingInitialOtp, hasEmailService])
|
||||
|
||||
const isOtpComplete = otp.length === 6
|
||||
|
||||
async function verifyCode() {
|
||||
if (!isOtpComplete || !email) return
|
||||
|
||||
setStatus('verifying')
|
||||
setErrorMessage('')
|
||||
|
||||
try {
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
const response = await client.emailOtp.verifyEmail({
|
||||
email: normalizedEmail,
|
||||
otp,
|
||||
})
|
||||
|
||||
if (response && !response.error) {
|
||||
setStatus('verified')
|
||||
|
||||
try {
|
||||
await refetchSession()
|
||||
} catch (e) {
|
||||
logger.warn('Failed to refetch session after verification', e)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
|
||||
if (isInviteFlow) {
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (isInviteFlow && redirectUrl) {
|
||||
window.location.href = redirectUrl
|
||||
} else {
|
||||
window.location.href = '/workspace'
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
logger.info('Setting invalid OTP state - API error response')
|
||||
const message = 'Invalid verification code. Please check and try again.'
|
||||
setStatus('error')
|
||||
setErrorMessage(message)
|
||||
logger.info('Error state after API error:', { errorMessage: message })
|
||||
setOtp('')
|
||||
}
|
||||
} catch (error: any) {
|
||||
let message = 'Verification failed. Please check your code and try again.'
|
||||
|
||||
if (error.message?.includes('expired')) {
|
||||
message = 'The verification code has expired. Please request a new one.'
|
||||
} else if (error.message?.includes('invalid')) {
|
||||
logger.info('Setting invalid OTP state - caught error')
|
||||
message = 'Invalid verification code. Please check and try again.'
|
||||
} else if (error.message?.includes('attempts')) {
|
||||
message = 'Too many failed attempts. Please request a new code.'
|
||||
}
|
||||
|
||||
setStatus('error')
|
||||
setErrorMessage(message)
|
||||
logger.info('Error state after caught error:', { errorMessage: message })
|
||||
|
||||
setOtp('')
|
||||
}
|
||||
}
|
||||
|
||||
function resendCode() {
|
||||
if (!email || !hasEmailService || !isEmailVerificationEnabled) return
|
||||
|
||||
setIsResending(true)
|
||||
setErrorMessage('')
|
||||
|
||||
const normalizedEmail = normalizeEmail(email)
|
||||
client.emailOtp
|
||||
.sendVerificationOtp({
|
||||
email: normalizedEmail,
|
||||
type: 'email-verification',
|
||||
})
|
||||
.then(() => {})
|
||||
.catch(() => {
|
||||
setErrorMessage('Failed to resend verification code. Please try again later.')
|
||||
})
|
||||
.finally(() => {
|
||||
setIsResending(false)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* On a complete (6-char) code, clear any lingering message — including a
|
||||
* resend failure (which sets `errorMessage` while `status` stays `idle`) — and
|
||||
* exit the error state, matching the prior unconditional reset on a full OTP.
|
||||
*/
|
||||
function handleOtpChange(value: string) {
|
||||
if (value.length === 6) {
|
||||
if (status === 'error') setStatus('idle')
|
||||
setErrorMessage('')
|
||||
}
|
||||
setOtp(value)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
otp.length === 6 &&
|
||||
email &&
|
||||
status !== 'verifying' &&
|
||||
status !== 'verified' &&
|
||||
!isResending
|
||||
) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
verifyCode()
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [otp, email, status, isResending])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!isEmailVerificationEnabled) {
|
||||
setStatus('verified')
|
||||
|
||||
const handleRedirect = async () => {
|
||||
try {
|
||||
await refetchSession()
|
||||
} catch (error) {
|
||||
logger.warn('Failed to refetch session during verification skip:', error)
|
||||
}
|
||||
|
||||
if (isInviteFlow && redirectUrl) {
|
||||
window.location.href = redirectUrl
|
||||
} else {
|
||||
router.push('/workspace')
|
||||
}
|
||||
}
|
||||
|
||||
handleRedirect()
|
||||
}
|
||||
}
|
||||
}, [isEmailVerificationEnabled, router, isInviteFlow, redirectUrl])
|
||||
|
||||
return {
|
||||
otp,
|
||||
email,
|
||||
status,
|
||||
isResending,
|
||||
errorMessage,
|
||||
isOtpComplete,
|
||||
hasEmailService,
|
||||
isProduction,
|
||||
isEmailVerificationEnabled,
|
||||
verifyCode,
|
||||
resendCode,
|
||||
handleOtpChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense, useEffect, useState } from 'react'
|
||||
import { cn, InputOTP, InputOTPGroup, InputOTPSlot } from '@sim/emcn'
|
||||
import {
|
||||
AuthFormMessage,
|
||||
AuthHeader,
|
||||
AuthNavPrompt,
|
||||
AuthSubmitButton,
|
||||
AuthTextLink,
|
||||
} from '@/app/(auth)/components'
|
||||
import { useVerification } from '@/app/(auth)/verify/use-verification'
|
||||
|
||||
interface VerifyContentProps {
|
||||
hasEmailService: boolean
|
||||
isProduction: boolean
|
||||
isEmailVerificationEnabled: boolean
|
||||
}
|
||||
|
||||
const OTP_SLOTS = [0, 1, 2, 3, 4, 5] as const
|
||||
|
||||
function VerificationForm({
|
||||
hasEmailService,
|
||||
isProduction,
|
||||
isEmailVerificationEnabled,
|
||||
}: {
|
||||
hasEmailService: boolean
|
||||
isProduction: boolean
|
||||
isEmailVerificationEnabled: boolean
|
||||
}) {
|
||||
const {
|
||||
otp,
|
||||
email,
|
||||
status,
|
||||
isResending,
|
||||
errorMessage,
|
||||
isOtpComplete,
|
||||
verifyCode,
|
||||
resendCode,
|
||||
handleOtpChange,
|
||||
} = useVerification({ hasEmailService, isProduction, isEmailVerificationEnabled })
|
||||
|
||||
const isVerified = status === 'verified'
|
||||
const isLoading = status === 'verifying' || isResending
|
||||
const isInvalidOtp = status === 'error'
|
||||
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [isResendDisabled, setIsResendDisabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown > 0) {
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
if (countdown === 0 && isResendDisabled) {
|
||||
setIsResendDisabled(false)
|
||||
}
|
||||
}, [countdown, isResendDisabled])
|
||||
|
||||
const handleResend = () => {
|
||||
resendCode()
|
||||
setIsResendDisabled(true)
|
||||
setCountdown(30)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='space-y-6'>
|
||||
<AuthHeader
|
||||
title={isVerified ? 'Email Verified' : 'Verify your email'}
|
||||
description={
|
||||
isVerified
|
||||
? 'Your email has been verified. Redirecting to dashboard...'
|
||||
: !isEmailVerificationEnabled
|
||||
? 'Email verification is disabled. Redirecting to dashboard...'
|
||||
: hasEmailService
|
||||
? `A verification code has been sent to ${email || 'your email'}`
|
||||
: !isProduction
|
||||
? 'Development mode: Check your console logs for the verification code'
|
||||
: 'Error: Email verification is enabled but no email service is configured'
|
||||
}
|
||||
/>
|
||||
|
||||
{!isVerified && isEmailVerificationEnabled && (
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-5'>
|
||||
<p className='text-center text-[var(--text-muted)] text-sm'>
|
||||
Enter the 6-digit code to verify your account.
|
||||
{hasEmailService ? " If you don't see it in your inbox, check your spam folder." : ''}
|
||||
</p>
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<InputOTP maxLength={6} value={otp} onChange={handleOtpChange} disabled={isLoading}>
|
||||
<InputOTPGroup>
|
||||
{OTP_SLOTS.map((index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
className={cn(isInvalidOtp && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<AuthFormMessage type='error' align='center'>
|
||||
<p>{errorMessage}</p>
|
||||
</AuthFormMessage>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthSubmitButton
|
||||
type='button'
|
||||
onClick={verifyCode}
|
||||
loading={isLoading}
|
||||
loadingLabel='Verifying…'
|
||||
disabled={!isOtpComplete}
|
||||
>
|
||||
Verify Email
|
||||
</AuthSubmitButton>
|
||||
|
||||
{hasEmailService && (
|
||||
<p className='text-center text-[var(--text-muted)] text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in <span className='text-[var(--text-primary)]'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<AuthTextLink onClick={handleResend} disabled={isLoading || isResendDisabled}>
|
||||
Resend
|
||||
</AuthTextLink>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<AuthNavPrompt
|
||||
href='/signup'
|
||||
linkLabel='Back to signup'
|
||||
onNavigate={() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem('verificationEmail')
|
||||
sessionStorage.removeItem('inviteRedirectUrl')
|
||||
sessionStorage.removeItem('isInviteFlow')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VerificationFormFallback() {
|
||||
return (
|
||||
<div className='text-center'>
|
||||
<div className='animate-pulse'>
|
||||
<div className='mx-auto mb-4 h-8 w-48 rounded bg-[var(--surface-4)]' />
|
||||
<div className='mx-auto h-4 w-64 rounded bg-[var(--surface-4)]' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VerifyContent({
|
||||
hasEmailService,
|
||||
isProduction,
|
||||
isEmailVerificationEnabled,
|
||||
}: VerifyContentProps) {
|
||||
return (
|
||||
<Suspense fallback={<VerificationFormFallback />}>
|
||||
<VerificationForm
|
||||
hasEmailService={hasEmailService}
|
||||
isProduction={isProduction}
|
||||
isEmailVerificationEnabled={isEmailVerificationEnabled}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
'use client'
|
||||
|
||||
import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { noop } from '@/lib/core/utils/request'
|
||||
import {
|
||||
ChatErrorState,
|
||||
ChatHeader,
|
||||
ChatInput,
|
||||
ChatLoadingState,
|
||||
type ChatMessage,
|
||||
ChatMessageContainer,
|
||||
EmailAuth,
|
||||
PasswordAuth,
|
||||
VoiceInterface,
|
||||
} from '@/app/(interfaces)/chat/components'
|
||||
import { CHAT_ERROR_MESSAGES, CHAT_REQUEST_TIMEOUT_MS } from '@/app/(interfaces)/chat/constants'
|
||||
import { useAudioStreaming, useChatStreaming } from '@/app/(interfaces)/chat/hooks'
|
||||
import SSOAuth from '@/ee/sso/components/sso-auth'
|
||||
import { useDeployedChatConfig } from '@/hooks/queries/chats'
|
||||
import { useGitHubStars } from '@/hooks/queries/github-stars'
|
||||
import { useVoiceSettings } from '@/hooks/queries/voice-settings'
|
||||
|
||||
const logger = createLogger('ChatClient')
|
||||
|
||||
interface AudioStreamingOptions {
|
||||
voiceId: string
|
||||
chatId: string
|
||||
onError: (error: Error) => void
|
||||
}
|
||||
|
||||
interface ChatRequestFile {
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
data: string
|
||||
}
|
||||
|
||||
interface ChatRequestPayload {
|
||||
input: string
|
||||
conversationId: string
|
||||
files?: ChatRequestFile[]
|
||||
}
|
||||
|
||||
const DEFAULT_VOICE_SETTINGS = {
|
||||
voiceId: 'cgSgspJ2msm6clMCkdW9', // Default ElevenLabs voice (Jessica) — Flash v2.5-optimized
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a File object to a base64 data URL
|
||||
*/
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an audio stream handler for text-to-speech conversion
|
||||
* @param streamTextToAudio - Function to stream text to audio
|
||||
* @param voiceId - The voice ID to use for TTS
|
||||
* @param chatId - Optional chat ID for deployed chat authentication
|
||||
* @returns Audio stream handler function or undefined
|
||||
*/
|
||||
function createAudioStreamHandler(
|
||||
streamTextToAudio: (text: string, options: AudioStreamingOptions) => Promise<void>,
|
||||
voiceId: string,
|
||||
chatId: string
|
||||
) {
|
||||
return async (text: string) => {
|
||||
try {
|
||||
await streamTextToAudio(text, {
|
||||
voiceId,
|
||||
chatId,
|
||||
onError: (error: Error) => {
|
||||
logger.error('Audio streaming error:', error)
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('TTS error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function ChatClient({ identifier }: { identifier: string }) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null)
|
||||
const [conversationId] = useState(() => generateId())
|
||||
|
||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
||||
const [userHasScrolled, setUserHasScrolled] = useState(false)
|
||||
const isUserScrollingRef = useRef(false)
|
||||
|
||||
const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false)
|
||||
|
||||
const { data: chatConfigResult, error: chatConfigError } = useDeployedChatConfig(identifier)
|
||||
const { data: voiceSettings } = useVoiceSettings()
|
||||
const { data: starCount } = useGitHubStars()
|
||||
|
||||
const sttAvailable = voiceSettings?.sttAvailable === true
|
||||
const authRequired = chatConfigResult?.kind === 'auth' ? chatConfigResult.authType : null
|
||||
const chatConfig = chatConfigResult?.kind === 'config' ? chatConfigResult.config : null
|
||||
|
||||
const welcomeMessage = chatConfig?.customizations?.welcomeMessage
|
||||
const welcomeChatMessage = useMemo<ChatMessage | null>(
|
||||
() =>
|
||||
welcomeMessage
|
||||
? {
|
||||
id: 'welcome',
|
||||
content: welcomeMessage,
|
||||
type: 'assistant',
|
||||
timestamp: new Date(),
|
||||
isInitialMessage: true,
|
||||
}
|
||||
: null,
|
||||
[welcomeMessage]
|
||||
)
|
||||
const displayMessages: ChatMessage[] = welcomeChatMessage
|
||||
? [welcomeChatMessage, ...messages]
|
||||
: messages
|
||||
|
||||
const { isStreamingResponse, abortControllerRef, stopStreaming, handleStreamedResponse } =
|
||||
useChatStreaming()
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const { isPlayingAudio, streamTextToAudio, stopAudio } = useAudioStreaming(audioContextRef)
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (messagesEndRef.current) {
|
||||
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollToMessage = useCallback(
|
||||
(messageId: string, scrollToShowOnlyMessage = false) => {
|
||||
const messageElement = document.querySelector(`[data-message-id="${messageId}"]`)
|
||||
if (messageElement && messagesContainerRef.current) {
|
||||
const container = messagesContainerRef.current
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const messageRect = messageElement.getBoundingClientRect()
|
||||
|
||||
if (scrollToShowOnlyMessage) {
|
||||
const scrollTop = container.scrollTop + messageRect.top - containerRect.top
|
||||
|
||||
container.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
} else {
|
||||
const scrollTop = container.scrollTop + messageRect.top - containerRect.top - 80
|
||||
|
||||
container.scrollTo({
|
||||
top: scrollTop,
|
||||
behavior: 'smooth',
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[messagesContainerRef]
|
||||
)
|
||||
|
||||
const isStreamingResponseRef = useRef(isStreamingResponse)
|
||||
isStreamingResponseRef.current = isStreamingResponse
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current
|
||||
if (!container) return
|
||||
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = container
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
setShowScrollButton(distanceFromBottom > 100)
|
||||
|
||||
if (isStreamingResponseRef.current && !isUserScrollingRef.current) {
|
||||
setUserHasScrolled(true)
|
||||
}
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
return () => container.removeEventListener('scroll', handleScroll)
|
||||
}, [chatConfig, isVoiceFirstMode, authRequired])
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreamingResponse) {
|
||||
setUserHasScrolled(false)
|
||||
|
||||
isUserScrollingRef.current = true
|
||||
const timeoutId = setTimeout(() => {
|
||||
isUserScrollingRef.current = false
|
||||
}, 1000)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [isStreamingResponse])
|
||||
|
||||
const handleSendMessage = async (
|
||||
messageParam?: string,
|
||||
isVoiceInput = false,
|
||||
files?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
file: File
|
||||
dataUrl?: string
|
||||
}>
|
||||
) => {
|
||||
const messageToSend = messageParam ?? inputValue
|
||||
if ((!messageToSend.trim() && (!files || files.length === 0)) || isLoading) return
|
||||
|
||||
logger.info('Sending message:', {
|
||||
messageToSend,
|
||||
isVoiceInput,
|
||||
conversationId,
|
||||
filesCount: files?.length,
|
||||
})
|
||||
|
||||
setUserHasScrolled(false)
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
content: messageToSend || (files && files.length > 0 ? `Sent ${files.length} file(s)` : ''),
|
||||
type: 'user',
|
||||
timestamp: new Date(),
|
||||
attachments: files?.map((file) => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
dataUrl: file.dataUrl || '',
|
||||
})),
|
||||
}
|
||||
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInputValue('')
|
||||
setIsLoading(true)
|
||||
|
||||
setTimeout(() => {
|
||||
scrollToMessage(userMessage.id, true)
|
||||
}, 100)
|
||||
|
||||
const abortController = new AbortController()
|
||||
const timeoutId = setTimeout(() => {
|
||||
abortController.abort()
|
||||
}, CHAT_REQUEST_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const payloadFiles =
|
||||
files && files.length > 0
|
||||
? await Promise.all(
|
||||
files.map(async (file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
data: file.dataUrl || (await fileToBase64(file.file)),
|
||||
}))
|
||||
)
|
||||
: undefined
|
||||
|
||||
const payload: ChatRequestPayload = {
|
||||
input:
|
||||
typeof userMessage.content === 'string'
|
||||
? userMessage.content
|
||||
: JSON.stringify(userMessage.content),
|
||||
conversationId,
|
||||
...(payloadFiles ? { files: payloadFiles } : {}),
|
||||
}
|
||||
|
||||
logger.info('API payload:', {
|
||||
...payload,
|
||||
files: payload.files ? `${payload.files.length} files` : undefined,
|
||||
})
|
||||
|
||||
// boundary-raw-fetch: deployed chat endpoint returns an SSE stream consumed by handleStreamedResponse via response.body.getReader()
|
||||
const response = await fetch(`/api/chat/${identifier}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
credentials: 'same-origin',
|
||||
signal: abortController.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
logger.error('API error response:', errorData)
|
||||
throw new Error(errorData.error || 'Failed to get response')
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error('Response body is missing')
|
||||
}
|
||||
|
||||
const shouldPlayAudio = isVoiceInput || isVoiceFirstMode
|
||||
const audioHandler =
|
||||
shouldPlayAudio && chatConfig?.id
|
||||
? createAudioStreamHandler(
|
||||
streamTextToAudio,
|
||||
DEFAULT_VOICE_SETTINGS.voiceId,
|
||||
chatConfig.id
|
||||
)
|
||||
: undefined
|
||||
|
||||
logger.info('Starting to handle streamed response:', { shouldPlayAudio })
|
||||
|
||||
await handleStreamedResponse(
|
||||
response,
|
||||
setMessages,
|
||||
setIsLoading,
|
||||
scrollToBottom,
|
||||
userHasScrolled,
|
||||
{
|
||||
voiceSettings: {
|
||||
isVoiceEnabled: shouldPlayAudio,
|
||||
voiceId: DEFAULT_VOICE_SETTINGS.voiceId,
|
||||
autoPlayResponses: shouldPlayAudio,
|
||||
},
|
||||
audioStreamHandler: audioHandler,
|
||||
outputConfigs: chatConfig?.outputConfigs,
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
logger.info('Request aborted by user or timeout')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
logger.error('Error sending message:', error)
|
||||
setIsLoading(false)
|
||||
const errorMessage: ChatMessage = {
|
||||
id: generateId(),
|
||||
content: CHAT_ERROR_MESSAGES.GENERIC_ERROR,
|
||||
type: 'assistant',
|
||||
timestamp: new Date(),
|
||||
}
|
||||
setMessages((prev) => [...prev, errorMessage])
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopAudio()
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close()
|
||||
}
|
||||
}
|
||||
}, [stopAudio])
|
||||
|
||||
const handleVoiceInterruption = useCallback(() => {
|
||||
stopAudio()
|
||||
|
||||
if (isStreamingResponse) {
|
||||
stopStreaming(setMessages)
|
||||
}
|
||||
}, [isStreamingResponse, stopStreaming, setMessages, stopAudio])
|
||||
|
||||
const handleVoiceStart = useCallback(() => {
|
||||
if (!sttAvailable) return
|
||||
setIsVoiceFirstMode(true)
|
||||
}, [sttAvailable])
|
||||
|
||||
const handleExitVoiceMode = useCallback(() => {
|
||||
setIsVoiceFirstMode(false)
|
||||
stopAudio()
|
||||
}, [stopAudio])
|
||||
|
||||
const handleVoiceTranscript = useCallback(
|
||||
(transcript: string) => {
|
||||
logger.info('Received voice transcript:', transcript)
|
||||
handleSendMessage(transcript, true)
|
||||
},
|
||||
[handleSendMessage]
|
||||
)
|
||||
|
||||
if (chatConfigError) {
|
||||
logger.error('Error fetching chat config:', chatConfigError)
|
||||
return <ChatErrorState error={CHAT_ERROR_MESSAGES.CHAT_UNAVAILABLE} />
|
||||
}
|
||||
|
||||
if (authRequired) {
|
||||
if (authRequired === 'password') {
|
||||
return <PasswordAuth identifier={identifier} />
|
||||
}
|
||||
if (authRequired === 'email') {
|
||||
return <EmailAuth identifier={identifier} />
|
||||
}
|
||||
if (authRequired === 'sso') {
|
||||
return <SSOAuth identifier={identifier} />
|
||||
}
|
||||
}
|
||||
|
||||
if (!chatConfig) {
|
||||
return <ChatLoadingState />
|
||||
}
|
||||
|
||||
if (isVoiceFirstMode) {
|
||||
return (
|
||||
<VoiceInterface
|
||||
onCallEnd={handleExitVoiceMode}
|
||||
onVoiceTranscript={handleVoiceTranscript}
|
||||
onVoiceStart={noop}
|
||||
onVoiceEnd={noop}
|
||||
onInterrupt={handleVoiceInterruption}
|
||||
isStreaming={isStreamingResponse}
|
||||
isPlayingAudio={isPlayingAudio}
|
||||
audioContextRef={audioContextRef}
|
||||
chatId={chatConfig?.id}
|
||||
messages={displayMessages.map((msg) => ({
|
||||
content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
|
||||
type: msg.type,
|
||||
}))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
{/* Header component */}
|
||||
<ChatHeader chatConfig={chatConfig} starCount={starCount} />
|
||||
|
||||
{/* Message Container component */}
|
||||
<ChatMessageContainer
|
||||
messages={displayMessages}
|
||||
isLoading={isLoading}
|
||||
showScrollButton={showScrollButton}
|
||||
messagesContainerRef={messagesContainerRef as RefObject<HTMLDivElement>}
|
||||
messagesEndRef={messagesEndRef as RefObject<HTMLDivElement>}
|
||||
scrollToBottom={scrollToBottom}
|
||||
scrollToMessage={scrollToMessage}
|
||||
chatConfig={chatConfig}
|
||||
/>
|
||||
|
||||
{/* Input area (free-standing at the bottom) */}
|
||||
<div className='relative p-3 pb-4 md:p-4 md:pb-6'>
|
||||
<div className='relative mx-auto max-w-3xl md:max-w-[748px]'>
|
||||
<ChatInput
|
||||
onSubmit={(value, isVoiceInput, files) => {
|
||||
void handleSendMessage(value, isVoiceInput, files)
|
||||
}}
|
||||
isStreaming={isStreamingResponse}
|
||||
onStopStreaming={() => stopStreaming(setMessages)}
|
||||
onVoiceStart={handleVoiceStart}
|
||||
sttAvailable={sttAvailable}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function ChatLoading() {
|
||||
return (
|
||||
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]'>
|
||||
<div className='border-[var(--border-1)] border-b px-4 py-3'>
|
||||
<div className='mx-auto flex max-w-3xl items-center justify-between'>
|
||||
<div className='flex items-center gap-[12px]'>
|
||||
<Skeleton className='size-[28px] rounded-[6px]' />
|
||||
<Skeleton className='h-[18px] w-[120px] rounded-[4px]' />
|
||||
</div>
|
||||
<Skeleton className='h-[28px] w-[80px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex min-h-0 flex-1 items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<Skeleton className='mx-auto h-8 w-32' />
|
||||
<Skeleton className='mx-auto h-4 w-48' />
|
||||
</div>
|
||||
<div className='mt-8 w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative p-3 pb-4 md:p-4 md:pb-6'>
|
||||
<div className='relative mx-auto max-w-3xl md:max-w-[748px]'>
|
||||
<Skeleton className='h-[48px] w-full rounded-[12px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import Script from 'next/script'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
Office?: {
|
||||
onReady: () => Promise<{ host: string | null; platform: string | null }>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Office.js nullifies window.history.replaceState and pushState (a legacy
|
||||
* IE10 workaround inside the library) which breaks Next.js's client-side
|
||||
* router. Cache the originals at module load — before <Script> renders
|
||||
* Office.js into the DOM — so we can restore them after it loads.
|
||||
*
|
||||
* See https://learn.microsoft.com/en-us/answers/questions/1070090/using-office-javascript-api-in-next-js.
|
||||
*/
|
||||
const cachedHistory =
|
||||
typeof window !== 'undefined'
|
||||
? {
|
||||
replaceState: window.history.replaceState.bind(window.history),
|
||||
pushState: window.history.pushState.bind(window.history),
|
||||
}
|
||||
: null
|
||||
|
||||
/**
|
||||
* Loads Office.js and signals readiness so Office host applications
|
||||
* (Excel, Word, PowerPoint, Outlook) recognize this page as a valid add-in.
|
||||
*
|
||||
* Office.onReady() must be called once Office.js is loaded — see
|
||||
* https://learn.microsoft.com/en-us/javascript/api/office#office-office-onready-function(1).
|
||||
*/
|
||||
export function OfficeEmbedInit() {
|
||||
return (
|
||||
<Script
|
||||
src='https://appsforoffice.microsoft.com/lib/1/hosted/office.js'
|
||||
strategy='afterInteractive'
|
||||
onReady={() => {
|
||||
if (cachedHistory) {
|
||||
window.history.replaceState = cachedHistory.replaceState
|
||||
window.history.pushState = cachedHistory.pushState
|
||||
}
|
||||
void window.Office?.onReady()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { db } from '@sim/db'
|
||||
import { chat } from '@sim/db/schema'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { and, eq, isNull } from 'drizzle-orm'
|
||||
import type { Metadata } from 'next'
|
||||
import ChatClient from '@/app/(interfaces)/chat/[identifier]/chat'
|
||||
import { OfficeEmbedInit } from '@/app/(interfaces)/chat/[identifier]/office-embed-init'
|
||||
|
||||
const logger = createLogger('ChatMetadata')
|
||||
|
||||
/**
|
||||
* Only fully public, active deployments are indexable. Auth-gated (password,
|
||||
* email, SSO) and inactive/nonexistent chats are noindexed at the page level
|
||||
* so Google never indexes an auth wall — narrower than blocking `/chat/`
|
||||
* entirely in robots.ts, which would also hide genuinely public deployments.
|
||||
*
|
||||
* Errors from the lookup fail toward noindex rather than throwing: unlike
|
||||
* the identical query in app/api/chat/[identifier]/route.ts (which must
|
||||
* surface failures to the caller), a metadata resolution error has no
|
||||
* error.tsx boundary in this route to catch it — throwing here would take
|
||||
* the whole page down instead of just skipping indexability, and "can't
|
||||
* confirm this is safe to index" should default to not indexing it anyway.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ identifier: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { identifier } = await params
|
||||
|
||||
let isIndexable = false
|
||||
try {
|
||||
const [deployment] = await db
|
||||
.select({ authType: chat.authType, isActive: chat.isActive })
|
||||
.from(chat)
|
||||
.where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
|
||||
.limit(1)
|
||||
|
||||
isIndexable = Boolean(deployment?.isActive && deployment.authType === 'public')
|
||||
} catch (error) {
|
||||
logger.error('Failed to resolve chat deployment for metadata', {
|
||||
identifier,
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Chat',
|
||||
...(!isIndexable && { robots: { index: false, follow: false } }),
|
||||
}
|
||||
}
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default async function ChatPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ identifier: string }>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const { identifier } = await params
|
||||
const { embed } = await searchParams
|
||||
const isOfficeEmbed = embed === 'office' || (Array.isArray(embed) && embed.includes('office'))
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOfficeEmbed && <OfficeEmbedInit />}
|
||||
<ChatClient key={identifier} identifier={identifier} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { quickValidateEmail } from '@/lib/messaging/email/validation'
|
||||
import { AuthSubmitButton } from '@/app/(auth)/components'
|
||||
import { AUTH_TEXT_LINK } from '@/app/(auth)/components/auth-button-classes'
|
||||
import { useChatEmailOtpRequest, useChatEmailOtpVerify } from '@/hooks/queries/chats'
|
||||
|
||||
const logger = createLogger('EmailAuth')
|
||||
|
||||
interface EmailAuthProps {
|
||||
identifier: string
|
||||
}
|
||||
|
||||
const validateEmailField = (emailValue: string): string[] => {
|
||||
const errors: string[] = []
|
||||
|
||||
if (!emailValue || !emailValue.trim()) {
|
||||
errors.push('Email is required.')
|
||||
return errors
|
||||
}
|
||||
|
||||
const validation = quickValidateEmail(emailValue.trim().toLowerCase())
|
||||
if (!validation.isValid) {
|
||||
errors.push(validation.reason || 'Please enter a valid email address.')
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
export default function EmailAuth({ identifier }: EmailAuthProps) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [authError, setAuthError] = useState<string | null>(null)
|
||||
const [emailErrors, setEmailErrors] = useState<string[]>([])
|
||||
const [showEmailValidationError, setShowEmailValidationError] = useState(false)
|
||||
|
||||
const [showOtpVerification, setShowOtpVerification] = useState(false)
|
||||
const [otpValue, setOtpValue] = useState('')
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
|
||||
const requestOtp = useChatEmailOtpRequest(identifier)
|
||||
const verifyOtp = useChatEmailOtpVerify(identifier)
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) return
|
||||
const timer = setTimeout(() => setCountdown((c) => c - 1), 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [countdown])
|
||||
|
||||
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newEmail = e.target.value
|
||||
setEmail(newEmail)
|
||||
const errors = validateEmailField(newEmail)
|
||||
setEmailErrors(errors)
|
||||
setShowEmailValidationError(false)
|
||||
}
|
||||
|
||||
const handleSendOtp = async () => {
|
||||
const emailValidationErrors = validateEmailField(email)
|
||||
setEmailErrors(emailValidationErrors)
|
||||
setShowEmailValidationError(emailValidationErrors.length > 0)
|
||||
|
||||
if (emailValidationErrors.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setAuthError(null)
|
||||
|
||||
try {
|
||||
await requestOtp.mutateAsync({ email })
|
||||
setShowOtpVerification(true)
|
||||
} catch (error) {
|
||||
logger.error('Error sending OTP:', error)
|
||||
setEmailErrors([toError(error).message || 'Failed to send verification code'])
|
||||
setShowEmailValidationError(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyOtp = async (otp?: string) => {
|
||||
const codeToVerify = otp || otpValue
|
||||
|
||||
if (!codeToVerify || codeToVerify.length !== 6) {
|
||||
return
|
||||
}
|
||||
|
||||
setAuthError(null)
|
||||
|
||||
try {
|
||||
await verifyOtp.mutateAsync({ email, otp: codeToVerify })
|
||||
} catch (error) {
|
||||
logger.error('Error verifying OTP:', error)
|
||||
setAuthError(toError(error).message || 'Invalid verification code')
|
||||
}
|
||||
}
|
||||
|
||||
const handleResendOtp = async () => {
|
||||
setAuthError(null)
|
||||
setCountdown(30)
|
||||
|
||||
try {
|
||||
await requestOtp.mutateAsync({ email })
|
||||
setOtpValue('')
|
||||
} catch (error) {
|
||||
logger.error('Error resending OTP:', error)
|
||||
setAuthError(toError(error).message || 'Failed to resend verification code')
|
||||
setCountdown(0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center px-4 py-16'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
{showOtpVerification ? 'Verify Your Email' : 'Email Verification'}
|
||||
</h1>
|
||||
<p className='text-[color-mix(in_srgb,var(--text-muted)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
{showOtpVerification
|
||||
? `A verification code has been sent to ${email}`
|
||||
: 'This chat requires email verification'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 w-full max-w-[410px]'>
|
||||
{!showOtpVerification ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleSendOtp()
|
||||
}}
|
||||
className='space-y-6'
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='email'>Email</Label>
|
||||
</div>
|
||||
<Input
|
||||
id='email'
|
||||
name='email'
|
||||
placeholder='Enter your email'
|
||||
required
|
||||
autoCapitalize='none'
|
||||
autoComplete='email'
|
||||
autoCorrect='off'
|
||||
value={email}
|
||||
onChange={handleEmailChange}
|
||||
className={cn(
|
||||
showEmailValidationError &&
|
||||
emailErrors.length > 0 &&
|
||||
'border-[var(--text-error)] focus:border-[var(--text-error)]'
|
||||
)}
|
||||
/>
|
||||
{showEmailValidationError && emailErrors.length > 0 && (
|
||||
<div className='mt-1 space-y-1 text-[var(--text-error)] text-xs'>
|
||||
{emailErrors.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AuthSubmitButton
|
||||
type='submit'
|
||||
loading={requestOtp.isPending}
|
||||
loadingLabel='Sending Code…'
|
||||
>
|
||||
Continue
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
) : (
|
||||
<div className='space-y-6'>
|
||||
<p className='text-center text-[var(--text-muted)] text-sm'>
|
||||
Enter the 6-digit code to verify your account. If you don't see it in your inbox,
|
||||
check your spam folder.
|
||||
</p>
|
||||
|
||||
<div className='flex justify-center'>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={otpValue}
|
||||
onChange={(value) => {
|
||||
setOtpValue(value)
|
||||
if (value.length === 6) {
|
||||
handleVerifyOtp(value)
|
||||
}
|
||||
}}
|
||||
disabled={verifyOtp.isPending}
|
||||
className={cn('gap-2', authError && 'otp-error')}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{[0, 1, 2, 3, 4, 5].map((index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
className={cn(authError && 'border-[var(--text-error)]')}
|
||||
/>
|
||||
))}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
|
||||
{authError && (
|
||||
<div className='mt-1 space-y-1 text-center text-[var(--text-error)] text-xs'>
|
||||
<p>{authError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AuthSubmitButton
|
||||
onClick={() => handleVerifyOtp()}
|
||||
disabled={otpValue.length !== 6}
|
||||
loading={verifyOtp.isPending}
|
||||
loadingLabel='Verifying…'
|
||||
>
|
||||
Verify Email
|
||||
</AuthSubmitButton>
|
||||
|
||||
<div className='text-center'>
|
||||
<p className='text-[var(--text-muted)] text-sm'>
|
||||
Didn't receive a code?{' '}
|
||||
{countdown > 0 ? (
|
||||
<span>
|
||||
Resend in{' '}
|
||||
<span className='font-medium text-[var(--text-primary)]'>{countdown}s</span>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={AUTH_TEXT_LINK}
|
||||
onClick={handleResendOtp}
|
||||
disabled={verifyOtp.isPending || requestOtp.isPending}
|
||||
>
|
||||
Resend
|
||||
</button>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='text-center font-light text-sm'>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowOtpVerification(false)
|
||||
setOtpValue('')
|
||||
setAuthError(null)
|
||||
}}
|
||||
className={AUTH_TEXT_LINK}
|
||||
>
|
||||
Change email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { cn, Input, Label } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { Eye, EyeOff } from 'lucide-react'
|
||||
import { AuthSubmitButton } from '@/app/(auth)/components'
|
||||
import { useChatPasswordAuth } from '@/hooks/queries/chats'
|
||||
|
||||
const logger = createLogger('PasswordAuth')
|
||||
|
||||
interface PasswordAuthProps {
|
||||
identifier: string
|
||||
}
|
||||
|
||||
export default function PasswordAuth({ identifier }: PasswordAuthProps) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [showValidationError, setShowValidationError] = useState(false)
|
||||
const [passwordErrors, setPasswordErrors] = useState<string[]>([])
|
||||
const authenticate = useChatPasswordAuth(identifier)
|
||||
|
||||
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newPassword = e.target.value
|
||||
setPassword(newPassword)
|
||||
setShowValidationError(false)
|
||||
setPasswordErrors([])
|
||||
}
|
||||
|
||||
const handleAuthenticate = async () => {
|
||||
if (!password.trim()) {
|
||||
setPasswordErrors(['Password is required'])
|
||||
setShowValidationError(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await authenticate.mutateAsync({ password })
|
||||
setPassword('')
|
||||
} catch (error) {
|
||||
logger.error('Authentication error:', error)
|
||||
setPasswordErrors([toError(error).message || 'Invalid password. Please try again.'])
|
||||
setShowValidationError(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center px-4 py-16'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
<div className='space-y-1 text-center'>
|
||||
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
Password Required
|
||||
</h1>
|
||||
<p className='text-[color-mix(in_srgb,var(--text-muted)_60%,transparent)] text-lg leading-[125%] tracking-[0.02em]'>
|
||||
This chat is password-protected
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
handleAuthenticate()
|
||||
}}
|
||||
className='mt-8 w-full max-w-[410px] space-y-6'
|
||||
>
|
||||
<div className='space-y-6'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Label htmlFor='password'>Password</Label>
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<div className='relative'>
|
||||
<Input
|
||||
id='password'
|
||||
name='password'
|
||||
required
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoCapitalize='none'
|
||||
autoComplete='new-password'
|
||||
autoCorrect='off'
|
||||
placeholder='Enter password'
|
||||
value={password}
|
||||
onChange={handlePasswordChange}
|
||||
className={cn(
|
||||
'pr-10',
|
||||
showValidationError &&
|
||||
passwordErrors.length > 0 &&
|
||||
'border-[var(--text-error)] focus:border-[var(--text-error)]'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className='-translate-y-1/2 absolute top-1/2 right-3 text-[var(--text-muted)] hover:text-[var(--text-primary)]'
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute right-0 left-0 z-10 grid transition-[grid-template-rows] duration-200 ease-out',
|
||||
showValidationError && passwordErrors.length > 0
|
||||
? 'grid-rows-[1fr]'
|
||||
: 'grid-rows-[0fr]'
|
||||
)}
|
||||
aria-live='polite'
|
||||
>
|
||||
<div className='overflow-hidden'>
|
||||
<div className='mt-1 space-y-1 text-[var(--text-error)] text-xs'>
|
||||
{passwordErrors.map((error) => (
|
||||
<p key={error}>{error}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuthSubmitButton
|
||||
type='submit'
|
||||
disabled={!password.trim()}
|
||||
loading={authenticate.isPending}
|
||||
loadingLabel='Authenticating…'
|
||||
>
|
||||
Continue
|
||||
</AuthSubmitButton>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { Button } from '@sim/emcn'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface ChatErrorStateProps {
|
||||
error: string
|
||||
}
|
||||
|
||||
export function ChatErrorState({ error }: ChatErrorStateProps) {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='flex flex-1 items-center justify-center px-4 py-16 text-center'>
|
||||
<div className='flex w-full max-w-[410px] flex-col items-center gap-3'>
|
||||
<h1 className='text-balance text-[40px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'>
|
||||
Chat Unavailable
|
||||
</h1>
|
||||
<p className='text-[var(--text-muted)] text-lg'>{error}</p>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={() => router.push('/workspace')}
|
||||
className='h-[32px] w-full gap-2 px-2.5 text-sm'
|
||||
>
|
||||
Return to Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { GithubIcon } from '@/components/icons'
|
||||
import { SimWordmark } from '@/app/(landing)/components/navbar/components'
|
||||
import { useBrandConfig } from '@/ee/whitelabeling'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
chatConfig: {
|
||||
title?: string
|
||||
customizations?: {
|
||||
headerText?: string
|
||||
logoUrl?: string
|
||||
imageUrl?: string
|
||||
primaryColor?: string
|
||||
}
|
||||
} | null
|
||||
starCount: string
|
||||
}
|
||||
|
||||
export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) {
|
||||
const brand = useBrandConfig()
|
||||
const customImage = chatConfig?.customizations?.imageUrl || chatConfig?.customizations?.logoUrl
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label='Chat navigation'
|
||||
className='flex w-full items-center justify-between px-4 pt-3 pb-[21px] sm:px-8 sm:pt-[8.5px] md:px-[44px] md:pt-4'
|
||||
>
|
||||
<div className='flex items-center gap-[34px]'>
|
||||
<div className='flex items-center gap-3'>
|
||||
{customImage && (
|
||||
<Image
|
||||
src={customImage}
|
||||
alt={`${chatConfig?.title || 'Chat'} logo`}
|
||||
width={24}
|
||||
height={24}
|
||||
unoptimized
|
||||
className='size-6 rounded-md object-cover'
|
||||
/>
|
||||
)}
|
||||
<h2 className='font-medium text-[var(--text-primary)] text-lg'>
|
||||
{chatConfig?.customizations?.headerText || chatConfig?.title || 'Chat'}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!brand.logoUrl && (
|
||||
<div className='flex items-center gap-4'>
|
||||
<a
|
||||
href='https://github.com/simstudioai/sim'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className='flex items-center gap-2 text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]'
|
||||
aria-label={`GitHub repository - ${starCount} stars`}
|
||||
>
|
||||
<GithubIcon className='size-[16px]' aria-hidden='true' />
|
||||
<span aria-live='polite'>{starCount}</span>
|
||||
</a>
|
||||
{/* Only show Sim logo if no custom branding is set */}
|
||||
|
||||
<Link
|
||||
href='https://sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label='Sim home'
|
||||
className='flex items-center'
|
||||
>
|
||||
<SimWordmark />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export { default as EmailAuth } from './auth/email/email-auth'
|
||||
export { default as PasswordAuth } from './auth/password/password-auth'
|
||||
export { ChatErrorState } from './error-state/error-state'
|
||||
export { ChatHeader } from './header/header'
|
||||
export { ChatInput } from './input/input'
|
||||
export { ChatLoadingState } from './loading-state/loading-state'
|
||||
export type { ChatMessage } from './message/message'
|
||||
export { ChatMessageContainer } from './message-container/message-container'
|
||||
export { VoiceInterface } from './voice-interface/voice-interface'
|
||||
@@ -0,0 +1,357 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Badge, Button, cn, handleKeyboardActivation, Tooltip } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { ArrowUp, Mic, Paperclip, X } from 'lucide-react'
|
||||
import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation'
|
||||
import { VoiceInput } from '@/app/(interfaces)/chat/components/input/voice-input'
|
||||
|
||||
const logger = createLogger('ChatInput')
|
||||
|
||||
const MAX_TEXTAREA_HEIGHT = 200
|
||||
|
||||
interface AttachedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
type: string
|
||||
file: File
|
||||
dataUrl?: string
|
||||
}
|
||||
|
||||
export const ChatInput: React.FC<{
|
||||
onSubmit?: (value: string, isVoiceInput?: boolean, files?: AttachedFile[]) => void
|
||||
isStreaming?: boolean
|
||||
onStopStreaming?: () => void
|
||||
onVoiceStart?: () => void
|
||||
voiceOnly?: boolean
|
||||
sttAvailable?: boolean
|
||||
}> = ({
|
||||
onSubmit,
|
||||
isStreaming = false,
|
||||
onStopStreaming,
|
||||
onVoiceStart,
|
||||
voiceOnly = false,
|
||||
sttAvailable = false,
|
||||
}) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
|
||||
const [uploadErrors, setUploadErrors] = useState<string[]>([])
|
||||
const [dragCounter, setDragCounter] = useState(0)
|
||||
const isDragOver = dragCounter > 0
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = textareaRef.current
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = `${Math.min(el.scrollHeight, MAX_TEXTAREA_HEIGHT)}px`
|
||||
}, [inputValue])
|
||||
|
||||
const handleFileSelect = async (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles) return
|
||||
|
||||
const newFiles: AttachedFile[] = []
|
||||
const maxSize = 10 * 1024 * 1024
|
||||
const maxFiles = 15
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i++) {
|
||||
if (attachedFiles.length + newFiles.length >= maxFiles) break
|
||||
|
||||
const file = selectedFiles[i]
|
||||
|
||||
if (file.size > maxSize) {
|
||||
setUploadErrors((prev) => [...prev, `${file.name} is too large (max 10MB)`])
|
||||
continue
|
||||
}
|
||||
|
||||
const isDuplicate = attachedFiles.some(
|
||||
(existing) => existing.name === file.name && existing.size === file.size
|
||||
)
|
||||
if (isDuplicate) {
|
||||
setUploadErrors((prev) => [...prev, `${file.name} already added`])
|
||||
continue
|
||||
}
|
||||
|
||||
let dataUrl: string | undefined
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Error reading file:', error)
|
||||
}
|
||||
}
|
||||
|
||||
newFiles.push({
|
||||
id: generateId(),
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
file,
|
||||
dataUrl,
|
||||
})
|
||||
}
|
||||
|
||||
if (newFiles.length > 0) {
|
||||
setAttachedFiles((prev) => [...prev, ...newFiles])
|
||||
setUploadErrors([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveFile = useCallback((fileId: string) => {
|
||||
setAttachedFiles((prev) => prev.filter((f) => f.id !== fileId))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (isStreaming) return
|
||||
if (!inputValue.trim() && attachedFiles.length === 0) return
|
||||
onSubmit?.(inputValue.trim(), false, attachedFiles)
|
||||
setInputValue('')
|
||||
setAttachedFiles([])
|
||||
setUploadErrors([])
|
||||
}, [isStreaming, inputValue, attachedFiles, onSubmit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
)
|
||||
|
||||
const focusTextarea = useCallback(() => {
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if ((e.target as HTMLElement).closest('button')) return
|
||||
textareaRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const canSubmit = (inputValue.trim().length > 0 || attachedFiles.length > 0) && !isStreaming
|
||||
|
||||
if (voiceOnly) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div className='flex items-center justify-center'>
|
||||
{sttAvailable && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div>
|
||||
<VoiceInput
|
||||
onVoiceStart={onVoiceStart ?? (() => {})}
|
||||
disabled={isStreaming}
|
||||
large={true}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Start voice conversation</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div className='fixed right-0 bottom-0 left-0 flex w-full items-center justify-center bg-gradient-to-t from-[var(--bg)] to-transparent px-4 pb-4 md:px-0 md:pb-4'>
|
||||
<div className='w-full max-w-3xl md:max-w-[748px]'>
|
||||
{/* Error Messages */}
|
||||
{uploadErrors.length > 0 && (
|
||||
<div className='mb-3 flex flex-col gap-2'>
|
||||
{uploadErrors.map((error, idx) => (
|
||||
<Badge key={`${error}-${idx}`} variant='red' size='lg' dot className='max-w-full'>
|
||||
{error}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input container */}
|
||||
<div
|
||||
role='group'
|
||||
aria-label='Chat message input'
|
||||
onClick={handleContainerClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return
|
||||
handleKeyboardActivation(event, focusTextarea)
|
||||
}}
|
||||
className={cn(
|
||||
'relative z-10 cursor-text rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)] px-2.5 py-2',
|
||||
isDragOver && 'border-purple-500'
|
||||
)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!isStreaming) setDragCounter((prev) => prev + 1)
|
||||
}}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (!isStreaming) e.dataTransfer.dropEffect = 'copy'
|
||||
}}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter((prev) => Math.max(0, prev - 1))
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragCounter(0)
|
||||
if (!isStreaming) handleFileSelect(e.dataTransfer.files)
|
||||
}}
|
||||
>
|
||||
{/* File thumbnails */}
|
||||
{attachedFiles.length > 0 && (
|
||||
<div className='mb-1.5 flex flex-wrap gap-1.5'>
|
||||
{attachedFiles.map((file) => (
|
||||
<Tooltip.Root key={file.id}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<div className='group relative size-[56px] flex-shrink-0 cursor-pointer overflow-hidden rounded-[8px] border border-[var(--border-1)] bg-[var(--surface-3)]'>
|
||||
{file.dataUrl ? (
|
||||
<img
|
||||
src={file.dataUrl}
|
||||
alt={file.name}
|
||||
className='h-full w-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<div className='flex h-full w-full flex-col items-center justify-center gap-0.5 text-[var(--text-muted)]'>
|
||||
<Paperclip className='size-[18px]' />
|
||||
<span className='max-w-[48px] truncate px-[2px] text-[9px]'>
|
||||
{file.name.split('.').pop()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant='ghost'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleRemoveFile(file.id)
|
||||
}}
|
||||
className='absolute top-[2px] right-[2px] size-[16px] rounded-full bg-black/60 p-0 text-white opacity-0 hover-hover:text-white group-hover:opacity-100'
|
||||
>
|
||||
<X className='size-[10px]' />
|
||||
</Button>
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p className='max-w-[200px] truncate'>{file.name}</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Textarea */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={isDragOver ? 'Drop files here...' : 'Enter a message...'}
|
||||
rows={1}
|
||||
className='m-0 h-auto min-h-[24px] w-full resize-none overflow-y-auto overflow-x-hidden border-0 bg-transparent p-1 text-[15px] text-[var(--text-primary)] leading-[24px] caret-[var(--text-primary)] outline-none [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-[var(--text-muted)] focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden'
|
||||
/>
|
||||
|
||||
{/* Bottom row */}
|
||||
<div className='flex items-center justify-between'>
|
||||
{/* Left: attach */}
|
||||
<div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='quiet'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isStreaming || attachedFiles.length >= 15}
|
||||
className='size-[28px] rounded-full p-0'
|
||||
>
|
||||
<Paperclip className='size-[16px]' strokeWidth={2} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Attach files</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
multiple
|
||||
accept={CHAT_ACCEPT_ATTRIBUTE}
|
||||
onChange={(e) => {
|
||||
handleFileSelect(e.target.files)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}}
|
||||
className='hidden'
|
||||
disabled={isStreaming}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: mic + send */}
|
||||
<div className='flex items-center gap-1.5'>
|
||||
{sttAvailable && (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='quiet'
|
||||
onClick={onVoiceStart}
|
||||
disabled={isStreaming}
|
||||
className='size-[28px] rounded-full p-0'
|
||||
>
|
||||
<Mic className='size-[16px]' strokeWidth={2} />
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top'>
|
||||
<p>Start voice conversation</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
|
||||
{isStreaming ? (
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={onStopStreaming}
|
||||
className='size-[28px] rounded-full p-0'
|
||||
title='Stop generation'
|
||||
>
|
||||
<svg
|
||||
className='block size-[14px] fill-current'
|
||||
viewBox='0 0 24 24'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<rect x='4' y='4' width='16' height='16' rx='3' ry='3' />
|
||||
</svg>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className='size-[28px] rounded-full p-0'
|
||||
>
|
||||
<ArrowUp className='block size-[16px]' strokeWidth={2.25} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
import { domAnimation, LazyMotion, m } from 'framer-motion'
|
||||
import { Mic } from 'lucide-react'
|
||||
|
||||
interface VoiceInputProps {
|
||||
onVoiceStart: () => void
|
||||
isListening?: boolean
|
||||
disabled?: boolean
|
||||
large?: boolean
|
||||
minimal?: boolean
|
||||
}
|
||||
|
||||
export function VoiceInput({
|
||||
onVoiceStart,
|
||||
isListening = false,
|
||||
disabled = false,
|
||||
large = false,
|
||||
minimal = false,
|
||||
}: VoiceInputProps) {
|
||||
const handleVoiceClick = useCallback(() => {
|
||||
if (disabled) return
|
||||
onVoiceStart()
|
||||
}, [disabled, onVoiceStart])
|
||||
|
||||
if (minimal) {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
onClick={handleVoiceClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-center rounded-full p-1.5 text-gray-600 transition-colors duration-200 hover:bg-gray-100 md:p-2 ${
|
||||
disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
|
||||
}`}
|
||||
title='Start voice conversation'
|
||||
>
|
||||
<Mic size={16} className='md:h-5 md:w-5' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (large) {
|
||||
return (
|
||||
<div className='flex flex-col items-center'>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.button
|
||||
type='button'
|
||||
onClick={handleVoiceClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-center rounded-full border-2 p-6 transition-all duration-200 ${
|
||||
isListening
|
||||
? 'border-red-400 bg-red-500/20 text-red-600 hover:bg-red-500/30'
|
||||
: 'border-blue-300 bg-blue-500/10 text-blue-600 hover:bg-blue-500/20'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title='Start voice conversation'
|
||||
>
|
||||
<Mic size={32} />
|
||||
</m.button>
|
||||
</LazyMotion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex items-center'>
|
||||
<LazyMotion features={domAnimation}>
|
||||
<m.button
|
||||
type='button'
|
||||
onClick={handleVoiceClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center justify-center rounded-full p-2.5 transition-all duration-200 md:p-3 ${
|
||||
isListening
|
||||
? 'bg-red-500 text-white hover:bg-red-600'
|
||||
: 'bg-black text-white hover:bg-zinc-700'
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title='Start voice conversation'
|
||||
>
|
||||
<Mic size={16} className='md:hidden' />
|
||||
<Mic size={18} className='hidden md:block' />
|
||||
</m.button>
|
||||
</LazyMotion>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export function ChatLoadingState() {
|
||||
return (
|
||||
<div className='light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)]'>
|
||||
<div className='flex flex-1 items-center justify-center px-4'>
|
||||
<div className='w-full max-w-[410px]'>
|
||||
<div className='flex flex-col items-center justify-center'>
|
||||
{/* Title skeleton */}
|
||||
<div className='space-y-2 text-center'>
|
||||
<Skeleton className='mx-auto h-8 w-32' />
|
||||
<Skeleton className='mx-auto h-4 w-48' />
|
||||
</div>
|
||||
|
||||
{/* Chat skeleton */}
|
||||
<div className='mt-8 w-full space-y-8'>
|
||||
<div className='space-y-2'>
|
||||
<Skeleton className='h-4 w-16' />
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-full rounded-[10px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
'use client'
|
||||
|
||||
import { memo, type RefObject } from 'react'
|
||||
import { Button } from '@sim/emcn'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import {
|
||||
type ChatMessage,
|
||||
ClientChatMessage,
|
||||
} from '@/app/(interfaces)/chat/components/message/message'
|
||||
|
||||
interface ChatMessageContainerProps {
|
||||
messages: ChatMessage[]
|
||||
isLoading: boolean
|
||||
showScrollButton: boolean
|
||||
messagesContainerRef: RefObject<HTMLDivElement>
|
||||
messagesEndRef: RefObject<HTMLDivElement>
|
||||
scrollToBottom: () => void
|
||||
scrollToMessage?: (messageId: string) => void
|
||||
chatConfig: {
|
||||
description?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export const ChatMessageContainer = memo(function ChatMessageContainer({
|
||||
messages,
|
||||
isLoading,
|
||||
showScrollButton,
|
||||
messagesContainerRef,
|
||||
messagesEndRef,
|
||||
scrollToBottom,
|
||||
scrollToMessage,
|
||||
chatConfig,
|
||||
}: ChatMessageContainerProps) {
|
||||
return (
|
||||
<div className='relative flex flex-1 flex-col overflow-hidden'>
|
||||
{/* Scrollable Messages Area */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
className='absolute inset-0 touch-pan-y overflow-y-auto overscroll-auto scroll-smooth'
|
||||
>
|
||||
<div className='mx-auto max-w-3xl px-4 pt-10 pb-20'>
|
||||
{messages.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center py-10'>
|
||||
<div className='space-y-2 text-center'>
|
||||
<h3 className='font-medium text-[var(--text-primary)] text-lg'>
|
||||
How can I help you today?
|
||||
</h3>
|
||||
<p className='text-[var(--text-muted)] text-sm'>
|
||||
{chatConfig?.description || 'Ask me anything.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => <ClientChatMessage key={message.id} message={message} />)
|
||||
)}
|
||||
|
||||
{/* Loading indicator (shows only when executing) */}
|
||||
{isLoading && (
|
||||
<div className='px-4 py-5'>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='flex'>
|
||||
<div className='max-w-[80%]'>
|
||||
<div className='flex h-6 items-center'>
|
||||
<div className='loading-dot size-3 rounded-full bg-[var(--text-primary)]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* End of messages marker for scrolling */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button - appears when user scrolls up */}
|
||||
{showScrollButton && (
|
||||
<div className='-translate-x-1/2 absolute bottom-16 left-1/2 z-20 transform'>
|
||||
<Button
|
||||
onClick={scrollToBottom}
|
||||
size='sm'
|
||||
className='gap-1 rounded-full px-3 py-1 shadow-lg'
|
||||
>
|
||||
<ArrowDown className='size-3.5' />
|
||||
<span className='sr-only'>Scroll to bottom</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button, cn, Download, Loader } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { Music } from 'lucide-react'
|
||||
import { DefaultFileIcon, getDocumentIcon } from '@/components/icons/document-icons'
|
||||
import { isSafeHttpUrl } from '@/lib/core/utils/urls'
|
||||
import type { ChatFile } from '@/app/(interfaces)/chat/components/message/message'
|
||||
|
||||
const logger = createLogger('ChatFileDownload')
|
||||
|
||||
interface ChatFileDownloadProps {
|
||||
file: ChatFile
|
||||
}
|
||||
|
||||
interface ChatFileDownloadAllProps {
|
||||
files: ChatFile[]
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function isAudioFile(mimeType: string, filename: string): boolean {
|
||||
const audioMimeTypes = [
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/mp3',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
'audio/aac',
|
||||
'audio/flac',
|
||||
]
|
||||
const audioExtensions = ['mp3', 'wav', 'ogg', 'webm', 'aac', 'flac', 'm4a']
|
||||
const extension = filename.split('.').pop()?.toLowerCase()
|
||||
|
||||
return (
|
||||
audioMimeTypes.some((t) => mimeType.includes(t)) ||
|
||||
(extension ? audioExtensions.includes(extension) : false)
|
||||
)
|
||||
}
|
||||
|
||||
function isImageFile(mimeType: string): boolean {
|
||||
return mimeType.startsWith('image/')
|
||||
}
|
||||
|
||||
function getFileUrl(file: ChatFile): string {
|
||||
return `/api/files/serve/${encodeURIComponent(file.key)}?context=${file.context || 'execution'}`
|
||||
}
|
||||
|
||||
async function triggerDownload(url: string, filename: string): Promise<void> {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch file: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
logger.info(`Downloaded: ${filename}`)
|
||||
}
|
||||
|
||||
export function ChatFileDownload({ file }: ChatFileDownloadProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (isDownloading) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
logger.info(`Initiating download for file: ${file.name}`)
|
||||
const url = getFileUrl(file)
|
||||
await triggerDownload(url, file.name)
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
if (file.url && isSafeHttpUrl(file.url)) {
|
||||
window.open(file.url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isAudioFile(file.type, file.name)) {
|
||||
return <Music className='size-4 text-purple-500' />
|
||||
}
|
||||
if (isImageFile(file.type)) {
|
||||
const ImageIcon = DefaultFileIcon
|
||||
return <ImageIcon className='size-5' />
|
||||
}
|
||||
const DocumentIcon = getDocumentIcon(file.type, file.name)
|
||||
return <DocumentIcon className='size-5' />
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='default'
|
||||
onClick={handleDownload}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
disabled={isDownloading}
|
||||
className='flex h-auto w-[200px] items-center gap-2 rounded-lg px-3 py-2'
|
||||
>
|
||||
<div className='flex size-8 flex-shrink-0 items-center justify-center'>{renderIcon()}</div>
|
||||
<div className='min-w-0 flex-1 text-left'>
|
||||
<div className='w-[100px] truncate text-xs'>{file.name}</div>
|
||||
<div className='text-[var(--text-muted)] text-micro'>{formatFileSize(file.size)}</div>
|
||||
</div>
|
||||
<div className='flex-shrink-0'>
|
||||
{isDownloading ? (
|
||||
<Loader className='size-3.5' animate />
|
||||
) : (
|
||||
<Download
|
||||
className={cn('size-3.5 transition-opacity', isHovered ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatFileDownloadAll({ files }: ChatFileDownloadAllProps) {
|
||||
const [isDownloading, setIsDownloading] = useState(false)
|
||||
|
||||
if (!files || files.length === 0) return null
|
||||
|
||||
const handleDownloadAll = async () => {
|
||||
if (isDownloading) return
|
||||
|
||||
setIsDownloading(true)
|
||||
|
||||
try {
|
||||
logger.info(`Initiating download for ${files.length} files`)
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
try {
|
||||
const url = getFileUrl(file)
|
||||
await triggerDownload(url, file.name)
|
||||
logger.info(`Downloaded file ${i + 1}/${files.length}: ${file.name}`)
|
||||
|
||||
if (i < files.length - 1) {
|
||||
await sleep(150)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Failed to download file ${file.name}:`, error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='ghost-secondary'
|
||||
onClick={handleDownloadAll}
|
||||
disabled={isDownloading}
|
||||
className='p-0'
|
||||
>
|
||||
{isDownloading ? (
|
||||
<Loader className='size-3' animate />
|
||||
) : (
|
||||
<Download className='size-3' strokeWidth={2} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { type HTMLAttributes, memo, type ReactNode } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { CopyCodeButton, Tooltip } from '@sim/emcn'
|
||||
import { extractTextContent } from '@/lib/core/utils/react-node-text'
|
||||
|
||||
function LinkWithPreview({ href, children }: { href: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<a
|
||||
href={href}
|
||||
className='text-[var(--text-primary)] underline decoration-dashed underline-offset-4'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5} className='max-w-sm'>
|
||||
<span className='truncate font-medium text-xs'>{href}</span>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)
|
||||
}
|
||||
|
||||
const COMPONENTS = {
|
||||
p: ({ children }: React.HTMLAttributes<HTMLParagraphElement>) => (
|
||||
<p className='mb-1 font-sans text-[var(--text-primary)] text-base leading-relaxed last:mb-0'>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
|
||||
h1: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h1 className='mt-10 mb-5 font-sans font-semibold text-2xl text-[var(--text-primary)]'>
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h2 className='mt-8 mb-4 font-sans font-semibold text-[var(--text-primary)] text-xl'>
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h3 className='mt-7 mb-3 font-sans font-semibold text-[var(--text-primary)] text-lg'>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }: React.HTMLAttributes<HTMLHeadingElement>) => (
|
||||
<h4 className='mt-5 mb-2 font-sans font-semibold text-[var(--text-primary)] text-base'>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
|
||||
ul: ({ children }: React.HTMLAttributes<HTMLUListElement>) => (
|
||||
<ul
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-[var(--text-primary)]'
|
||||
style={{ listStyleType: 'disc' }}
|
||||
>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }: React.HTMLAttributes<HTMLOListElement>) => (
|
||||
<ol
|
||||
className='mt-1 mb-1 space-y-1 pl-6 font-sans text-[var(--text-primary)]'
|
||||
style={{ listStyleType: 'decimal' }}
|
||||
>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }: React.LiHTMLAttributes<HTMLLIElement>) => (
|
||||
<li className='font-sans text-[var(--text-primary)]' style={{ display: 'list-item' }}>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
|
||||
pre: ({ children }: HTMLAttributes<HTMLPreElement>) => {
|
||||
let codeProps: HTMLAttributes<HTMLElement> = {}
|
||||
let codeContent: ReactNode = children
|
||||
|
||||
if (
|
||||
React.isValidElement<{ className?: string; children?: ReactNode }>(children) &&
|
||||
children.type === 'code'
|
||||
) {
|
||||
const childElement = children as React.ReactElement<{
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}>
|
||||
codeProps = { className: childElement.props.className }
|
||||
codeContent = childElement.props.children
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='my-6 overflow-hidden rounded-lg border border-[var(--divider)] text-sm'>
|
||||
<div className='flex items-center justify-between border-[var(--divider)] border-b bg-[var(--surface-4)] px-4 py-1.5'>
|
||||
<span className='font-sans text-[var(--text-tertiary)] text-xs'>
|
||||
{codeProps.className?.replace('language-', '') || 'code'}
|
||||
</span>
|
||||
<CopyCodeButton
|
||||
code={extractTextContent(codeContent)}
|
||||
className='text-[var(--text-tertiary)] hover-hover:bg-[var(--surface-5)] hover-hover:text-[var(--text-secondary)]'
|
||||
/>
|
||||
</div>
|
||||
<pre className='overflow-x-auto bg-[var(--surface-5)] p-4 font-mono text-[var(--text-primary)]'>
|
||||
{codeContent}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
inlineCode: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className='rounded bg-[var(--surface-5)] px-1 py-0.5 font-mono text-[var(--text-primary)] text-inherit'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
|
||||
blockquote: ({ children }: React.HTMLAttributes<HTMLQuoteElement>) => (
|
||||
<blockquote className='my-4 break-words border-[var(--divider)] border-l-2 pl-4 font-sans text-[var(--text-primary)] italic [&>p:first-child]:mt-0 [&>p:last-child]:mb-0 [&>p]:my-2'>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
|
||||
hr: () => <hr className='my-8 border-[var(--divider)] border-t' />,
|
||||
|
||||
a: ({ href, children, ...props }: React.AnchorHTMLAttributes<HTMLAnchorElement>) => (
|
||||
<LinkWithPreview href={href || '#'} {...props}>
|
||||
{children}
|
||||
</LinkWithPreview>
|
||||
),
|
||||
|
||||
table: ({ children }: React.TableHTMLAttributes<HTMLTableElement>) => (
|
||||
<div className='my-4 w-full overflow-x-auto'>
|
||||
<table className='min-w-full table-auto border border-[var(--border)] font-sans text-sm'>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<thead className='bg-[var(--surface-3)] text-left'>{children}</thead>
|
||||
),
|
||||
tbody: ({ children }: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||
<tbody className='divide-y divide-[var(--divider)] bg-[var(--surface-2)]'>{children}</tbody>
|
||||
),
|
||||
tr: ({ children }: React.HTMLAttributes<HTMLTableRowElement>) => (
|
||||
<tr className='border-[var(--divider)] border-b transition-colors hover:bg-[var(--surface-hover)]'>
|
||||
{children}
|
||||
</tr>
|
||||
),
|
||||
th: ({ children }: React.ThHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<th className='border-[var(--border)] border-r px-4 py-2 font-medium text-[var(--text-secondary)] last:border-r-0'>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }: React.TdHTMLAttributes<HTMLTableCellElement>) => (
|
||||
<td className='break-words border-[var(--border)] border-r px-4 py-2 text-[var(--text-body)] last:border-r-0'>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
|
||||
img: ({ src, alt, ...props }: React.ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
<img src={src} alt={alt || 'Image'} className='my-3 h-auto max-w-full rounded-md' {...props} />
|
||||
),
|
||||
}
|
||||
|
||||
const MarkdownRenderer = memo(function MarkdownRenderer({ content }: { content: string }) {
|
||||
return (
|
||||
<div className='space-y-4 break-words font-sans text-[var(--text-primary)] text-base leading-relaxed'>
|
||||
<Streamdown mode='static' components={COMPONENTS}>
|
||||
{content.trim()}
|
||||
</Streamdown>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default MarkdownRenderer
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@sim/emcn', () => ({
|
||||
Duplicate: () => null,
|
||||
Tooltip: {},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', () => ({
|
||||
ChatFileDownload: () => null,
|
||||
ChatFileDownloadAll: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/(interfaces)/chat/components/message/components/markdown-renderer', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
import { escapeHtml } from '@/app/(interfaces)/chat/components/message/message'
|
||||
|
||||
describe('escapeHtml', () => {
|
||||
it('escapes all five HTML-significant characters', () => {
|
||||
expect(escapeHtml('&<>"\'')).toBe('&<>"'')
|
||||
})
|
||||
|
||||
it('neutralizes a markup-breakout filename payload', () => {
|
||||
const payload = '</title><img src=x onerror=alert(document.origin)>'
|
||||
const escaped = escapeHtml(payload)
|
||||
expect(escaped).not.toContain('<img')
|
||||
expect(escaped).not.toContain('</title>')
|
||||
expect(escaped).toBe('</title><img src=x onerror=alert(document.origin)>')
|
||||
})
|
||||
|
||||
it('escapes ampersands first so entities are not double-broken', () => {
|
||||
expect(escapeHtml('a & b < c')).toBe('a & b < c')
|
||||
})
|
||||
|
||||
it('leaves safe strings untouched', () => {
|
||||
expect(escapeHtml('report-2026.pdf')).toBe('report-2026.pdf')
|
||||
expect(escapeHtml('')).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,282 @@
|
||||
'use client'
|
||||
|
||||
import { memo, useState } from 'react'
|
||||
import { Button, cn, Duplicate, Tooltip } from '@sim/emcn'
|
||||
import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react'
|
||||
import {
|
||||
ChatFileDownload,
|
||||
ChatFileDownloadAll,
|
||||
} from '@/app/(interfaces)/chat/components/message/components/file-download'
|
||||
import MarkdownRenderer from '@/app/(interfaces)/chat/components/message/components/markdown-renderer'
|
||||
|
||||
export interface ChatAttachment {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
dataUrl: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface ChatFile {
|
||||
id: string
|
||||
name: string
|
||||
url: string
|
||||
key: string
|
||||
size: number
|
||||
type: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string
|
||||
content: string | Record<string, unknown>
|
||||
type: 'user' | 'assistant'
|
||||
timestamp: Date
|
||||
isInitialMessage?: boolean
|
||||
isStreaming?: boolean
|
||||
attachments?: ChatAttachment[]
|
||||
files?: ChatFile[]
|
||||
}
|
||||
|
||||
const HTML_ESCAPES: Record<string, string> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Escapes HTML entities so untrusted strings are safe to interpolate into markup.
|
||||
*/
|
||||
export function escapeHtml(value: string): string {
|
||||
return value.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c] || c)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens an image attachment preview in a new tab via a blob URL,
|
||||
* escaping the user-controlled filename and data URL to prevent XSS.
|
||||
*/
|
||||
function openAttachmentPreview(name: string, dataUrl: string): void {
|
||||
const safeName = escapeHtml(name)
|
||||
const safeUrl = escapeHtml(dataUrl)
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>${safeName}</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: #000; }
|
||||
img { max-width: 100%; max-height: 100vh; object-fit: contain; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${safeUrl}" alt="${safeName}" />
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
const blob = new Blob([html], { type: 'text/html' })
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
window.open(blobUrl, '_blank', 'noopener,noreferrer')
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000)
|
||||
}
|
||||
|
||||
export const ClientChatMessage = memo(
|
||||
function ClientChatMessage({ message }: { message: ChatMessage }) {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
|
||||
const isJsonObject = typeof message.content === 'object' && message.content !== null
|
||||
|
||||
// Since tool calls are now handled via SSE events and stored in message.toolCalls,
|
||||
// we can use the content directly without parsing
|
||||
const cleanTextContent = message.content
|
||||
|
||||
const content =
|
||||
message.type === 'user' ? (
|
||||
<div className='px-4 py-5' data-message-id={message.id}>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
{/* File attachments displayed above the message */}
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<div className='mb-2 flex justify-end'>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{message.attachments.map((attachment) => {
|
||||
const isImage = attachment.type.startsWith('image/')
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.includes('pdf'))
|
||||
return <FileText className='size-5 text-[var(--text-muted)] md:size-6' />
|
||||
if (type.startsWith('image/'))
|
||||
return <ImageIcon className='size-5 text-[var(--text-muted)] md:size-6' />
|
||||
if (type.includes('text') || type.includes('json'))
|
||||
return <FileText className='size-5 text-[var(--text-muted)] md:size-6' />
|
||||
return <FileIcon className='size-5 text-[var(--text-muted)] md:size-6' />
|
||||
}
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes || bytes === 0) return ''
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${Math.round((bytes / k ** i) * 10) / 10} ${sizes[i]}`
|
||||
}
|
||||
|
||||
const isInteractive =
|
||||
!!attachment.dataUrl?.trim() && attachment.dataUrl.startsWith('data:')
|
||||
|
||||
const handleOpenPreview = () => {
|
||||
const validDataUrl = attachment.dataUrl?.trim()
|
||||
if (!validDataUrl?.startsWith('data:')) return
|
||||
openAttachmentPreview(attachment.name, validDataUrl)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attachment.id}
|
||||
role={isInteractive ? 'button' : undefined}
|
||||
aria-disabled={!isInteractive}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-2xl border border-[var(--border-1)] bg-[var(--surface-2)]',
|
||||
isInteractive && 'cursor-pointer',
|
||||
isImage
|
||||
? 'size-16 md:size-20'
|
||||
: 'flex h-16 min-w-[140px] max-w-[220px] items-center gap-2 px-3 md:h-20 md:min-w-[160px] md:max-w-[240px]'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (!isInteractive) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleOpenPreview()
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!isInteractive) return
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleOpenPreview()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isImage &&
|
||||
attachment.dataUrl?.trim() &&
|
||||
attachment.dataUrl.startsWith('data:') ? (
|
||||
<img
|
||||
src={attachment.dataUrl}
|
||||
alt={attachment.name}
|
||||
className='size-full object-cover'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className='flex size-10 flex-shrink-0 items-center justify-center rounded bg-[var(--surface-3)] md:size-12'>
|
||||
{getFileIcon(attachment.type)}
|
||||
</div>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-[var(--text-primary)] text-xs md:text-sm'>
|
||||
{attachment.name}
|
||||
</div>
|
||||
{attachment.size && (
|
||||
<div className='text-[var(--text-muted)] text-micro md:text-xs'>
|
||||
{formatFileSize(attachment.size)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only render message bubble if there's actual text content (not just file count message) */}
|
||||
{message.content && !String(message.content).startsWith('Sent') && (
|
||||
<div className='flex justify-end'>
|
||||
<div className='max-w-[80%] rounded-3xl bg-[var(--surface-3)] px-4 py-3'>
|
||||
<div className='whitespace-pre-wrap break-words text-[var(--text-primary)] text-base leading-relaxed'>
|
||||
{isJsonObject ? (
|
||||
<pre>{JSON.stringify(message.content, null, 2)}</pre>
|
||||
) : (
|
||||
<span>{message.content as string}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='px-4 pt-5 pb-2' data-message-id={message.id}>
|
||||
<div className='mx-auto max-w-3xl'>
|
||||
<div className='flex flex-col space-y-3'>
|
||||
{/* Direct content rendering - tool calls are now handled via SSE events */}
|
||||
<div>
|
||||
<div className='break-words text-base'>
|
||||
{isJsonObject ? (
|
||||
<pre className='text-[var(--text-primary)]'>
|
||||
{JSON.stringify(cleanTextContent, null, 2)}
|
||||
</pre>
|
||||
) : (
|
||||
<MarkdownRenderer content={cleanTextContent as string} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{message.files && message.files.length > 0 && (
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
{message.files.map((file) => (
|
||||
<ChatFileDownload key={file.id} file={file} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.type === 'assistant' && !isJsonObject && !message.isInitialMessage && (
|
||||
<div className='flex items-center justify-start space-x-2'>
|
||||
{/* Copy Button - Only show when not streaming */}
|
||||
{!message.isStreaming && (
|
||||
<Tooltip.Root delayDuration={300}>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='ghost-secondary'
|
||||
className='p-0'
|
||||
onClick={() => {
|
||||
const contentToCopy =
|
||||
typeof cleanTextContent === 'string'
|
||||
? cleanTextContent
|
||||
: JSON.stringify(cleanTextContent, null, 2)
|
||||
navigator.clipboard.writeText(contentToCopy)
|
||||
setIsCopied(true)
|
||||
setTimeout(() => setIsCopied(false), 2000)
|
||||
}}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className='size-3' strokeWidth={2} />
|
||||
) : (
|
||||
<Duplicate className='size-3' />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side='top' align='center' sideOffset={5}>
|
||||
{isCopied ? 'Copied!' : 'Copy to clipboard'}
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
)}
|
||||
{/* Download All Button - Only show when there are files */}
|
||||
{!message.isStreaming && message.files && (
|
||||
<ChatFileDownloadAll files={message.files} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return <Tooltip.Provider>{content}</Tooltip.Provider>
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.message.content === nextProps.message.content &&
|
||||
prevProps.message.isStreaming === nextProps.message.isStreaming &&
|
||||
prevProps.message.isInitialMessage === nextProps.message.isInitialMessage &&
|
||||
prevProps.message.files?.length === nextProps.message.files?.length
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,503 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import * as THREE from 'three'
|
||||
|
||||
const logger = createLogger('Particles')
|
||||
|
||||
interface ShaderUniforms {
|
||||
u_time: { type: string; value: number }
|
||||
u_frequency: { type: string; value: number }
|
||||
u_red: { type: string; value: number }
|
||||
u_green: { type: string; value: number }
|
||||
u_blue: { type: string; value: number }
|
||||
}
|
||||
|
||||
interface ParticlesProps {
|
||||
audioLevels: number[]
|
||||
isListening: boolean
|
||||
isPlayingAudio: boolean
|
||||
isStreaming: boolean
|
||||
isMuted: boolean
|
||||
isProcessingInterruption?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
class SimpleBloomComposer {
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private scene: THREE.Scene
|
||||
private camera: THREE.Camera
|
||||
private bloomScene: THREE.Scene
|
||||
private bloomMaterial: THREE.ShaderMaterial
|
||||
private renderTarget: THREE.WebGLRenderTarget
|
||||
private quad: THREE.Mesh
|
||||
|
||||
constructor(renderer: THREE.WebGLRenderer, scene: THREE.Scene, camera: THREE.Camera) {
|
||||
this.renderer = renderer
|
||||
this.scene = scene
|
||||
this.camera = camera
|
||||
|
||||
this.bloomScene = new THREE.Scene()
|
||||
|
||||
this.renderTarget = new THREE.WebGLRenderTarget(
|
||||
renderer.domElement.width,
|
||||
renderer.domElement.height,
|
||||
{
|
||||
minFilter: THREE.LinearFilter,
|
||||
magFilter: THREE.LinearFilter,
|
||||
format: THREE.RGBAFormat,
|
||||
}
|
||||
)
|
||||
|
||||
this.bloomMaterial = new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
tDiffuse: { value: null },
|
||||
strength: { value: 1.5 },
|
||||
threshold: { value: 0.3 },
|
||||
radius: { value: 0.8 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying vec2 vUv;
|
||||
void main() {
|
||||
vUv = uv;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`,
|
||||
fragmentShader: `
|
||||
uniform sampler2D tDiffuse;
|
||||
uniform float strength;
|
||||
uniform float threshold;
|
||||
uniform float radius;
|
||||
varying vec2 vUv;
|
||||
|
||||
void main() {
|
||||
vec4 color = texture2D(tDiffuse, vUv);
|
||||
|
||||
// Simple bloom effect
|
||||
float brightness = dot(color.rgb, vec3(0.299, 0.587, 0.114));
|
||||
if (brightness > threshold) {
|
||||
color.rgb *= strength;
|
||||
}
|
||||
|
||||
gl_FragColor = color;
|
||||
}
|
||||
`,
|
||||
})
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(2, 2)
|
||||
this.quad = new THREE.Mesh(geometry, this.bloomMaterial)
|
||||
this.bloomScene.add(this.quad)
|
||||
}
|
||||
|
||||
render() {
|
||||
this.renderer.setRenderTarget(this.renderTarget)
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
this.bloomMaterial.uniforms.tDiffuse.value = this.renderTarget.texture
|
||||
this.renderer.setRenderTarget(null)
|
||||
this.renderer.render(this.bloomScene, new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1))
|
||||
}
|
||||
|
||||
setSize(width: number, height: number) {
|
||||
this.renderTarget.setSize(width, height)
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.renderTarget.dispose()
|
||||
this.bloomMaterial.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
const vertexShader = `
|
||||
vec3 mod289(vec3 x)
|
||||
{
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec4 mod289(vec4 x)
|
||||
{
|
||||
return x - floor(x * (1.0 / 289.0)) * 289.0;
|
||||
}
|
||||
|
||||
vec4 permute(vec4 x)
|
||||
{
|
||||
return mod289(((x*34.0)+10.0)*x);
|
||||
}
|
||||
|
||||
vec4 taylorInvSqrt(vec4 r)
|
||||
{
|
||||
return 1.79284291400159 - 0.85373472095314 * r;
|
||||
}
|
||||
|
||||
vec3 fade(vec3 t) {
|
||||
return t*t*t*(t*(t*6.0-15.0)+10.0);
|
||||
}
|
||||
|
||||
float pnoise(vec3 P, vec3 rep)
|
||||
{
|
||||
vec3 Pi0 = mod(floor(P), rep); // Integer part, modulo period
|
||||
vec3 Pi1 = mod(Pi0 + vec3(1.0), rep); // Integer part + 1, mod period
|
||||
Pi0 = mod289(Pi0);
|
||||
Pi1 = mod289(Pi1);
|
||||
vec3 Pf0 = fract(P); // Fractional part for interpolation
|
||||
vec3 Pf1 = Pf0 - vec3(1.0); // Fractional part - 1.0
|
||||
vec4 ix = vec4(Pi0.x, Pi1.x, Pi0.x, Pi1.x);
|
||||
vec4 iy = vec4(Pi0.yy, Pi1.yy);
|
||||
vec4 iz0 = Pi0.zzzz;
|
||||
vec4 iz1 = Pi1.zzzz;
|
||||
|
||||
vec4 ixy = permute(permute(ix) + iy);
|
||||
vec4 ixy0 = permute(ixy + iz0);
|
||||
vec4 ixy1 = permute(ixy + iz1);
|
||||
|
||||
vec4 gx0 = ixy0 * (1.0 / 7.0);
|
||||
vec4 gy0 = fract(floor(gx0) * (1.0 / 7.0)) - 0.5;
|
||||
gx0 = fract(gx0);
|
||||
vec4 gz0 = vec4(0.5) - abs(gx0) - abs(gy0);
|
||||
vec4 sz0 = step(gz0, vec4(0.0));
|
||||
gx0 -= sz0 * (step(0.0, gx0) - 0.5);
|
||||
gy0 -= sz0 * (step(0.0, gy0) - 0.5);
|
||||
|
||||
vec4 gx1 = ixy1 * (1.0 / 7.0);
|
||||
vec4 gy1 = fract(floor(gx1) * (1.0 / 7.0)) - 0.5;
|
||||
gx1 = fract(gx1);
|
||||
vec4 gz1 = vec4(0.5) - abs(gx1) - abs(gy1);
|
||||
vec4 sz1 = step(gz1, vec4(0.0));
|
||||
gx1 -= sz1 * (step(0.0, gx1) - 0.5);
|
||||
gy1 -= sz1 * (step(0.0, gy1) - 0.5);
|
||||
|
||||
vec3 g000 = vec3(gx0.x,gy0.x,gz0.x);
|
||||
vec3 g100 = vec3(gx0.y,gy0.y,gz0.y);
|
||||
vec3 g010 = vec3(gx0.z,gy0.z,gz0.z);
|
||||
vec3 g110 = vec3(gx0.w,gy0.w,gz0.w);
|
||||
vec3 g001 = vec3(gx1.x,gy1.x,gz1.x);
|
||||
vec3 g101 = vec3(gx1.y,gy1.y,gz1.y);
|
||||
vec3 g011 = vec3(gx1.z,gy1.z,gz1.z);
|
||||
vec3 g111 = vec3(gx1.w,gy1.w,gz1.w);
|
||||
|
||||
vec4 norm0 = taylorInvSqrt(vec4(dot(g000, g000), dot(g010, g010), dot(g100, g100), dot(g110, g110)));
|
||||
g000 *= norm0.x;
|
||||
g010 *= norm0.y;
|
||||
g100 *= norm0.z;
|
||||
g110 *= norm0.w;
|
||||
vec4 norm1 = taylorInvSqrt(vec4(dot(g001, g001), dot(g011, g011), dot(g101, g101), dot(g111, g111)));
|
||||
g001 *= norm1.x;
|
||||
g011 *= norm1.y;
|
||||
g101 *= norm1.z;
|
||||
g111 *= norm1.w;
|
||||
|
||||
float n000 = dot(g000, Pf0);
|
||||
float n100 = dot(g100, vec3(Pf1.x, Pf0.yz));
|
||||
float n010 = dot(g010, vec3(Pf0.x, Pf1.y, Pf0.z));
|
||||
float n110 = dot(g110, vec3(Pf1.xy, Pf0.z));
|
||||
float n001 = dot(g001, vec3(Pf0.xy, Pf1.z));
|
||||
float n101 = dot(g101, vec3(Pf1.x, Pf0.y, Pf1.z));
|
||||
float n011 = dot(g011, vec3(Pf0.x, Pf1.yz));
|
||||
float n111 = dot(g111, Pf1);
|
||||
|
||||
vec3 fade_xyz = fade(Pf0);
|
||||
vec4 n_z = mix(vec4(n000, n100, n010, n110), vec4(n001, n101, n011, n111), fade_xyz.z);
|
||||
vec2 n_yz = mix(n_z.xy, n_z.zw, fade_xyz.y);
|
||||
float n_xyz = mix(n_yz.x, n_yz.y, fade_xyz.x);
|
||||
return 2.2 * n_xyz;
|
||||
}
|
||||
|
||||
uniform float u_time;
|
||||
uniform float u_frequency;
|
||||
|
||||
void main() {
|
||||
float noise = 5. * pnoise(position + u_time, vec3(10.));
|
||||
|
||||
float displacement = (u_frequency / 30.) * (noise / 10.);
|
||||
|
||||
vec3 newPosition = position + normal * displacement;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
const fragmentShader = `
|
||||
uniform float u_red;
|
||||
uniform float u_blue;
|
||||
uniform float u_green;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = vec4(vec3(u_red, u_green, u_blue), 1.0);
|
||||
}
|
||||
`
|
||||
|
||||
export function ParticlesVisualization({
|
||||
audioLevels,
|
||||
isListening,
|
||||
isPlayingAudio,
|
||||
isStreaming,
|
||||
isMuted,
|
||||
isProcessingInterruption,
|
||||
className,
|
||||
}: ParticlesProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
|
||||
const sceneRef = useRef<THREE.Scene | null>(null)
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null)
|
||||
const meshRef = useRef<THREE.Mesh | null>(null)
|
||||
const uniformsRef = useRef<ShaderUniforms | null>(null)
|
||||
const clockRef = useRef<THREE.Clock | null>(null)
|
||||
const bloomComposerRef = useRef<SimpleBloomComposer | null>(null)
|
||||
const animationFrameRef = useRef<number>(0)
|
||||
const mouseRef = useRef({ x: 0, y: 0 })
|
||||
const isInitializedRef = useRef(false)
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = 0
|
||||
}
|
||||
|
||||
if (bloomComposerRef.current) {
|
||||
bloomComposerRef.current.dispose()
|
||||
bloomComposerRef.current = null
|
||||
}
|
||||
|
||||
if (rendererRef.current) {
|
||||
if (rendererRef.current.domElement?.parentNode) {
|
||||
rendererRef.current.domElement.parentNode.removeChild(rendererRef.current.domElement)
|
||||
}
|
||||
rendererRef.current.dispose()
|
||||
rendererRef.current = null
|
||||
}
|
||||
|
||||
sceneRef.current = null
|
||||
cameraRef.current = null
|
||||
meshRef.current = null
|
||||
uniformsRef.current = null
|
||||
clockRef.current = null
|
||||
isInitializedRef.current = false
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || isInitializedRef.current) return
|
||||
|
||||
const container = containerRef.current
|
||||
const containerWidth = 400
|
||||
const containerHeight = 400
|
||||
|
||||
isInitializedRef.current = true
|
||||
|
||||
while (container.firstChild) {
|
||||
container.removeChild(container.firstChild)
|
||||
}
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
|
||||
renderer.setSize(containerWidth, containerHeight)
|
||||
renderer.setClearColor(0x000000, 0)
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
container.appendChild(renderer.domElement)
|
||||
rendererRef.current = renderer
|
||||
|
||||
const scene = new THREE.Scene()
|
||||
sceneRef.current = scene
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(45, containerWidth / containerHeight, 0.1, 1000)
|
||||
camera.position.set(0, -2, 14)
|
||||
camera.lookAt(0, 0, 0)
|
||||
cameraRef.current = camera
|
||||
|
||||
const uniforms = {
|
||||
u_time: { type: 'f', value: 0.0 },
|
||||
u_frequency: { type: 'f', value: 0.0 },
|
||||
u_red: { type: 'f', value: 0.8 },
|
||||
u_green: { type: 'f', value: 0.6 },
|
||||
u_blue: { type: 'f', value: 1.0 },
|
||||
}
|
||||
uniformsRef.current = uniforms
|
||||
|
||||
let mat: THREE.Material
|
||||
try {
|
||||
mat = new THREE.ShaderMaterial({
|
||||
uniforms,
|
||||
vertexShader,
|
||||
fragmentShader,
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('❌ Shader compilation error, using fallback material:', error)
|
||||
mat = new THREE.MeshBasicMaterial({
|
||||
color: 0xb794f6, // Light purple color
|
||||
wireframe: true,
|
||||
})
|
||||
}
|
||||
|
||||
const geo = new THREE.IcosahedronGeometry(4, 30) // Match tutorial: radius 4, subdivisions 30
|
||||
const mesh = new THREE.Mesh(geo, mat)
|
||||
|
||||
if (mat instanceof THREE.ShaderMaterial || mat instanceof THREE.MeshBasicMaterial) {
|
||||
mat.wireframe = true
|
||||
}
|
||||
|
||||
scene.add(mesh)
|
||||
meshRef.current = mesh
|
||||
|
||||
const bloomComposer = new SimpleBloomComposer(renderer, scene, camera)
|
||||
bloomComposerRef.current = bloomComposer
|
||||
|
||||
const clock = new THREE.Clock()
|
||||
clockRef.current = clock
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const rect = container.getBoundingClientRect()
|
||||
const windowHalfX = containerWidth / 2
|
||||
const windowHalfY = containerHeight / 2
|
||||
mouseRef.current.x = (e.clientX - rect.left - windowHalfX) / 100
|
||||
mouseRef.current.y = (e.clientY - rect.top - windowHalfY) / 100
|
||||
}
|
||||
|
||||
container.addEventListener('mousemove', handleMouseMove)
|
||||
|
||||
const updateCameraPosition = () => {
|
||||
if (!camera || !scene) return
|
||||
camera.position.x += (mouseRef.current.x - camera.position.x) * 0.05
|
||||
camera.position.y += (-mouseRef.current.y - camera.position.y) * 0.5
|
||||
camera.lookAt(scene.position)
|
||||
}
|
||||
|
||||
const calculateAudioIntensity = (elapsedTime: number, avgLevel: number) => {
|
||||
const baselineIntensity = 8 + Math.sin(elapsedTime * 0.5) * 3
|
||||
let audioIntensity = baselineIntensity
|
||||
|
||||
if (isMuted) {
|
||||
// When muted, only show minimal baseline animation
|
||||
audioIntensity = baselineIntensity * 0.2
|
||||
} else if (isProcessingInterruption) {
|
||||
// Special pulsing effect during interruption processing
|
||||
audioIntensity = 35 + Math.sin(elapsedTime * 4) * 10
|
||||
} else if (isPlayingAudio) {
|
||||
// Strong animation when AI is speaking - use simulated levels + enhancement
|
||||
const aiIntensity = 60 + Math.sin(elapsedTime * 3) * 20
|
||||
audioIntensity = Math.max(avgLevel * 0.8, aiIntensity)
|
||||
} else if (isStreaming) {
|
||||
// Pulsing animation when AI is thinking/streaming
|
||||
audioIntensity = 40 + Math.sin(elapsedTime * 2) * 15
|
||||
} else if (isListening && avgLevel > 0) {
|
||||
// Scale user input more dramatically for better visual feedback
|
||||
const userVoiceIntensity = avgLevel * 2.5 // Amplify user voice significantly
|
||||
audioIntensity = Math.max(userVoiceIntensity, baselineIntensity * 1.5)
|
||||
|
||||
// Add some dynamic variation based on audio levels
|
||||
const variationFactor = Math.min(avgLevel / 20, 1) // Cap at reasonable level
|
||||
audioIntensity += Math.sin(elapsedTime * 8) * (10 * variationFactor)
|
||||
} else {
|
||||
// Idle state - subtle breathing animation
|
||||
audioIntensity = baselineIntensity
|
||||
}
|
||||
|
||||
// Clamp to reasonable range
|
||||
audioIntensity = Math.max(audioIntensity, 3) // Never completely still
|
||||
audioIntensity = Math.min(audioIntensity, 120) // Prevent excessive animation
|
||||
|
||||
return audioIntensity
|
||||
}
|
||||
|
||||
const updateShaderColors = (
|
||||
uniforms: ShaderUniforms,
|
||||
elapsedTime: number,
|
||||
avgLevel: number
|
||||
) => {
|
||||
if (isMuted) {
|
||||
// Muted: dim purple-gray
|
||||
uniforms.u_red.value = 0.25
|
||||
uniforms.u_green.value = 0.1
|
||||
uniforms.u_blue.value = 0.5
|
||||
} else if (isProcessingInterruption) {
|
||||
// Interruption: bright purple
|
||||
uniforms.u_red.value = 0.6
|
||||
uniforms.u_green.value = 0.2
|
||||
uniforms.u_blue.value = 0.9
|
||||
} else if (isPlayingAudio) {
|
||||
// AI speaking: brand purple (#701FFC)
|
||||
uniforms.u_red.value = 0.44
|
||||
uniforms.u_green.value = 0.12
|
||||
uniforms.u_blue.value = 0.99
|
||||
} else if (isListening && avgLevel > 10) {
|
||||
// User speaking: lighter purple with intensity-based variation
|
||||
const intensity = Math.min(avgLevel / 50, 1)
|
||||
uniforms.u_red.value = 0.35 + intensity * 0.15
|
||||
uniforms.u_green.value = 0.1 + intensity * 0.1
|
||||
uniforms.u_blue.value = 0.8 + intensity * 0.2
|
||||
} else if (isStreaming) {
|
||||
// AI thinking: pulsing brand purple
|
||||
const pulse = (Math.sin(elapsedTime * 2) + 1) / 2
|
||||
uniforms.u_red.value = 0.35 + pulse * 0.15
|
||||
uniforms.u_green.value = 0.08 + pulse * 0.08
|
||||
uniforms.u_blue.value = 0.95 + pulse * 0.05
|
||||
} else {
|
||||
// Default idle: soft brand purple
|
||||
uniforms.u_red.value = 0.4
|
||||
uniforms.u_green.value = 0.15
|
||||
uniforms.u_blue.value = 0.9
|
||||
}
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
if (!camera || !clock || !scene || !bloomComposer || !isInitializedRef.current) return
|
||||
|
||||
updateCameraPosition()
|
||||
|
||||
if (uniforms) {
|
||||
const elapsedTime = clock.getElapsedTime()
|
||||
const avgLevel = audioLevels.reduce((sum, level) => sum + level, 0) / audioLevels.length
|
||||
|
||||
uniforms.u_time.value = elapsedTime
|
||||
|
||||
const audioIntensity = calculateAudioIntensity(elapsedTime, avgLevel)
|
||||
updateShaderColors(uniforms, elapsedTime, avgLevel)
|
||||
|
||||
uniforms.u_frequency.value = audioIntensity
|
||||
}
|
||||
|
||||
bloomComposer.render()
|
||||
animationFrameRef.current = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('mousemove', handleMouseMove)
|
||||
cleanup()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (
|
||||
rendererRef.current &&
|
||||
cameraRef.current &&
|
||||
bloomComposerRef.current &&
|
||||
containerRef.current
|
||||
) {
|
||||
const containerWidth = 400
|
||||
const containerHeight = 400
|
||||
|
||||
cameraRef.current.aspect = containerWidth / containerHeight
|
||||
cameraRef.current.updateProjectionMatrix()
|
||||
rendererRef.current.setSize(containerWidth, containerHeight)
|
||||
bloomComposerRef.current.setSize(containerWidth, containerHeight)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={className}
|
||||
style={{
|
||||
width: '400px',
|
||||
height: '400px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
'use client'
|
||||
|
||||
import { type RefObject, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Button, cn } from '@sim/emcn'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { Mic, MicOff, Phone } from 'lucide-react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { requestJson } from '@/lib/api/client/request'
|
||||
import { speechTokenContract } from '@/lib/api/contracts/media/speech'
|
||||
import { arrayBufferToBase64, floatTo16BitPCM } from '@/lib/speech/audio'
|
||||
import {
|
||||
CHUNK_SEND_INTERVAL_MS,
|
||||
ELEVENLABS_WS_URL,
|
||||
MAX_CHAT_SESSION_MS,
|
||||
SAMPLE_RATE,
|
||||
} from '@/lib/speech/config'
|
||||
|
||||
const ParticlesVisualization = dynamic(
|
||||
() =>
|
||||
import('@/app/(interfaces)/chat/components/voice-interface/components/particles').then(
|
||||
(mod) => mod.ParticlesVisualization
|
||||
),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const logger = createLogger('VoiceInterface')
|
||||
|
||||
interface VoiceInterfaceProps {
|
||||
onCallEnd?: () => void
|
||||
onVoiceTranscript?: (transcript: string) => void
|
||||
onVoiceStart?: () => void
|
||||
onVoiceEnd?: () => void
|
||||
onInterrupt?: () => void
|
||||
isStreaming?: boolean
|
||||
isPlayingAudio?: boolean
|
||||
audioContextRef?: RefObject<AudioContext | null>
|
||||
messages?: Array<{ content: string; type: 'user' | 'assistant' }>
|
||||
className?: string
|
||||
chatId?: string
|
||||
}
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ content: string; type: 'user' | 'assistant' }> = []
|
||||
|
||||
export function VoiceInterface({
|
||||
onCallEnd,
|
||||
onVoiceTranscript,
|
||||
onVoiceStart,
|
||||
onVoiceEnd,
|
||||
onInterrupt,
|
||||
isStreaming = false,
|
||||
isPlayingAudio = false,
|
||||
audioContextRef: sharedAudioContextRef,
|
||||
messages = EMPTY_MESSAGES,
|
||||
className,
|
||||
chatId,
|
||||
}: VoiceInterfaceProps) {
|
||||
const [state, setState] = useState<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isMuted, setIsMuted] = useState(false)
|
||||
const [audioLevels, setAudioLevels] = useState<number[]>(() => new Array(200).fill(0))
|
||||
const permissionStatusRef = useRef<'prompt' | 'granted' | 'denied'>('prompt')
|
||||
const [currentTranscript, setCurrentTranscript] = useState('')
|
||||
|
||||
const currentStateRef = useRef<'idle' | 'listening' | 'agent_speaking'>('idle')
|
||||
const isCallEndedRef = useRef(false)
|
||||
|
||||
const updateState = useCallback((next: 'idle' | 'listening' | 'agent_speaking') => {
|
||||
setState(next)
|
||||
currentStateRef.current = next
|
||||
}, [])
|
||||
|
||||
const mediaStreamRef = useRef<MediaStream | null>(null)
|
||||
const audioContextRef = useRef<AudioContext | null>(null)
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
const isMutedRef = useRef(false)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const processorRef = useRef<ScriptProcessorNode | null>(null)
|
||||
const pcmBufferRef = useRef<Float32Array[]>([])
|
||||
const sendIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const sessionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const committedTextRef = useRef('')
|
||||
const lastPartialRef = useRef('')
|
||||
const onVoiceTranscriptRef = useRef(onVoiceTranscript)
|
||||
|
||||
onVoiceTranscriptRef.current = onVoiceTranscript
|
||||
|
||||
const updateIsMuted = useCallback((next: boolean) => {
|
||||
setIsMuted(next)
|
||||
isMutedRef.current = next
|
||||
}, [])
|
||||
|
||||
const stopSendingAudio = useCallback(() => {
|
||||
if (sessionTimerRef.current) {
|
||||
clearTimeout(sessionTimerRef.current)
|
||||
sessionTimerRef.current = null
|
||||
}
|
||||
if (sendIntervalRef.current) {
|
||||
clearInterval(sendIntervalRef.current)
|
||||
sendIntervalRef.current = null
|
||||
}
|
||||
pcmBufferRef.current = []
|
||||
}, [])
|
||||
|
||||
const flushAudioBuffer = useCallback(() => {
|
||||
const ws = wsRef.current
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return
|
||||
|
||||
const chunks = pcmBufferRef.current
|
||||
if (chunks.length === 0) return
|
||||
pcmBufferRef.current = []
|
||||
|
||||
let totalLength = 0
|
||||
for (const chunk of chunks) totalLength += chunk.length
|
||||
const merged = new Float32Array(totalLength)
|
||||
let offset = 0
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset)
|
||||
offset += chunk.length
|
||||
}
|
||||
|
||||
const pcm16 = floatTo16BitPCM(merged)
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
message_type: 'input_audio_chunk',
|
||||
audio_base_64: arrayBufferToBase64(pcm16),
|
||||
sample_rate: SAMPLE_RATE,
|
||||
commit: false,
|
||||
})
|
||||
)
|
||||
}, [])
|
||||
|
||||
const startSendingAudio = useCallback(() => {
|
||||
if (sendIntervalRef.current) return
|
||||
pcmBufferRef.current = []
|
||||
sendIntervalRef.current = setInterval(flushAudioBuffer, CHUNK_SEND_INTERVAL_MS)
|
||||
}, [flushAudioBuffer])
|
||||
|
||||
const closeWebSocket = useCallback(() => {
|
||||
stopSendingAudio()
|
||||
if (wsRef.current) {
|
||||
if (
|
||||
wsRef.current.readyState === WebSocket.OPEN ||
|
||||
wsRef.current.readyState === WebSocket.CONNECTING
|
||||
) {
|
||||
wsRef.current.close()
|
||||
}
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [stopSendingAudio])
|
||||
|
||||
const connectWebSocket = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
let tokenData: Awaited<ReturnType<typeof requestJson<typeof speechTokenContract>>>
|
||||
try {
|
||||
tokenData = await requestJson(speechTokenContract, {
|
||||
body: chatId ? { chatId } : {},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('Failed to get STT token', err)
|
||||
return false
|
||||
}
|
||||
|
||||
const token = typeof tokenData.token === 'string' ? tokenData.token : undefined
|
||||
if (!token) {
|
||||
logger.error('STT token missing from response')
|
||||
return false
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
token,
|
||||
model_id: 'scribe_v2_realtime',
|
||||
audio_format: 'pcm_16000',
|
||||
commit_strategy: 'vad',
|
||||
vad_silence_threshold_secs: '1.0',
|
||||
})
|
||||
|
||||
const ws = new WebSocket(`${ELEVENLABS_WS_URL}?${params.toString()}`)
|
||||
wsRef.current = ws
|
||||
committedTextRef.current = ''
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
ws.onopen = () => resolve(true)
|
||||
ws.onerror = () => {
|
||||
logger.error('STT WebSocket connection error')
|
||||
resolve(false)
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (isCallEndedRef.current) return
|
||||
|
||||
try {
|
||||
const msg = JSON.parse(event.data)
|
||||
|
||||
if (msg.message_type === 'partial_transcript') {
|
||||
if (msg.text) {
|
||||
lastPartialRef.current = msg.text
|
||||
setCurrentTranscript(msg.text)
|
||||
}
|
||||
} else if (
|
||||
msg.message_type === 'committed_transcript' ||
|
||||
msg.message_type === 'committed_transcript_with_timestamps'
|
||||
) {
|
||||
const finalText = msg.text || lastPartialRef.current
|
||||
lastPartialRef.current = ''
|
||||
if (finalText) {
|
||||
committedTextRef.current = committedTextRef.current
|
||||
? `${committedTextRef.current} ${finalText}`
|
||||
: finalText
|
||||
setCurrentTranscript('')
|
||||
onVoiceTranscriptRef.current?.(finalText)
|
||||
}
|
||||
} else if (
|
||||
msg.message_type === 'error' ||
|
||||
msg.message_type === 'auth_error' ||
|
||||
msg.message_type === 'quota_exceeded'
|
||||
) {
|
||||
logger.error('ElevenLabs STT error', { type: msg.message_type, error: msg.error })
|
||||
}
|
||||
} catch {
|
||||
// Ignore non-JSON messages
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null
|
||||
if (currentStateRef.current === 'listening' && !isCallEndedRef.current) {
|
||||
stopSendingAudio()
|
||||
updateState('idle')
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error('Failed to connect STT WebSocket', error)
|
||||
return false
|
||||
}
|
||||
}, [chatId])
|
||||
|
||||
const setupAudioPipeline = useCallback(async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 1,
|
||||
sampleRate: SAMPLE_RATE,
|
||||
},
|
||||
})
|
||||
|
||||
permissionStatusRef.current = 'granted'
|
||||
mediaStreamRef.current = stream
|
||||
|
||||
const ac = new AudioContext({ sampleRate: SAMPLE_RATE })
|
||||
audioContextRef.current = ac
|
||||
|
||||
if (ac.state === 'suspended') {
|
||||
await ac.resume()
|
||||
}
|
||||
|
||||
const source = ac.createMediaStreamSource(stream)
|
||||
|
||||
const analyser = ac.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
analyser.smoothingTimeConstant = 0.8
|
||||
source.connect(analyser)
|
||||
analyserRef.current = analyser
|
||||
|
||||
const processor = ac.createScriptProcessor(4096, 1, 1)
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (!isMutedRef.current && currentStateRef.current === 'listening') {
|
||||
pcmBufferRef.current.push(new Float32Array(e.inputBuffer.getChannelData(0)))
|
||||
}
|
||||
}
|
||||
source.connect(processor)
|
||||
processor.connect(ac.destination)
|
||||
processorRef.current = processor
|
||||
|
||||
const updateVisualization = () => {
|
||||
if (!analyserRef.current) return
|
||||
const bufferLength = analyserRef.current.frequencyBinCount
|
||||
const dataArray = new Uint8Array(bufferLength)
|
||||
analyserRef.current.getByteFrequencyData(dataArray)
|
||||
|
||||
const levels = []
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const dataIndex = Math.floor((i / 200) * bufferLength)
|
||||
const value = dataArray[dataIndex] || 0
|
||||
levels.push((value / 255) * 100)
|
||||
}
|
||||
|
||||
setAudioLevels(levels)
|
||||
animationFrameRef.current = requestAnimationFrame(updateVisualization)
|
||||
}
|
||||
updateVisualization()
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
logger.error('Error setting up audio pipeline:', error)
|
||||
permissionStatusRef.current = 'denied'
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startListening = useCallback(async () => {
|
||||
if (currentStateRef.current !== 'idle' || isMutedRef.current || isCallEndedRef.current) return
|
||||
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
const connected = await connectWebSocket()
|
||||
if (!connected || isCallEndedRef.current) return
|
||||
}
|
||||
|
||||
updateState('listening')
|
||||
setCurrentTranscript('')
|
||||
startSendingAudio()
|
||||
|
||||
sessionTimerRef.current = setTimeout(() => {
|
||||
logger.info('Voice session reached max duration, stopping')
|
||||
stopSendingAudio()
|
||||
closeWebSocket()
|
||||
updateState('idle')
|
||||
}, MAX_CHAT_SESSION_MS)
|
||||
}, [connectWebSocket, updateState, startSendingAudio, stopSendingAudio, closeWebSocket])
|
||||
|
||||
const stopListening = useCallback(() => {
|
||||
stopSendingAudio()
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
}, [updateState, stopSendingAudio])
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlayingAudio && state === 'listening') {
|
||||
stopSendingAudio()
|
||||
closeWebSocket()
|
||||
updateState('agent_speaking')
|
||||
setCurrentTranscript('')
|
||||
|
||||
updateIsMuted(true)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = false
|
||||
})
|
||||
}
|
||||
} else if (!isPlayingAudio && state === 'agent_speaking') {
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
|
||||
updateIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isPlayingAudio, state, updateState, updateIsMuted, stopSendingAudio, closeWebSocket])
|
||||
|
||||
const handleInterrupt = useCallback(() => {
|
||||
if (state === 'agent_speaking') {
|
||||
onInterrupt?.()
|
||||
|
||||
updateIsMuted(false)
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = true
|
||||
})
|
||||
}
|
||||
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
}
|
||||
}, [state, onInterrupt, updateState, updateIsMuted])
|
||||
|
||||
const handleCallEnd = useCallback(() => {
|
||||
isCallEndedRef.current = true
|
||||
|
||||
stopSendingAudio()
|
||||
closeWebSocket()
|
||||
updateState('idle')
|
||||
setCurrentTranscript('')
|
||||
updateIsMuted(false)
|
||||
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect()
|
||||
processorRef.current = null
|
||||
}
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
|
||||
mediaStreamRef.current = null
|
||||
}
|
||||
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
audioContextRef.current.close().catch(() => {})
|
||||
audioContextRef.current = null
|
||||
}
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
|
||||
onInterrupt?.()
|
||||
onCallEnd?.()
|
||||
}, [onCallEnd, onInterrupt, updateState, updateIsMuted, stopSendingAudio, closeWebSocket])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') {
|
||||
event.preventDefault()
|
||||
handleInterrupt()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleInterrupt])
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (state === 'agent_speaking') {
|
||||
handleInterrupt()
|
||||
return
|
||||
}
|
||||
|
||||
const newMutedState = !isMuted
|
||||
updateIsMuted(newMutedState)
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getAudioTracks().forEach((track) => {
|
||||
track.enabled = !newMutedState
|
||||
})
|
||||
}
|
||||
|
||||
if (newMutedState) {
|
||||
stopListening()
|
||||
} else if (state === 'idle') {
|
||||
startListening()
|
||||
}
|
||||
}, [isMuted, state, handleInterrupt, stopListening, startListening, updateIsMuted])
|
||||
|
||||
useEffect(() => {
|
||||
isCallEndedRef.current = false
|
||||
let cancelled = false
|
||||
|
||||
async function init() {
|
||||
const audioOk = await setupAudioPipeline()
|
||||
if (!audioOk || cancelled) return
|
||||
|
||||
const wsOk = await connectWebSocket()
|
||||
if (!wsOk || cancelled) return
|
||||
|
||||
setIsInitialized(true)
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [setupAudioPipeline, connectWebSocket])
|
||||
|
||||
useEffect(() => {
|
||||
if (isInitialized && !isMuted && state === 'idle') {
|
||||
startListening()
|
||||
}
|
||||
}, [isInitialized, isMuted, state, startListening])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isCallEndedRef.current = true
|
||||
|
||||
stopSendingAudio()
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
|
||||
if (processorRef.current) {
|
||||
processorRef.current.disconnect()
|
||||
processorRef.current = null
|
||||
}
|
||||
|
||||
if (mediaStreamRef.current) {
|
||||
mediaStreamRef.current.getTracks().forEach((track) => track.stop())
|
||||
mediaStreamRef.current = null
|
||||
}
|
||||
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close()
|
||||
audioContextRef.current = null
|
||||
}
|
||||
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
}
|
||||
}, [stopSendingAudio])
|
||||
|
||||
const getStatusText = () => {
|
||||
switch (state) {
|
||||
case 'listening':
|
||||
return 'Listening...'
|
||||
case 'agent_speaking':
|
||||
return 'Press Space or tap to interrupt'
|
||||
default:
|
||||
return isInitialized ? 'Ready' : 'Initializing...'
|
||||
}
|
||||
}
|
||||
|
||||
const getButtonContent = () => {
|
||||
if (state === 'agent_speaking') {
|
||||
return (
|
||||
<svg className='size-6' viewBox='0 0 24 24' fill='currentColor'>
|
||||
<rect x='6' y='6' width='12' height='12' rx='2' />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return isMuted ? <MicOff className='size-6' /> : <Mic className='size-6' />
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'light fixed inset-0 z-[100] flex flex-col bg-[var(--bg)] text-[var(--text-primary)]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className='flex flex-1 flex-col items-center justify-center px-8'>
|
||||
<div className='relative mb-16'>
|
||||
<ParticlesVisualization
|
||||
audioLevels={audioLevels}
|
||||
isListening={state === 'listening'}
|
||||
isPlayingAudio={state === 'agent_speaking'}
|
||||
isStreaming={isStreaming}
|
||||
isMuted={isMuted}
|
||||
className='h-80 w-80 md:h-96 md:w-96'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mb-16 flex h-24 items-center justify-center'>
|
||||
{currentTranscript && (
|
||||
<div className='max-w-2xl px-8'>
|
||||
<p className='overflow-hidden text-center text-[var(--text-primary)] text-xl leading-relaxed'>
|
||||
{currentTranscript}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className='mb-8 text-center text-[var(--text-muted)] text-lg'>
|
||||
{getStatusText()}
|
||||
{isMuted && <span className='ml-2 text-[var(--text-muted)] text-sm'>(Muted)</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className='px-8 pb-12'>
|
||||
<div className='flex items-center justify-center gap-x-12'>
|
||||
<Button onClick={handleCallEnd} variant='outline' className='size-14 rounded-full p-0'>
|
||||
<Phone className='size-6 rotate-[135deg]' />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={toggleMute}
|
||||
variant='outline'
|
||||
disabled={!isInitialized}
|
||||
className={cn(
|
||||
'size-14 rounded-full p-0',
|
||||
isMuted ? 'text-[var(--text-muted)]' : 'text-[var(--text-primary)]'
|
||||
)}
|
||||
>
|
||||
{getButtonContent()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export const CHAT_ERROR_MESSAGES = {
|
||||
GENERIC_ERROR: 'Sorry, there was an error processing your message. Please try again.',
|
||||
NETWORK_ERROR: 'Unable to connect to the server. Please check your connection and try again.',
|
||||
TIMEOUT_ERROR: 'Request timed out. Please try again.',
|
||||
AUTH_REQUIRED_PASSWORD: 'This chat requires a password to access.',
|
||||
AUTH_REQUIRED_EMAIL: 'Please provide your email to access this chat.',
|
||||
CHAT_UNAVAILABLE: 'This chat is currently unavailable. Please try again later.',
|
||||
NO_CHAT_TRIGGER:
|
||||
'No Chat trigger configured for this workflow. Add a Chat Trigger block to enable chat.',
|
||||
USAGE_LIMIT_EXCEEDED: 'Usage limit exceeded. Please upgrade your plan to continue using chat.',
|
||||
} as const
|
||||
|
||||
export const CHAT_REQUEST_TIMEOUT_MS = 300000 // 5 minutes (same as in chat.tsx)
|
||||
|
||||
export type ChatErrorType = keyof typeof CHAT_ERROR_MESSAGES
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useAudioStreaming } from './use-audio-streaming'
|
||||
export { useChatStreaming } from './use-chat-streaming'
|
||||
@@ -0,0 +1,170 @@
|
||||
'use client'
|
||||
|
||||
import { type RefObject, useCallback, useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
|
||||
const logger = createLogger('UseAudioStreaming')
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext?: typeof AudioContext
|
||||
}
|
||||
}
|
||||
|
||||
interface AudioStreamingOptions {
|
||||
voiceId: string
|
||||
modelId?: string
|
||||
chatId: string
|
||||
onAudioStart?: () => void
|
||||
onAudioEnd?: () => void
|
||||
onError?: (error: Error) => void
|
||||
}
|
||||
|
||||
interface AudioQueueItem {
|
||||
text: string
|
||||
options: AudioStreamingOptions
|
||||
}
|
||||
|
||||
export function useAudioStreaming(sharedAudioContextRef?: RefObject<AudioContext | null>) {
|
||||
const [isPlayingAudio, setIsPlayingAudio] = useState(false)
|
||||
const localAudioContextRef = useRef<AudioContext | null>(null)
|
||||
const audioContextRef = sharedAudioContextRef || localAudioContextRef
|
||||
const currentSourceRef = useRef<AudioBufferSourceNode | null>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const audioQueueRef = useRef<AudioQueueItem[]>([])
|
||||
const isProcessingQueueRef = useRef(false)
|
||||
|
||||
const getAudioContext = useCallback(() => {
|
||||
if (!audioContextRef.current) {
|
||||
const AudioContextConstructor = window.AudioContext || window.webkitAudioContext
|
||||
if (!AudioContextConstructor) {
|
||||
throw new Error('AudioContext is not supported in this browser')
|
||||
}
|
||||
audioContextRef.current = new AudioContextConstructor()
|
||||
}
|
||||
return audioContextRef.current
|
||||
}, [])
|
||||
|
||||
const stopAudio = useCallback(() => {
|
||||
abortControllerRef.current?.abort()
|
||||
|
||||
if (currentSourceRef.current) {
|
||||
try {
|
||||
currentSourceRef.current.stop()
|
||||
} catch (e) {
|
||||
// Already stopped
|
||||
}
|
||||
currentSourceRef.current = null
|
||||
}
|
||||
|
||||
audioQueueRef.current = []
|
||||
isProcessingQueueRef.current = false
|
||||
|
||||
setIsPlayingAudio(false)
|
||||
}, [])
|
||||
|
||||
const processAudioQueue = useCallback(async () => {
|
||||
if (isProcessingQueueRef.current || audioQueueRef.current.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = true
|
||||
const item = audioQueueRef.current.shift()
|
||||
|
||||
if (!item) {
|
||||
isProcessingQueueRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const { text, options } = item
|
||||
const {
|
||||
voiceId,
|
||||
modelId = 'eleven_flash_v2_5',
|
||||
chatId,
|
||||
onAudioStart,
|
||||
onAudioEnd,
|
||||
onError,
|
||||
} = options
|
||||
|
||||
try {
|
||||
const audioContext = getAudioContext()
|
||||
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume()
|
||||
}
|
||||
// boundary-raw-fetch: TTS proxy returns raw audio bytes consumed via response.arrayBuffer() and decoded by AudioContext.decodeAudioData
|
||||
const response = await fetch('/api/proxy/tts/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
text,
|
||||
voiceId,
|
||||
modelId,
|
||||
chatId,
|
||||
}),
|
||||
signal: abortControllerRef.current?.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => '')
|
||||
throw new Error(errorText || `TTS request failed: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer)
|
||||
|
||||
const source = audioContext.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
source.connect(audioContext.destination)
|
||||
source.onended = () => {
|
||||
currentSourceRef.current = null
|
||||
onAudioEnd?.()
|
||||
|
||||
isProcessingQueueRef.current = false
|
||||
|
||||
if (audioQueueRef.current.length === 0) {
|
||||
setIsPlayingAudio(false)
|
||||
}
|
||||
|
||||
setTimeout(() => processAudioQueue(), 0)
|
||||
}
|
||||
|
||||
currentSourceRef.current = source
|
||||
source.start(0)
|
||||
setIsPlayingAudio(true)
|
||||
onAudioStart?.()
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name !== 'AbortError') {
|
||||
logger.error('Audio streaming error:', error)
|
||||
onError?.(error)
|
||||
}
|
||||
|
||||
isProcessingQueueRef.current = false
|
||||
setTimeout(() => processAudioQueue(), 0)
|
||||
}
|
||||
}, [getAudioContext])
|
||||
|
||||
const streamTextToAudio = useCallback(
|
||||
async (text: string, options: AudioStreamingOptions) => {
|
||||
if (!text.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!abortControllerRef.current || abortControllerRef.current.signal.aborted) {
|
||||
abortControllerRef.current = new AbortController()
|
||||
}
|
||||
|
||||
audioQueueRef.current.push({ text, options })
|
||||
processAudioQueue()
|
||||
},
|
||||
[processAudioQueue]
|
||||
)
|
||||
|
||||
return {
|
||||
isPlayingAudio,
|
||||
streamTextToAudio,
|
||||
stopAudio,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,475 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { generateId } from '@sim/utils/id'
|
||||
import { readSSEEvents } from '@/lib/core/utils/sse'
|
||||
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
|
||||
import type { ChatFile, ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
|
||||
import { CHAT_ERROR_MESSAGES } from '@/app/(interfaces)/chat/constants'
|
||||
|
||||
const logger = createLogger('UseChatStreaming')
|
||||
|
||||
function extractFilesFromData(
|
||||
data: any,
|
||||
files: ChatFile[] = [],
|
||||
seenIds = new Set<string>()
|
||||
): ChatFile[] {
|
||||
if (!data || typeof data !== 'object') {
|
||||
return files
|
||||
}
|
||||
|
||||
if (isUserFileWithMetadata(data)) {
|
||||
if (!seenIds.has(data.id)) {
|
||||
seenIds.add(data.id)
|
||||
files.push({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
key: data.key,
|
||||
size: data.size,
|
||||
type: data.type,
|
||||
context: data.context,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
for (const item of data) {
|
||||
extractFilesFromData(item, files, seenIds)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
for (const value of Object.values(data)) {
|
||||
extractFilesFromData(value, files, seenIds)
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
interface VoiceSettings {
|
||||
isVoiceEnabled: boolean
|
||||
voiceId: string
|
||||
autoPlayResponses: boolean
|
||||
voiceFirstMode?: boolean
|
||||
textStreamingInVoiceMode?: 'hidden' | 'synced' | 'normal'
|
||||
conversationMode?: boolean
|
||||
}
|
||||
|
||||
export interface StreamingOptions {
|
||||
voiceSettings?: VoiceSettings
|
||||
onAudioStart?: () => void
|
||||
onAudioEnd?: () => void
|
||||
audioStreamHandler?: (text: string) => Promise<void>
|
||||
outputConfigs?: Array<{ blockId: string; path?: string }>
|
||||
}
|
||||
|
||||
export function useChatStreaming() {
|
||||
const [isStreamingResponse, setIsStreamingResponse] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const accumulatedTextRef = useRef<string>('')
|
||||
const lastStreamedPositionRef = useRef<number>(0)
|
||||
const audioStreamingActiveRef = useRef<boolean>(false)
|
||||
const lastDisplayedPositionRef = useRef<number>(0) // Track displayed text in synced mode
|
||||
|
||||
const stopStreaming = (setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>) => {
|
||||
if (abortControllerRef.current) {
|
||||
// Abort the fetch request
|
||||
abortControllerRef.current.abort()
|
||||
abortControllerRef.current = null
|
||||
|
||||
const latestContent = accumulatedTextRef.current
|
||||
|
||||
setMessages((prev) => {
|
||||
const lastMessage = prev[prev.length - 1]
|
||||
|
||||
if (lastMessage && lastMessage.type === 'assistant') {
|
||||
const content = latestContent || lastMessage.content
|
||||
const updatedContent =
|
||||
content + (content ? '\n\n_Response stopped by user._' : '_Response stopped by user._')
|
||||
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{ ...lastMessage, content: updatedContent, isStreaming: false },
|
||||
]
|
||||
}
|
||||
|
||||
return prev
|
||||
})
|
||||
|
||||
setIsStreamingResponse(false)
|
||||
accumulatedTextRef.current = ''
|
||||
lastStreamedPositionRef.current = 0
|
||||
lastDisplayedPositionRef.current = 0
|
||||
audioStreamingActiveRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleStreamedResponse = async (
|
||||
response: Response,
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>,
|
||||
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
scrollToBottom: () => void,
|
||||
userHasScrolled?: boolean,
|
||||
streamingOptions?: StreamingOptions
|
||||
) => {
|
||||
logger.info('[useChatStreaming] handleStreamedResponse called')
|
||||
// Set streaming state
|
||||
setIsStreamingResponse(true)
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
// Check if we should stream audio
|
||||
const shouldPlayAudio =
|
||||
streamingOptions?.voiceSettings?.isVoiceEnabled &&
|
||||
streamingOptions?.voiceSettings?.autoPlayResponses &&
|
||||
streamingOptions?.audioStreamHandler
|
||||
|
||||
if (!response.body) {
|
||||
setIsLoading(false)
|
||||
setIsStreamingResponse(false)
|
||||
return
|
||||
}
|
||||
|
||||
let accumulatedText = ''
|
||||
let lastAudioPosition = 0
|
||||
|
||||
const messageIdMap = new Map<string, string>()
|
||||
const messageId = generateId()
|
||||
|
||||
const UI_BATCH_MAX_MS = 50
|
||||
let uiDirty = false
|
||||
let uiRAF: number | null = null
|
||||
let uiTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let lastUIFlush = 0
|
||||
|
||||
const flushUI = () => {
|
||||
if (uiRAF !== null) {
|
||||
cancelAnimationFrame(uiRAF)
|
||||
uiRAF = null
|
||||
}
|
||||
if (uiTimer !== null) {
|
||||
clearTimeout(uiTimer)
|
||||
uiTimer = null
|
||||
}
|
||||
if (!uiDirty) return
|
||||
uiDirty = false
|
||||
lastUIFlush = performance.now()
|
||||
const snapshot = accumulatedText
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => {
|
||||
if (msg.id !== messageId) return msg
|
||||
if (!msg.isStreaming) return msg
|
||||
return { ...msg, content: snapshot }
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const scheduleUIFlush = () => {
|
||||
if (uiRAF !== null) return
|
||||
const elapsed = performance.now() - lastUIFlush
|
||||
if (elapsed >= UI_BATCH_MAX_MS) {
|
||||
flushUI()
|
||||
return
|
||||
}
|
||||
uiRAF = requestAnimationFrame(flushUI)
|
||||
if (uiTimer === null) {
|
||||
uiTimer = setTimeout(flushUI, Math.max(0, UI_BATCH_MAX_MS - elapsed))
|
||||
}
|
||||
}
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: messageId,
|
||||
content: '',
|
||||
type: 'assistant',
|
||||
timestamp: new Date(),
|
||||
isStreaming: true,
|
||||
},
|
||||
])
|
||||
|
||||
setIsLoading(false)
|
||||
|
||||
let terminated = false
|
||||
|
||||
try {
|
||||
await readSSEEvents<{
|
||||
blockId?: string
|
||||
chunk?: string
|
||||
event?: string
|
||||
error?: string
|
||||
data?: {
|
||||
success: boolean
|
||||
error?: string | { message?: string }
|
||||
output?: Record<string, Record<string, any>>
|
||||
}
|
||||
}>(response.body, {
|
||||
signal: abortControllerRef.current.signal,
|
||||
onParseError: (_data, parseError) => {
|
||||
logger.error('Error parsing stream data:', parseError)
|
||||
},
|
||||
onEvent: async (json) => {
|
||||
const { blockId, chunk: contentChunk, event: eventType } = json
|
||||
|
||||
if (eventType === 'error' || json.event === 'error') {
|
||||
const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
content: errorMessage,
|
||||
isStreaming: false,
|
||||
type: 'assistant' as const,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
)
|
||||
setIsLoading(false)
|
||||
terminated = true
|
||||
return true
|
||||
}
|
||||
|
||||
if (eventType === 'final' && json.data) {
|
||||
flushUI()
|
||||
const finalData = json.data
|
||||
|
||||
const outputConfigs = streamingOptions?.outputConfigs
|
||||
const formattedOutputs: string[] = []
|
||||
let extractedFiles: ChatFile[] = []
|
||||
|
||||
const formatValue = (value: any): string | null => {
|
||||
if (value === null || value === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isUserFileWithMetadata(value)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const getOutputValue = (blockOutputs: Record<string, any>, path?: string) => {
|
||||
if (!path || path === 'content') {
|
||||
if (blockOutputs.content !== undefined) return blockOutputs.content
|
||||
if (blockOutputs.result !== undefined) return blockOutputs.result
|
||||
return blockOutputs
|
||||
}
|
||||
|
||||
if (blockOutputs[path] !== undefined) {
|
||||
return blockOutputs[path]
|
||||
}
|
||||
|
||||
if (path.includes('.')) {
|
||||
return path.split('.').reduce<any>((current, segment) => {
|
||||
if (current && typeof current === 'object' && segment in current) {
|
||||
return current[segment]
|
||||
}
|
||||
return undefined
|
||||
}, blockOutputs)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (outputConfigs?.length && finalData.output) {
|
||||
for (const config of outputConfigs) {
|
||||
const blockOutputs = finalData.output[config.blockId]
|
||||
if (!blockOutputs) continue
|
||||
|
||||
const value = getOutputValue(blockOutputs, config.path)
|
||||
|
||||
if (isUserFileWithMetadata(value)) {
|
||||
extractedFiles.push({
|
||||
id: value.id,
|
||||
name: value.name,
|
||||
url: value.url,
|
||||
key: value.key,
|
||||
size: value.size,
|
||||
type: value.type,
|
||||
context: value.context,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const nestedFiles = extractFilesFromData(value)
|
||||
if (nestedFiles.length > 0) {
|
||||
extractedFiles = [...extractedFiles, ...nestedFiles]
|
||||
continue
|
||||
}
|
||||
|
||||
const formatted = formatValue(value)
|
||||
if (formatted) {
|
||||
formattedOutputs.push(formatted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let finalContent = accumulatedText
|
||||
|
||||
if (formattedOutputs.length > 0) {
|
||||
const nonEmptyOutputs = formattedOutputs.filter((output) => output.trim())
|
||||
if (nonEmptyOutputs.length > 0) {
|
||||
const combinedOutputs = nonEmptyOutputs.join('\n\n')
|
||||
finalContent = finalContent
|
||||
? `${finalContent.trim()}\n\n${combinedOutputs}`
|
||||
: combinedOutputs
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalContent && extractedFiles.length === 0) {
|
||||
if (finalData.error) {
|
||||
if (typeof finalData.error === 'string') {
|
||||
finalContent = finalData.error
|
||||
} else if (typeof finalData.error?.message === 'string') {
|
||||
finalContent = finalData.error.message
|
||||
}
|
||||
} else if (finalData.success && finalData.output) {
|
||||
const fallbackOutput = Object.values(finalData.output)
|
||||
.map((block) => formatValue(block)?.trim())
|
||||
.filter(Boolean)[0]
|
||||
if (fallbackOutput) {
|
||||
finalContent = fallbackOutput
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) =>
|
||||
msg.id === messageId
|
||||
? {
|
||||
...msg,
|
||||
isStreaming: false,
|
||||
content: finalContent ?? msg.content,
|
||||
files: extractedFiles.length > 0 ? extractedFiles : undefined,
|
||||
}
|
||||
: msg
|
||||
)
|
||||
)
|
||||
|
||||
accumulatedTextRef.current = ''
|
||||
lastStreamedPositionRef.current = 0
|
||||
lastDisplayedPositionRef.current = 0
|
||||
audioStreamingActiveRef.current = false
|
||||
|
||||
terminated = true
|
||||
return true
|
||||
}
|
||||
|
||||
if (blockId && contentChunk) {
|
||||
if (!messageIdMap.has(blockId)) {
|
||||
messageIdMap.set(blockId, messageId)
|
||||
}
|
||||
|
||||
accumulatedText += contentChunk
|
||||
accumulatedTextRef.current = accumulatedText
|
||||
logger.debug('[useChatStreaming] Received chunk', {
|
||||
blockId,
|
||||
chunkLength: contentChunk.length,
|
||||
totalLength: accumulatedText.length,
|
||||
messageId,
|
||||
chunk: contentChunk.substring(0, 20),
|
||||
})
|
||||
uiDirty = true
|
||||
scheduleUIFlush()
|
||||
|
||||
if (shouldPlayAudio && streamingOptions?.audioStreamHandler) {
|
||||
const newText = accumulatedText.substring(lastAudioPosition)
|
||||
const sentenceEndings = ['. ', '! ', '? ', '.\n', '!\n', '?\n', '.', '!', '?']
|
||||
let sentenceEnd = -1
|
||||
|
||||
for (const ending of sentenceEndings) {
|
||||
const index = newText.indexOf(ending)
|
||||
if (index > 0) {
|
||||
sentenceEnd = index + ending.length
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (sentenceEnd > 0) {
|
||||
const sentence = newText.substring(0, sentenceEnd).trim()
|
||||
if (sentence && sentence.length >= 3) {
|
||||
try {
|
||||
await streamingOptions.audioStreamHandler(sentence)
|
||||
lastAudioPosition += sentenceEnd
|
||||
} catch (error) {
|
||||
logger.error('TTS error:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (blockId && eventType === 'end') {
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (!terminated) {
|
||||
flushUI()
|
||||
if (
|
||||
shouldPlayAudio &&
|
||||
streamingOptions?.audioStreamHandler &&
|
||||
accumulatedText.length > lastAudioPosition
|
||||
) {
|
||||
const remainingText = accumulatedText.substring(lastAudioPosition).trim()
|
||||
if (remainingText) {
|
||||
try {
|
||||
await streamingOptions.audioStreamHandler(remainingText)
|
||||
} catch (error) {
|
||||
logger.error('TTS error for remaining text:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error processing stream:', error)
|
||||
flushUI()
|
||||
setMessages((prev) =>
|
||||
prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
|
||||
)
|
||||
} finally {
|
||||
if (uiRAF !== null) cancelAnimationFrame(uiRAF)
|
||||
if (uiTimer !== null) clearTimeout(uiTimer)
|
||||
setIsStreamingResponse(false)
|
||||
abortControllerRef.current = null
|
||||
|
||||
if (!userHasScrolled) {
|
||||
setTimeout(() => {
|
||||
scrollToBottom()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
if (shouldPlayAudio) {
|
||||
streamingOptions?.onAudioEnd?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isStreamingResponse,
|
||||
setIsStreamingResponse,
|
||||
abortControllerRef,
|
||||
stopStreaming,
|
||||
handleStreamedResponse,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { InterfacesShell } from './interfaces-shell'
|
||||
@@ -0,0 +1 @@
|
||||
export { InterfacesShell } from './interfaces-shell'
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { SupportFooter } from '@/app/(auth)/components'
|
||||
import { LogoShell } from '@/app/(landing)/components'
|
||||
|
||||
/**
|
||||
* Chrome for the `(interfaces)` route group (chat + resume) — the lightweight,
|
||||
* logo-only frame their entry/gate screens wear (chat email / password auth, the
|
||||
* embedded SSO gate, the "chat unavailable" message, and the resume gate).
|
||||
*
|
||||
* It is the shared {@link LogoShell} (light, logo-only header) plus a
|
||||
* {@link SupportFooter}. Content is full-width — gate forms center themselves;
|
||||
* the live chat UI renders a `fixed inset-0` overlay that covers this frame, and
|
||||
* voice mode is full-screen — so the frame is only ever visible on the
|
||||
* gate/message states, giving chat and resume the same chrome as the auth pages.
|
||||
*/
|
||||
interface InterfacesShellProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function InterfacesShell({ children }: InterfacesShellProps) {
|
||||
return <LogoShell footer={<SupportFooter position='static' />}>{children}</LogoShell>
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { InterfacesShell } from '@/app/(interfaces)/components'
|
||||
|
||||
/**
|
||||
* Route-group layout for runtime interfaces — chat (`/chat/:identifier`) and
|
||||
* resume (`/resume/...`). It renders the shared {@link InterfacesShell} (light,
|
||||
* logo-only chrome) around every interface page, so their entry/gate screens get
|
||||
* the same frame as the auth sign-in pages. Immersive states (the live chat
|
||||
* overlay, voice mode) render full-screen on top of this frame.
|
||||
*/
|
||||
export default function InterfacesLayout({ children }: { children: ReactNode }) {
|
||||
return <InterfacesShell>{children}</InterfacesShell>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface PageParams {
|
||||
workflowId: string
|
||||
executionId: string
|
||||
contextId: string
|
||||
}
|
||||
|
||||
export default async function ResumePage({ params }: { params: Promise<PageParams> }) {
|
||||
const { workflowId, executionId, contextId } = await params
|
||||
redirect(`/resume/${workflowId}/${executionId}?contextId=${contextId}`)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Skeleton } from '@sim/emcn'
|
||||
|
||||
export default function ResumeLoading() {
|
||||
return (
|
||||
<div className='min-h-screen bg-background'>
|
||||
<div className='border-b px-4 py-3'>
|
||||
<div className='mx-auto flex max-w-[1200px] items-center justify-between'>
|
||||
<Skeleton className='h-[24px] w-[80px] rounded-[4px]' />
|
||||
<Skeleton className='h-[28px] w-[100px] rounded-[6px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mx-auto max-w-[1200px] px-6 py-8'>
|
||||
<div className='grid grid-cols-[280px_1fr] gap-6'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Skeleton className='h-[20px] w-[120px] rounded-[4px]' />
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className='h-[48px] w-full rounded-[8px]' />
|
||||
))}
|
||||
</div>
|
||||
<div className='rounded-[8px] border p-6'>
|
||||
<Skeleton className='h-[24px] w-[200px] rounded-[4px]' />
|
||||
<Skeleton className='mt-[12px] h-[16px] w-[320px] rounded-[4px]' />
|
||||
<div className='mt-[24px] space-y-[16px]'>
|
||||
<div className='space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[80px] rounded-[4px]' />
|
||||
<Skeleton className='h-[40px] w-full rounded-[8px]' />
|
||||
</div>
|
||||
<div className='space-y-[8px]'>
|
||||
<Skeleton className='h-[14px] w-[100px] rounded-[4px]' />
|
||||
<Skeleton className='h-[80px] w-full rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-[24px] flex gap-[12px]'>
|
||||
<Skeleton className='h-[40px] w-[120px] rounded-[8px]' />
|
||||
<Skeleton className='h-[40px] w-[120px] rounded-[8px]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
|
||||
import ResumeExecutionPage from '@/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Resume Execution',
|
||||
robots: { index: false },
|
||||
}
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
interface PageParams {
|
||||
workflowId: string
|
||||
executionId: string
|
||||
}
|
||||
|
||||
export default async function ResumeExecutionPageWrapper({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<PageParams>
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const resolvedParams = await params
|
||||
const resolvedSearchParams = await searchParams
|
||||
|
||||
const { workflowId, executionId } = resolvedParams
|
||||
const initialContextIdParam = resolvedSearchParams?.contextId
|
||||
const initialContextId = Array.isArray(initialContextIdParam)
|
||||
? initialContextIdParam[0]
|
||||
: initialContextIdParam
|
||||
|
||||
const detail = await PauseResumeManager.getPausedExecutionDetail({
|
||||
workflowId,
|
||||
executionId,
|
||||
})
|
||||
|
||||
return (
|
||||
<ResumeExecutionPage
|
||||
params={resolvedParams}
|
||||
initialExecutionDetail={detail ? structuredClone(detail) : null}
|
||||
initialContextId={initialContextId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,986 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
ChipInput,
|
||||
ChipSelect,
|
||||
ChipTextarea,
|
||||
Code,
|
||||
cn,
|
||||
Label,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tooltip,
|
||||
} from '@sim/emcn'
|
||||
import { formatDateTime } from '@sim/utils/formatting'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import {
|
||||
type PauseContextDetail,
|
||||
type PausedExecutionDetail,
|
||||
type PausePointWithQueue,
|
||||
resumeKeys,
|
||||
usePauseContextDetail,
|
||||
useResumeContext,
|
||||
useResumeExecutionDetail,
|
||||
} from '@/hooks/queries/resume-execution'
|
||||
|
||||
interface NormalizedInputField {
|
||||
id: string
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
description?: string
|
||||
placeholder?: string
|
||||
value?: any
|
||||
required?: boolean
|
||||
options?: any[]
|
||||
rows?: number
|
||||
}
|
||||
|
||||
interface ResponseStructureRow {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
value: any
|
||||
}
|
||||
|
||||
interface ResumeExecutionPageProps {
|
||||
params: { workflowId: string; executionId: string }
|
||||
initialExecutionDetail: PausedExecutionDetail | null
|
||||
initialContextId?: string | null
|
||||
}
|
||||
|
||||
const STATUS_BADGE_VARIANT: Record<string, 'orange' | 'blue' | 'green' | 'red' | 'gray'> = {
|
||||
paused: 'orange',
|
||||
queued: 'blue',
|
||||
resuming: 'blue',
|
||||
resumed: 'green',
|
||||
failed: 'red',
|
||||
}
|
||||
|
||||
function formatDate(value: string | null): string {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
return formatDateTime(new Date(value))
|
||||
} catch {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string): string {
|
||||
if (!status) return 'Unknown'
|
||||
return status.charAt(0).toUpperCase() + status.slice(1)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<Badge variant={STATUS_BADGE_VARIANT[status] ?? 'gray'} size='sm'>
|
||||
{getStatusLabel(status)}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
function getBlockNameFromSnapshot(
|
||||
executionSnapshot: { snapshot?: string } | null | undefined,
|
||||
blockId: string | undefined
|
||||
): string | null {
|
||||
if (!executionSnapshot?.snapshot || !blockId) return null
|
||||
try {
|
||||
const parsed = JSON.parse(executionSnapshot.snapshot)
|
||||
const workflowState = parsed?.workflow
|
||||
if (!workflowState?.blocks || !Array.isArray(workflowState.blocks)) return null
|
||||
const block = workflowState.blocks.find((b: { id: string }) => b.id === blockId)
|
||||
return block?.metadata?.name || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const DISPLAY_VALUE_PREVIEW_MAX_CHARS = 5000
|
||||
|
||||
function truncateForPreview(text: string): { text: string; truncated: boolean } {
|
||||
if (text.length <= DISPLAY_VALUE_PREVIEW_MAX_CHARS) return { text, truncated: false }
|
||||
return { text: text.slice(0, DISPLAY_VALUE_PREVIEW_MAX_CHARS), truncated: true }
|
||||
}
|
||||
|
||||
function renderStructuredValuePreview(value: unknown) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span className='text-[12px] text-[var(--text-muted)]'>—</span>
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
const prettyPrinted = JSON.stringify(value, null, 2)
|
||||
const { text, truncated } = truncateForPreview(prettyPrinted)
|
||||
return (
|
||||
<div className='min-w-[220px]'>
|
||||
<Code.Viewer
|
||||
code={truncated ? `${text}\n…` : text}
|
||||
language='json'
|
||||
wrapText
|
||||
className='max-h-[220px]'
|
||||
/>
|
||||
{truncated && (
|
||||
<p className='mt-1 text-[11px] text-[var(--text-muted)]'>
|
||||
Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '}
|
||||
{prettyPrinted.length.toLocaleString()} characters shown).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { text: stringValue, truncated } = truncateForPreview(String(value))
|
||||
return (
|
||||
<div className='max-w-full'>
|
||||
<div className='inline-flex max-w-full rounded-[6px] border border-[var(--border)] bg-[var(--surface-5)] px-2 py-1 font-mono text-[12px] text-[var(--text-primary)] leading-4 [white-space:pre-wrap] [word-break:break-word]'>
|
||||
{truncated ? `${stringValue}…` : stringValue}
|
||||
</div>
|
||||
{truncated && (
|
||||
<p className='mt-1 text-[11px] text-[var(--text-muted)]'>
|
||||
Value truncated for preview ({DISPLAY_VALUE_PREVIEW_MAX_CHARS.toLocaleString()} of{' '}
|
||||
{String(value).length.toLocaleString()} characters shown).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ResumeExecutionPage({
|
||||
params,
|
||||
initialExecutionDetail,
|
||||
initialContextId,
|
||||
}: ResumeExecutionPageProps) {
|
||||
const { workflowId, executionId } = params
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const {
|
||||
data: executionDetail,
|
||||
isFetching: refreshingExecution,
|
||||
refetch: refetchExecutionDetail,
|
||||
} = useResumeExecutionDetail(workflowId, executionId, initialExecutionDetail ?? undefined)
|
||||
const pausePoints = executionDetail?.pausePoints ?? []
|
||||
|
||||
const defaultContextId = useMemo(() => {
|
||||
if (initialContextId) return initialContextId
|
||||
return (
|
||||
pausePoints.find((point) => point.resumeStatus === 'paused')?.contextId ??
|
||||
pausePoints[0]?.contextId
|
||||
)
|
||||
}, [initialContextId, pausePoints])
|
||||
|
||||
const [selectedContextId, setSelectedContextId] = useState<string | null>(
|
||||
defaultContextId ?? null
|
||||
)
|
||||
const { data: selectedDetail, isLoading: loadingDetail } = usePauseContextDetail(
|
||||
workflowId,
|
||||
executionId,
|
||||
selectedContextId ?? undefined
|
||||
)
|
||||
const [selectedStatus, setSelectedStatus] =
|
||||
useState<PausePointWithQueue['resumeStatus']>('paused')
|
||||
const [queuePosition, setQueuePosition] = useState<number | null | undefined>(undefined)
|
||||
const resumeInputsRef = useRef<Record<string, string>>({})
|
||||
const [resumeInput, setResumeInput] = useState('')
|
||||
const [formValuesByContext, setFormValuesByContext] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({})
|
||||
const [formValues, setFormValues] = useState<Record<string, string>>({})
|
||||
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
|
||||
const [loadingAction, setLoadingAction] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [message, setMessage] = useState<string | null>(null)
|
||||
|
||||
const resumeMutation = useResumeContext()
|
||||
|
||||
const normalizeInputFormatFields = useCallback((raw: any): NormalizedInputField[] => {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((field: any, index: number) => {
|
||||
if (!field || typeof field !== 'object') return null
|
||||
const name = typeof field.name === 'string' ? field.name.trim() : ''
|
||||
if (!name) return null
|
||||
return {
|
||||
id: typeof field.id === 'string' && field.id.length > 0 ? field.id : `field_${index}`,
|
||||
name,
|
||||
label:
|
||||
typeof field.label === 'string' && field.label.trim().length > 0
|
||||
? field.label.trim()
|
||||
: name,
|
||||
type:
|
||||
typeof field.type === 'string' && field.type.trim().length > 0 ? field.type : 'string',
|
||||
description:
|
||||
typeof field.description === 'string' && field.description.trim().length > 0
|
||||
? field.description.trim()
|
||||
: undefined,
|
||||
placeholder:
|
||||
typeof field.placeholder === 'string' && field.placeholder.trim().length > 0
|
||||
? field.placeholder.trim()
|
||||
: undefined,
|
||||
value: field.value,
|
||||
required: field.required === true,
|
||||
options: Array.isArray(field.options) ? field.options : undefined,
|
||||
rows: typeof field.rows === 'number' ? field.rows : undefined,
|
||||
} as NormalizedInputField
|
||||
})
|
||||
.filter((field): field is NormalizedInputField => field !== null)
|
||||
}, [])
|
||||
|
||||
const formatValueForInputField = useCallback(
|
||||
(field: NormalizedInputField, value: any): string => {
|
||||
if (value === undefined || value === null) return ''
|
||||
switch (field.type) {
|
||||
case 'boolean':
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true' || normalized === 'false') return normalized
|
||||
}
|
||||
return ''
|
||||
case 'number':
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? String(value) : ''
|
||||
if (typeof value === 'string') return value
|
||||
return ''
|
||||
case 'array':
|
||||
case 'object':
|
||||
case 'files':
|
||||
if (typeof value === 'string') return value
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
default:
|
||||
return typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const buildInitialFormValues = useCallback(
|
||||
(fields: NormalizedInputField[], submission?: Record<string, any>) => {
|
||||
const initial: Record<string, string> = {}
|
||||
for (const field of fields) {
|
||||
const candidate =
|
||||
submission && Object.hasOwn(submission, field.name) ? submission[field.name] : field.value
|
||||
initial[field.name] = formatValueForInputField(field, candidate)
|
||||
}
|
||||
return initial
|
||||
},
|
||||
[formatValueForInputField]
|
||||
)
|
||||
|
||||
const formatStructureValue = useCallback((value: any): string => {
|
||||
if (value === null || value === undefined) return '—'
|
||||
if (typeof value === 'string') return value
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
||||
try {
|
||||
return JSON.stringify(value, null, 2)
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const parseFormValue = useCallback(
|
||||
(field: NormalizedInputField, rawValue: string): { value: any; error?: string } => {
|
||||
const value = rawValue ?? ''
|
||||
switch (field.type) {
|
||||
case 'number': {
|
||||
if (!value.trim()) return { value: null }
|
||||
const numericValue = Number(value)
|
||||
if (Number.isNaN(numericValue)) return { value: null, error: 'Enter a valid number.' }
|
||||
return { value: numericValue }
|
||||
}
|
||||
case 'boolean': {
|
||||
if (value === 'true') return { value: true }
|
||||
if (value === 'false') return { value: false }
|
||||
if (!value) return { value: null }
|
||||
return { value: null, error: 'Select true or false.' }
|
||||
}
|
||||
case 'array':
|
||||
case 'object':
|
||||
case 'files': {
|
||||
if (!value.trim()) {
|
||||
if (field.type === 'array') return { value: [] }
|
||||
return { value: {} }
|
||||
}
|
||||
try {
|
||||
return { value: JSON.parse(value) }
|
||||
} catch {
|
||||
return { value: null, error: 'Enter valid JSON.' }
|
||||
}
|
||||
}
|
||||
default:
|
||||
return { value }
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleFormFieldChange = useCallback(
|
||||
(fieldName: string, newValue: string) => {
|
||||
if (!selectedContextId) return
|
||||
setFormValues((prev) => {
|
||||
const updated = { ...prev, [fieldName]: newValue }
|
||||
setFormValuesByContext((map) => ({ ...map, [selectedContextId]: updated }))
|
||||
return updated
|
||||
})
|
||||
setFormErrors((prev) => {
|
||||
if (!prev[fieldName]) return prev
|
||||
const { [fieldName]: _, ...rest } = prev
|
||||
return rest
|
||||
})
|
||||
},
|
||||
[selectedContextId]
|
||||
)
|
||||
|
||||
const renderFieldInput = useCallback(
|
||||
(field: NormalizedInputField) => {
|
||||
const value = formValues[field.name] ?? ''
|
||||
switch (field.type) {
|
||||
case 'boolean': {
|
||||
const selectValue = value === 'true' || value === 'false' ? value : '__unset__'
|
||||
return (
|
||||
<ChipSelect
|
||||
value={selectValue}
|
||||
onChange={(val) => handleFormFieldChange(field.name, val)}
|
||||
placeholder={field.required ? 'Select true or false' : 'Select...'}
|
||||
options={[
|
||||
...(field.required ? [] : [{ label: 'Not set', value: '__unset__' }]),
|
||||
{ label: 'True', value: 'true' },
|
||||
{ label: 'False', value: 'false' },
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case 'number':
|
||||
return (
|
||||
<ChipInput
|
||||
type='number'
|
||||
value={value}
|
||||
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder ?? 'Enter a number...'}
|
||||
error={Boolean(formErrors[field.name])}
|
||||
/>
|
||||
)
|
||||
case 'array':
|
||||
case 'object':
|
||||
case 'files':
|
||||
return (
|
||||
<ChipTextarea
|
||||
value={value}
|
||||
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder ?? (field.type === 'array' ? '[...]' : '{...}')}
|
||||
rows={5}
|
||||
error={Boolean(formErrors[field.name])}
|
||||
/>
|
||||
)
|
||||
default: {
|
||||
if (field.rows !== undefined && field.rows > 3) {
|
||||
return (
|
||||
<ChipTextarea
|
||||
value={value}
|
||||
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder ?? 'Enter value...'}
|
||||
rows={5}
|
||||
error={Boolean(formErrors[field.name])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<ChipInput
|
||||
value={value}
|
||||
onChange={(e) => handleFormFieldChange(field.name, e.target.value)}
|
||||
placeholder={field.placeholder ?? 'Enter value...'}
|
||||
error={Boolean(formErrors[field.name])}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[formValues, formErrors, handleFormFieldChange]
|
||||
)
|
||||
|
||||
const renderDisabledFieldInput = useCallback(
|
||||
(field: NormalizedInputField, resumedValues: Record<string, any>) => {
|
||||
const rawValue = resumedValues[field.name]
|
||||
const value =
|
||||
rawValue !== undefined
|
||||
? typeof rawValue === 'object'
|
||||
? JSON.stringify(rawValue)
|
||||
: String(rawValue)
|
||||
: ''
|
||||
switch (field.type) {
|
||||
case 'boolean': {
|
||||
const displayValue = rawValue === true ? 'True' : rawValue === false ? 'False' : 'Not set'
|
||||
return <ChipInput value={displayValue} disabled />
|
||||
}
|
||||
case 'number':
|
||||
return <ChipInput type='number' value={value} disabled />
|
||||
case 'array':
|
||||
case 'object':
|
||||
case 'files':
|
||||
return <ChipTextarea value={value} viewOnly rows={5} />
|
||||
default: {
|
||||
if (field.rows !== undefined && field.rows > 3) {
|
||||
return <ChipTextarea value={value} viewOnly rows={5} />
|
||||
}
|
||||
return <ChipInput value={value} disabled />
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const selectedOperation = useMemo(
|
||||
() => selectedDetail?.pausePoint.response?.data?.operation || 'human',
|
||||
[selectedDetail]
|
||||
)
|
||||
const isHumanMode = selectedOperation === 'human'
|
||||
|
||||
const inputFormatFields = useMemo(
|
||||
() => normalizeInputFormatFields(selectedDetail?.pausePoint.response?.data?.inputFormat),
|
||||
[normalizeInputFormatFields, selectedDetail]
|
||||
)
|
||||
const hasInputFormat = inputFormatFields.length > 0
|
||||
|
||||
const responseStructureRows = useMemo<ResponseStructureRow[]>(() => {
|
||||
const raw = selectedDetail?.pausePoint.response?.data?.responseStructure
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((entry: any, index: number) => {
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const name =
|
||||
typeof entry.name === 'string' && entry.name.length > 0 ? entry.name : `field_${index}`
|
||||
const type =
|
||||
typeof entry.type === 'string' && entry.type.length > 0
|
||||
? entry.type
|
||||
: Array.isArray(entry.value)
|
||||
? 'array'
|
||||
: typeof entry.value
|
||||
return {
|
||||
id: entry.id ?? `${name}_${index}`,
|
||||
name,
|
||||
type,
|
||||
value: entry.value,
|
||||
} as ResponseStructureRow
|
||||
})
|
||||
.filter((row): row is ResponseStructureRow => row !== null)
|
||||
}, [selectedDetail])
|
||||
|
||||
const seedFormFromDetail = useCallback(
|
||||
(detail: PauseContextDetail) => {
|
||||
const responseData = detail.pausePoint.response?.data ?? {}
|
||||
const operation = responseData.operation || 'human'
|
||||
const fetchedInputFields = normalizeInputFormatFields(responseData.inputFormat)
|
||||
const submission =
|
||||
responseData &&
|
||||
typeof responseData.submission === 'object' &&
|
||||
!Array.isArray(responseData.submission)
|
||||
? (responseData.submission as Record<string, any>)
|
||||
: undefined
|
||||
if (operation === 'human' && fetchedInputFields.length > 0) {
|
||||
const baseValues = buildInitialFormValues(fetchedInputFields, submission)
|
||||
let mergedValues = baseValues
|
||||
setFormValuesByContext((prev) => {
|
||||
const existingValues = prev[detail.pausePoint.contextId]
|
||||
if (existingValues) mergedValues = { ...baseValues, ...existingValues }
|
||||
return { ...prev, [detail.pausePoint.contextId]: mergedValues }
|
||||
})
|
||||
setFormValues(mergedValues)
|
||||
setFormErrors({})
|
||||
if (resumeInputsRef.current[detail.pausePoint.contextId] !== undefined) {
|
||||
delete resumeInputsRef.current[detail.pausePoint.contextId]
|
||||
}
|
||||
setResumeInput('')
|
||||
} else {
|
||||
const initialValue =
|
||||
typeof responseData === 'string'
|
||||
? responseData
|
||||
: JSON.stringify(responseData ?? {}, null, 2)
|
||||
if (resumeInputsRef.current[detail.pausePoint.contextId] !== undefined) {
|
||||
setResumeInput(resumeInputsRef.current[detail.pausePoint.contextId])
|
||||
} else {
|
||||
setResumeInput(initialValue)
|
||||
resumeInputsRef.current = {
|
||||
...resumeInputsRef.current,
|
||||
[detail.pausePoint.contextId]: initialValue,
|
||||
}
|
||||
}
|
||||
setFormValues({})
|
||||
setFormErrors({})
|
||||
}
|
||||
},
|
||||
[normalizeInputFormatFields, buildInitialFormValues]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDetail) return
|
||||
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
|
||||
setQueuePosition(selectedDetail.pausePoint.queuePosition)
|
||||
seedFormFromDetail(selectedDetail)
|
||||
}, [selectedDetail, seedFormFromDetail])
|
||||
|
||||
const handleRefreshExecution = useCallback(async () => {
|
||||
const { data } = await refetchExecutionDetail()
|
||||
if (!selectedContextId) {
|
||||
const firstPaused =
|
||||
data?.pausePoints.find((point) => point.resumeStatus === 'paused')?.contextId ?? null
|
||||
setSelectedContextId(firstPaused)
|
||||
}
|
||||
}, [refetchExecutionDetail, selectedContextId])
|
||||
|
||||
const handleResume = useCallback(
|
||||
async () => {
|
||||
if (!selectedContextId || !selectedDetail) {
|
||||
setError('No pause point is selected. Refresh and try again.')
|
||||
return
|
||||
}
|
||||
setLoadingAction(true)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
let resumePayload: any
|
||||
try {
|
||||
if (isHumanMode && hasInputFormat) {
|
||||
const errors: Record<string, string> = {}
|
||||
const submission: Record<string, any> = {}
|
||||
for (const field of inputFormatFields) {
|
||||
const rawValue = formValues[field.name] ?? ''
|
||||
const hasValue =
|
||||
field.type === 'boolean'
|
||||
? rawValue === 'true' || rawValue === 'false'
|
||||
: rawValue.trim().length > 0 && rawValue !== '__unset__'
|
||||
if (!hasValue || rawValue === '__unset__') {
|
||||
if (field.required) errors[field.name] = 'This field is required.'
|
||||
continue
|
||||
}
|
||||
const { value, error: parseError } = parseFormValue(field, rawValue)
|
||||
if (parseError) {
|
||||
errors[field.name] = parseError
|
||||
continue
|
||||
}
|
||||
if (value !== undefined) submission[field.name] = value
|
||||
}
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setFormErrors(errors)
|
||||
setError('Fix the highlighted fields before resuming.')
|
||||
setLoadingAction(false)
|
||||
return
|
||||
}
|
||||
setFormErrors({})
|
||||
resumePayload = { submission }
|
||||
} else {
|
||||
let parsedInput: any
|
||||
if (resumeInput && resumeInput.trim().length > 0) {
|
||||
try {
|
||||
parsedInput = JSON.parse(resumeInput)
|
||||
} catch {
|
||||
setError('Resume input must be valid JSON.')
|
||||
setLoadingAction(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
resumePayload = parsedInput
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to prepare resume payload.')
|
||||
setLoadingAction(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { ok, payload } = await resumeMutation.mutateAsync({
|
||||
workflowId,
|
||||
executionId,
|
||||
contextId: selectedContextId,
|
||||
input: resumePayload,
|
||||
})
|
||||
if (!ok) {
|
||||
setError(payload.error || 'Failed to resume execution.')
|
||||
setSelectedStatus(selectedDetail.pausePoint.resumeStatus)
|
||||
return
|
||||
}
|
||||
const nextStatus = payload.status === 'queued' ? 'queued' : 'resuming'
|
||||
const nextQueuePosition = payload.queuePosition ?? null
|
||||
const fallbackContextId =
|
||||
executionDetail?.pausePoints.find(
|
||||
(point) => point.contextId !== selectedContextId && point.resumeStatus === 'paused'
|
||||
)?.contextId ?? null
|
||||
queryClient.setQueryData<PausedExecutionDetail>(
|
||||
resumeKeys.execution(workflowId, executionId),
|
||||
(prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
pausePoints: prev.pausePoints.map((point) =>
|
||||
point.contextId === selectedContextId
|
||||
? { ...point, resumeStatus: nextStatus, queuePosition: nextQueuePosition }
|
||||
: point
|
||||
),
|
||||
}
|
||||
}
|
||||
)
|
||||
queryClient.setQueryData<PauseContextDetail | null>(
|
||||
resumeKeys.context(workflowId, executionId, selectedContextId),
|
||||
(prev) => {
|
||||
if (!prev || prev.pausePoint.contextId !== selectedContextId) return prev
|
||||
return {
|
||||
...prev,
|
||||
pausePoint: {
|
||||
...prev.pausePoint,
|
||||
resumeStatus: nextStatus,
|
||||
queuePosition: nextQueuePosition,
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
setSelectedStatus(nextStatus)
|
||||
setQueuePosition(nextQueuePosition)
|
||||
setSelectedContextId((prev) => (prev !== selectedContextId ? prev : fallbackContextId))
|
||||
setMessage(
|
||||
payload.status === 'queued' ? 'Resume request queued.' : 'Resume started successfully.'
|
||||
)
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Unexpected error while resuming execution.')
|
||||
} finally {
|
||||
setLoadingAction(false)
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
workflowId,
|
||||
executionId,
|
||||
selectedContextId,
|
||||
isHumanMode,
|
||||
hasInputFormat,
|
||||
inputFormatFields,
|
||||
formValues,
|
||||
parseFormValue,
|
||||
resumeInput,
|
||||
selectedDetail,
|
||||
executionDetail,
|
||||
queryClient,
|
||||
]
|
||||
)
|
||||
|
||||
const isFormComplete = useMemo(() => {
|
||||
if (!isHumanMode || !hasInputFormat) return true
|
||||
return inputFormatFields.every((field) => {
|
||||
const rawValue = formValues[field.name] ?? ''
|
||||
if (field.type === 'boolean') {
|
||||
if (field.required) return rawValue === 'true' || rawValue === 'false'
|
||||
return rawValue === '' || rawValue === 'true' || rawValue === 'false'
|
||||
}
|
||||
if (!field.required) return true
|
||||
return rawValue.trim().length > 0
|
||||
})
|
||||
}, [isHumanMode, hasInputFormat, inputFormatFields, formValues])
|
||||
|
||||
const resumeDisabled =
|
||||
loadingAction ||
|
||||
selectedStatus === 'resumed' ||
|
||||
selectedStatus === 'failed' ||
|
||||
selectedStatus === 'resuming' ||
|
||||
selectedStatus === 'queued' ||
|
||||
(isHumanMode && hasInputFormat && (!isFormComplete || Object.keys(formErrors).length > 0))
|
||||
|
||||
const getBlockName = (pause: PausePointWithQueue) => {
|
||||
const pauseBlockId = pause.blockId || pause.triggerBlockId
|
||||
return (
|
||||
getBlockNameFromSnapshot(executionDetail?.executionSnapshot, pauseBlockId) ||
|
||||
'Human in the Loop'
|
||||
)
|
||||
}
|
||||
|
||||
// Not found state
|
||||
if (!executionDetail) {
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div className='flex flex-1 items-center justify-center p-6'>
|
||||
<div className='max-w-[400px] text-center'>
|
||||
<h1 className='mb-2 font-medium text-[20px] text-[var(--text-primary)]'>
|
||||
Execution Not Found
|
||||
</h1>
|
||||
<p className='mb-6 text-[14px] text-[var(--text-secondary)]'>
|
||||
This execution could not be located or has already completed.
|
||||
</p>
|
||||
<Button variant='outline' onClick={() => router.push('/')}>
|
||||
Return Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip.Provider>
|
||||
<div className='mx-auto w-full max-w-[1200px] px-6 py-8'>
|
||||
{/* Header */}
|
||||
<div className='mb-8 flex items-center justify-between'>
|
||||
<div>
|
||||
<h1 className='font-medium text-[20px] text-[var(--text-primary)]'>Paused Execution</h1>
|
||||
<p className='mt-1 text-[14px] text-[var(--text-secondary)]'>
|
||||
Select a pause point to review and resume
|
||||
</p>
|
||||
</div>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={handleRefreshExecution}
|
||||
disabled={refreshingExecution}
|
||||
className='gap-1.5 px-2.5'
|
||||
aria-label='Refresh execution details'
|
||||
>
|
||||
<RefreshCw className={cn('size-[14px]', refreshingExecution && 'animate-spin')} />
|
||||
Refresh
|
||||
</Button>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content>Refresh</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
</div>
|
||||
|
||||
{/* Main Layout */}
|
||||
<div className='grid grid-cols-[280px_1fr] gap-6'>
|
||||
{/* Pause Points List */}
|
||||
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<div className='border-[var(--border)] border-b px-4 py-3'>
|
||||
<Label>Pause Points</Label>
|
||||
</div>
|
||||
<div>
|
||||
{pausePoints.length === 0 ? (
|
||||
<div className='px-4 py-8 text-center text-[13px] text-[var(--text-secondary)]'>
|
||||
No pause points
|
||||
</div>
|
||||
) : (
|
||||
pausePoints.map((pause) => (
|
||||
<Button
|
||||
key={pause.contextId}
|
||||
variant={pause.contextId === selectedContextId ? 'active' : 'ghost'}
|
||||
onClick={() => {
|
||||
setSelectedContextId(pause.contextId)
|
||||
setError(null)
|
||||
setMessage(null)
|
||||
}}
|
||||
className='w-full justify-between rounded-none px-4 py-3'
|
||||
>
|
||||
<span className='text-[13px]'>{getBlockName(pause)}</span>
|
||||
<StatusBadge status={pause.resumeStatus} />
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail Panel */}
|
||||
<div>
|
||||
{loadingDetail && !selectedDetail ? (
|
||||
<div className='flex h-[200px] items-center justify-center rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>Loading…</span>
|
||||
</div>
|
||||
) : !selectedContextId ? (
|
||||
<div className='flex h-[200px] items-center justify-center rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Select a pause point
|
||||
</span>
|
||||
</div>
|
||||
) : !selectedDetail ? (
|
||||
<div className='flex h-[200px] items-center justify-center rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<span className='text-[13px] text-[var(--text-secondary)]'>
|
||||
Could not load details
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{/* Status Header */}
|
||||
<div className='flex items-center justify-between rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)] px-4 py-3'>
|
||||
<div>
|
||||
<Label>{getBlockName(selectedDetail.pausePoint)}</Label>
|
||||
<p className='mt-[2px] text-[12px] text-[var(--text-muted)]'>
|
||||
Paused at {formatDate(selectedDetail.pausePoint.registeredAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<StatusBadge status={selectedStatus} />
|
||||
{queuePosition && queuePosition > 0 && (
|
||||
<Badge variant='gray' size='sm'>
|
||||
Queue #{queuePosition}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Already resolved - show form fields with submitted values */}
|
||||
{selectedStatus === 'resumed' || selectedStatus === 'failed' ? (
|
||||
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<div className='border-[var(--border)] border-b px-4 py-3'>
|
||||
<Label>Resume Form</Label>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
{selectedStatus === 'failed' &&
|
||||
selectedDetail.pausePoint.latestResumeEntry?.failureReason && (
|
||||
<Badge variant='red' size='sm'>
|
||||
{selectedDetail.pausePoint.latestResumeEntry.failureReason}
|
||||
</Badge>
|
||||
)}
|
||||
{inputFormatFields.length > 0 &&
|
||||
selectedDetail.pausePoint.latestResumeEntry?.resumeInput ? (
|
||||
inputFormatFields.map((field) => (
|
||||
<div key={field.id} className='flex flex-col gap-[9px]'>
|
||||
<Label>{field.label}</Label>
|
||||
{field.description && (
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
{renderDisabledFieldInput(
|
||||
field,
|
||||
selectedDetail.pausePoint.latestResumeEntry?.resumeInput
|
||||
?.submission ??
|
||||
selectedDetail.pausePoint.latestResumeEntry?.resumeInput ??
|
||||
{}
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : selectedDetail.pausePoint.latestResumeEntry?.resumeInput ? (
|
||||
<ChipTextarea
|
||||
value={JSON.stringify(
|
||||
selectedDetail.pausePoint.latestResumeEntry.resumeInput,
|
||||
null,
|
||||
2
|
||||
)}
|
||||
viewOnly
|
||||
rows={6}
|
||||
/>
|
||||
) : (
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
No input data provided
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Display Data */}
|
||||
{responseStructureRows.length > 0 ? (
|
||||
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<div className='border-[var(--border)] border-b px-4 py-3'>
|
||||
<Label>Display Data</Label>
|
||||
</div>
|
||||
<div className='p-4'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Field</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{responseStructureRows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>{row.name}</TableCell>
|
||||
<TableCell>{row.type}</TableCell>
|
||||
<TableCell>{renderStructuredValuePreview(row.value)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<div className='border-[var(--border)] border-b px-4 py-3'>
|
||||
<Label>Display Data</Label>
|
||||
</div>
|
||||
<div className='p-4'>
|
||||
<p className='text-[13px] text-[var(--text-muted)]'>
|
||||
No display data configured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resume Form */}
|
||||
{isHumanMode && hasInputFormat ? (
|
||||
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<div className='border-[var(--border)] border-b px-4 py-3'>
|
||||
<Label>Resume Form</Label>
|
||||
</div>
|
||||
<div className='flex flex-col gap-4 p-4'>
|
||||
{inputFormatFields.map((field) => (
|
||||
<div key={field.id} className='flex flex-col gap-[9px]'>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className='ml-1 text-[var(--text-error)]'>*</span>
|
||||
)}
|
||||
</Label>
|
||||
{field.description && (
|
||||
<p className='text-[12px] text-[var(--text-muted)]'>
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
{renderFieldInput(field)}
|
||||
{formErrors[field.name] && (
|
||||
<Badge variant='red' size='sm'>
|
||||
{formErrors[field.name]}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className='overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--surface-1)]'>
|
||||
<div className='border-[var(--border)] border-b px-4 py-3'>
|
||||
<Label>Resume Input (JSON)</Label>
|
||||
</div>
|
||||
<div className='p-4'>
|
||||
<ChipTextarea
|
||||
value={resumeInput}
|
||||
onChange={(e) => {
|
||||
setResumeInput(e.target.value)
|
||||
if (selectedContextId) {
|
||||
resumeInputsRef.current = {
|
||||
...resumeInputsRef.current,
|
||||
[selectedContextId]: e.target.value,
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder='{"example": "value"}'
|
||||
rows={6}
|
||||
spellCheck={false}
|
||||
className='min-h-[180px] font-mono'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{error && <Badge variant='red'>{error}</Badge>}
|
||||
{message && <Badge variant='green'>{message}</Badge>}
|
||||
|
||||
{/* Action */}
|
||||
<Button variant='primary' onClick={handleResume} disabled={resumeDisabled}>
|
||||
{loadingAction ? 'Resuming...' : 'Resume Execution'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# Landing Page - Build & Optimization Instructions
|
||||
|
||||
This route group owns `/` and the entire public marketing surface - the home page, platform/solutions pages, pricing, legal, and the marketing subroutes (`/blog`, `/library`, `/models`, `/integrations`, `/demo`, `/partners`, `/changelog`). Read this file in full before adding or changing anything here. Positioning and language rules live in `.claude/rules/constitution.md`; SEO/GEO rules in `.claude/rules/landing-seo-geo.md`. Both apply to every file in this directory.
|
||||
|
||||
`/blog` (editorial/company-voice posts) and `/library` (AEO/GEO content - listicles, comparisons, how-tos) are two separate route trees over one shared engine: `apps/sim/lib/content/` (generic registry factory, MDX components, SEO builders) instantiated per-section by the thin `apps/sim/lib/blog/` and `apps/sim/lib/library/` modules, rendered through the shared `Content*Page` components in `components/`. Content lives in `apps/sim/content/blog/` and `apps/sim/content/library/`; both share `apps/sim/content/authors/`. Adding a post to either section, or adding a new content section entirely, must reuse this engine - never hand-roll a divergent registry or page layout. Every new marketing subroute (including any future content section) needs `app/sitemap.ts` and `app/robots.ts` updated, same as `/blog` and `/library` were.
|
||||
|
||||
## What this is
|
||||
|
||||
- `app/(landing)/` - the marketing site. A shared `layout.tsx` renders the chrome once (the `LandingShell`: light tokens, navbar with server-side GitHub stars, footer, site-wide JSON-LD); each page supplies only its `<main>` content.
|
||||
- The legacy `app/(home)/` group (old dark landing + `--landing-*` tokens) has been **deleted** - its marketing pages were migrated here and its chrome retired. Do not reintroduce `--landing-*` tokens, Martian Mono accents, or a separate marketing theme.
|
||||
|
||||
## Styling - draw from the platform's light mode
|
||||
|
||||
The landing page looks like the product. Its visual language is the workspace UI in light mode, not a separate marketing theme.
|
||||
|
||||
- **Always light.** The root wrapper in `landing.tsx` carries the `light` class, which pins every token to its light value (see `app/_styles/globals.css`, the `:root, .light` block). Never add `dark:` variants here; never read the user's theme.
|
||||
- **Use platform tokens, never hex.** Canvas `--bg`, surfaces `--surface-1`…`--surface-7`, cards/modals `--surface-2`, hover `--surface-hover`, active `--surface-active`; text `--text-primary` / `--text-secondary` / `--text-muted` / `--text-body`, icons `--text-icon`; borders `--border` (dividers) / `--border-1` (fields); brand `--brand-agent` / `--brand-secondary` / `--brand-accent`. Do **not** use the legacy `--landing-*` tokens - they belong to the old dark landing.
|
||||
- **Use emcn components where they fit.** The chip family (`Chip`, `ChipLink`, `ChipTag`, `ChipInput`, `ChipModal*`, …) from `@/components/emcn` is the canonical chrome - a demo-request form is a `ChipModal` with `ChipModalField`s, a pill CTA is a `Chip`/`ChipLink`. Components own their chrome; pass props, not className overrides. Full consumer rules: `.claude/rules/sim-styling.md`.
|
||||
- **Typography is the platform's.** Season is the global body font (`font-season` is applied on `<body>` in the root layout). Use the platform text scale (`text-small` = 13px, `text-base` = 15px, etc. - see `tailwind.config.ts`). Don't add new fonts or font CSS variables without explicit direction.
|
||||
- **Never touch global styles.** No additions to `app/_styles/globals.css`. All styling is local Tailwind classes; `cn()` from `@/lib/core/utils/cn` for conditionals; no inline `style` attributes.
|
||||
- **Responsive - desktop is the source of truth, scaled down via `max-*` overrides.** The page is fully responsive (iPad + phone). The desktop layout stays the unprefixed baseline; smaller screens are handled by *layering* `max-*` overrides on top, so desktop renders byte-identically. Tiers:
|
||||
- `max-xl:` (≤1279) - the hero's two-panel split (absolute visual + logos) collapses to a stacked, in-flow column. The split needs ≥1280 to avoid the headline colliding with the visual panel; iPad-landscape (1024) therefore gets the stacked hero with the desktop nav.
|
||||
- `max-lg:` (≤1023) - the desktop nav clusters hide (`hidden lg:flex`) and `MobileNav` (hamburger sheet) takes over; multi-column grids step down (mothership 4→2, footer 7→3); shared gutter `px-20 → max-lg:px-8`; section gaps tighten.
|
||||
- `max-md:` (≤767) - Features beats drop the floating callout (`max-md:hidden`) and show the un-masked backdrop preview full-width.
|
||||
- `max-sm:` (≤639) - single-column grids, smallest type scale, `px-5` gutter, hero CTA row stacks.
|
||||
|
||||
When adding a new section, give it the same `px-20 max-lg:px-8 max-sm:px-5` gutter so the navbar wordmark stays aligned with section content at every width. Verify desktop is unchanged and there is zero horizontal overflow at 1280 / 1024 / 768 / 390 before shipping.
|
||||
|
||||
## Performance - page speed is a feature
|
||||
|
||||
Target: Lighthouse 95+ on mobile, LCP < 2.0s, CLS < 0.05, minimal hydration cost.
|
||||
|
||||
- **Server Components by default.** `'use client'` only on the smallest leaf that genuinely needs interactivity (a button with state, not the section containing it). The navbar, hero copy, footer, and every static section must be server-rendered HTML.
|
||||
- **No heavy client libraries above the fold.** No animation frameworks (framer-motion etc.), no ReactFlow, no chart libs in the initial bundle. If a below-fold section truly needs one, load it with `next/dynamic` and a dimension-stable placeholder.
|
||||
- **Images via `next/image` always.** The LCP element (logo or hero visual) gets `priority`; everything below the fold lazy-loads (the default). Every image has explicit `width`/`height` - zero layout shift.
|
||||
- **Prefer CSS over JS.** Hover states, transitions, marquees, and reveal effects in CSS (`transition-*`, `animation`) rather than scroll listeners or animation libraries. Decorative motion respects `prefers-reduced-motion`.
|
||||
- **Static rendering.** The page is statically generated with `revalidate` (set in `page.tsx`). Never fetch per-request data in the page tree; anything dynamic (e.g. GitHub stars) is fetched at build/revalidate time or deferred to a client island. A `cookies()`/`headers()`/`unstable_noStore()` call anywhere in the tree - including the root `app/layout.tsx` - silently overrides every page's `revalidate` and forces the whole app dynamic. If a marketing page builds as `ƒ` instead of `○`/`●` (check `bun run build`'s route table), look upstream, not just at the page itself.
|
||||
- **Reserve space for everything.** Fixed dimensions or aspect ratios on all media, embeds, and async content. CLS budget is effectively zero.
|
||||
- **Decorative canvases and animations are non-interactive.** A hand-built product-demo animation or embedded ReactFlow canvas is presentation only - no drag handlers, no `nodesDraggable`/`panOnDrag`/`elementsSelectable` on ReactFlow. A visitor should never be able to click or drag a decorative element.
|
||||
- **Lazy-mount a heavy client island's second occurrence.** If the same animated component appears twice on a page, only the first (usually the hero) loads eagerly - the rest go through a small `'use client'` mount wrapper built on the shared `hooks/use-lazy-mount.ts` hook: `next/dynamic(..., { ssr: false })` gated by the hook's `IntersectionObserver`. See `components/product-demo/components/product-demo-visual-mount/` for the reference pattern, and `.claude/rules/sim-imports.md` for the barrel-cleanup step that must come with it.
|
||||
- **Don't prefetch authenticated-app routes from an always-visible CTA.** `<Link>` prefetches its target route's JS once it's in the viewport - a navbar/hero CTA to `/signup` or `/login` is always in view, so it downloads that route's bundle on every pageview. Pass `prefetch={false}` there. Leave the default on CTAs that only enter the viewport on scroll (prefetch-on-approach is the desired behavior there).
|
||||
|
||||
## SEO
|
||||
|
||||
`page.tsx` owns the metadata (title, description, OG/Twitter, canonical, robots) - keep it the single source of truth and keep it aligned with the constitution's claim hierarchy. Beyond metadata:
|
||||
|
||||
- **One `<h1>`, in the hero, containing "Sim" and "AI workspace".** Strict hierarchy below it: H2 per section, H3 for items within a section. Never skip levels, never add a second H1.
|
||||
- **Semantic landmarks**: `<header>`, `<main>`, `<footer>`, `<nav>`; each section is `<section id="…" aria-labelledby="…-heading">`. Decorative/animated elements get `aria-hidden='true'`.
|
||||
- **Structured data**: emit JSON-LD (`Organization`, `WebSite`, `WebApplication` with `featureList`, `FAQPage` if an FAQ exists) from a server component rendered before visible content. Keep `featureList` in sync with the features actually shown on the page.
|
||||
- **Crawlable links**: all internal navigation uses Next `<Link>` with real `href`s - never `onClick` navigation. External links get `rel='noopener noreferrer'`.
|
||||
- **All copy is server-rendered text.** No text baked into images, no content that only exists after a client effect runs.
|
||||
- After adding routes or anchors, verify `app/sitemap.ts` and `app/robots.ts` still reflect reality.
|
||||
|
||||
## GEO (generative engine optimization)
|
||||
|
||||
AI crawlers and answer engines read this page. Optimize for extraction:
|
||||
|
||||
- **Answer-first sections.** Each H2 + first paragraph should directly answer a question a user would ask an AI ("What is Sim?", "What integrations does Sim support?", "How much does Sim cost?").
|
||||
- **Atomic blocks.** Every feature card, template, and pricing tier must be independently quotable - self-contained, with "Sim" named explicitly. Never "the platform", "our tool", or a bare pronoun as the subject.
|
||||
- **Specific numbers over vague claims.** "1,000+ integrations", "every major LLM", "100,000+ builders" - and only numbers that are true and shipped.
|
||||
- **sr-only summaries.** The hero gets a `<p className='sr-only'>` (~50 words) stating what Sim is, who it's for, and what it does - a clean citation target for AI summarizers.
|
||||
- The first 150 visible characters of the page must include "Sim", "AI workspace", and "AI agents".
|
||||
|
||||
## Copy
|
||||
|
||||
Follow `.claude/rules/constitution.md` exactly: Sim is "the open-source AI workspace where teams build, deploy, and manage AI agents" - never a workflow tool or automation platform. Direct sentences, active voice, concrete examples, no exclamation marks, no unexplained jargon on public pages.
|
||||
|
||||
## Structure
|
||||
|
||||
**Directory convention - mirror `app/workspace/[workspaceId]/<feature>`.** Every component lives in its own kebab-case folder holding `<name>.tsx` plus an `index.ts` barrel (relative re-export); a component's children nest under that folder's `components/` (each itself foldered, with its own `index.ts`). Non-component modules - `types.ts`, `constants.ts`, data files - sit at the relevant folder root. Never leave a bare `<name>.tsx` flat inside a `components/` directory.
|
||||
|
||||
```
|
||||
(landing)/
|
||||
├── CLAUDE.md # this file
|
||||
├── page.tsx # route entry: metadata + <Landing />
|
||||
├── landing.tsx # root composition: <main> section order
|
||||
├── workflows/ # a platform route: page.tsx (metadata) + workflows.tsx (config + shell)
|
||||
├── hooks/ # cross-page client hooks (useLazyMount, …) - bare files, no folder/barrel
|
||||
└── components/
|
||||
├── index.ts # top barrel
|
||||
├── navbar/{navbar.tsx, index.ts, components/<chip>/…} # <header><nav>: wordmark, dropdowns, stars, auth chips
|
||||
├── hero/{hero.tsx, index.ts, components/hero-visual/…} # h1, description, CTA, platform visual, logos
|
||||
├── lifecycle/, features/, footer/, testimonials/ # each: <name>.tsx + index.ts (+ components/)
|
||||
├── shared/ # cross-page reused chrome (folder-per-component + barrel)
|
||||
│ ├── landing-shell/ # light wrapper + skip link + Navbar(stars) + Footer; wraps every page
|
||||
│ ├── hero-cta/ # the one email-capture + Sign-up CTA (hero + every platform hero)
|
||||
│ └── logos/ # the one customer-logo set; layout='grid' (hero) | 'row' (platform)
|
||||
└── platform-page/ # the reusable platform-page layout (Workflows, Tables, Files, …)
|
||||
├── platform-page.tsx, index.ts, types.ts, constants.ts # PlatformPage + the content contract + spacing
|
||||
└── components/ # platform-hero, platform-logos-row, platform-card-row (→ card, pill-cta),
|
||||
# platform-visual-frame, platform-structured-data
|
||||
```
|
||||
|
||||
Each section component's TSDoc carries its layout spec - read it before implementing. Section components own their landmark (Navbar → `<header>`, Footer → `<footer>`, the rest → `<section>`); the shared `LandingShell` owns the page frame (light wrapper, skip link, navbar, footer, GitHub stars via `@/lib/github/stars` - fetched at build/revalidate time, never client-fetched), and the page's `<main>` owns the section order and rhythm. Platform routes consume `PlatformPage` with a single content `config` - see `platform-page/CLAUDE`-level TSDoc on `platform-page.tsx`. Sub-components of a section go in `components/<section>/components/`.
|
||||
|
||||
Absolute imports only in component code (`@/app/(landing)/components/...`); `index.ts` barrels use relative re-exports (`export { X } from './x'`), matching the workspace convention. Props interfaces for every component. No `utils.ts` until two files share a helper.
|
||||
|
||||
## Checklist for every section you add
|
||||
|
||||
1. Server Component unless it provably needs client state; if client, it's a leaf.
|
||||
2. H2 with `id` + `aria-labelledby` wiring; heading hierarchy intact.
|
||||
3. Platform light tokens and emcn chrome only - no hex colors, no `--landing-*`, no `dark:`.
|
||||
4. Images: `next/image`, explicit dimensions, `priority` only on the LCP element.
|
||||
5. Copy passes the constitution (language table, claim hierarchy, tone).
|
||||
6. "Sim" named explicitly; section quotable in isolation.
|
||||
7. JSON-LD updated if the section adds features, FAQs, or pricing.
|
||||
8. No layout shift: load the page, watch nothing move.
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContentPostLoading } from '@/app/(landing)/components'
|
||||
|
||||
export default function BlogPostLoading() {
|
||||
return <ContentPostLoading />
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
|
||||
import { BLOG_SECTION, buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
|
||||
import { getBaseUrl } from '@/lib/core/utils/urls'
|
||||
import { ContentPostPage } from '@/app/(landing)/components'
|
||||
|
||||
export const dynamicParams = false
|
||||
|
||||
export async function generateStaticParams() {
|
||||
const posts = await getAllPostMeta()
|
||||
return posts.map((p) => ({ slug: p.slug }))
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const post = await getPostBySlug(slug)
|
||||
return buildPostMetadata(post)
|
||||
}
|
||||
|
||||
export const revalidate = 86400
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const post = await getPostBySlug(slug)
|
||||
const related = await getRelatedPosts(slug, 3)
|
||||
|
||||
return (
|
||||
<ContentPostPage
|
||||
basePath={BLOG_SECTION.basePath}
|
||||
backLabel='Back to Blog'
|
||||
post={post}
|
||||
related={related}
|
||||
graphJsonLd={buildPostGraphJsonLd(post)}
|
||||
shareUrl={`${getBaseUrl()}${BLOG_SECTION.basePath}/${slug}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContentAuthorLoading } from '@/app/(landing)/components'
|
||||
|
||||
export default function AuthorLoading() {
|
||||
return <ContentAuthorLoading />
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { BLOG_SECTION, buildAuthorGraphJsonLd, buildAuthorMetadata } from '@/lib/blog/seo'
|
||||
import { ContentAuthorPage } from '@/app/(landing)/components'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
return buildAuthorMetadata(id, posts[0]?.author)
|
||||
}
|
||||
|
||||
export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
|
||||
const author = posts[0]?.author
|
||||
|
||||
return (
|
||||
<ContentAuthorPage
|
||||
basePath={BLOG_SECTION.basePath}
|
||||
authorName={author?.name}
|
||||
authorAvatarUrl={author?.avatarUrl}
|
||||
posts={posts}
|
||||
graphJsonLd={author ? buildAuthorGraphJsonLd(author) : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Blog route segment. The shared landing layout owns the chrome (navbar, footer,
|
||||
* site-wide JSON-LD, scroll port); this layout only provides the `<main>`
|
||||
* landmark for every blog page. Blog pages emit their own page-specific JSON-LD.
|
||||
*/
|
||||
export default function BlogLayout({ children }: { children: ReactNode }) {
|
||||
return <main id='main-content'>{children}</main>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContentIndexLoading } from '@/app/(landing)/components'
|
||||
|
||||
export default function BlogLoading() {
|
||||
return <ContentIndexLoading />
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { BLOG_SECTION, buildCollectionPageJsonLd, buildIndexMetadata } from '@/lib/blog/seo'
|
||||
import { ContentIndexPage } from '@/app/(landing)/components'
|
||||
|
||||
/**
|
||||
* Filtered/paginated variants render genuinely different lists, but only the
|
||||
* bare index is indexable — see `buildIndexMetadata` in `@/lib/content/seo`
|
||||
* for the shared noindex policy.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; tag?: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { page, tag } = await searchParams
|
||||
const pageNum = Math.max(1, Number(page || 1))
|
||||
return buildIndexMetadata({ tag, pageNum })
|
||||
}
|
||||
|
||||
export default async function BlogIndex({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; tag?: string }>
|
||||
}) {
|
||||
const { page, tag } = await searchParams
|
||||
const pageNum = Math.max(1, Number(page || 1))
|
||||
const posts = await getAllPostMeta()
|
||||
|
||||
return (
|
||||
<ContentIndexPage
|
||||
basePath={BLOG_SECTION.basePath}
|
||||
heading='Latest from Sim'
|
||||
subheading={BLOG_SECTION.description}
|
||||
posts={posts}
|
||||
page={pageNum}
|
||||
tag={tag}
|
||||
collectionJsonLd={buildCollectionPageJsonLd()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { latestModified } from '@/lib/content/utils'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function GET() {
|
||||
const posts = await getAllPostMeta()
|
||||
const items = posts.slice(0, 50)
|
||||
const site = SITE_URL
|
||||
const lastBuildDate = (latestModified(items) ?? new Date()).toUTCString()
|
||||
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>Sim Blog</title>
|
||||
<link>${site}</link>
|
||||
<description>Announcements, insights, and guides for AI agent workflows.</description>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>${lastBuildDate}</lastBuildDate>
|
||||
<atom:link href="${site}/blog/rss.xml" rel="self" type="application/rss+xml" />
|
||||
${items
|
||||
.map(
|
||||
(p) => `
|
||||
<item>
|
||||
<title><![CDATA[${p.title}]]></title>
|
||||
<link>${p.canonical}</link>
|
||||
<guid>${p.canonical}</guid>
|
||||
<pubDate>${new Date(p.date).toUTCString()}</pubDate>
|
||||
<description><![CDATA[${p.description}]]></description>
|
||||
${(p.authors || [p.author])
|
||||
.map((a) => `<author><![CDATA[${a.name}${a.url ? ` (${a.url})` : ''}]]></author>`)
|
||||
.join('\n')}
|
||||
${p.tags.map((t) => `<category><![CDATA[${t}]]></category>`).join('\n ')}
|
||||
</item>`
|
||||
)
|
||||
.join('')}
|
||||
</channel>
|
||||
</rss>`
|
||||
|
||||
return new NextResponse(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getAllPostMeta } from '@/lib/blog/registry'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
export async function GET() {
|
||||
const posts = await getAllPostMeta()
|
||||
const base = SITE_URL
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">
|
||||
${posts
|
||||
.map(
|
||||
(p) => `<url>
|
||||
<loc>${p.canonical}</loc>
|
||||
<image:image>
|
||||
<image:loc>${p.ogImage.startsWith('http') ? p.ogImage : `${base}${p.ogImage}`}</image:loc>
|
||||
<image:title><![CDATA[${p.title}]]></image:title>
|
||||
<image:caption><![CDATA[${p.description}]]></image:caption>
|
||||
</image:image>
|
||||
</url>`
|
||||
)
|
||||
.join('\n')}
|
||||
</urlset>`
|
||||
return new NextResponse(xml, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContentTagsLoading } from '@/app/(landing)/components'
|
||||
|
||||
export default function TagsLoading() {
|
||||
return <ContentTagsLoading />
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { getAllTags } from '@/lib/blog/registry'
|
||||
import { BLOG_SECTION, buildTagsBreadcrumbJsonLd, buildTagsMetadata } from '@/lib/blog/seo'
|
||||
import { ContentTagsPage } from '@/app/(landing)/components'
|
||||
|
||||
export const metadata: Metadata = buildTagsMetadata()
|
||||
|
||||
export default async function TagsIndex() {
|
||||
const tags = await getAllTags()
|
||||
return (
|
||||
<ContentTagsPage
|
||||
basePath={BLOG_SECTION.basePath}
|
||||
tags={tags}
|
||||
breadcrumbJsonLd={buildTagsBreadcrumbJsonLd()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Suspense } from 'react'
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import { getAshbyJobs } from '@/lib/ashby/jobs'
|
||||
import {
|
||||
filterPostings,
|
||||
groupByDepartment,
|
||||
hasActiveFilters,
|
||||
JobBoard,
|
||||
JobGroups,
|
||||
} from '@/app/(landing)/careers/components/job-board'
|
||||
import { careersSearchParamsCache } from '@/app/(landing)/careers/search-params'
|
||||
|
||||
interface CareersProps {
|
||||
searchParams: Promise<SearchParams>
|
||||
}
|
||||
|
||||
/**
|
||||
* The careers page — a mission-led hero above the live open-roles board. Roles
|
||||
* are pulled from Sim's public Ashby job board at build/revalidate time
|
||||
* ({@link getAshbyJobs}) and server-rendered in full, so every posting is in the
|
||||
* crawlable HTML; the interactive {@link JobBoard} hydrates on top to add
|
||||
* Team/Location filtering.
|
||||
*
|
||||
* Both sections share the landing gutter — capped and centered at `max-w-[1460px]`
|
||||
* with the navbar-aligned `px-20 max-lg:px-8 max-sm:px-5` so the headline starts on
|
||||
* the same vertical line as the wordmark. The hero carries the single `<h1>`
|
||||
* (containing "Sim" and "AI workspace") plus an sr-only product summary for AI
|
||||
* citation (landing CLAUDE.md → GEO); the roles section owns its own `<h2>`.
|
||||
*
|
||||
* Because {@link JobBoard} reads the URL via nuqs (`useSearchParams`), it sits under
|
||||
* a `<Suspense>` boundary. The page parses the same `?team=`/`?location=` query on
|
||||
* the server ({@link careersSearchParamsCache}) and pre-filters the fallback to
|
||||
* match, so a deep-linked filter renders the correct roles server-side — the list
|
||||
* never flashes unfiltered before the client board hydrates.
|
||||
*/
|
||||
export default async function Careers({ searchParams }: CareersProps) {
|
||||
const [{ team, location }, postings] = await Promise.all([
|
||||
careersSearchParamsCache.parse(searchParams),
|
||||
getAshbyJobs(),
|
||||
])
|
||||
const fallbackGroups = groupByDepartment(filterPostings(postings, team, location))
|
||||
|
||||
return (
|
||||
<main id='main-content'>
|
||||
<section
|
||||
id='careers-hero'
|
||||
aria-labelledby='careers-heading'
|
||||
className='mx-auto flex w-full max-w-[1460px] flex-col gap-5 px-20 pt-20 pb-10 max-sm:px-5 max-sm:pt-16 max-lg:px-8'
|
||||
>
|
||||
<p className='sr-only'>
|
||||
Careers at Sim, the open-source AI workspace where teams build, deploy, and manage AI
|
||||
agents. Sim is hiring engineers, designers, and go-to-market builders to help teams
|
||||
automate real work across 1,000+ integrations and every major LLM — visually,
|
||||
conversationally, or with code.
|
||||
</p>
|
||||
|
||||
<h1
|
||||
id='careers-heading'
|
||||
className='max-w-[24ch] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
|
||||
>
|
||||
Help build Sim, the AI workspace for teams.
|
||||
</h1>
|
||||
<p className='max-w-[60ch] text-pretty text-[var(--text-body)] text-lg leading-[1.5] max-sm:text-base'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. We're
|
||||
a small, high-agency team shipping fast to thousands of builders. If you want to own real
|
||||
work and shape the workspace teams live in, we'd love to meet you.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id='open-roles'
|
||||
aria-labelledby='open-roles-heading'
|
||||
className='mx-auto flex w-full max-w-[1460px] flex-col gap-10 px-20 pt-6 pb-24 max-sm:px-5 max-sm:pb-16 max-lg:px-8'
|
||||
>
|
||||
<h2
|
||||
id='open-roles-heading'
|
||||
className='text-[24px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'
|
||||
>
|
||||
Open roles
|
||||
</h2>
|
||||
|
||||
<Suspense
|
||||
fallback={
|
||||
<JobGroups groups={fallbackGroups} filtersActive={hasActiveFilters(team, location)} />
|
||||
}
|
||||
>
|
||||
<JobBoard postings={postings} />
|
||||
</Suspense>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { JobBoard } from './job-board'
|
||||
export { JobGroups } from './job-groups'
|
||||
export { filterPostings, groupByDepartment, hasActiveFilters } from './utils'
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { ChipSelect, type ChipSelectOption } from '@sim/emcn'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import { JobGroups } from '@/app/(landing)/careers/components/job-board/job-groups'
|
||||
import {
|
||||
filterPostings,
|
||||
groupByDepartment,
|
||||
hasActiveFilters,
|
||||
} from '@/app/(landing)/careers/components/job-board/utils'
|
||||
import {
|
||||
ALL_FILTER_VALUE,
|
||||
careersParsers,
|
||||
careersUrlKeys,
|
||||
} from '@/app/(landing)/careers/search-params'
|
||||
|
||||
interface JobBoardProps {
|
||||
postings: CareerPosting[]
|
||||
}
|
||||
|
||||
/** Builds `{ label, value }` options for a filter, with an "All" row at the top. */
|
||||
function toFilterOptions(values: string[], allLabel: string): ChipSelectOption[] {
|
||||
return [
|
||||
{ label: allLabel, value: ALL_FILTER_VALUE },
|
||||
...values.map((value) => ({ label: value, value })),
|
||||
]
|
||||
}
|
||||
|
||||
/** Distinct, alphabetically sorted values from a list. */
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
/**
|
||||
* The interactive open-roles board — the single `'use client'` leaf on the
|
||||
* careers page. Every posting is server-rendered into the HTML (via the static
|
||||
* {@link JobGroups} Suspense fallback in `careers.tsx`), so all roles stay
|
||||
* crawlable; this leaf hydrates on top to add Team/Location filtering. Filter
|
||||
* state lives in the URL via nuqs (`?team=`/`?location=`) so a filtered view is
|
||||
* shareable and survives reload/back-forward. The filter set is small and
|
||||
* static, so filtering reads the instant URL value directly (no debounce).
|
||||
*/
|
||||
export function JobBoard({ postings }: JobBoardProps) {
|
||||
const [{ team, location }, setFilters] = useQueryStates(careersParsers, careersUrlKeys)
|
||||
|
||||
const teamOptions = toFilterOptions(uniqueSorted(postings.map((p) => p.department)), 'All teams')
|
||||
const locationOptions = toFilterOptions(
|
||||
uniqueSorted(postings.flatMap((p) => (p.location ? [p.location] : []))),
|
||||
'All locations'
|
||||
)
|
||||
const groups = groupByDepartment(filterPostings(postings, team, location))
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-10'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<ChipSelect
|
||||
options={teamOptions}
|
||||
value={team}
|
||||
onChange={(value) => setFilters({ team: value })}
|
||||
aria-label='Filter roles by team'
|
||||
/>
|
||||
<ChipSelect
|
||||
options={locationOptions}
|
||||
value={location}
|
||||
onChange={(value) => setFilters({ location: value })}
|
||||
aria-label='Filter roles by location'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JobGroups groups={groups} filtersActive={hasActiveFilters(team, location)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { ArrowRight } from '@sim/emcn/icons'
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import type { DepartmentGroup } from '@/app/(landing)/careers/components/job-board/utils'
|
||||
|
||||
/** Empty-state copy: distinguishes a truly empty board from a filtered-to-zero view. */
|
||||
const NO_OPEN_ROLES_MESSAGE = 'No open roles right now — check back soon.'
|
||||
const NO_MATCHING_ROLES_MESSAGE =
|
||||
'No roles match these filters right now. Try clearing them, or check back soon.'
|
||||
|
||||
interface JobGroupsProps {
|
||||
groups: DepartmentGroup[]
|
||||
/**
|
||||
* Whether a Team/Location filter is active. Selects the empty-state copy so an
|
||||
* unfiltered empty board ("no open roles") never reads as a filtered miss ("no
|
||||
* matches") — and the server fallback and client board always agree.
|
||||
*/
|
||||
filtersActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The presentational open-roles list: one labeled section per department, each a
|
||||
* list of {@link JobRow}s. Server-safe (no client hooks) so it renders both as
|
||||
* the static Suspense fallback and inside the client {@link JobBoard}.
|
||||
*/
|
||||
export function JobGroups({ groups, filtersActive = false }: JobGroupsProps) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<p className='py-10 text-[var(--text-muted)] text-base'>
|
||||
{filtersActive ? NO_MATCHING_ROLES_MESSAGE : NO_OPEN_ROLES_MESSAGE}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-12'>
|
||||
{groups.map((group) => (
|
||||
<section
|
||||
key={group.department}
|
||||
aria-label={`${group.department} roles`}
|
||||
className='flex flex-col'
|
||||
>
|
||||
<h3 className='pb-2 font-medium text-[var(--text-muted)] text-sm'>{group.department}</h3>
|
||||
<ul className='flex flex-col'>
|
||||
{group.postings.map((posting) => (
|
||||
<li key={posting.id}>
|
||||
<JobRow posting={posting} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JobRowProps {
|
||||
posting: CareerPosting
|
||||
}
|
||||
|
||||
/**
|
||||
* A single role row: title over a metadata line, with an "Apply" affordance that
|
||||
* links out to the posting on Ashby. The whole row is the link target; hovering
|
||||
* tints the row and advances the arrow. The metadata values are de-duplicated
|
||||
* because a remote posting normalizes both `location` and `workplaceType` to
|
||||
* "Remote", which would otherwise render "Remote · Remote" and collide as keys.
|
||||
*/
|
||||
function JobRow({ posting }: JobRowProps) {
|
||||
const meta = Array.from(
|
||||
new Set(
|
||||
[
|
||||
posting.location,
|
||||
posting.employmentType,
|
||||
posting.workplaceType,
|
||||
posting.compensationSummary,
|
||||
].filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={posting.jobUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={cn(
|
||||
'-mx-3 group flex items-center justify-between gap-6 rounded-lg border-[var(--border)]',
|
||||
'border-t px-3 py-5 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<h4 className='truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
{posting.title}
|
||||
</h4>
|
||||
<div className='flex flex-wrap items-center gap-x-2 gap-y-1 text-[var(--text-muted)] text-sm'>
|
||||
{meta.map((item, index) => (
|
||||
<span key={item} className='flex items-center gap-2'>
|
||||
{index > 0 && (
|
||||
<span aria-hidden className='text-[var(--text-muted)]'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className='flex shrink-0 items-center gap-1.5 font-medium text-[var(--text-body)] text-sm'>
|
||||
Apply
|
||||
<ArrowRight className='size-[14px] text-[var(--text-icon)] transition-transform group-hover:translate-x-0.5' />
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import { ALL_FILTER_VALUE } from '@/app/(landing)/careers/search-params'
|
||||
|
||||
export interface DepartmentGroup {
|
||||
department: string
|
||||
postings: CareerPosting[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows postings to a selected Team and Location, treating {@link ALL_FILTER_VALUE}
|
||||
* as "any". Shared by the server-rendered fallback and the client board so a
|
||||
* deep-linked filter resolves to the exact same set on both sides.
|
||||
*/
|
||||
export function filterPostings(
|
||||
postings: CareerPosting[],
|
||||
team: string,
|
||||
location: string
|
||||
): CareerPosting[] {
|
||||
return postings.filter(
|
||||
(posting) =>
|
||||
(team === ALL_FILTER_VALUE || posting.department === team) &&
|
||||
(location === ALL_FILTER_VALUE || posting.location === location)
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether either the Team or Location filter is narrowing the board. */
|
||||
export function hasActiveFilters(team: string, location: string): boolean {
|
||||
return team !== ALL_FILTER_VALUE || location !== ALL_FILTER_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets postings by department, preserving their incoming order (the fetcher
|
||||
* pre-sorts by department then title). Shared by the interactive board and its
|
||||
* static Suspense fallback so the two can never render a different grouping.
|
||||
*/
|
||||
export function groupByDepartment(postings: CareerPosting[]): DepartmentGroup[] {
|
||||
const byDepartment = new Map<string, CareerPosting[]>()
|
||||
for (const posting of postings) {
|
||||
const bucket = byDepartment.get(posting.department)
|
||||
if (bucket) bucket.push(posting)
|
||||
else byDepartment.set(posting.department, [posting])
|
||||
}
|
||||
return Array.from(byDepartment, ([department, items]) => ({ department, postings: items }))
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { SearchParams } from 'nuqs/server'
|
||||
import { buildLandingMetadata, withFilteredNoindex } from '@/lib/landing/seo'
|
||||
import Careers from '@/app/(landing)/careers/careers'
|
||||
import { ALL_FILTER_VALUE, careersSearchParamsCache } from '@/app/(landing)/careers/search-params'
|
||||
|
||||
/**
|
||||
* `team`/`location` render a genuinely different server-rendered job list (see
|
||||
* search-params.ts), so filtered URLs are noindexed rather than
|
||||
* self-canonicalized — same policy as the integrations/models/blog catalogs.
|
||||
*/
|
||||
export async function generateMetadata({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<SearchParams>
|
||||
}): Promise<Metadata> {
|
||||
const { team, location } = await careersSearchParamsCache.parse(searchParams)
|
||||
const isFiltered = team !== ALL_FILTER_VALUE || location !== ALL_FILTER_VALUE
|
||||
|
||||
const base = buildLandingMetadata({
|
||||
title: 'Careers | Sim, the AI Workspace',
|
||||
description:
|
||||
'Join Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. See open engineering, design, and go-to-market roles.',
|
||||
path: '/careers',
|
||||
keywords:
|
||||
'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs',
|
||||
})
|
||||
|
||||
return withFilteredNoindex(base, isFiltered)
|
||||
}
|
||||
|
||||
export default function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {
|
||||
return <Careers searchParams={searchParams} />
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { createSearchParamsCache, parseAsString } from 'nuqs/server'
|
||||
|
||||
/**
|
||||
* Sentinel value for an inactive filter — matches every posting. Namespaced with
|
||||
* underscores so it can never collide with a real Ashby department or location
|
||||
* value (e.g. a team literally named "all").
|
||||
*/
|
||||
export const ALL_FILTER_VALUE = '__all__'
|
||||
|
||||
/**
|
||||
* Co-located, typed URL query params for the careers job board's Team and
|
||||
* Location filters. Shareable, deep-linkable view-state over an already-rendered
|
||||
* list, so it lives in the URL (nuqs) — never in a store. The values are dynamic
|
||||
* (departments/locations come from the live board), so plain string parsers with
|
||||
* an `all` sentinel default rather than a fixed literal set.
|
||||
*/
|
||||
export const careersParsers = {
|
||||
team: parseAsString.withDefault(ALL_FILTER_VALUE),
|
||||
location: parseAsString.withDefault(ALL_FILTER_VALUE),
|
||||
} as const
|
||||
|
||||
/** Clean URLs, no back-stack churn — the filters are a passive view switch. */
|
||||
export const careersUrlKeys = {
|
||||
history: 'replace',
|
||||
shallow: true,
|
||||
clearOnDefault: true,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Server-side reader for the same parser map. The page parses the request's
|
||||
* query with this so the statically-rendered fallback is filtered to match a
|
||||
* deep-linked `?team=`/`?location=` URL — the roles never flash unfiltered before
|
||||
* the client board hydrates.
|
||||
*/
|
||||
export const careersSearchParamsCache = createSearchParamsCache(careersParsers)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import { ChangelogActions, ChangelogTimeline } from '@/app/(landing)/changelog/components'
|
||||
import type { ChangelogEntry, GitHubRelease } from '@/app/(landing)/changelog/types'
|
||||
import { mapReleases, releasesEndpoint } from '@/app/(landing)/changelog/utils'
|
||||
import { ProseHero, ProseShell } from '@/app/(landing)/components/prose-page'
|
||||
|
||||
const logger = createLogger('Changelog')
|
||||
|
||||
/**
|
||||
* Changelog page - reuses the shared prose primitives ({@link ProseShell} +
|
||||
* {@link ProseHero}) so its headline and column match Terms and Privacy, then
|
||||
* renders the GitHub-release timeline. The first page of releases is
|
||||
* fetched here on the server at build/revalidate time; the {@link ChangelogTimeline}
|
||||
* client leaf paginates the rest. Re-authored from the prior dark changelog onto
|
||||
* the platform light tokens.
|
||||
*/
|
||||
|
||||
const LEAD =
|
||||
'Every new feature, improvement, and fix in Sim, the open-source AI workspace, with release notes straight from GitHub.'
|
||||
|
||||
async function getInitialEntries(): Promise<ChangelogEntry[]> {
|
||||
try {
|
||||
// boundary-raw-fetch: external GitHub Releases API (cross-origin), not a same-origin contract
|
||||
const res = await fetch(releasesEndpoint(1), {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
next: { revalidate: 3600 },
|
||||
})
|
||||
const releases = (await res.json()) as GitHubRelease[]
|
||||
return mapReleases(releases ?? [])
|
||||
} catch (error) {
|
||||
logger.warn('Failed to load initial changelog releases from GitHub', {
|
||||
error: getErrorMessage(error),
|
||||
})
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export default async function Changelog() {
|
||||
const entries = await getInitialEntries()
|
||||
|
||||
return (
|
||||
<ProseShell>
|
||||
<ProseHero title='Changelog' lead={LEAD} actions={<ChangelogActions />} />
|
||||
<section id='releases' aria-label='Release history'>
|
||||
<ChangelogTimeline initialEntries={entries} />
|
||||
</section>
|
||||
</ProseShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import { ChipLink } from '@sim/emcn'
|
||||
import { BookOpen, Github, Rss } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Changelog hero actions - the GitHub / Documentation / RSS pill links shown
|
||||
* beneath the changelog headline. A small client leaf because `ChipLink` is a
|
||||
* Client Component and its `leftIcon` is a component reference that cannot cross
|
||||
* the server→client boundary as a prop (same pattern as the platform pill CTA).
|
||||
* GitHub is the primary filled chip; Docs and RSS are the default pills.
|
||||
*/
|
||||
export function ChangelogActions() {
|
||||
return (
|
||||
<div className='flex flex-wrap items-center gap-1'>
|
||||
<ChipLink
|
||||
variant='primary'
|
||||
href='https://github.com/simstudioai/sim/releases'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
leftIcon={Github}
|
||||
>
|
||||
View on GitHub
|
||||
</ChipLink>
|
||||
<ChipLink
|
||||
href='https://docs.sim.ai'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
leftIcon={BookOpen}
|
||||
>
|
||||
Documentation
|
||||
</ChipLink>
|
||||
<ChipLink href='/changelog.xml' leftIcon={Rss}>
|
||||
RSS Feed
|
||||
</ChipLink>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChangelogActions } from './changelog-actions'
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { type ReactNode, useRef, useState } from 'react'
|
||||
import { Streamdown } from 'streamdown'
|
||||
import 'streamdown/styles.css'
|
||||
import { Avatar, AvatarFallback, AvatarImage, Chip, cn } from '@sim/emcn'
|
||||
import { formatDate } from '@sim/utils/formatting'
|
||||
import type { ChangelogEntry, GitHubRelease } from '@/app/(landing)/changelog/types'
|
||||
import { mapReleases, releasesEndpoint } from '@/app/(landing)/changelog/utils'
|
||||
|
||||
/**
|
||||
* The changelog timeline - the single client leaf of the changelog page. Renders
|
||||
* each GitHub release as a `<section>` (an `<h2>` version tag + contributor
|
||||
* avatars + cleaned markdown via {@link Streamdown}) and paginates further pages
|
||||
* from the GitHub Releases API on demand. Re-authored from the prior dark
|
||||
* timeline onto the platform light tokens; the fetch, markdown cleaning, and
|
||||
* load-more behavior are preserved.
|
||||
*/
|
||||
|
||||
interface ChangelogTimelineProps {
|
||||
initialEntries: ChangelogEntry[]
|
||||
}
|
||||
|
||||
function stripContributors(body: string): string {
|
||||
let output = body
|
||||
output = output.replace(
|
||||
/(^|\n)#{1,6}\s*Contributors\s*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi,
|
||||
'\n'
|
||||
)
|
||||
output = output.replace(
|
||||
/(^|\n)\s*(?:\*\*|__)?\s*Contributors\s*(?:\*\*|__)?\s*:?\s*\n[\s\S]*?(?=\n\s*\n|\n#{1,6}\s|$)/gi,
|
||||
'\n'
|
||||
)
|
||||
output = output.replace(
|
||||
/(^|\n)[-*+]\s*(?:@[A-Za-z0-9-]+(?:\s*,\s*|\s+))+@[A-Za-z0-9-]+\s*(?=\n)/g,
|
||||
'\n'
|
||||
)
|
||||
output = output.replace(
|
||||
/(^|\n)\s*(?:@[A-Za-z0-9-]+(?:\s*,\s*|\s+))+@[A-Za-z0-9-]+\s*(?=\n)/g,
|
||||
'\n'
|
||||
)
|
||||
return output
|
||||
}
|
||||
|
||||
function stripPrReferences(body: string): string {
|
||||
return body.replace(/\s*\(\s*\[#\d+\]\([^)]*\)\s*\)/g, '').replace(/\s*\(\s*#\d+\s*\)/g, '')
|
||||
}
|
||||
|
||||
function cleanMarkdown(body: string): string {
|
||||
return stripPrReferences(stripContributors(body))
|
||||
}
|
||||
|
||||
function isContributorsLabel(children: ReactNode): boolean {
|
||||
return /^\s*contributors\s*:?\s*$/i.test(String(children))
|
||||
}
|
||||
|
||||
export function ChangelogTimeline({ initialEntries }: ChangelogTimelineProps) {
|
||||
const [entries, setEntries] = useState<ChangelogEntry[]>(initialEntries)
|
||||
const [loading, setLoading] = useState<boolean>(false)
|
||||
const [done, setDone] = useState<boolean>(false)
|
||||
const pageRef = useRef(1)
|
||||
|
||||
const loadMore = async () => {
|
||||
if (loading || done) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const nextPage = pageRef.current + 1
|
||||
// boundary-raw-fetch: external GitHub Releases API (cross-origin), not a same-origin contract
|
||||
const res = await fetch(releasesEndpoint(nextPage), {
|
||||
headers: { Accept: 'application/vnd.github+json' },
|
||||
})
|
||||
const releases = (await res.json()) as GitHubRelease[]
|
||||
const mapped = mapReleases(releases ?? [])
|
||||
|
||||
if (mapped.length === 0) {
|
||||
setDone(true)
|
||||
} else {
|
||||
setEntries((prev) => [...prev, ...mapped])
|
||||
pageRef.current = nextPage
|
||||
}
|
||||
} catch {
|
||||
setDone(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-7'>
|
||||
{entries.map((entry) => {
|
||||
const headingId = `release-${entry.tag}-heading`
|
||||
return (
|
||||
<section key={entry.tag} aria-labelledby={headingId} className='flex flex-col'>
|
||||
<div className='flex items-center justify-between gap-4'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<h2 id={headingId} className='text-[18px] text-[var(--text-primary)] leading-[1.3]'>
|
||||
{entry.tag}
|
||||
</h2>
|
||||
{entry.contributors.length > 0 ? (
|
||||
<div className='flex'>
|
||||
{entry.contributors.slice(0, 5).map((contributor, index) => (
|
||||
<a
|
||||
key={contributor}
|
||||
href={`https://github.com/${contributor}`}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
aria-label={`View @${contributor} on GitHub`}
|
||||
title={`@${contributor}`}
|
||||
className={index === 0 ? 'block' : '-ms-2 block'}
|
||||
>
|
||||
<Avatar className='size-6 ring-2 ring-[var(--bg)]'>
|
||||
<AvatarImage
|
||||
src={`https://avatars.githubusercontent.com/${contributor}`}
|
||||
alt={`@${contributor}`}
|
||||
className='hover:z-10'
|
||||
/>
|
||||
<AvatarFallback>{contributor.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
</a>
|
||||
))}
|
||||
{entry.contributors.length > 5 ? (
|
||||
<div className='-ms-2 relative flex size-6 items-center justify-center rounded-full bg-[var(--surface-2)] text-[10px] text-[var(--text-body)] ring-2 ring-[var(--bg)] hover:z-10'>
|
||||
+{entry.contributors.length - 5}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className='text-[12px] text-[var(--text-muted)]'>
|
||||
{formatDate(new Date(entry.date))}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div aria-hidden='true' className='mt-[9px] mb-3 h-px bg-[var(--border)]' />
|
||||
|
||||
<div className='max-w-none'>
|
||||
<Streamdown
|
||||
mode='static'
|
||||
components={{
|
||||
h2: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h3
|
||||
className='mt-5 mb-2 text-[14px] text-[var(--text-primary)] leading-[1.4]'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h3: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<h4
|
||||
className='mt-4 mb-1 text-[14px] text-[var(--text-primary)] leading-[1.4]'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
ul: ({ children, ...props }) => (
|
||||
<ul className='mt-2 mb-3 space-y-1.5' {...props}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
li: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<li
|
||||
className='text-[13px] text-[var(--text-body)] leading-relaxed'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</li>
|
||||
),
|
||||
p: ({ children, ...props }) =>
|
||||
isContributorsLabel(children) ? null : (
|
||||
<p
|
||||
className='mb-3 text-[13px] text-[var(--text-body)] leading-relaxed'
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
strong: ({ children, ...props }) => (
|
||||
<strong className='text-[var(--text-primary)]' {...props}>
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
inlineCode: ({ children }) => (
|
||||
<code className='whitespace-normal rounded bg-[var(--surface-2)] px-1 py-0.5 font-mono text-[var(--text-primary)] not-italic'>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
img: () => null,
|
||||
a: ({ children, className, ...props }) => (
|
||||
<a
|
||||
{...props}
|
||||
className={cn('text-[var(--text-primary)] underline', className)}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{cleanMarkdown(entry.content)}
|
||||
</Streamdown>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
|
||||
{!done ? (
|
||||
<div>
|
||||
<Chip type='button' flush onClick={loadMore} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Show more'}
|
||||
</Chip>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ChangelogTimeline } from './changelog-timeline'
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ChangelogActions } from './changelog-actions'
|
||||
export { ChangelogTimeline } from './changelog-timeline'
|
||||
@@ -0,0 +1,18 @@
|
||||
import { buildLandingMetadata } from '@/lib/landing/seo'
|
||||
import Changelog from '@/app/(landing)/changelog/changelog'
|
||||
|
||||
export const revalidate = 3600
|
||||
|
||||
const TITLE = 'Changelog | Sim, the AI Workspace'
|
||||
const DESCRIPTION =
|
||||
'Every new feature, improvement, and fix in Sim, the open-source AI workspace, with release notes straight from GitHub.'
|
||||
|
||||
export const metadata = buildLandingMetadata({
|
||||
title: TITLE,
|
||||
description: DESCRIPTION,
|
||||
path: '/changelog',
|
||||
})
|
||||
|
||||
export default function Page() {
|
||||
return <Changelog />
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Changelog types shared between the server page (initial fetch) and the client
|
||||
* timeline (load-more). {@link GitHubRelease} is the minimal shape we read from
|
||||
* the GitHub Releases API; {@link ChangelogEntry} is the normalized entry the UI
|
||||
* renders.
|
||||
*/
|
||||
|
||||
/** The minimal subset of a GitHub Releases API item that the changelog reads. */
|
||||
export interface GitHubRelease {
|
||||
tag_name: string
|
||||
name: string | null
|
||||
body: string | null
|
||||
published_at: string
|
||||
html_url: string
|
||||
prerelease: boolean
|
||||
}
|
||||
|
||||
/** A normalized changelog entry rendered in the timeline. */
|
||||
export interface ChangelogEntry {
|
||||
tag: string
|
||||
title: string
|
||||
content: string
|
||||
date: string
|
||||
url: string
|
||||
contributors: string[]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ChangelogEntry, GitHubRelease } from '@/app/(landing)/changelog/types'
|
||||
|
||||
/**
|
||||
* Changelog helpers shared by the server page (initial page) and the client
|
||||
* timeline (subsequent pages), so the GitHub-release → entry mapping is defined
|
||||
* once and both surfaces stay in sync.
|
||||
*/
|
||||
|
||||
/** How many releases to request per GitHub API page. */
|
||||
export const RELEASES_PER_PAGE = 10
|
||||
|
||||
/** Builds the GitHub Releases endpoint for a given 1-based page. */
|
||||
export function releasesEndpoint(page: number): string {
|
||||
return `https://api.github.com/repos/simstudioai/sim/releases?per_page=${RELEASES_PER_PAGE}&page=${page}`
|
||||
}
|
||||
|
||||
/** Removes literal ` ` artifacts from release bodies. */
|
||||
export function sanitizeContent(body: string): string {
|
||||
return body.replace(/ /g, '')
|
||||
}
|
||||
|
||||
/** Extracts unique `@handle` GitHub mentions from a release body. */
|
||||
export function extractMentions(body: string): string[] {
|
||||
const matches = body.match(/@([A-Za-z0-9-]+)/g) ?? []
|
||||
return Array.from(new Set(matches.map((mention) => mention.slice(1))))
|
||||
}
|
||||
|
||||
/** Maps non-prerelease GitHub releases to normalized {@link ChangelogEntry} items. */
|
||||
export function mapReleases(releases: GitHubRelease[]): ChangelogEntry[] {
|
||||
return releases.reduce<ChangelogEntry[]>((acc, release) => {
|
||||
if (release.prerelease) {
|
||||
return acc
|
||||
}
|
||||
|
||||
const body = String(release.body ?? '')
|
||||
acc.push({
|
||||
tag: release.tag_name,
|
||||
title: release.name || release.tag_name,
|
||||
content: sanitizeContent(body),
|
||||
date: release.published_at,
|
||||
url: release.html_url,
|
||||
contributors: extractMentions(body),
|
||||
})
|
||||
return acc
|
||||
}, [])
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { CompetitorProfile } from '@/lib/compare/data'
|
||||
import { simProfile } from '@/lib/compare/data'
|
||||
import { SITE_URL } from '@/lib/core/utils/urls'
|
||||
import { buildLandingMetadata } from '@/lib/landing/seo'
|
||||
import { COMPARISON_SECTIONS, getFactGroup } from '@/app/(landing)/comparison/comparison-sections'
|
||||
import { BrandIconTile, SimIconTile } from '@/app/(landing)/comparison/components/brand-icon-tile'
|
||||
import { ComparisonCards } from '@/app/(landing)/comparison/components/comparison-cards'
|
||||
import { ComparisonTable } from '@/app/(landing)/comparison/components/comparison-table'
|
||||
import {
|
||||
ALL_COMPETITORS,
|
||||
buildBottomLine,
|
||||
buildComparisonFaqs,
|
||||
getCompetitorBySlug,
|
||||
getLatestVerifiedDate,
|
||||
SIM_LATEST_VERIFIED,
|
||||
} from '@/app/(landing)/comparison/utils'
|
||||
import { BackLink } from '@/app/(landing)/components'
|
||||
import { Cta } from '@/app/(landing)/components/cta/cta'
|
||||
import { JsonLd } from '@/app/(landing)/components/json-ld'
|
||||
import { LandingFAQ } from '@/app/(landing)/components/landing-faq'
|
||||
|
||||
const baseUrl = SITE_URL
|
||||
|
||||
export const revalidate = 3600
|
||||
export const dynamicParams = false
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return ALL_COMPETITORS.map((competitor) => ({ provider: competitor.id }))
|
||||
}
|
||||
|
||||
/** Flattens a profile's facts into JSON-LD `additionalProperty` entries, in {@link COMPARISON_SECTIONS} order. */
|
||||
function factsToProperties(profile: CompetitorProfile) {
|
||||
return COMPARISON_SECTIONS.flatMap((section) => {
|
||||
const group = getFactGroup(profile, section.group)
|
||||
return section.rows.map((row) => ({
|
||||
'@type': 'PropertyValue',
|
||||
name: row.label,
|
||||
value: group[row.key]?.value ?? 'Unknown',
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { provider: providerSlug } = await params
|
||||
const competitor = getCompetitorBySlug(providerSlug)
|
||||
|
||||
if (!competitor) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return buildLandingMetadata({
|
||||
title: `Sim vs ${competitor.name} | Sim, the AI Workspace`,
|
||||
description: `Compare Sim, the open-source AI workspace, to ${competitor.name} on platform, AI, integrations, pricing, security, and support. Sourced and dated facts.`,
|
||||
path: `/comparison/${competitor.id}`,
|
||||
keywords: [
|
||||
`Sim vs ${competitor.name}`,
|
||||
`${competitor.name} alternative`,
|
||||
`${competitor.name} vs Sim`,
|
||||
`open source ${competitor.name} alternative`,
|
||||
`${competitor.name} comparison`,
|
||||
'AI agent workspace',
|
||||
'AI workflow automation comparison',
|
||||
].join(', '),
|
||||
})
|
||||
}
|
||||
|
||||
export default async function ComparisonProviderPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ provider: string }>
|
||||
}) {
|
||||
const { provider: providerSlug } = await params
|
||||
const competitor = getCompetitorBySlug(providerSlug)
|
||||
|
||||
if (!competitor) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const faqs = buildComparisonFaqs(competitor)
|
||||
const verdict = buildBottomLine(competitor)
|
||||
const CompetitorIcon = competitor.brand?.icon
|
||||
|
||||
const breadcrumbJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{ '@type': 'ListItem', position: 1, name: 'Home', item: baseUrl },
|
||||
{ '@type': 'ListItem', position: 2, name: 'Comparison', item: `${baseUrl}/comparison` },
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 3,
|
||||
name: `Sim vs ${competitor.name}`,
|
||||
item: `${baseUrl}/comparison/${competitor.id}`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const latestVerified = new Date(
|
||||
Math.max(SIM_LATEST_VERIFIED.getTime(), getLatestVerifiedDate(competitor).getTime())
|
||||
)
|
||||
|
||||
const productComparisonJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'ItemList',
|
||||
name: `Sim vs ${competitor.name}`,
|
||||
description: `Feature and pricing comparison between Sim and ${competitor.name}.`,
|
||||
url: `${baseUrl}/comparison/${competitor.id}`,
|
||||
dateModified: latestVerified.toISOString().slice(0, 10),
|
||||
numberOfItems: 2,
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
item: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: 'Sim',
|
||||
applicationCategory: 'BusinessApplication',
|
||||
url: SITE_URL,
|
||||
description: simProfile.oneLiner,
|
||||
additionalProperty: factsToProperties(simProfile),
|
||||
},
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
item: {
|
||||
'@type': 'SoftwareApplication',
|
||||
name: competitor.name,
|
||||
applicationCategory: 'BusinessApplication',
|
||||
url: competitor.website,
|
||||
description: competitor.oneLiner,
|
||||
additionalProperty: factsToProperties(competitor),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const faqJsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.answer,
|
||||
},
|
||||
})),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={breadcrumbJsonLd} />
|
||||
<JsonLd data={productComparisonJsonLd} />
|
||||
<JsonLd data={faqJsonLd} />
|
||||
|
||||
<main id='main-content' className='bg-[var(--bg)]'>
|
||||
<div className='mx-auto w-full max-w-[1446px] px-12 pt-[112px] max-sm:px-5 max-sm:pt-20 max-lg:px-8'>
|
||||
<div className='mb-6'>
|
||||
<BackLink href='/comparison' label='Back to comparisons' />
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<h1
|
||||
id='comparison-heading'
|
||||
className='text-balance text-[28px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[40px]'
|
||||
>
|
||||
Sim vs {competitor.name}
|
||||
</h1>
|
||||
<p className='max-w-[720px] text-[var(--text-muted)] text-sm leading-[150%] tracking-[0.02em] lg:text-base'>
|
||||
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents
|
||||
visually, conversationally, or with code. Here is how Sim compares to{' '}
|
||||
{competitor.name} on platform architecture, AI capabilities, integrations, pricing,
|
||||
security, and support. Every fact below is sourced and dated.
|
||||
</p>
|
||||
<p className='sr-only'>
|
||||
Sim is an open-source AI workspace for building, deploying, and managing AI agents.
|
||||
This page compares Sim to {competitor.name} across platform architecture, AI
|
||||
capabilities, integrations, pricing, security and compliance, observability, and
|
||||
support, using sourced, dated facts for buyers evaluating both platforms.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='mx-auto w-full max-w-[1446px]'>
|
||||
<div className='mx-12 border-[var(--border)] border-x max-sm:mx-5 max-lg:mx-8'>
|
||||
<div className='grid grid-cols-1 sm:grid-cols-2'>
|
||||
<section
|
||||
aria-labelledby='what-is-sim-heading'
|
||||
className='border-[var(--border)] border-r px-6 py-6 max-sm:border-r-0 max-sm:border-b'
|
||||
>
|
||||
<h2
|
||||
id='what-is-sim-heading'
|
||||
className='mb-2 flex items-center gap-2.5 text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
<SimIconTile className='size-9' />
|
||||
What is Sim?
|
||||
</h2>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{simProfile.oneLiner}
|
||||
</p>
|
||||
</section>
|
||||
<section aria-labelledby='what-is-competitor-heading' className='px-6 py-6'>
|
||||
<h2
|
||||
id='what-is-competitor-heading'
|
||||
className='mb-2 flex items-center gap-2.5 text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
{CompetitorIcon ? (
|
||||
<BrandIconTile
|
||||
icon={CompetitorIcon}
|
||||
selfFramed={competitor.brand?.selfFramed}
|
||||
className='size-9'
|
||||
iconClassName='size-5'
|
||||
/>
|
||||
) : null}
|
||||
What is {competitor.name}?
|
||||
</h2>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{competitor.oneLiner}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='comparison-table-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='comparison-table-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Sim vs {competitor.name}: feature-by-feature comparison
|
||||
</h2>
|
||||
<ComparisonTable sim={simProfile} competitor={competitor} />
|
||||
</section>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<div className='grid grid-cols-1 lg:grid-cols-2'>
|
||||
<section
|
||||
aria-labelledby='sim-standout-heading'
|
||||
className='border-[var(--border)] border-r max-lg:border-r-0 max-lg:border-b'
|
||||
>
|
||||
<div className='px-6 pt-6 pb-2'>
|
||||
<h2
|
||||
id='sim-standout-heading'
|
||||
className='text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
Sim standout features
|
||||
</h2>
|
||||
</div>
|
||||
<ComparisonCards items={simProfile.standoutFeatures} />
|
||||
</section>
|
||||
<section aria-labelledby='competitor-limitations-heading'>
|
||||
<div className='px-6 pt-6 pb-2'>
|
||||
<h2
|
||||
id='competitor-limitations-heading'
|
||||
className='text-[18px] text-[var(--text-primary)] leading-snug tracking-[-0.01em]'
|
||||
>
|
||||
Documented {competitor.name} limitations
|
||||
</h2>
|
||||
</div>
|
||||
<ComparisonCards items={competitor.limitations} />
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='bottom-line-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='bottom-line-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Bottom line
|
||||
</h2>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{verdict.chooseSim}
|
||||
</p>
|
||||
<p className='text-[var(--text-body)] text-small leading-[150%]'>
|
||||
{verdict.chooseCompetitor}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className='h-px w-full bg-[var(--border)]' />
|
||||
|
||||
<section aria-labelledby='faq-heading' className='px-6 py-10'>
|
||||
<h2
|
||||
id='faq-heading'
|
||||
className='mb-4 text-[20px] text-[var(--text-primary)] leading-[100%] tracking-[-0.02em] lg:text-[24px]'
|
||||
>
|
||||
Frequently asked questions
|
||||
</h2>
|
||||
<div>
|
||||
<LandingFAQ faqs={faqs} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='-mt-px h-px w-full bg-[var(--border)]' />
|
||||
</main>
|
||||
|
||||
<div className='py-16'>
|
||||
<Cta />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ComparisonFacts, CompetitorProfile, Fact } from '@/lib/compare/data'
|
||||
|
||||
/**
|
||||
* The one place a {@link ComparisonFacts} group is read back out of a profile
|
||||
* by group key. Every render-side consumer (the table, key-differences strip,
|
||||
* JSON-LD builder) needs this same lookup; centralizing it here means there is
|
||||
* exactly one cast to reason about instead of one per call site.
|
||||
*/
|
||||
export function getFactGroup<G extends keyof ComparisonFacts>(
|
||||
profile: CompetitorProfile,
|
||||
group: G
|
||||
): Record<string, Fact> {
|
||||
return profile.facts[group] as Record<string, Fact>
|
||||
}
|
||||
|
||||
/**
|
||||
* One row in a comparison table section. Maps a human label to a fact key
|
||||
* within a {@link ComparisonFacts} group. `key` is intentionally `string`
|
||||
* (rather than a per-group `keyof` union) so a single array can hold rows
|
||||
* for every group without TypeScript collapsing the distributed generic to
|
||||
* `never`; correctness is enforced once, by construction, in
|
||||
* {@link COMPARISON_SECTIONS} below, and the renderer reads through it.
|
||||
*/
|
||||
export interface ComparisonRowDef {
|
||||
key: string
|
||||
label: string
|
||||
}
|
||||
|
||||
/** One section of the comparison table, mirroring a {@link ComparisonFacts} group. */
|
||||
export interface ComparisonSectionDef {
|
||||
group: keyof ComparisonFacts
|
||||
title: string
|
||||
rows: ComparisonRowDef[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-checks a section's rows against its own group's actual fact keys
|
||||
* (via the per-call generic `G`), then widens to the plain `ComparisonRowDef`
|
||||
* shape used by {@link COMPARISON_SECTIONS}. This is where row-key
|
||||
* correctness is actually enforced. A typo here fails the build.
|
||||
*/
|
||||
function defineSection<G extends keyof ComparisonFacts>(section: {
|
||||
group: G
|
||||
title: string
|
||||
rows: Array<{ key: keyof ComparisonFacts[G]; label: string }>
|
||||
}): ComparisonSectionDef {
|
||||
return section as ComparisonSectionDef
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical section/row order for rendering a {@link ComparisonFacts} profile
|
||||
* pair as a table. Single source of truth for row labels. Add a field here
|
||||
* once and every comparison page picks it up.
|
||||
*/
|
||||
export const COMPARISON_SECTIONS: ComparisonSectionDef[] = [
|
||||
defineSection({
|
||||
group: 'platform',
|
||||
title: 'Platform',
|
||||
rows: [
|
||||
{ key: 'builderType', label: 'Builder type' },
|
||||
{ key: 'learningCurve', label: 'Learning curve' },
|
||||
{ key: 'selfHostOption', label: 'Self-hosting' },
|
||||
{ key: 'deploymentOptions', label: 'Deployment options' },
|
||||
{ key: 'templates', label: 'Templates' },
|
||||
{ key: 'license', label: 'License' },
|
||||
{ key: 'environmentPromotion', label: 'Environment promotion' },
|
||||
{ key: 'versionControlDepth', label: 'Version control' },
|
||||
{ key: 'realtimeCollaboration', label: 'Realtime collaboration' },
|
||||
{ key: 'nativeFileStorage', label: 'Native file storage' },
|
||||
{ key: 'dataTables', label: 'Native data tables' },
|
||||
{ key: 'richTextEditor', label: 'Rich-text document editor' },
|
||||
{ key: 'subWorkflows', label: 'Sub-workflows (composition)' },
|
||||
{ key: 'customBlocks', label: 'Custom blocks (org-wide reuse)' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'pricing',
|
||||
title: 'Pricing',
|
||||
rows: [
|
||||
{ key: 'pricingModel', label: 'Pricing model' },
|
||||
{ key: 'entryPaidPlan', label: 'Entry paid plan' },
|
||||
{ key: 'freeTier', label: 'Free tier' },
|
||||
{ key: 'byok', label: 'Bring your own key' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'security',
|
||||
title: 'Security & compliance',
|
||||
rows: [
|
||||
{ key: 'soc2', label: 'SOC 2' },
|
||||
{ key: 'dataResidency', label: 'Data residency' },
|
||||
{ key: 'rbac', label: 'Role-based access control' },
|
||||
{ key: 'auditLogging', label: 'Audit logging' },
|
||||
{ key: 'additionalCompliance', label: 'Additional compliance' },
|
||||
{ key: 'modelAndToolGovernance', label: 'Model & tool governance' },
|
||||
{ key: 'credentialGovernance', label: 'Credential governance' },
|
||||
{ key: 'sso', label: 'Single sign-on (SSO)' },
|
||||
{ key: 'thirdPartyVetting', label: 'Vetted first-party integrations' },
|
||||
{ key: 'piiRedaction', label: 'PII redaction' },
|
||||
{ key: 'dataRetention', label: 'Custom data retention' },
|
||||
{ key: 'whiteLabeling', label: 'White-labeling' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'aiCapabilities',
|
||||
title: 'AI capabilities',
|
||||
rows: [
|
||||
{ key: 'multiLlmSupport', label: 'Multi-LLM support' },
|
||||
{ key: 'agentReasoningBlocks', label: 'Agent reasoning blocks' },
|
||||
{ key: 'naturalLanguageBuilding', label: 'Natural-language building' },
|
||||
{ key: 'knowledgeBaseRag', label: 'Knowledge base / RAG' },
|
||||
{ key: 'mcpSupport', label: 'MCP support' },
|
||||
{ key: 'evaluationGuardrails', label: 'Evaluation & guardrails' },
|
||||
{ key: 'humanInTheLoop', label: 'Human-in-the-loop' },
|
||||
{ key: 'generativeMedia', label: 'Generative media' },
|
||||
{ key: 'dynamicToolUse', label: 'Dynamic tool use' },
|
||||
{ key: 'modelFallback', label: 'Automatic model fallback' },
|
||||
{ key: 'agentSkills', label: 'Agent skills' },
|
||||
{ key: 'nativeChatDeployment', label: 'Native chat deployment' },
|
||||
{ key: 'parallelExecution', label: 'Parallel execution' },
|
||||
{ key: 'a2aProtocol', label: 'Agent2Agent (A2A) protocol' },
|
||||
{ key: 'loopIteration', label: 'Loop / iteration block' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'integrations',
|
||||
title: 'Integrations',
|
||||
rows: [
|
||||
{ key: 'integrationCount', label: 'Integrations' },
|
||||
{ key: 'triggerTypes', label: 'Trigger types' },
|
||||
{ key: 'customCodeSteps', label: 'Custom code steps' },
|
||||
{ key: 'apiPublishing', label: 'API publishing' },
|
||||
{ key: 'extensibilitySdk', label: 'SDKs & extensibility' },
|
||||
{ key: 'mcpPublishing', label: 'Publish as MCP server' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'observability',
|
||||
title: 'Observability & durability',
|
||||
rows: [
|
||||
{ key: 'tracingDepth', label: 'Tracing & observability' },
|
||||
{ key: 'durabilityModel', label: 'Durability & retries' },
|
||||
{ key: 'failureAlerting', label: 'Failure alerting' },
|
||||
{ key: 'dataDrains', label: 'Data drains' },
|
||||
{ key: 'asyncExecution', label: 'Async execution' },
|
||||
{ key: 'executionLimits', label: 'Execution limits' },
|
||||
{ key: 'partialFailureHandling', label: 'Partial-failure handling' },
|
||||
{ key: 'unattendedExecution', label: 'Unattended execution' },
|
||||
],
|
||||
}),
|
||||
defineSection({
|
||||
group: 'support',
|
||||
title: 'Support',
|
||||
rows: [
|
||||
{ key: 'supportChannels', label: 'Support channels' },
|
||||
{ key: 'sla', label: 'SLA' },
|
||||
{ key: 'community', label: 'Community' },
|
||||
{ key: 'academy', label: 'Academy / training' },
|
||||
],
|
||||
}),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user