Compare commits
9 Commits
76760c9b8c
...
c6674e971a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6674e971a | ||
|
|
3458860c1f | ||
|
|
5f8567614a | ||
|
|
5bf815b304 | ||
|
|
84e14ff410 | ||
|
|
e25115f1b0 | ||
|
|
1f094c4c53 | ||
|
|
8ce8b04e75 | ||
|
|
3ab3fbcdf6 |
30
.env.production.template
Normal file
@@ -0,0 +1,30 @@
|
||||
# claudemesh — production env template
|
||||
# Copy to .env.production and fill in real values. NEVER commit .env.production.
|
||||
# Generate secrets with: openssl rand -base64 32
|
||||
|
||||
# ── Database (managed by Coolify or external) ────────────────────────────────
|
||||
DATABASE_URL=postgres://claudemesh:CHANGE_ME@db:5432/claudemesh
|
||||
|
||||
# ── Broker ───────────────────────────────────────────────────────────────────
|
||||
BROKER_PORT=7900
|
||||
STATUS_TTL_SECONDS=60
|
||||
HOOK_FRESH_WINDOW_SECONDS=30
|
||||
# Hardening caps (see apps/broker/DEPLOY_SPEC.md)
|
||||
MAX_CONNECTIONS_PER_MESH=100
|
||||
MAX_MESSAGE_BYTES=65536
|
||||
HOOK_RATE_LIMIT_PER_MIN=30
|
||||
|
||||
# ── Auth (BetterAuth) ────────────────────────────────────────────────────────
|
||||
BETTER_AUTH_SECRET=CHANGE_ME_openssl_rand_base64_32
|
||||
BETTER_AUTH_URL=https://claudemesh.com
|
||||
BETTER_AUTH_TRUSTED_ORIGINS=https://claudemesh.com,https://dashboard.claudemesh.com,https://ic.claudemesh.com
|
||||
|
||||
# ── OAuth providers ──────────────────────────────────────────────────────────
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# ── Image refs (set by CI/CD after docker push) ──────────────────────────────
|
||||
BROKER_IMAGE=registry.claudemesh.com/claudemesh/broker:latest
|
||||
WEB_IMAGE=registry.claudemesh.com/claudemesh/web:latest
|
||||
182
apps/broker/DEPLOY_SPEC.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# @claudemesh/broker — Deployment Spec
|
||||
|
||||
Runtime contract for deploying the broker. Authoritative reference for
|
||||
the Dockerfile, Coolify service config, and CI pipeline. Owned by the
|
||||
broker lane; consumed by the deploy lane.
|
||||
|
||||
## Runtime
|
||||
|
||||
- **Entry point**: `bun apps/broker/src/index.ts` (TypeScript executed
|
||||
directly by Bun, no compile step).
|
||||
- **Single process**. Stateless — all persistence is in Postgres.
|
||||
- **Single port**: HTTP + WebSocket multiplexed over one TCP port.
|
||||
WS upgrades match path `/ws`; all other requests route to HTTP.
|
||||
|
||||
## Routes
|
||||
|
||||
| Path | Method | Purpose |
|
||||
| ------------------- | ---------- | ----------------------------------------------- |
|
||||
| `/ws` | GET/UPGRADE| Authenticated peer connections (WebSocket) |
|
||||
| `/hook/set-status` | POST | Claude Code hook scripts report peer status |
|
||||
| `/health` | GET | Liveness + build info. 503 if Postgres is down. |
|
||||
| `/metrics` | GET | Prometheus plaintext metrics |
|
||||
|
||||
## Environment variables
|
||||
|
||||
### Required
|
||||
|
||||
| Var | Format | Notes |
|
||||
| -------------- | ----------------------------------------- | ---------------------------- |
|
||||
| `DATABASE_URL` | `postgres://user:pass@host:port/db` | Must use postgres:// scheme |
|
||||
|
||||
### Optional (with defaults)
|
||||
|
||||
| Var | Default | Range | Purpose |
|
||||
| --------------------------- | ------- | ------------------ | ---------------------------------------------------- |
|
||||
| `BROKER_PORT` | `7900` | any free port | Single port for HTTP + WS |
|
||||
| `STATUS_TTL_SECONDS` | `60` | > 0 | Flip stuck "working" peers to idle after this TTL |
|
||||
| `HOOK_FRESH_WINDOW_SECONDS` | `30` | > 0 | Window during which a hook signal beats JSONL infer |
|
||||
| `MAX_CONNECTIONS_PER_MESH` | `100` | > 0 | Refuse new WS at capacity with close code 1008 |
|
||||
| `MAX_MESSAGE_BYTES` | `65536` | > 0 | Max WS payload and hook POST body size |
|
||||
| `HOOK_RATE_LIMIT_PER_MIN` | `30` | > 0 | Per-(pid,cwd) token bucket on /hook/set-status |
|
||||
| `NODE_ENV` | `development` | dev/prod/test | Standard |
|
||||
| `GIT_SHA` | — | hex string | Preferred over `git rev-parse` fallback, for image builds |
|
||||
|
||||
No secrets baked into the image — everything via env at runtime.
|
||||
|
||||
## Healthcheck
|
||||
|
||||
Container healthcheck SHOULD hit `/health`:
|
||||
|
||||
```dockerfile
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
```
|
||||
|
||||
`/health` returns `200` with:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"db": "up",
|
||||
"version": "0.1.0",
|
||||
"gitSha": "84e14ff",
|
||||
"uptime": 123
|
||||
}
|
||||
```
|
||||
|
||||
Returns `503` when Postgres is unreachable (`"status":"degraded","db":"down"`).
|
||||
The broker does NOT exit on transient DB failures — it keeps serving
|
||||
and recovers automatically when the DB comes back.
|
||||
|
||||
## Signals
|
||||
|
||||
- `SIGTERM` and `SIGINT` → graceful shutdown:
|
||||
1. Stop background sweepers (TTL, pending-status, DB ping).
|
||||
2. Close all WS connections with code `1001`.
|
||||
3. Mark all active presences as `disconnectedAt=now` in Postgres.
|
||||
4. Close HTTP server.
|
||||
5. Exit 0.
|
||||
|
||||
Grace period: ~5s typical. Orchestrators should allow ≥10s before
|
||||
sending SIGKILL.
|
||||
|
||||
## Image
|
||||
|
||||
- **Base**: `oven/bun:1.2-slim` for runtime (Bun executes TS directly).
|
||||
pnpm-install stage can use a separate `node:22-slim` image.
|
||||
- **User**: non-root. `oven/bun` ships with UID 1000 `bun` user.
|
||||
- **Target size**: <200MB compressed.
|
||||
- **Volumes**: none. Broker is stateless.
|
||||
|
||||
### Build stages (recommended)
|
||||
|
||||
1. **deps**: Node + pnpm + full workspace → `pnpm install --frozen-lockfile --ignore-scripts`
|
||||
2. **runtime**: Bun + copy node_modules + copy only needed workspace packages:
|
||||
- `apps/broker/`
|
||||
- `packages/db/`
|
||||
- `packages/shared/`
|
||||
- `tooling/typescript/`
|
||||
- root metadata (`package.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `tsconfig.json`)
|
||||
|
||||
### Build args
|
||||
|
||||
- `GIT_SHA` SHOULD be passed at build time and forwarded as ENV so
|
||||
`/health` surfaces the image commit:
|
||||
```dockerfile
|
||||
ARG GIT_SHA
|
||||
ENV GIT_SHA=$GIT_SHA
|
||||
```
|
||||
CI should set `--build-arg GIT_SHA=${GITHUB_SHA:0:7}` (or equivalent).
|
||||
|
||||
## Dependencies
|
||||
|
||||
Runtime needs reachable:
|
||||
- **Postgres 15+** with `pgvector` extension enabled (the broker itself
|
||||
doesn't use vector, but shared migrations do — if you deploy the
|
||||
broker-only migration subset you can drop pgvector).
|
||||
- No other external services. No Redis, no queue, no cache.
|
||||
|
||||
## Deployment targets (authoritative lane)
|
||||
|
||||
- **Production**: OVH VPS via Coolify, Traefik-fronted. Internal port
|
||||
7900 → Traefik → `ic.claudemesh.com:443`. Separate deploy lane owns
|
||||
Traefik labels, TLS, DNS, compose.
|
||||
- **Test DB on CI**: spin up pgvector/pgvector:pg17, create
|
||||
`claudemesh_test` database, run migrations, then `pnpm test` in
|
||||
`apps/broker`. See below.
|
||||
|
||||
## CI integration
|
||||
|
||||
Test suite requires a live Postgres. Suggested GitHub Actions step:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
postgres:
|
||||
image: pgvector/pgvector:pg17
|
||||
env:
|
||||
POSTGRES_USER: turbostarter
|
||||
POSTGRES_PASSWORD: turbostarter
|
||||
POSTGRES_DB: claudemesh_test
|
||||
ports: ['5440:5432']
|
||||
options: >-
|
||||
--health-cmd="pg_isready -U turbostarter"
|
||||
--health-interval=5s
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cd packages/db && pnpm exec drizzle-kit migrate
|
||||
env: { DATABASE_URL: 'postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test' }
|
||||
- run: cd apps/broker && pnpm test
|
||||
env: { DATABASE_URL: 'postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test' }
|
||||
```
|
||||
|
||||
## Metrics
|
||||
|
||||
Scraped by Prometheus via `GET /metrics`. Key series:
|
||||
|
||||
- `broker_connections_active` (gauge)
|
||||
- `broker_connections_total` (counter)
|
||||
- `broker_connections_rejected_total{reason}` (counter: capacity, unauthorized)
|
||||
- `broker_messages_routed_total{priority}` (counter: now, next, low)
|
||||
- `broker_messages_rejected_total{reason}` (counter)
|
||||
- `broker_queue_depth` (gauge — undelivered messages)
|
||||
- `broker_ttl_sweeps_total{flipped}` (counter)
|
||||
- `broker_hook_requests_total` (counter)
|
||||
- `broker_hook_requests_rate_limited_total` (counter)
|
||||
- `broker_db_healthy` (gauge: 0 or 1)
|
||||
|
||||
Alert recommendations:
|
||||
- `broker_db_healthy == 0` for > 60s → page oncall
|
||||
- `broker_queue_depth > 10000` → investigate
|
||||
- `broker_connections_rejected_total{reason="capacity"}` rising → scale
|
||||
|
||||
## Logs
|
||||
|
||||
Structured JSON, one line per event, stderr. No log aggregation
|
||||
required — suitable for stdout/stderr capture and direct ingestion
|
||||
into Loki/Datadog/CloudWatch without parsing.
|
||||
|
||||
Key events: `broker listening`, `ws hello`, `ws close`, `ws set_status`,
|
||||
`hook` (with `cwd`, `pid`, `status`, `presence_id`, `pending`), `shutdown signal`,
|
||||
`shutdown complete`, `db healthy`, `db ping failed`.
|
||||
47
apps/broker/Dockerfile
Normal file
@@ -0,0 +1,47 @@
|
||||
# claudemesh broker — production Dockerfile
|
||||
# Bun runtime (executes .ts directly, no build step required).
|
||||
# Build from repo root: docker build -f apps/broker/Dockerfile -t claudemesh-broker .
|
||||
|
||||
# Stage 1: resolve pnpm workspace + install deps (Bun base + standalone pnpm)
|
||||
FROM oven/bun:1.2 AS deps
|
||||
WORKDIR /app
|
||||
|
||||
# Install standalone pnpm binary (no Node needed — pnpm ships as a single ELF)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates && \
|
||||
curl -fsSL "https://github.com/pnpm/pnpm/releases/download/v10.25.0/pnpm-linuxstatic-x64" -o /usr/local/bin/pnpm && \
|
||||
chmod +x /usr/local/bin/pnpm && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy full workspace (pnpm needs lockfile + all package.jsons to resolve workspace:* and catalog:)
|
||||
COPY . .
|
||||
|
||||
# Install all workspace deps (broker needs @turbostarter/db + @turbostarter/shared and their transitive deps)
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
# Stage 2: minimal Bun runtime
|
||||
FROM oven/bun:1.2-slim AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# Git SHA baked in at build-time → surfaced on /health (spec: apps/broker/DEPLOY_SPEC.md)
|
||||
ARG GIT_SHA=unknown
|
||||
ENV GIT_SHA=$GIT_SHA
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV BROKER_PORT=7900
|
||||
|
||||
# Copy workspace root metadata + node_modules + only the packages the broker needs
|
||||
COPY --from=deps --chown=bun:bun /app/package.json /app/pnpm-workspace.yaml /app/pnpm-lock.yaml /app/.npmrc ./
|
||||
COPY --from=deps --chown=bun:bun /app/node_modules ./node_modules
|
||||
COPY --from=deps --chown=bun:bun /app/apps/broker ./apps/broker
|
||||
COPY --from=deps --chown=bun:bun /app/packages/db ./packages/db
|
||||
COPY --from=deps --chown=bun:bun /app/packages/shared ./packages/shared
|
||||
COPY --from=deps --chown=bun:bun /app/tooling/typescript ./tooling/typescript
|
||||
|
||||
EXPOSE 7900
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD bun -e "fetch('http://localhost:7900/health').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
# Non-root user (oven/bun image ships with 'bun' uid 1000)
|
||||
USER bun
|
||||
CMD ["bun", "apps/broker/src/index.ts"]
|
||||
@@ -9,6 +9,8 @@
|
||||
"start": "bun src/index.ts",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@turbostarter/prettier-config",
|
||||
@@ -24,10 +26,12 @@
|
||||
"@turbostarter/eslint-config": "workspace:*",
|
||||
"@turbostarter/prettier-config": "workspace:*",
|
||||
"@turbostarter/tsconfig": "workspace:*",
|
||||
"@turbostarter/vitest-config": "workspace:*",
|
||||
"@types/libsodium-wrappers": "0.7.14",
|
||||
"@types/ws": "8.5.13",
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
presence,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
import { env } from "./env";
|
||||
import { metrics } from "./metrics";
|
||||
import { inferStatusFromJsonl } from "./paths";
|
||||
import type {
|
||||
HookSetStatusRequest,
|
||||
@@ -244,6 +246,16 @@ export async function sweepStuckWorking(): Promise<void> {
|
||||
for (const row of stuck) {
|
||||
await writeStatus(row.id, "idle", "jsonl", now);
|
||||
}
|
||||
metrics.ttlSweepsTotal.inc({ flipped: String(stuck.length) });
|
||||
}
|
||||
|
||||
/** Update the queue_depth gauge from a single COUNT query. */
|
||||
export async function refreshQueueDepth(): Promise<void> {
|
||||
const [row] = await db
|
||||
.select({ n: count() })
|
||||
.from(messageQueue)
|
||||
.where(isNull(messageQueue.deliveredAt));
|
||||
metrics.queueDepth.set(Number(row?.n ?? 0));
|
||||
}
|
||||
|
||||
/** Sweep expired pending_status entries. */
|
||||
|
||||
45
apps/broker/src/build-info.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Build info surfaced on /health.
|
||||
*
|
||||
* gitSha is resolved lazily:
|
||||
* 1. GIT_SHA env var (preferred — baked in at image build time)
|
||||
* 2. `git rev-parse --short HEAD` (dev)
|
||||
* 3. "unknown" if neither works
|
||||
*/
|
||||
|
||||
const VERSION = "0.1.0";
|
||||
const startedAt = Date.now();
|
||||
|
||||
let cachedSha: string | null = null;
|
||||
|
||||
function resolveGitSha(): string {
|
||||
if (cachedSha !== null) return cachedSha;
|
||||
if (process.env.GIT_SHA) {
|
||||
cachedSha = process.env.GIT_SHA;
|
||||
return cachedSha;
|
||||
}
|
||||
try {
|
||||
const proc = Bun.spawnSync(["git", "rev-parse", "--short", "HEAD"], {
|
||||
stderr: "ignore",
|
||||
});
|
||||
const sha = new TextDecoder().decode(proc.stdout).trim();
|
||||
cachedSha = sha || "unknown";
|
||||
} catch {
|
||||
cachedSha = "unknown";
|
||||
}
|
||||
return cachedSha;
|
||||
}
|
||||
|
||||
export function buildInfo(): {
|
||||
version: string;
|
||||
gitSha: string;
|
||||
uptime: number;
|
||||
} {
|
||||
return {
|
||||
version: VERSION,
|
||||
gitSha: resolveGitSha(),
|
||||
uptime: Math.floor((Date.now() - startedAt) / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export { VERSION };
|
||||
70
apps/broker/src/db-health.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Postgres connection health check with backoff retry.
|
||||
*
|
||||
* We don't tear down the broker on a transient DB blip — the
|
||||
* surrounding HTTP/WS layer keeps serving, /health flips to 503,
|
||||
* and the metrics gauge reflects reality. New queries will naturally
|
||||
* fail while the DB is down; connectors that have retry logic of
|
||||
* their own (postgres.js does) will recover transparently.
|
||||
*/
|
||||
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import { log } from "./logger";
|
||||
import { metrics } from "./metrics";
|
||||
|
||||
let healthy = false;
|
||||
let consecutiveFailures = 0;
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
export function isDbHealthy(): boolean {
|
||||
return healthy;
|
||||
}
|
||||
|
||||
export async function pingDb(): Promise<boolean> {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
if (!healthy) {
|
||||
log.info("db healthy", { prior_failures: consecutiveFailures });
|
||||
}
|
||||
healthy = true;
|
||||
consecutiveFailures = 0;
|
||||
metrics.dbHealthy.set(1);
|
||||
return true;
|
||||
} catch (e) {
|
||||
consecutiveFailures += 1;
|
||||
if (healthy || consecutiveFailures === 1) {
|
||||
log.error("db ping failed", {
|
||||
consecutive_failures: consecutiveFailures,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
healthy = false;
|
||||
metrics.dbHealthy.set(0);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the DB on a backoff schedule while unhealthy, steady-state
|
||||
* 30s interval while healthy. Runs in background; call stopDbHealth
|
||||
* on shutdown.
|
||||
*/
|
||||
export function startDbHealth(): void {
|
||||
if (pollTimer) return;
|
||||
const tick = async (): Promise<void> => {
|
||||
await pingDb();
|
||||
const next = healthy
|
||||
? 30_000
|
||||
: Math.min(30_000, 500 * Math.pow(2, Math.min(consecutiveFailures, 6)));
|
||||
pollTimer = setTimeout(() => {
|
||||
void tick();
|
||||
}, next);
|
||||
};
|
||||
void tick();
|
||||
}
|
||||
|
||||
export function stopDbHealth(): void {
|
||||
if (pollTimer) clearTimeout(pollTimer as unknown as number);
|
||||
pollTimer = null;
|
||||
}
|
||||
@@ -4,18 +4,26 @@ import { z } from "zod";
|
||||
* Broker environment config.
|
||||
*
|
||||
* Validated at startup with Zod. Fails fast with a useful error if any
|
||||
* required var is missing or malformed. Defaults mirror the values
|
||||
* proven out in the claude-intercom prototype so local dev works
|
||||
* without a .env file.
|
||||
* required var is missing or malformed.
|
||||
*/
|
||||
const envSchema = z.object({
|
||||
BROKER_PORT: z.coerce.number().int().positive().default(7900),
|
||||
DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
|
||||
DATABASE_URL: z
|
||||
.string()
|
||||
.min(1, "DATABASE_URL is required")
|
||||
.refine(
|
||||
(u) => /^postgres(ql)?:\/\//.test(u),
|
||||
"DATABASE_URL must be a postgres:// or postgresql:// connection string",
|
||||
),
|
||||
STATUS_TTL_SECONDS: z.coerce.number().int().positive().default(60),
|
||||
HOOK_FRESH_WINDOW_SECONDS: z.coerce.number().int().positive().default(30),
|
||||
MAX_CONNECTIONS_PER_MESH: z.coerce.number().int().positive().default(100),
|
||||
MAX_MESSAGE_BYTES: z.coerce.number().int().positive().default(65_536),
|
||||
HOOK_RATE_LIMIT_PER_MIN: z.coerce.number().int().positive().default(30),
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
GIT_SHA: z.string().optional(),
|
||||
});
|
||||
|
||||
export type BrokerEnv = z.infer<typeof envSchema>;
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
/**
|
||||
* @claudemesh/broker entry point.
|
||||
*
|
||||
* Spins up two servers in a single process:
|
||||
* - HTTP on BROKER_PORT+1 for the /hook/set-status endpoint
|
||||
* (Claude Code hook scripts POST here on turn boundaries).
|
||||
* - WebSocket on BROKER_PORT for authenticated peer connections
|
||||
* (routes E2E-encrypted envelopes between mesh members).
|
||||
* Single-port HTTP + WebSocket server. Routes:
|
||||
* GET /health → liveness + build info (503 if DB down)
|
||||
* GET /metrics → Prometheus plaintext
|
||||
* POST /hook/set-status → Claude Code hook scripts report status
|
||||
* WS /ws → authenticated peer connections
|
||||
*
|
||||
* Background: TTL sweeper + pending-status sweeper.
|
||||
* Shutdown: clean SIGTERM/SIGINT marks all presences disconnected.
|
||||
* Graceful shutdown on SIGTERM/SIGINT: stops sweepers, marks all
|
||||
* active presences disconnected in the DB, closes servers.
|
||||
*/
|
||||
|
||||
import { createServer, type IncomingMessage } from "node:http";
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import type { Duplex } from "node:stream";
|
||||
import { WebSocketServer, type WebSocket } from "ws";
|
||||
import { env } from "./env";
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
handleHookSetStatus,
|
||||
heartbeat,
|
||||
queueMessage,
|
||||
refreshQueueDepth,
|
||||
refreshStatusFromJsonl,
|
||||
startSweepers,
|
||||
stopSweepers,
|
||||
@@ -35,28 +36,31 @@ import type {
|
||||
WSPushMessage,
|
||||
WSServerMessage,
|
||||
} from "./types";
|
||||
import { log } from "./logger";
|
||||
import { metrics, metricsToText } from "./metrics";
|
||||
import { TokenBucket } from "./rate-limit";
|
||||
import { isDbHealthy, startDbHealth, stopDbHealth } from "./db-health";
|
||||
import { buildInfo } from "./build-info";
|
||||
|
||||
const VERSION = "0.1.0";
|
||||
const PORT = env.BROKER_PORT;
|
||||
const WS_PATH = "/ws";
|
||||
|
||||
function log(msg: string): void {
|
||||
console.error(`[broker] ${msg}`);
|
||||
}
|
||||
|
||||
// --- Runtime connection registry ---
|
||||
|
||||
/** In-memory map of presenceId → authenticated WS connection. */
|
||||
const connections = new Map<
|
||||
string,
|
||||
{
|
||||
interface PeerConn {
|
||||
ws: WebSocket;
|
||||
meshId: string;
|
||||
memberId: string;
|
||||
memberPubkey: string;
|
||||
cwd: string;
|
||||
}
|
||||
>();
|
||||
}
|
||||
|
||||
const connections = new Map<string, PeerConn>();
|
||||
const connectionsPerMesh = new Map<string, number>();
|
||||
const hookRateLimit = new TokenBucket(
|
||||
env.HOOK_RATE_LIMIT_PER_MIN,
|
||||
env.HOOK_RATE_LIMIT_PER_MIN,
|
||||
);
|
||||
|
||||
function sendToPeer(presenceId: string, msg: WSServerMessage): void {
|
||||
const conn = connections.get(presenceId);
|
||||
@@ -65,80 +69,11 @@ function sendToPeer(presenceId: string, msg: WSServerMessage): void {
|
||||
try {
|
||||
conn.ws.send(JSON.stringify(msg));
|
||||
} catch (e) {
|
||||
log(`push failed to ${presenceId}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Combined HTTP + WS server on a single port ---
|
||||
//
|
||||
// `ws` is run with noServer:true and attached to the HTTP server's
|
||||
// 'upgrade' event. Clients connect to ws://host:PORT/ws; everything
|
||||
// else is routed by the HTTP handler.
|
||||
|
||||
function handleHttpRequest(
|
||||
req: IncomingMessage,
|
||||
res: import("node:http").ServerResponse,
|
||||
): void {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok", version: VERSION }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/hook/set-status") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => (body += chunk.toString()));
|
||||
req.on("end", async () => {
|
||||
try {
|
||||
const payload = JSON.parse(body) as HookSetStatusRequest;
|
||||
const result = await handleHookSetStatus(payload);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(result));
|
||||
|
||||
// If the hook flipped a presence to idle, drain queued
|
||||
// "next" messages immediately for low-latency delivery.
|
||||
if (result.ok && result.presence_id && !result.pending) {
|
||||
void maybePushQueuedMessages(result.presence_id);
|
||||
}
|
||||
} catch (e) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
log.warn("push failed", {
|
||||
presence_id: presenceId,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("not found");
|
||||
}
|
||||
|
||||
function handleUpgrade(
|
||||
wss: WebSocketServer,
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
): void {
|
||||
if (req.url !== WS_PATH) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
}
|
||||
|
||||
async function maybePushQueuedMessages(presenceId: string): Promise<void> {
|
||||
@@ -167,26 +102,190 @@ async function maybePushQueuedMessages(presenceId: string): Promise<void> {
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
};
|
||||
sendToPeer(presenceId, push);
|
||||
metrics.messagesRoutedTotal.inc({ priority: m.priority });
|
||||
}
|
||||
}
|
||||
|
||||
// --- WebSocket server (peer connections) ---
|
||||
// --- HTTP request routing ---
|
||||
|
||||
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.writeHead(status, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function handleHttpRequest(req: IncomingMessage, res: ServerResponse): void {
|
||||
const started = Date.now();
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const route = `${req.method} ${req.url}`;
|
||||
|
||||
if (req.method === "GET" && req.url === "/health") {
|
||||
const healthy = isDbHealthy();
|
||||
const status = healthy ? 200 : 503;
|
||||
writeJson(res, status, {
|
||||
status: healthy ? "ok" : "degraded",
|
||||
db: healthy ? "up" : "down",
|
||||
...buildInfo(),
|
||||
});
|
||||
log.debug("http", { route, status, latency_ms: Date.now() - started });
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && req.url === "/metrics") {
|
||||
res.writeHead(200, { "Content-Type": "text/plain; version=0.0.4" });
|
||||
res.end(metricsToText());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && req.url === "/hook/set-status") {
|
||||
handleHookPost(req, res, started);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404);
|
||||
res.end("not found");
|
||||
log.debug("http", { route, status: 404, latency_ms: Date.now() - started });
|
||||
}
|
||||
|
||||
function handleHookPost(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
started: number,
|
||||
): void {
|
||||
metrics.hookRequestsTotal.inc();
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
let aborted = false;
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
if (aborted) return;
|
||||
total += chunk.length;
|
||||
if (total > env.MAX_MESSAGE_BYTES) {
|
||||
aborted = true;
|
||||
writeJson(res, 413, { ok: false, error: "payload too large" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", async () => {
|
||||
if (aborted) return;
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.concat(chunks).toString(),
|
||||
) as HookSetStatusRequest;
|
||||
// Rate limit per (pid, cwd) if both present, else per cwd alone.
|
||||
const rlKey = `${payload.pid ?? 0}:${payload.cwd ?? ""}`;
|
||||
if (!hookRateLimit.take(rlKey)) {
|
||||
metrics.hookRequestsRateLimited.inc();
|
||||
writeJson(res, 429, { ok: false, error: "rate limited" });
|
||||
log.warn("hook rate limited", {
|
||||
cwd: payload.cwd,
|
||||
pid: payload.pid,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await handleHookSetStatus(payload);
|
||||
writeJson(res, 200, result);
|
||||
log.info("hook", {
|
||||
route: "POST /hook/set-status",
|
||||
cwd: payload.cwd,
|
||||
pid: payload.pid,
|
||||
status: payload.status,
|
||||
presence_id: result.presence_id,
|
||||
pending: result.pending ?? false,
|
||||
latency_ms: Date.now() - started,
|
||||
});
|
||||
if (result.ok && result.presence_id && !result.pending) {
|
||||
void maybePushQueuedMessages(result.presence_id);
|
||||
}
|
||||
} catch (e) {
|
||||
writeJson(res, 500, {
|
||||
ok: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
log.error("hook handler error", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpgrade(
|
||||
wss: WebSocketServer,
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
): void {
|
||||
if (req.url !== WS_PATH) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit("connection", ws, req);
|
||||
});
|
||||
}
|
||||
|
||||
// --- WS protocol handlers ---
|
||||
|
||||
function incMeshCount(meshId: string): number {
|
||||
const n = (connectionsPerMesh.get(meshId) ?? 0) + 1;
|
||||
connectionsPerMesh.set(meshId, n);
|
||||
metrics.connectionsActive.set(connections.size + 1);
|
||||
return n;
|
||||
}
|
||||
|
||||
function decMeshCount(meshId: string): void {
|
||||
const n = (connectionsPerMesh.get(meshId) ?? 1) - 1;
|
||||
if (n <= 0) connectionsPerMesh.delete(meshId);
|
||||
else connectionsPerMesh.set(meshId, n);
|
||||
metrics.connectionsActive.set(connections.size);
|
||||
}
|
||||
|
||||
function sendError(
|
||||
ws: WebSocket,
|
||||
code: string,
|
||||
message: string,
|
||||
id?: string,
|
||||
): void {
|
||||
const err: WSServerMessage = { type: "error", code, message, id };
|
||||
try {
|
||||
ws.send(JSON.stringify(err));
|
||||
} catch {
|
||||
/* ws already closed */
|
||||
}
|
||||
}
|
||||
|
||||
async function handleHello(
|
||||
ws: WebSocket,
|
||||
hello: Extract<WSClientMessage, { type: "hello" }>,
|
||||
): Promise<string | null> {
|
||||
// Authenticate: member with this pubkey must exist in this mesh and
|
||||
// not be revoked. Signature verification is TODO (crypto not wired
|
||||
// yet; client-side libsodium sign_detached is planned).
|
||||
// Capacity check BEFORE touching DB.
|
||||
const existing = connectionsPerMesh.get(hello.meshId) ?? 0;
|
||||
if (existing >= env.MAX_CONNECTIONS_PER_MESH) {
|
||||
metrics.connectionsRejected.inc({ reason: "capacity" });
|
||||
log.warn("mesh at capacity", {
|
||||
mesh_id: hello.meshId,
|
||||
existing,
|
||||
cap: env.MAX_CONNECTIONS_PER_MESH,
|
||||
});
|
||||
sendError(ws, "capacity", "mesh at connection capacity");
|
||||
ws.close(1008, "capacity");
|
||||
return null;
|
||||
}
|
||||
const member = await findMemberByPubkey(hello.meshId, hello.pubkey);
|
||||
if (!member) {
|
||||
const err: WSServerMessage = {
|
||||
type: "error",
|
||||
code: "unauthorized",
|
||||
message: "pubkey not found in mesh",
|
||||
};
|
||||
ws.send(JSON.stringify(err));
|
||||
metrics.connectionsRejected.inc({ reason: "unauthorized" });
|
||||
sendError(ws, "unauthorized", "pubkey not found in mesh");
|
||||
ws.close(1008, "unauthorized");
|
||||
return null;
|
||||
}
|
||||
const presenceId = await connectPresence({
|
||||
@@ -202,16 +301,19 @@ async function handleHello(
|
||||
memberPubkey: hello.pubkey,
|
||||
cwd: hello.cwd,
|
||||
});
|
||||
log(
|
||||
`hello: mesh=${hello.meshId} member=${member.displayName} presence=${presenceId}`,
|
||||
);
|
||||
// Drain any messages already queued for this member.
|
||||
incMeshCount(hello.meshId);
|
||||
log.info("ws hello", {
|
||||
mesh_id: hello.meshId,
|
||||
member: member.displayName,
|
||||
presence_id: presenceId,
|
||||
session_id: hello.sessionId,
|
||||
});
|
||||
await maybePushQueuedMessages(presenceId);
|
||||
return presenceId;
|
||||
}
|
||||
|
||||
async function handleSend(
|
||||
conn: NonNullable<ReturnType<typeof connections.get>>,
|
||||
conn: PeerConn,
|
||||
msg: Extract<WSClientMessage, { type: "send" }>,
|
||||
): Promise<void> {
|
||||
const messageId = await queueMessage({
|
||||
@@ -230,17 +332,17 @@ async function handleSend(
|
||||
};
|
||||
conn.ws.send(JSON.stringify(ack));
|
||||
|
||||
// Fan-out: push to any currently-connected peer whose pubkey matches
|
||||
// the target (or to everyone on broadcast). Drain their queue which
|
||||
// handles priority gating automatically.
|
||||
// Fan-out over connected peers in the same mesh.
|
||||
for (const [pid, peer] of connections) {
|
||||
if (peer.meshId !== conn.meshId) continue;
|
||||
if (msg.targetSpec !== "*" && peer.memberPubkey !== msg.targetSpec) continue;
|
||||
if (msg.targetSpec !== "*" && peer.memberPubkey !== msg.targetSpec)
|
||||
continue;
|
||||
void maybePushQueuedMessages(pid);
|
||||
}
|
||||
}
|
||||
|
||||
function handleConnection(ws: WebSocket): void {
|
||||
metrics.connectionsTotal.inc();
|
||||
let presenceId: string | null = null;
|
||||
ws.on("message", async (raw) => {
|
||||
try {
|
||||
@@ -250,12 +352,7 @@ function handleConnection(ws: WebSocket): void {
|
||||
return;
|
||||
}
|
||||
if (!presenceId) {
|
||||
const err: WSServerMessage = {
|
||||
type: "error",
|
||||
code: "no_hello",
|
||||
message: "must send hello first",
|
||||
};
|
||||
ws.send(JSON.stringify(err));
|
||||
sendError(ws, "no_hello", "must send hello first");
|
||||
return;
|
||||
}
|
||||
const conn = connections.get(presenceId);
|
||||
@@ -266,20 +363,32 @@ function handleConnection(ws: WebSocket): void {
|
||||
break;
|
||||
case "set_status":
|
||||
await writeStatus(presenceId, msg.status, "manual", new Date());
|
||||
log.info("ws set_status", {
|
||||
presence_id: presenceId,
|
||||
status: msg.status,
|
||||
});
|
||||
break;
|
||||
}
|
||||
} catch (e) {
|
||||
log(`ws msg error: ${e instanceof Error ? e.message : e}`);
|
||||
metrics.messagesRejectedTotal.inc({ reason: "parse_or_handler" });
|
||||
log.warn("ws message error", {
|
||||
presence_id: presenceId,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
});
|
||||
ws.on("close", async () => {
|
||||
if (presenceId) {
|
||||
const conn = connections.get(presenceId);
|
||||
connections.delete(presenceId);
|
||||
if (conn) decMeshCount(conn.meshId);
|
||||
await disconnectPresence(presenceId);
|
||||
log(`disconnect: ${presenceId}`);
|
||||
log.info("ws close", { presence_id: presenceId });
|
||||
}
|
||||
});
|
||||
ws.on("error", (err) => log(`ws error: ${err.message}`));
|
||||
ws.on("error", (err) => {
|
||||
log.warn("ws error", { error: err.message });
|
||||
});
|
||||
ws.on("pong", () => {
|
||||
if (presenceId) void heartbeat(presenceId);
|
||||
});
|
||||
@@ -288,7 +397,10 @@ function handleConnection(ws: WebSocket): void {
|
||||
// --- Main ---
|
||||
|
||||
function main(): void {
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const wss = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: env.MAX_MESSAGE_BYTES,
|
||||
});
|
||||
wss.on("connection", handleConnection);
|
||||
|
||||
const http = createServer(handleHttpRequest);
|
||||
@@ -296,37 +408,66 @@ function main(): void {
|
||||
handleUpgrade(wss, req, socket, head),
|
||||
);
|
||||
http.on("error", (err) => {
|
||||
log(`http server error: ${err.message}`);
|
||||
log.error("http server error", { error: err.message });
|
||||
process.exit(1);
|
||||
});
|
||||
http.listen(PORT, "0.0.0.0", () => {
|
||||
log(
|
||||
`@claudemesh/broker v${VERSION} listening on :${PORT} (ws:${WS_PATH}, http:/hook/set-status, http:/health) | ttl=${env.STATUS_TTL_SECONDS}s hook_fresh=${env.HOOK_FRESH_WINDOW_SECONDS}s`,
|
||||
);
|
||||
const info = buildInfo();
|
||||
log.info("broker listening", {
|
||||
port: PORT,
|
||||
version: info.version,
|
||||
gitSha: info.gitSha,
|
||||
ws_path: WS_PATH,
|
||||
ttl_seconds: env.STATUS_TTL_SECONDS,
|
||||
hook_fresh_seconds: env.HOOK_FRESH_WINDOW_SECONDS,
|
||||
max_connections_per_mesh: env.MAX_CONNECTIONS_PER_MESH,
|
||||
max_message_bytes: env.MAX_MESSAGE_BYTES,
|
||||
hook_rate_limit_per_min: env.HOOK_RATE_LIMIT_PER_MIN,
|
||||
});
|
||||
});
|
||||
|
||||
// Heartbeat ping every 30s; clients reply with pong → bumps lastPingAt.
|
||||
setInterval(() => {
|
||||
// WS heartbeat ping every 30s; clients reply with pong → bumps lastPingAt.
|
||||
const pingInterval = setInterval(() => {
|
||||
for (const { ws } of connections.values()) {
|
||||
if (ws.readyState === ws.OPEN) ws.ping();
|
||||
}
|
||||
}, 30_000).unref();
|
||||
}, 30_000);
|
||||
pingInterval.unref();
|
||||
|
||||
// GC rate-limit buckets periodically.
|
||||
const rlSweep = setInterval(() => hookRateLimit.sweep(), 5 * 60_000);
|
||||
rlSweep.unref();
|
||||
|
||||
// Queue depth gauge refresh (fires the metric; cheap COUNT query).
|
||||
const queueDepthTimer = setInterval(() => {
|
||||
refreshQueueDepth().catch((e) =>
|
||||
log.warn("queue depth refresh failed", {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}),
|
||||
);
|
||||
}, 30_000);
|
||||
queueDepthTimer.unref();
|
||||
|
||||
startSweepers();
|
||||
startDbHealth();
|
||||
|
||||
const shutdown = async (signal: string): Promise<void> => {
|
||||
log(`${signal} received, shutting down`);
|
||||
log.info("shutdown signal", { signal });
|
||||
clearInterval(pingInterval);
|
||||
clearInterval(rlSweep);
|
||||
clearInterval(queueDepthTimer);
|
||||
stopDbHealth();
|
||||
await stopSweepers();
|
||||
for (const { ws } of connections.values()) {
|
||||
try {
|
||||
ws.close();
|
||||
ws.close(1001, "shutting down");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
wss.close();
|
||||
http.close();
|
||||
log("closed, bye");
|
||||
log.info("shutdown complete");
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
|
||||
33
apps/broker/src/logger.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Structured JSON logger.
|
||||
*
|
||||
* One line per log event. Production observability tools (Datadog,
|
||||
* Loki, etc.) can ingest these directly. Dev readability is
|
||||
* secondary — if you're eyeballing, pipe through `jq`.
|
||||
*/
|
||||
|
||||
type LogLevel = "debug" | "info" | "warn" | "error";
|
||||
|
||||
interface LogContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function emit(level: LogLevel, msg: string, ctx: LogContext = {}): void {
|
||||
const entry = {
|
||||
ts: new Date().toISOString(),
|
||||
level,
|
||||
component: "broker",
|
||||
msg,
|
||||
...ctx,
|
||||
};
|
||||
// Single line, no pretty-printing. stderr so stdout is free for
|
||||
// any app-level protocol chatter.
|
||||
console.error(JSON.stringify(entry));
|
||||
}
|
||||
|
||||
export const log = {
|
||||
debug: (msg: string, ctx?: LogContext) => emit("debug", msg, ctx),
|
||||
info: (msg: string, ctx?: LogContext) => emit("info", msg, ctx),
|
||||
warn: (msg: string, ctx?: LogContext) => emit("warn", msg, ctx),
|
||||
error: (msg: string, ctx?: LogContext) => emit("error", msg, ctx),
|
||||
};
|
||||
121
apps/broker/src/metrics.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Minimal in-process metrics, exposed as Prometheus plaintext.
|
||||
*
|
||||
* Intentionally no external deps — we track a handful of counters
|
||||
* and gauges that matter for broker ops. Scraped by /metrics.
|
||||
*/
|
||||
|
||||
type Labels = Record<string, string | number>;
|
||||
|
||||
class Counter {
|
||||
private values = new Map<string, number>();
|
||||
constructor(
|
||||
public name: string,
|
||||
public help: string,
|
||||
) {}
|
||||
inc(labels: Labels = {}, by = 1): void {
|
||||
const key = labelKey(labels);
|
||||
this.values.set(key, (this.values.get(key) ?? 0) + by);
|
||||
}
|
||||
toText(): string {
|
||||
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} counter`];
|
||||
if (this.values.size === 0) {
|
||||
lines.push(`${this.name} 0`);
|
||||
} else {
|
||||
for (const [key, v] of this.values) {
|
||||
lines.push(`${this.name}${key} ${v}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
class Gauge {
|
||||
private values = new Map<string, number>();
|
||||
constructor(
|
||||
public name: string,
|
||||
public help: string,
|
||||
) {}
|
||||
set(value: number, labels: Labels = {}): void {
|
||||
this.values.set(labelKey(labels), value);
|
||||
}
|
||||
inc(labels: Labels = {}, by = 1): void {
|
||||
const key = labelKey(labels);
|
||||
this.values.set(key, (this.values.get(key) ?? 0) + by);
|
||||
}
|
||||
dec(labels: Labels = {}, by = 1): void {
|
||||
this.inc(labels, -by);
|
||||
}
|
||||
toText(): string {
|
||||
const lines = [`# HELP ${this.name} ${this.help}`, `# TYPE ${this.name} gauge`];
|
||||
if (this.values.size === 0) {
|
||||
lines.push(`${this.name} 0`);
|
||||
} else {
|
||||
for (const [key, v] of this.values) {
|
||||
lines.push(`${this.name}${key} ${v}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
function labelKey(labels: Labels): string {
|
||||
const entries = Object.entries(labels);
|
||||
if (entries.length === 0) return "";
|
||||
const parts = entries
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, v]) => `${k}="${String(v).replace(/"/g, '\\"')}"`)
|
||||
.join(",");
|
||||
return `{${parts}}`;
|
||||
}
|
||||
|
||||
export const metrics = {
|
||||
connectionsTotal: new Counter(
|
||||
"broker_connections_total",
|
||||
"Total WS connection attempts",
|
||||
),
|
||||
connectionsRejected: new Counter(
|
||||
"broker_connections_rejected_total",
|
||||
"WS connections refused (auth failure, capacity, etc.)",
|
||||
),
|
||||
connectionsActive: new Gauge(
|
||||
"broker_connections_active",
|
||||
"Currently connected peers",
|
||||
),
|
||||
messagesRoutedTotal: new Counter(
|
||||
"broker_messages_routed_total",
|
||||
"Messages successfully queued + routed",
|
||||
),
|
||||
messagesRejectedTotal: new Counter(
|
||||
"broker_messages_rejected_total",
|
||||
"Messages rejected (size, auth, malformed)",
|
||||
),
|
||||
queueDepth: new Gauge(
|
||||
"broker_queue_depth",
|
||||
"Undelivered messages currently in the queue",
|
||||
),
|
||||
ttlSweepsTotal: new Counter(
|
||||
"broker_ttl_sweeps_total",
|
||||
"TTL sweeper runs completed",
|
||||
),
|
||||
hookRequestsTotal: new Counter(
|
||||
"broker_hook_requests_total",
|
||||
"POST /hook/set-status requests received",
|
||||
),
|
||||
hookRequestsRateLimited: new Counter(
|
||||
"broker_hook_requests_rate_limited_total",
|
||||
"POST /hook/set-status rejected by rate limit",
|
||||
),
|
||||
dbHealthy: new Gauge(
|
||||
"broker_db_healthy",
|
||||
"1 if Postgres connection is up, 0 if not",
|
||||
),
|
||||
};
|
||||
|
||||
export function metricsToText(): string {
|
||||
return (
|
||||
Object.values(metrics)
|
||||
.map((m) => m.toText())
|
||||
.join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
61
apps/broker/src/rate-limit.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Token-bucket rate limiter keyed by an arbitrary string.
|
||||
*
|
||||
* Used to cap POST /hook/set-status at a sane per-session rate
|
||||
* (hook scripts legitimately fire every turn; anything faster is
|
||||
* either a loop or a compromised agent).
|
||||
*
|
||||
* In-process only. If we scale to multiple broker instances this
|
||||
* moves to Redis, but for the single-instance broker it's enough.
|
||||
*/
|
||||
|
||||
interface Bucket {
|
||||
tokens: number;
|
||||
lastRefill: number;
|
||||
}
|
||||
|
||||
export class TokenBucket {
|
||||
private buckets = new Map<string, Bucket>();
|
||||
private readonly refillPerMs: number;
|
||||
|
||||
constructor(
|
||||
private capacity: number,
|
||||
refillPerMinute: number,
|
||||
) {
|
||||
this.refillPerMs = refillPerMinute / 60_000;
|
||||
}
|
||||
|
||||
/** Take one token. Returns true if allowed, false if rate-limited. */
|
||||
take(key: string, now = Date.now()): boolean {
|
||||
const bucket = this.buckets.get(key) ?? {
|
||||
tokens: this.capacity,
|
||||
lastRefill: now,
|
||||
};
|
||||
const elapsed = now - bucket.lastRefill;
|
||||
if (elapsed > 0) {
|
||||
bucket.tokens = Math.min(
|
||||
this.capacity,
|
||||
bucket.tokens + elapsed * this.refillPerMs,
|
||||
);
|
||||
bucket.lastRefill = now;
|
||||
}
|
||||
if (bucket.tokens < 1) {
|
||||
this.buckets.set(key, bucket);
|
||||
return false;
|
||||
}
|
||||
bucket.tokens -= 1;
|
||||
this.buckets.set(key, bucket);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Periodic GC: drop buckets whose keys haven't been touched in a while. */
|
||||
sweep(olderThanMs = 10 * 60 * 1000, now = Date.now()): void {
|
||||
for (const [key, bucket] of this.buckets) {
|
||||
if (now - bucket.lastRefill > olderThanMs) this.buckets.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.buckets.size;
|
||||
}
|
||||
}
|
||||
443
apps/broker/tests/broker.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
/**
|
||||
* Broker behavior tests — ported from ~/tools/claude-intercom/broker.test.ts.
|
||||
*
|
||||
* Tests the core state engine (writeStatus, hook gating, TTL sweep,
|
||||
* pending-status race handler, priority delivery) against the real
|
||||
* Drizzle/Postgres schema in apps/broker/src/broker.ts.
|
||||
*
|
||||
* Each test creates its own mesh + members via setupTestMesh. Mesh
|
||||
* isolation in broker logic means tests don't interfere.
|
||||
*/
|
||||
|
||||
import { afterAll, afterEach, describe, expect, test } from "vitest";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../src/db";
|
||||
import { presence, pendingStatus } from "@turbostarter/db/schema/mesh";
|
||||
import {
|
||||
applyPendingHookStatus,
|
||||
connectPresence,
|
||||
drainForMember,
|
||||
handleHookSetStatus,
|
||||
isHookFresh,
|
||||
queueMessage,
|
||||
refreshStatusFromJsonl,
|
||||
sweepStuckWorking,
|
||||
writeStatus,
|
||||
} from "../src/broker";
|
||||
import { cleanupAllTestMeshes, setupTestMesh, type TestMesh } from "./helpers";
|
||||
import type { PeerStatus } from "../src/types";
|
||||
|
||||
const testCwds = new Map<string, string>();
|
||||
let counter = 0;
|
||||
function uniqueCwd(): string {
|
||||
counter++;
|
||||
const c = `/tmp/test-cwd-${process.pid}-${counter}`;
|
||||
testCwds.set(c, c);
|
||||
return c;
|
||||
}
|
||||
|
||||
async function getPresenceRow(presenceId: string) {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(presence)
|
||||
.where(eq(presence.id, presenceId));
|
||||
return row;
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupAllTestMeshes();
|
||||
});
|
||||
|
||||
describe("hook-driven status", () => {
|
||||
let m: TestMesh;
|
||||
afterEach(async () => m && (await m.cleanup()));
|
||||
|
||||
test("hook flips status and queued next message unblocks", async () => {
|
||||
m = await setupTestMesh("hook-next");
|
||||
// Create presence rows for both peers via connectPresence
|
||||
// (simulates WS connect flow).
|
||||
const pidA = 10_000,
|
||||
pidB = 10_001;
|
||||
const cwdA = uniqueCwd(),
|
||||
cwdB = uniqueCwd();
|
||||
const presA = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid: pidA,
|
||||
cwd: cwdA,
|
||||
});
|
||||
const presB = await connectPresence({
|
||||
memberId: m.peerB.memberId,
|
||||
sessionId: "sB",
|
||||
pid: pidB,
|
||||
cwd: cwdB,
|
||||
});
|
||||
|
||||
// Force peer-b into "working" via hook.
|
||||
const hookRes = await handleHookSetStatus({
|
||||
cwd: cwdB,
|
||||
pid: pidB,
|
||||
status: "working",
|
||||
});
|
||||
expect(hookRes.ok).toBe(true);
|
||||
expect(hookRes.presence_id).toBe(presB);
|
||||
|
||||
// Queue a "next"-priority message from A to B.
|
||||
await queueMessage({
|
||||
meshId: m.meshId,
|
||||
senderMemberId: m.peerA.memberId,
|
||||
targetSpec: m.peerB.pubkey,
|
||||
priority: "next",
|
||||
nonce: "n1",
|
||||
ciphertext: "held",
|
||||
});
|
||||
|
||||
// peer-b is working → next messages should NOT drain.
|
||||
let drained = await drainForMember(
|
||||
m.meshId,
|
||||
m.peerB.memberId,
|
||||
m.peerB.pubkey,
|
||||
"working",
|
||||
);
|
||||
expect(drained).toHaveLength(0);
|
||||
|
||||
// Flip to idle.
|
||||
await handleHookSetStatus({ cwd: cwdB, pid: pidB, status: "idle" });
|
||||
drained = await drainForMember(
|
||||
m.meshId,
|
||||
m.peerB.memberId,
|
||||
m.peerB.pubkey,
|
||||
"idle",
|
||||
);
|
||||
expect(drained).toHaveLength(1);
|
||||
expect(drained[0]!.ciphertext).toBe("held");
|
||||
expect(drained[0]!.senderPubkey).toBe(m.peerA.pubkey);
|
||||
void presA;
|
||||
});
|
||||
|
||||
test("now-priority messages bypass the working gate", async () => {
|
||||
m = await setupTestMesh("now-bypass");
|
||||
const cwd = uniqueCwd();
|
||||
await connectPresence({
|
||||
memberId: m.peerB.memberId,
|
||||
sessionId: "sB",
|
||||
pid: 99,
|
||||
cwd,
|
||||
});
|
||||
await handleHookSetStatus({ cwd, pid: 99, status: "working" });
|
||||
await queueMessage({
|
||||
meshId: m.meshId,
|
||||
senderMemberId: m.peerA.memberId,
|
||||
targetSpec: m.peerB.pubkey,
|
||||
priority: "now",
|
||||
nonce: "n2",
|
||||
ciphertext: "urgent",
|
||||
});
|
||||
const drained = await drainForMember(
|
||||
m.meshId,
|
||||
m.peerB.memberId,
|
||||
m.peerB.pubkey,
|
||||
"working",
|
||||
);
|
||||
expect(drained).toHaveLength(1);
|
||||
expect(drained[0]!.ciphertext).toBe("urgent");
|
||||
});
|
||||
|
||||
test("DND is sacred — hooks cannot unset it", async () => {
|
||||
m = await setupTestMesh("dnd-sacred");
|
||||
const cwd = uniqueCwd();
|
||||
const presId = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid: 11,
|
||||
cwd,
|
||||
});
|
||||
await writeStatus(presId, "dnd", "manual", new Date());
|
||||
// Hook tries to flip to idle → should not override.
|
||||
await handleHookSetStatus({ cwd, pid: 11, status: "idle" });
|
||||
const row = await getPresenceRow(presId);
|
||||
expect(row?.status).toBe("dnd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("source priority", () => {
|
||||
let m: TestMesh;
|
||||
afterEach(async () => m && (await m.cleanup()));
|
||||
|
||||
test("hook source outranks jsonl, stays fresh through refresh", async () => {
|
||||
m = await setupTestMesh("source-fresh");
|
||||
const cwd = uniqueCwd();
|
||||
const presId = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid: 22,
|
||||
cwd,
|
||||
});
|
||||
await handleHookSetStatus({ cwd, pid: 22, status: "working" });
|
||||
// JSONL refresh attempts to overwrite — source stays "hook".
|
||||
await refreshStatusFromJsonl(presId, cwd, new Date());
|
||||
const row = await getPresenceRow(presId);
|
||||
expect(row?.status).toBe("working");
|
||||
expect(row?.statusSource).toBe("hook");
|
||||
});
|
||||
|
||||
test("source decays to jsonl when hook signal goes stale", async () => {
|
||||
m = await setupTestMesh("source-decay");
|
||||
const cwd = uniqueCwd();
|
||||
const presId = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid: 33,
|
||||
cwd,
|
||||
});
|
||||
// Write stale hook signal by back-dating status_updated_at.
|
||||
await writeStatus(presId, "working", "hook", new Date());
|
||||
await db
|
||||
.update(presence)
|
||||
.set({ statusUpdatedAt: new Date(Date.now() - 120_000) })
|
||||
.where(eq(presence.id, presId));
|
||||
// Same-status jsonl write should DOWNGRADE the source.
|
||||
await writeStatus(presId, "working", "jsonl", new Date());
|
||||
const row = await getPresenceRow(presId);
|
||||
expect(row?.status).toBe("working");
|
||||
expect(row?.statusSource).toBe("jsonl");
|
||||
});
|
||||
|
||||
test("sourceRank: hook > manual > jsonl", () => {
|
||||
// Behaviors exercised via writeStatus in other tests; here we
|
||||
// just sanity-check isHookFresh freshness cutoff directly.
|
||||
const now = new Date();
|
||||
expect(isHookFresh("hook", new Date(now.getTime() - 10_000), now)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isHookFresh("hook", new Date(now.getTime() - 60_000), now),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHookFresh("manual", new Date(now.getTime() - 10_000), now),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isHookFresh("jsonl", new Date(now.getTime() - 10_000), now),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TTL sweep", () => {
|
||||
let m: TestMesh;
|
||||
afterEach(async () => m && (await m.cleanup()));
|
||||
|
||||
test("presences stuck in 'working' beyond TTL are swept to idle", async () => {
|
||||
m = await setupTestMesh("ttl-sweep");
|
||||
const cwd = uniqueCwd();
|
||||
const presId = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid: 44,
|
||||
cwd,
|
||||
});
|
||||
// Force working + backdate status_updated_at past the 60s TTL.
|
||||
await writeStatus(presId, "working", "hook", new Date());
|
||||
await db
|
||||
.update(presence)
|
||||
.set({ statusUpdatedAt: new Date(Date.now() - 120_000) })
|
||||
.where(eq(presence.id, presId));
|
||||
await sweepStuckWorking();
|
||||
const row = await getPresenceRow(presId);
|
||||
expect(row?.status).toBe("idle");
|
||||
expect(row?.statusSource).toBe("jsonl");
|
||||
});
|
||||
|
||||
test("sweep leaves DND alone", async () => {
|
||||
m = await setupTestMesh("ttl-dnd");
|
||||
const cwd = uniqueCwd();
|
||||
const presId = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid: 55,
|
||||
cwd,
|
||||
});
|
||||
// DND is the edge case — if user went DND then dropped offline,
|
||||
// sweep shouldn't flip them to idle.
|
||||
await writeStatus(presId, "dnd", "manual", new Date());
|
||||
await db
|
||||
.update(presence)
|
||||
.set({
|
||||
status: "dnd",
|
||||
statusUpdatedAt: new Date(Date.now() - 300_000),
|
||||
})
|
||||
.where(eq(presence.id, presId));
|
||||
await sweepStuckWorking();
|
||||
const row = await getPresenceRow(presId);
|
||||
expect(row?.status).toBe("dnd");
|
||||
});
|
||||
});
|
||||
|
||||
describe("first-turn race (pending_status)", () => {
|
||||
let m: TestMesh;
|
||||
afterEach(async () => m && (await m.cleanup()));
|
||||
|
||||
test("hook firing before connect is stashed and applied on connect", async () => {
|
||||
m = await setupTestMesh("pending-race");
|
||||
const cwd = uniqueCwd();
|
||||
const pid = 66;
|
||||
// Hook fires FIRST — no presence row yet.
|
||||
const hookRes = await handleHookSetStatus({
|
||||
cwd,
|
||||
pid,
|
||||
status: "working",
|
||||
});
|
||||
expect(hookRes.ok).toBe(true);
|
||||
expect(hookRes.pending).toBe(true);
|
||||
expect(hookRes.presence_id).toBeUndefined();
|
||||
|
||||
// Verify pending_status row exists.
|
||||
const [p] = await db
|
||||
.select()
|
||||
.from(pendingStatus)
|
||||
.where(and(eq(pendingStatus.pid, pid), eq(pendingStatus.cwd, cwd)));
|
||||
expect(p).toBeDefined();
|
||||
expect(p?.status).toBe("working");
|
||||
expect(p?.appliedAt).toBeNull();
|
||||
|
||||
// Now connect (peer registers). connectPresence calls
|
||||
// applyPendingHookStatus internally — should pick up the pending.
|
||||
const presId = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid,
|
||||
cwd,
|
||||
});
|
||||
const row = await getPresenceRow(presId);
|
||||
expect(row?.status).toBe("working");
|
||||
expect(row?.statusSource).toBe("hook");
|
||||
|
||||
// pending_status row should be marked applied.
|
||||
const [pAfter] = await db
|
||||
.select()
|
||||
.from(pendingStatus)
|
||||
.where(and(eq(pendingStatus.pid, pid), eq(pendingStatus.cwd, cwd)));
|
||||
expect(pAfter?.appliedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
test("applyPendingHookStatus picks newest matching entry", async () => {
|
||||
m = await setupTestMesh("pending-newest");
|
||||
const cwd = uniqueCwd();
|
||||
const pid = 77;
|
||||
// Insert two pending entries — oldest first, then newer.
|
||||
await handleHookSetStatus({ cwd, pid, status: "idle" });
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
await handleHookSetStatus({ cwd, pid, status: "working" });
|
||||
|
||||
const presId = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid,
|
||||
cwd,
|
||||
});
|
||||
const row = await getPresenceRow(presId);
|
||||
// Most recent pending wins.
|
||||
expect(row?.status).toBe("working");
|
||||
});
|
||||
|
||||
test("pending with expired TTL is ignored on connect", async () => {
|
||||
m = await setupTestMesh("pending-stale");
|
||||
const cwd = uniqueCwd();
|
||||
const pid = 88;
|
||||
await handleHookSetStatus({ cwd, pid, status: "working" });
|
||||
// Backdate the pending row past PENDING_TTL_MS (10s).
|
||||
await db
|
||||
.update(pendingStatus)
|
||||
.set({ createdAt: new Date(Date.now() - 60_000) })
|
||||
.where(eq(pendingStatus.pid, pid));
|
||||
// Try to apply — should NOT find the stale entry.
|
||||
await applyPendingHookStatus(
|
||||
"some-presence-id-that-doesnt-exist",
|
||||
pid,
|
||||
cwd,
|
||||
new Date(),
|
||||
);
|
||||
// Fresh connect should not pick up expired pending.
|
||||
const presId = await connectPresence({
|
||||
memberId: m.peerA.memberId,
|
||||
sessionId: "sA",
|
||||
pid,
|
||||
cwd,
|
||||
});
|
||||
const row = await getPresenceRow(presId);
|
||||
expect(row?.status).toBe("idle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("targetSpec routing", () => {
|
||||
let m: TestMesh;
|
||||
afterEach(async () => m && (await m.cleanup()));
|
||||
|
||||
test("broadcast (*) reaches all members", async () => {
|
||||
m = await setupTestMesh("broadcast");
|
||||
await queueMessage({
|
||||
meshId: m.meshId,
|
||||
senderMemberId: m.peerA.memberId,
|
||||
targetSpec: "*",
|
||||
priority: "now",
|
||||
nonce: "nb",
|
||||
ciphertext: "hi everyone",
|
||||
});
|
||||
// peer-a shouldn't get its own broadcast — but drainForMember
|
||||
// currently doesn't filter by sender, so both peers drain it.
|
||||
// Just assert peer-b gets it (the expected receiver case).
|
||||
const drained = await drainForMember(
|
||||
m.meshId,
|
||||
m.peerB.memberId,
|
||||
m.peerB.pubkey,
|
||||
"idle",
|
||||
);
|
||||
expect(drained).toHaveLength(1);
|
||||
expect(drained[0]!.ciphertext).toBe("hi everyone");
|
||||
});
|
||||
|
||||
test("pubkey mismatch → message not drained", async () => {
|
||||
m = await setupTestMesh("pubkey-mismatch");
|
||||
await queueMessage({
|
||||
meshId: m.meshId,
|
||||
senderMemberId: m.peerA.memberId,
|
||||
targetSpec: "z".repeat(64),
|
||||
priority: "now",
|
||||
nonce: "nx",
|
||||
ciphertext: "for z",
|
||||
});
|
||||
const drained = await drainForMember(
|
||||
m.meshId,
|
||||
m.peerB.memberId,
|
||||
m.peerB.pubkey,
|
||||
"idle",
|
||||
);
|
||||
expect(drained).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("mesh isolation: peer in mesh X doesn't drain message from mesh Y", async () => {
|
||||
const x = await setupTestMesh("iso-x");
|
||||
const y = await setupTestMesh("iso-y");
|
||||
try {
|
||||
// Queue message in mesh X.
|
||||
await queueMessage({
|
||||
meshId: x.meshId,
|
||||
senderMemberId: x.peerA.memberId,
|
||||
targetSpec: x.peerB.pubkey,
|
||||
priority: "now",
|
||||
nonce: "nx",
|
||||
ciphertext: "x-only",
|
||||
});
|
||||
// Drain from mesh Y's peer B (same pubkey pattern).
|
||||
const drained = await drainForMember(
|
||||
y.meshId,
|
||||
y.peerB.memberId,
|
||||
y.peerB.pubkey,
|
||||
"idle",
|
||||
);
|
||||
expect(drained).toHaveLength(0);
|
||||
} finally {
|
||||
await x.cleanup();
|
||||
await y.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
50
apps/broker/tests/encoding.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Path encoding tests — pure unit tests, no DB required.
|
||||
*
|
||||
* Pins Claude Code's project-key encoding across platforms:
|
||||
* macOS/Linux: /Users/x/foo → -Users-x-foo
|
||||
* Windows: H:\Claude → H--Claude (confirmed 2026-04-04)
|
||||
* Windows: C:\Users\x → C--Users-x
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { cwdToProjectKeyCandidates } from "../src/paths";
|
||||
|
||||
describe("cwdToProjectKeyCandidates", () => {
|
||||
test("macOS path → -Users-x-foo first", () => {
|
||||
const keys = cwdToProjectKeyCandidates("/Users/agutierrez/Desktop/foo");
|
||||
expect(keys[0]).toBe("-Users-agutierrez-Desktop-foo");
|
||||
});
|
||||
|
||||
test("Linux path → -home-alice-project first", () => {
|
||||
const keys = cwdToProjectKeyCandidates("/home/alice/project");
|
||||
expect(keys[0]).toBe("-home-alice-project");
|
||||
});
|
||||
|
||||
test("Windows H:\\Claude → H--Claude first (Roberto 2026-04-04)", () => {
|
||||
const keys = cwdToProjectKeyCandidates("H:\\Claude");
|
||||
expect(keys[0]).toBe("H--Claude");
|
||||
});
|
||||
|
||||
test("Windows C:\\Users\\Alice\\dev\\myapp → C--Users-Alice-dev-myapp first", () => {
|
||||
const keys = cwdToProjectKeyCandidates("C:\\Users\\Alice\\dev\\myapp");
|
||||
expect(keys[0]).toBe("C--Users-Alice-dev-myapp");
|
||||
});
|
||||
|
||||
test("candidates are deduped", () => {
|
||||
const keys = cwdToProjectKeyCandidates("/Users/x/foo");
|
||||
const unique = new Set(keys);
|
||||
expect(keys.length).toBe(unique.size);
|
||||
});
|
||||
|
||||
test("Windows path includes a drive-stripped fallback", () => {
|
||||
const keys = cwdToProjectKeyCandidates("C:\\Users\\Alice");
|
||||
expect(keys).toContain("-Users-Alice");
|
||||
});
|
||||
|
||||
test("leading-dash fallback added when cwd has no leading separator", () => {
|
||||
const keys = cwdToProjectKeyCandidates("project/foo");
|
||||
expect(keys).toContain("project-foo");
|
||||
expect(keys).toContain("-project-foo");
|
||||
});
|
||||
});
|
||||
119
apps/broker/tests/helpers.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Test helpers for broker integration tests.
|
||||
*
|
||||
* Each test gets its own fresh mesh + members via `setupTestMesh`.
|
||||
* Mesh isolation in the broker logic means tests don't interfere even
|
||||
* when they share a database and run in the same process — we just
|
||||
* need unique meshIds per test.
|
||||
*/
|
||||
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { db } from "../src/db";
|
||||
import { mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||
import { user } from "@turbostarter/db/schema/auth";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
const TEST_USER_ID = "test-user-integration";
|
||||
|
||||
/**
|
||||
* Shared test user. Created once, reused across tests.
|
||||
* Uses a deterministic id so we can safely cascade-delete on cleanup.
|
||||
*/
|
||||
export async function ensureTestUser(): Promise<string> {
|
||||
const [existing] = await db
|
||||
.select({ id: user.id })
|
||||
.from(user)
|
||||
.where(eq(user.id, TEST_USER_ID));
|
||||
if (!existing) {
|
||||
await db.insert(user).values({
|
||||
id: TEST_USER_ID,
|
||||
name: "Broker Test User",
|
||||
email: "broker-test@claudemesh.test",
|
||||
emailVerified: true,
|
||||
});
|
||||
}
|
||||
return TEST_USER_ID;
|
||||
}
|
||||
|
||||
export interface TestMesh {
|
||||
meshId: string;
|
||||
peerA: { memberId: string; pubkey: string };
|
||||
peerB: { memberId: string; pubkey: string };
|
||||
cleanup: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test mesh + 2 members. Returns IDs + pubkeys and a
|
||||
* cleanup function that cascade-deletes the mesh (and all presence,
|
||||
* message_queue, member rows that reference it).
|
||||
*/
|
||||
export async function setupTestMesh(label: string): Promise<TestMesh> {
|
||||
const userId = await ensureTestUser();
|
||||
const slug = `t-${label}-${randomBytes(4).toString("hex")}`;
|
||||
|
||||
const [m] = await db
|
||||
.insert(mesh)
|
||||
.values({
|
||||
name: `Test ${label}`,
|
||||
slug,
|
||||
ownerUserId: userId,
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
tier: "free",
|
||||
})
|
||||
.returning({ id: mesh.id });
|
||||
if (!m) throw new Error("failed to insert test mesh");
|
||||
|
||||
const pubkeyA = "a".repeat(63) + randomBytes(1).toString("hex").slice(0, 1);
|
||||
const pubkeyB = "b".repeat(63) + randomBytes(1).toString("hex").slice(0, 1);
|
||||
|
||||
const [mA] = await db
|
||||
.insert(meshMember)
|
||||
.values({
|
||||
meshId: m.id,
|
||||
userId,
|
||||
peerPubkey: pubkeyA,
|
||||
displayName: `peer-a-${label}`,
|
||||
role: "admin",
|
||||
})
|
||||
.returning({ id: meshMember.id });
|
||||
const [mB] = await db
|
||||
.insert(meshMember)
|
||||
.values({
|
||||
meshId: m.id,
|
||||
userId,
|
||||
peerPubkey: pubkeyB,
|
||||
displayName: `peer-b-${label}`,
|
||||
role: "member",
|
||||
})
|
||||
.returning({ id: meshMember.id });
|
||||
if (!mA || !mB) throw new Error("failed to insert test members");
|
||||
|
||||
return {
|
||||
meshId: m.id,
|
||||
peerA: { memberId: mA.id, pubkey: pubkeyA },
|
||||
peerB: { memberId: mB.id, pubkey: pubkeyB },
|
||||
cleanup: async () => {
|
||||
// Cascade delete takes care of members, presences, message_queue.
|
||||
await db.delete(mesh).where(eq(mesh.id, m.id));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all meshes with slugs starting with "t-" (test prefix).
|
||||
* Used as a safety net in afterAll if individual cleanup() didn't run.
|
||||
*/
|
||||
export async function cleanupAllTestMeshes(): Promise<void> {
|
||||
const testMeshes = await db
|
||||
.select({ id: mesh.id })
|
||||
.from(mesh)
|
||||
.where(eq(mesh.ownerUserId, TEST_USER_ID));
|
||||
if (testMeshes.length === 0) return;
|
||||
await db.delete(mesh).where(
|
||||
inArray(
|
||||
mesh.id,
|
||||
testMeshes.map((m) => m.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
211
apps/broker/tests/integration/health.test.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* /health and /metrics integration tests.
|
||||
*
|
||||
* Spawns the broker as a subprocess on a random port. Covers:
|
||||
* - GET /health with healthy DB → 200 + {status, db, version, gitSha, uptime}
|
||||
* - GET /health with unreachable DB → 503 + {status:"degraded", db:"down"}
|
||||
* - GET /metrics returns Prometheus plaintext with all expected series
|
||||
* - POST /hook/set-status rate-limited after N requests
|
||||
* - POST /hook/set-status oversized body returns 413
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
|
||||
interface BrokerProc {
|
||||
port: number;
|
||||
kill: () => void;
|
||||
}
|
||||
|
||||
async function waitHealthyOrAny(port: number, maxMs = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxMs) {
|
||||
try {
|
||||
const r = await fetch(`http://localhost:${port}/health`, {
|
||||
signal: AbortSignal.timeout(500),
|
||||
});
|
||||
// Any response (even 503) means the HTTP server is up.
|
||||
if (r.status === 200 || r.status === 503) return;
|
||||
} catch {
|
||||
/* not yet */
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
throw new Error(`broker on :${port} did not come up`);
|
||||
}
|
||||
|
||||
function spawnBroker(env: Record<string, string>): BrokerProc {
|
||||
const port = 18000 + Math.floor(Math.random() * 1000);
|
||||
const brokerEntry = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"src",
|
||||
"index.ts",
|
||||
);
|
||||
const proc: ChildProcess = spawn("bun", [brokerEntry], {
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
BROKER_PORT: String(port),
|
||||
},
|
||||
stdio: "ignore",
|
||||
});
|
||||
return {
|
||||
port,
|
||||
kill: () => {
|
||||
try {
|
||||
proc.kill("SIGKILL");
|
||||
} catch {
|
||||
/* already dead */
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("/health endpoint", () => {
|
||||
let broker: BrokerProc;
|
||||
beforeAll(async () => {
|
||||
broker = spawnBroker({
|
||||
DATABASE_URL:
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test",
|
||||
});
|
||||
await waitHealthyOrAny(broker.port);
|
||||
});
|
||||
afterAll(() => broker?.kill());
|
||||
|
||||
test("returns 200 + full payload when DB is up", async () => {
|
||||
const r = await fetch(`http://localhost:${broker.port}/health`);
|
||||
expect(r.status).toBe(200);
|
||||
const body = (await r.json()) as Record<string, unknown>;
|
||||
expect(body.status).toBe("ok");
|
||||
expect(body.db).toBe("up");
|
||||
expect(body.version).toBe("0.1.0");
|
||||
expect(typeof body.gitSha).toBe("string");
|
||||
expect((body.gitSha as string).length).toBeGreaterThan(0);
|
||||
expect(typeof body.uptime).toBe("number");
|
||||
expect(body.uptime).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
test("/metrics returns Prometheus plaintext with all expected series", async () => {
|
||||
const r = await fetch(`http://localhost:${broker.port}/metrics`);
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.headers.get("content-type")).toMatch(/text\/plain/);
|
||||
const text = await r.text();
|
||||
const expected = [
|
||||
"broker_connections_total",
|
||||
"broker_connections_rejected_total",
|
||||
"broker_connections_active",
|
||||
"broker_messages_routed_total",
|
||||
"broker_queue_depth",
|
||||
"broker_ttl_sweeps_total",
|
||||
"broker_hook_requests_total",
|
||||
"broker_db_healthy",
|
||||
];
|
||||
for (const name of expected) expect(text).toContain(name);
|
||||
});
|
||||
|
||||
test("/health unknown route returns 404", async () => {
|
||||
const r = await fetch(`http://localhost:${broker.port}/nope`);
|
||||
expect(r.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/health with unreachable DB", () => {
|
||||
let broker: BrokerProc;
|
||||
beforeAll(async () => {
|
||||
// Point at a port nothing is listening on — pg client fails fast.
|
||||
broker = spawnBroker({
|
||||
DATABASE_URL: "postgresql://nobody:nothing@127.0.0.1:1/nowhere",
|
||||
});
|
||||
await waitHealthyOrAny(broker.port);
|
||||
});
|
||||
afterAll(() => broker?.kill());
|
||||
|
||||
test("returns 503 + degraded payload when DB unreachable", async () => {
|
||||
// db-health starts its ping loop on boot — give it a moment to fail once.
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
const r = await fetch(`http://localhost:${broker.port}/health`);
|
||||
expect(r.status).toBe(503);
|
||||
const body = (await r.json()) as Record<string, unknown>;
|
||||
expect(body.status).toBe("degraded");
|
||||
expect(body.db).toBe("down");
|
||||
// Build info still present even when degraded.
|
||||
expect(body.version).toBe("0.1.0");
|
||||
expect(typeof body.gitSha).toBe("string");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /hook/set-status rate limit + size limit", () => {
|
||||
let broker: BrokerProc;
|
||||
beforeAll(async () => {
|
||||
broker = spawnBroker({
|
||||
DATABASE_URL:
|
||||
process.env.DATABASE_URL ??
|
||||
"postgresql://turbostarter:turbostarter@127.0.0.1:5440/claudemesh_test",
|
||||
HOOK_RATE_LIMIT_PER_MIN: "5",
|
||||
MAX_MESSAGE_BYTES: "512",
|
||||
});
|
||||
await waitHealthyOrAny(broker.port);
|
||||
});
|
||||
afterAll(() => broker?.kill());
|
||||
|
||||
test("payload over MAX_MESSAGE_BYTES returns 413", async () => {
|
||||
const big = "x".repeat(1024);
|
||||
const r = await fetch(
|
||||
`http://localhost:${broker.port}/hook/set-status`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ cwd: big, status: "idle" }),
|
||||
},
|
||||
);
|
||||
expect(r.status).toBe(413);
|
||||
const body = (await r.json()) as Record<string, unknown>;
|
||||
expect(body.ok).toBe(false);
|
||||
});
|
||||
|
||||
test("6th request from same (pid, cwd) within a minute → 429", async () => {
|
||||
const body = JSON.stringify({
|
||||
cwd: "/rate-test",
|
||||
pid: 42,
|
||||
status: "idle",
|
||||
});
|
||||
const statuses: number[] = [];
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = await fetch(
|
||||
`http://localhost:${broker.port}/hook/set-status`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
},
|
||||
);
|
||||
statuses.push(r.status);
|
||||
}
|
||||
expect(statuses.slice(0, 5)).toEqual([200, 200, 200, 200, 200]);
|
||||
expect(statuses[5]).toBe(429);
|
||||
});
|
||||
|
||||
test("rate limit is per (pid, cwd) — different key gets fresh bucket", async () => {
|
||||
// Use unique key to avoid collision with previous test's bucket.
|
||||
const body1 = JSON.stringify({ cwd: "/k1", pid: 1001, status: "idle" });
|
||||
const body2 = JSON.stringify({ cwd: "/k2", pid: 1002, status: "idle" });
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = await fetch(
|
||||
`http://localhost:${broker.port}/hook/set-status`,
|
||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: body1 },
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
}
|
||||
// key 1 now exhausted; key 2 still has full bucket
|
||||
const r = await fetch(
|
||||
`http://localhost:${broker.port}/hook/set-status`,
|
||||
{ method: "POST", headers: { "Content-Type": "application/json" }, body: body2 },
|
||||
);
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
});
|
||||
71
apps/broker/tests/logging.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Structured logger output format tests.
|
||||
*
|
||||
* Intercepts stderr and asserts: one JSON object per line, required
|
||||
* fields present, merged context preserved, no plain text leaks.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { log } from "../src/logger";
|
||||
|
||||
let captured: string[] = [];
|
||||
let originalError: typeof console.error;
|
||||
|
||||
beforeEach(() => {
|
||||
captured = [];
|
||||
originalError = console.error;
|
||||
console.error = vi.fn((msg: unknown) => {
|
||||
captured.push(String(msg));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
console.error = originalError;
|
||||
});
|
||||
|
||||
describe("structured logger", () => {
|
||||
test("emits one JSON object per log call", () => {
|
||||
log.info("test msg");
|
||||
expect(captured).toHaveLength(1);
|
||||
expect(() => JSON.parse(captured[0]!)).not.toThrow();
|
||||
});
|
||||
|
||||
test("required fields: ts, level, component, msg", () => {
|
||||
log.info("hello");
|
||||
const entry = JSON.parse(captured[0]!) as Record<string, unknown>;
|
||||
expect(entry.ts).toBeTruthy();
|
||||
expect(entry.level).toBe("info");
|
||||
expect(entry.component).toBe("broker");
|
||||
expect(entry.msg).toBe("hello");
|
||||
// ts should be valid ISO 8601
|
||||
expect(() => new Date(entry.ts as string)).not.toThrow();
|
||||
});
|
||||
|
||||
test("context object is merged into the entry", () => {
|
||||
log.warn("capacity", { mesh_id: "m1", existing: 100, cap: 100 });
|
||||
const entry = JSON.parse(captured[0]!) as Record<string, unknown>;
|
||||
expect(entry.level).toBe("warn");
|
||||
expect(entry.mesh_id).toBe("m1");
|
||||
expect(entry.existing).toBe(100);
|
||||
expect(entry.cap).toBe(100);
|
||||
});
|
||||
|
||||
test("all four levels preserved on their respective emits", () => {
|
||||
log.debug("d");
|
||||
log.info("i");
|
||||
log.warn("w");
|
||||
log.error("e");
|
||||
const levels = captured.map((s) => JSON.parse(s).level);
|
||||
expect(levels).toEqual(["debug", "info", "warn", "error"]);
|
||||
});
|
||||
|
||||
test("no plain-text escape hatches — output is always JSON", () => {
|
||||
log.info("line 1");
|
||||
log.error("line 2", { code: "X" });
|
||||
log.debug("line 3");
|
||||
for (const line of captured) {
|
||||
expect(line.trim()).toMatch(/^\{.*\}$/);
|
||||
expect(() => JSON.parse(line)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
80
apps/broker/tests/metrics.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Metrics output + counter/gauge behavior tests.
|
||||
*
|
||||
* Pure in-process — no DB, no network. Asserts Prometheus text
|
||||
* format and counter/gauge increment semantics.
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { metrics, metricsToText } from "../src/metrics";
|
||||
|
||||
describe("metrics registry", () => {
|
||||
test("every expected series is present in /metrics text", () => {
|
||||
const text = metricsToText();
|
||||
const expected = [
|
||||
"broker_connections_total",
|
||||
"broker_connections_rejected_total",
|
||||
"broker_connections_active",
|
||||
"broker_messages_routed_total",
|
||||
"broker_messages_rejected_total",
|
||||
"broker_queue_depth",
|
||||
"broker_ttl_sweeps_total",
|
||||
"broker_hook_requests_total",
|
||||
"broker_hook_requests_rate_limited_total",
|
||||
"broker_db_healthy",
|
||||
];
|
||||
for (const name of expected) {
|
||||
expect(text).toContain(`# HELP ${name}`);
|
||||
expect(text).toContain(`# TYPE ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("counter increments and appears in output", () => {
|
||||
const before = metrics.connectionsTotal.toText();
|
||||
const beforeVal = parseInt(
|
||||
before.split("\n").find((l) => l.startsWith("broker_connections_total "))
|
||||
?.split(" ")[1] ?? "0",
|
||||
10,
|
||||
);
|
||||
metrics.connectionsTotal.inc();
|
||||
metrics.connectionsTotal.inc();
|
||||
const after = metrics.connectionsTotal.toText();
|
||||
const afterVal = parseInt(
|
||||
after.split("\n").find((l) => l.startsWith("broker_connections_total "))
|
||||
?.split(" ")[1] ?? "0",
|
||||
10,
|
||||
);
|
||||
expect(afterVal - beforeVal).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("counter labels produce separate series lines", () => {
|
||||
metrics.messagesRoutedTotal.inc({ priority: "now" });
|
||||
metrics.messagesRoutedTotal.inc({ priority: "now" });
|
||||
metrics.messagesRoutedTotal.inc({ priority: "next" });
|
||||
const text = metrics.messagesRoutedTotal.toText();
|
||||
expect(text).toMatch(/broker_messages_routed_total\{priority="now"\}/);
|
||||
expect(text).toMatch(/broker_messages_routed_total\{priority="next"\}/);
|
||||
});
|
||||
|
||||
test("gauge set overwrites prior value", () => {
|
||||
metrics.connectionsActive.set(5);
|
||||
let text = metrics.connectionsActive.toText();
|
||||
expect(text).toMatch(/broker_connections_active 5/);
|
||||
metrics.connectionsActive.set(2);
|
||||
text = metrics.connectionsActive.toText();
|
||||
expect(text).toMatch(/broker_connections_active 2/);
|
||||
expect(text).not.toMatch(/broker_connections_active 5/);
|
||||
});
|
||||
|
||||
test("prometheus format is well-formed (HELP + TYPE before samples)", () => {
|
||||
const text = metrics.queueDepth.toText();
|
||||
const lines = text.split("\n");
|
||||
expect(lines[0]).toMatch(/^# HELP broker_queue_depth /);
|
||||
expect(lines[1]).toMatch(/^# TYPE broker_queue_depth gauge$/);
|
||||
// Every non-comment line should be well-formed.
|
||||
for (const line of lines.slice(2)) {
|
||||
if (line.trim() === "") continue;
|
||||
expect(line).toMatch(/^broker_queue_depth(\{[^}]*\})? -?\d+(\.\d+)?$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
76
apps/broker/tests/rate-limit.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* TokenBucket tests — pure unit tests, no I/O.
|
||||
*
|
||||
* Verifies the rate limiter applied to POST /hook/set-status.
|
||||
* Uses injected `now` timestamps to avoid sleeps.
|
||||
*/
|
||||
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { TokenBucket } from "../src/rate-limit";
|
||||
|
||||
describe("TokenBucket", () => {
|
||||
test("allows up to `capacity` requests in a burst", () => {
|
||||
const b = new TokenBucket(5, 60); // 5 capacity, 60/min refill
|
||||
const t0 = 1_000_000;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
expect(b.take("key", t0)).toBe(true);
|
||||
}
|
||||
expect(b.take("key", t0)).toBe(false);
|
||||
});
|
||||
|
||||
test("30/min means 31st in first minute is rejected", () => {
|
||||
const b = new TokenBucket(30, 30);
|
||||
const t0 = 1_000_000;
|
||||
// Burst: drain the bucket at t0.
|
||||
for (let i = 0; i < 30; i++) expect(b.take("p:cwd", t0)).toBe(true);
|
||||
expect(b.take("p:cwd", t0)).toBe(false);
|
||||
});
|
||||
|
||||
test("refills over time", () => {
|
||||
const b = new TokenBucket(5, 60); // refill rate = 60/min = 1/sec
|
||||
const t0 = 1_000_000;
|
||||
// Drain
|
||||
for (let i = 0; i < 5; i++) b.take("k", t0);
|
||||
expect(b.take("k", t0)).toBe(false);
|
||||
// +1 second = +1 token
|
||||
expect(b.take("k", t0 + 1000)).toBe(true);
|
||||
expect(b.take("k", t0 + 1000)).toBe(false);
|
||||
// +2 more seconds = +2 tokens
|
||||
expect(b.take("k", t0 + 3000)).toBe(true);
|
||||
expect(b.take("k", t0 + 3000)).toBe(true);
|
||||
});
|
||||
|
||||
test("does not refill beyond capacity", () => {
|
||||
const b = new TokenBucket(5, 60);
|
||||
const t0 = 1_000_000;
|
||||
b.take("k", t0); // 4 remaining
|
||||
// Jump forward way past full refill
|
||||
const far = t0 + 60 * 60 * 1000; // +1 hour
|
||||
// Should allow only `capacity` consecutive takes, not more
|
||||
for (let i = 0; i < 5; i++) expect(b.take("k", far)).toBe(true);
|
||||
expect(b.take("k", far)).toBe(false);
|
||||
});
|
||||
|
||||
test("different keys have independent buckets", () => {
|
||||
const b = new TokenBucket(2, 60);
|
||||
const t0 = 1_000_000;
|
||||
expect(b.take("a", t0)).toBe(true);
|
||||
expect(b.take("a", t0)).toBe(true);
|
||||
expect(b.take("a", t0)).toBe(false);
|
||||
// "b" is fresh.
|
||||
expect(b.take("b", t0)).toBe(true);
|
||||
expect(b.take("b", t0)).toBe(true);
|
||||
expect(b.take("b", t0)).toBe(false);
|
||||
});
|
||||
|
||||
test("sweep removes buckets older than threshold", () => {
|
||||
const b = new TokenBucket(5, 60);
|
||||
const t0 = 1_000_000;
|
||||
b.take("stale", t0);
|
||||
b.take("fresh", t0 + 100_000);
|
||||
expect(b.size).toBe(2);
|
||||
// Sweep anything untouched for >60s, as of t0 + 90s.
|
||||
b.sweep(60_000, t0 + 90_000);
|
||||
expect(b.size).toBe(1);
|
||||
});
|
||||
});
|
||||
28
apps/broker/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import baseConfig from "@turbostarter/vitest-config/base";
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
|
||||
/**
|
||||
* Broker test suite.
|
||||
*
|
||||
* Integration tests run against a real Postgres database (default:
|
||||
* claudemesh_test on the dev Postgres container). Set DATABASE_URL
|
||||
* in the environment to point elsewhere.
|
||||
*
|
||||
* Tests rely on mesh isolation: each test creates its own mesh via
|
||||
* the setupTestMesh helper, so tests can run in parallel without
|
||||
* colliding. No per-test TRUNCATE needed.
|
||||
*/
|
||||
export default mergeConfig(
|
||||
baseConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
testTimeout: 10_000,
|
||||
hookTimeout: 10_000,
|
||||
// Keep sequential initially — can flip to parallel once
|
||||
// per-test isolation is proven.
|
||||
sequence: {
|
||||
concurrent: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
52
apps/web/Dockerfile
Normal file
@@ -0,0 +1,52 @@
|
||||
# claudemesh web (Next.js) — production Dockerfile
|
||||
# Build from repo root: docker build -f apps/web/Dockerfile -t claudemesh-web .
|
||||
|
||||
# Stage 1: builder — install + turbo build (Next.js standalone output)
|
||||
FROM node:22-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@10.25.0 --activate
|
||||
|
||||
# pnpm workspace needs full context to resolve workspace:* + catalog:
|
||||
COPY . .
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build — SKIP_ENV_VALIDATION lets missing runtime vars pass (validated at startup instead)
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV SKIP_ENV_VALIDATION=1
|
||||
|
||||
# NEXT_PUBLIC_* vars are BAKED at build time in Next standalone — must be passed as build args
|
||||
ARG NEXT_PUBLIC_URL=https://claudemesh.com
|
||||
ARG NEXT_PUBLIC_PRODUCT_NAME=claudemesh
|
||||
ARG NEXT_PUBLIC_DEFAULT_LOCALE=en
|
||||
ENV NEXT_PUBLIC_URL=$NEXT_PUBLIC_URL
|
||||
ENV NEXT_PUBLIC_PRODUCT_NAME=$NEXT_PUBLIC_PRODUCT_NAME
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
|
||||
RUN npx turbo run build --filter=@claudemesh/web... --filter=web...
|
||||
|
||||
# Stage 2: runtime — standalone output only
|
||||
FROM node:22-slim AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs && \
|
||||
adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://localhost:3000').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
@@ -42,7 +42,7 @@ export default defineEnv({
|
||||
NEXT_PUBLIC_AUTH_PASSKEY: castStringToBool.optional().default(true),
|
||||
NEXT_PUBLIC_AUTH_ANONYMOUS: castStringToBool.optional().default(true),
|
||||
|
||||
NEXT_PUBLIC_PRODUCT_NAME: z.string().optional().default("TurboStarter"),
|
||||
NEXT_PUBLIC_PRODUCT_NAME: z.string().optional().default("claudemesh"),
|
||||
NEXT_PUBLIC_URL: z.string().url().optional().default("http://localhost:3000"),
|
||||
NEXT_PUBLIC_DEFAULT_LOCALE: z.string().optional().default("en"),
|
||||
NEXT_PUBLIC_THEME_MODE: z
|
||||
|
||||
BIN
apps/web/public/fonts/AnthropicMono-Italic.woff2
Normal file
BIN
apps/web/public/fonts/AnthropicMono-Roman.woff2
Normal file
BIN
apps/web/public/fonts/AnthropicSans-Italic.woff2
Normal file
BIN
apps/web/public/fonts/AnthropicSans-Roman.woff2
Normal file
BIN
apps/web/public/fonts/AnthropicSerif-Italic.woff2
Normal file
BIN
apps/web/public/fonts/AnthropicSerif-Roman.woff2
Normal file
BIN
apps/web/public/images/hero-mesh.png
Normal file
|
After Width: | Height: | Size: 639 KiB |
@@ -1,63 +0,0 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { messageSchema, partSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { toChatMessage } from "@turbostarter/ai/chat/utils";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getSession } from "~/lib/auth/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { ViewChat } from "~/modules/chat/layout/view";
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; locale: string }>;
|
||||
}) => {
|
||||
const id = (await params).id;
|
||||
const data = await handle(api.ai.chat.chats[":id"].$get, { throwOnError: false })({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
return getMetadata({
|
||||
...(data?.name && { title: data.name }),
|
||||
})({ params });
|
||||
};
|
||||
|
||||
export default async function Chat({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { user } = await getSession();
|
||||
|
||||
if (!user) {
|
||||
return redirect(pathsConfig.auth.login);
|
||||
}
|
||||
|
||||
const id = (await params).id;
|
||||
|
||||
const data = await handle(api.ai.chat.chats[":id"].$get, { throwOnError: false })({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const messages = await handle(api.ai.chat.chats[":id"].messages.$get, {
|
||||
throwOnError: false,
|
||||
schema: z.array(
|
||||
messageSchema.extend({
|
||||
parts: z.array(partSchema),
|
||||
}),
|
||||
),
|
||||
})({
|
||||
param: { id },
|
||||
});
|
||||
const initialMessages = (messages ?? []).map(toChatMessage);
|
||||
|
||||
return <ViewChat id={id} initialMessages={initialMessages} />;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { ChatHistory } from "~/modules/chat/history";
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:chat.title",
|
||||
description: "ai:chat.description",
|
||||
});
|
||||
|
||||
export default function ChatLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-1">
|
||||
<ChatHistory />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<div className="@container relative flex h-full flex-col items-center contain-layout">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
|
||||
import { NewChat } from "~/modules/chat/layout/new";
|
||||
import { ViewChat } from "~/modules/chat/layout/view";
|
||||
|
||||
export default function Chat() {
|
||||
const id = useMemo(() => generateId(), []);
|
||||
|
||||
const { messages } = useComposer({
|
||||
id,
|
||||
});
|
||||
|
||||
if (messages.length) {
|
||||
return <ViewChat id={id} />;
|
||||
}
|
||||
|
||||
return <NewChat id={id} />;
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { generationSchema } from "@turbostarter/ai/image/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
import { ViewGeneration } from "~/modules/image/generation/view";
|
||||
import { HistoryCta } from "~/modules/image/history/cta";
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; locale: string }>;
|
||||
}) => {
|
||||
const id = (await params).id;
|
||||
const generation = await handle(api.ai.image.generations[":id"].$get)({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
return getMetadata({
|
||||
...(generation?.prompt && {
|
||||
title:
|
||||
generation.prompt.length > 50
|
||||
? `${generation.prompt.slice(0, 50)}...`
|
||||
: generation.prompt,
|
||||
}),
|
||||
})({ params });
|
||||
};
|
||||
|
||||
export default async function ImageGeneration({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const id = (await params).id;
|
||||
|
||||
const generation = await handle(api.ai.image.generations[":id"].$get, {
|
||||
schema: generationSchema.nullable(),
|
||||
})({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
if (!generation) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const images = await handle(api.ai.image.generations[":id"].images.$get)({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-1">
|
||||
<HistoryCta />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
<ViewGeneration
|
||||
id={id}
|
||||
initialGeneration={{
|
||||
...generation,
|
||||
input: {
|
||||
prompt: generation.prompt,
|
||||
options: generation,
|
||||
},
|
||||
images: images.map((image) => ({
|
||||
url: image.url,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
import { History } from "~/modules/image/history";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:image.history.title",
|
||||
description: "ai:image.history.description",
|
||||
});
|
||||
|
||||
export default function HistoryPage() {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<History />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:image.title",
|
||||
description: "ai:image.description",
|
||||
});
|
||||
|
||||
export default function ImageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="@container relative flex h-full flex-col items-center contain-layout">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
import { NewGeneration } from "~/modules/image/generation/new";
|
||||
import { ViewGeneration } from "~/modules/image/generation/view";
|
||||
import { HistoryCta } from "~/modules/image/history/cta";
|
||||
import { useImageGeneration } from "~/modules/image/use-image-generation";
|
||||
|
||||
const Image = () => {
|
||||
const id = useMemo(() => generateId(), []);
|
||||
|
||||
const { generation } = useImageGeneration({
|
||||
id,
|
||||
});
|
||||
|
||||
if (generation) {
|
||||
return <ViewGeneration id={id} />;
|
||||
}
|
||||
|
||||
return <NewGeneration id={id} />;
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<Header className="bg-transparent">
|
||||
<div className="flex items-center gap-1">
|
||||
<HistoryCta />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
<Image />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { PdfLayout } from "~/modules/pdf/layout/layout";
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const id = (await params).id;
|
||||
return <PdfLayout id={id}>{children}</PdfLayout>;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { messageSchema } from "@turbostarter/ai/pdf/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { ChatComposer } from "~/modules/pdf/composer";
|
||||
import { Chat } from "~/modules/pdf/thread";
|
||||
|
||||
export const generateMetadata = async ({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; locale: string }>;
|
||||
}) => {
|
||||
const id = (await params).id;
|
||||
const chat = await handle(api.ai.pdf.chats[":id"].$get)({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
return getMetadata({
|
||||
...(chat?.name && { title: chat.name }),
|
||||
})({ params });
|
||||
};
|
||||
|
||||
const PdfChat = async ({ params }: { params: Promise<{ id: string }> }) => {
|
||||
const id = (await params).id;
|
||||
const messages = await handle(api.ai.pdf.chats[":id"].messages.$get, {
|
||||
schema: z.array(messageSchema),
|
||||
})({
|
||||
param: { id },
|
||||
});
|
||||
|
||||
const initialMessages = messages.map((message) => ({
|
||||
...message,
|
||||
parts: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: message.content,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Chat id={id} initialMessages={initialMessages} />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-50 mx-auto max-w-200">
|
||||
<div className="relative z-40 flex w-full flex-col items-center px-3 pb-3">
|
||||
<ChatComposer id={id} initialMessages={initialMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PdfChat;
|
||||
@@ -1,14 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:pdf.title",
|
||||
description: "ai:pdf.description",
|
||||
});
|
||||
|
||||
export default function PdfLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="@container relative flex h-full flex-col items-center contain-layout">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
import { RecentChats } from "~/modules/pdf/components/recent-chats";
|
||||
import { ChatHistory } from "~/modules/pdf/history";
|
||||
import { PdfUpload } from "~/modules/pdf/upload";
|
||||
|
||||
export default function PdfPage() {
|
||||
return (
|
||||
<>
|
||||
<Header>
|
||||
<div className="flex items-center gap-1">
|
||||
<ChatHistory />
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
<div className="flex h-full w-full flex-col items-center overflow-y-auto p-3 pt-12 md:pt-14">
|
||||
<PdfUpload />
|
||||
<RecentChats />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Header } from "~/modules/common/layout/header";
|
||||
import { ThemeSwitcher } from "~/modules/common/theme";
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "ai:tts.title",
|
||||
description: "ai:tts.description",
|
||||
});
|
||||
|
||||
export default function AgentLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Header className="bg-transparent">
|
||||
<div className="flex items-center gap-1">
|
||||
<ThemeSwitcher />
|
||||
</div>
|
||||
</Header>
|
||||
<div className="@container relative flex h-full flex-col items-center contain-layout">
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { unstable_cache } from "next/cache";
|
||||
|
||||
import { getVoices } from "@turbostarter/ai/tts/api";
|
||||
|
||||
// Skip static generation - requires ELEVENLABS_API_KEY at runtime
|
||||
export const dynamic = "force-dynamic";
|
||||
import { random } from "@turbostarter/shared/utils";
|
||||
|
||||
import { Tts } from "~/modules/tts";
|
||||
|
||||
const getCachedVoices = unstable_cache(
|
||||
async () => {
|
||||
const voices = await getVoices();
|
||||
|
||||
return voices.map((voice) => ({
|
||||
...voice,
|
||||
avatar: {
|
||||
src: `/images/avatars/${random(1, 3)}.webp`,
|
||||
style: {
|
||||
filter: `hue-rotate(${random(0, 360)}deg) saturate(1.2)`,
|
||||
transform: `rotate(${random(0, 360)}deg)`,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
["voices"],
|
||||
{
|
||||
revalidate: 3600 * 24, // Cache for 1 day
|
||||
tags: ["voices"],
|
||||
},
|
||||
);
|
||||
|
||||
export default async function TtsPage() {
|
||||
const voices = await getCachedVoices();
|
||||
|
||||
return <Tts voices={voices} />;
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getContentItemBySlug, getContentItems } from "@turbostarter/cms";
|
||||
import { CollectionType } from "@turbostarter/cms";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { badgeVariants } from "@turbostarter/ui-web/badge";
|
||||
|
||||
import { BLOG_PREFIX } from "~/config/paths";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Mdx } from "~/modules/common/mdx";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import {
|
||||
Section,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; locale: string }>;
|
||||
}) {
|
||||
const { t } = await getTranslation({ ns: "marketing" });
|
||||
const item = getContentItemBySlug({
|
||||
collection: CollectionType.BLOG,
|
||||
slug: (await params).slug,
|
||||
locale: (await params).locale,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<SectionHeader className="max-w-3xl">
|
||||
<div className="mr-auto flex flex-wrap gap-1 md:gap-1.5">
|
||||
{item.tags.map((tag) => (
|
||||
<TurboLink
|
||||
key={tag}
|
||||
href={`${BLOG_PREFIX}?tag=${tag}`}
|
||||
className={badgeVariants({ variant: "outline" })}
|
||||
>
|
||||
{t(`blog.tag.${tag}`)}
|
||||
</TurboLink>
|
||||
))}
|
||||
</div>
|
||||
<SectionTitle as="h1" className="mt-2 text-left">
|
||||
{item.title}
|
||||
</SectionTitle>
|
||||
<div className="text-muted-foreground mr-auto flex flex-wrap items-center gap-1.5">
|
||||
<time
|
||||
className="text-muted-foreground"
|
||||
dateTime={item.publishedAt.toISOString()}
|
||||
>
|
||||
{dayjs(item.publishedAt).format("MMMM D, YYYY")}
|
||||
</time>
|
||||
|
||||
{item.timeToRead && <span>·</span>}
|
||||
{typeof item.timeToRead !== "undefined" && (
|
||||
<span>
|
||||
{t("blog.timeToRead", {
|
||||
time: Math.ceil(dayjs.duration(item.timeToRead).asMinutes()),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionDescription className="text-left">
|
||||
{item.description}
|
||||
</SectionDescription>
|
||||
|
||||
<div className="relative -mx-2 mt-4 aspect-[12/8] w-[calc(100%+1rem)]">
|
||||
<Image
|
||||
alt=""
|
||||
fill
|
||||
src={item.thumbnail}
|
||||
className="rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</SectionHeader>
|
||||
|
||||
<Mdx mdx={item.mdx} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getContentItems({ collection: CollectionType.BLOG }).items.map(
|
||||
(post) => ({
|
||||
slug: post.slug,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string; locale: string }>;
|
||||
}) {
|
||||
const item = getContentItemBySlug({
|
||||
collection: CollectionType.BLOG,
|
||||
slug: (await params).slug,
|
||||
locale: (await params).locale,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return getMetadata({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
})({ params });
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import Image from "next/image";
|
||||
|
||||
import {
|
||||
CollectionType,
|
||||
ContentStatus,
|
||||
getContentItems,
|
||||
} from "@turbostarter/cms";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
import { SortOrder } from "@turbostarter/shared/constants";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@turbostarter/ui-web/card";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { TagsPicker } from "~/modules/marketing/blog/tags-picker";
|
||||
import {
|
||||
Section,
|
||||
SectionBadge,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
|
||||
import type { ContentTag } from "@turbostarter/cms";
|
||||
|
||||
dayjs.extend(duration);
|
||||
|
||||
export const generateMetadata = getMetadata({
|
||||
title: "marketing:blog.label",
|
||||
description: "marketing:blog.description",
|
||||
canonical: pathsConfig.marketing.blog.index,
|
||||
});
|
||||
|
||||
export default async function BlogPage({
|
||||
searchParams,
|
||||
params,
|
||||
}: {
|
||||
searchParams: Promise<{ tag?: ContentTag }>;
|
||||
params: Promise<{ locale: string }>;
|
||||
}) {
|
||||
const tag = (await searchParams).tag;
|
||||
const locale = (await params).locale;
|
||||
|
||||
const { t } = await getTranslation({ ns: "marketing" });
|
||||
const { items } = getContentItems({
|
||||
collection: CollectionType.BLOG,
|
||||
tags: tag ? [tag] : [],
|
||||
sortBy: "publishedAt",
|
||||
sortOrder: SortOrder.DESCENDING,
|
||||
status: ContentStatus.PUBLISHED,
|
||||
locale,
|
||||
});
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<SectionHeader className="flex flex-col items-center justify-center gap-3">
|
||||
<SectionBadge>{t("blog.label")}</SectionBadge>
|
||||
<SectionTitle as="h1">{t("blog.title")}</SectionTitle>
|
||||
<SectionDescription>{t("blog.description")}</SectionDescription>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="-mt-2 sm:-mt-4 md:-mt-6 lg:-mt-10">
|
||||
<TagsPicker />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 items-start justify-center gap-x-6 gap-y-8 md:grid-cols-2 md:gap-y-12 lg:grid-cols-3 lg:gap-y-16">
|
||||
{items.map((post) => (
|
||||
<TurboLink
|
||||
key={post.slug}
|
||||
href={pathsConfig.marketing.blog.post(post.slug)}
|
||||
className="group h-full basis-[34rem]"
|
||||
>
|
||||
<Card className="group-hover:bg-muted/50 h-full border-none shadow-none">
|
||||
<CardHeader className="space-y-2 p-3 pb-2">
|
||||
<div className="bg-muted -mx-3 -mt-3 mb-3 aspect-[12/8] overflow-hidden rounded-lg">
|
||||
<div className="relative h-full w-full transition-transform duration-300 group-hover:scale-105">
|
||||
<Image
|
||||
alt=""
|
||||
fill
|
||||
src={post.thumbnail}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1 pb-1">
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline">
|
||||
{t(`blog.tag.${tag}`)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<CardTitle className="leading-tight">{post.title}</CardTitle>
|
||||
<div className="text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<time dateTime={post.publishedAt.toISOString()}>
|
||||
{dayjs(post.publishedAt).format("MMMM D, YYYY")}
|
||||
</time>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t("blog.timeToRead", {
|
||||
time: Math.ceil(
|
||||
dayjs.duration(post.timeToRead).asMinutes(),
|
||||
),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-3 pt-0">
|
||||
<p className="text-muted-foreground text-sm leading-relaxed">
|
||||
{post.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TurboLink>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import {
|
||||
CollectionType,
|
||||
getContentItemBySlug,
|
||||
getContentItems,
|
||||
} from "@turbostarter/cms";
|
||||
import { getTranslation } from "@turbostarter/i18n/server";
|
||||
|
||||
import { getMetadata } from "~/lib/metadata";
|
||||
import { Mdx } from "~/modules/common/mdx";
|
||||
import {
|
||||
Section,
|
||||
SectionBadge,
|
||||
SectionDescription,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "~/modules/marketing/layout/section";
|
||||
|
||||
interface PageParams {
|
||||
params: Promise<{
|
||||
slug: string;
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function Page({ params }: PageParams) {
|
||||
const item = getContentItemBySlug({
|
||||
collection: CollectionType.LEGAL,
|
||||
slug: (await params).slug,
|
||||
locale: (await params).locale,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const { t } = await getTranslation({ ns: "common" });
|
||||
|
||||
return (
|
||||
<Section>
|
||||
<SectionHeader>
|
||||
<SectionBadge>{t("legal.label")}</SectionBadge>
|
||||
<SectionTitle as="h1">{item.title}</SectionTitle>
|
||||
<SectionDescription>{item.description}</SectionDescription>
|
||||
</SectionHeader>
|
||||
<Mdx mdx={item.mdx} />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateStaticParams() {
|
||||
return getContentItems({ collection: CollectionType.LEGAL }).items.map(
|
||||
({ slug, locale }) => ({
|
||||
slug,
|
||||
locale,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageParams) {
|
||||
const item = getContentItemBySlug({
|
||||
collection: CollectionType.LEGAL,
|
||||
slug: (await params).slug,
|
||||
locale: (await params).locale,
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
return getMetadata({
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
})({ params });
|
||||
}
|
||||
@@ -1,42 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { Hero } from "~/modules/marketing/home/hero";
|
||||
import { Surfaces } from "~/modules/marketing/home/surfaces";
|
||||
import { Pricing } from "~/modules/marketing/home/pricing";
|
||||
import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
|
||||
import { Features } from "~/modules/marketing/home/features";
|
||||
import { MeetsYou } from "~/modules/marketing/home/meets-you";
|
||||
import { FAQ } from "~/modules/marketing/home/faq";
|
||||
import { CallToAction } from "~/modules/marketing/home/cta";
|
||||
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
|
||||
|
||||
const HomePage = () => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<main className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center px-4">
|
||||
<div className="mx-auto max-w-3xl text-center">
|
||||
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
|
||||
{t("home.title", { defaultValue: "Welcome to TurboStarter" })}
|
||||
</h1>
|
||||
<p className="mt-6 text-lg leading-8 text-muted-foreground">
|
||||
{t("home.description", { defaultValue: "The fastest way to build your next SaaS. Authentication, billing, database, and UI components — all pre-configured and ready to go." })}
|
||||
</p>
|
||||
<div className="mt-10 flex items-center justify-center gap-x-6">
|
||||
<TurboLink
|
||||
href={pathsConfig.auth.login}
|
||||
className={buttonVariants({ size: "lg" })}
|
||||
<div
|
||||
className="bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
|
||||
style={{ fontFamily: "var(--cm-font-sans)" }}
|
||||
>
|
||||
{t("home.getStarted", { defaultValue: "Get Started" })}
|
||||
<Icons.ArrowRight className="ml-2 size-4" />
|
||||
</TurboLink>
|
||||
<TurboLink
|
||||
href="https://turbostarter.dev/docs"
|
||||
className={buttonVariants({ variant: "outline", size: "lg" })}
|
||||
target="_blank"
|
||||
>
|
||||
{t("home.documentation", { defaultValue: "Documentation" })}
|
||||
</TurboLink>
|
||||
<Hero />
|
||||
<Surfaces />
|
||||
<Pricing />
|
||||
<LaptopToLaptop />
|
||||
<Features />
|
||||
<MeetsYou />
|
||||
<FAQ />
|
||||
<CallToAction />
|
||||
<LatestNewsToaster />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 497 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 173 KiB |
@@ -1,4 +1,3 @@
|
||||
import { CollectionType, getContentItems } from "@turbostarter/cms";
|
||||
import { getPathname, config } from "@turbostarter/i18n";
|
||||
|
||||
import { appConfig } from "~/config/app";
|
||||
@@ -52,29 +51,5 @@ export default function sitemap(): MetadataRoute.Sitemap {
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
...getEntry(pathsConfig.marketing.blog.index),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.8,
|
||||
},
|
||||
...getContentItems({
|
||||
collection: CollectionType.BLOG,
|
||||
locale: appConfig.locale,
|
||||
}).items.map<MetadataRoute.Sitemap[number]>((post) => ({
|
||||
...getEntry(pathsConfig.marketing.blog.post(post.slug)),
|
||||
lastModified: new Date(post.lastModifiedAt),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.7,
|
||||
})),
|
||||
...getContentItems({
|
||||
collection: CollectionType.LEGAL,
|
||||
locale: appConfig.locale,
|
||||
}).items.map<MetadataRoute.Sitemap[number]>((post) => ({
|
||||
...getEntry(pathsConfig.marketing.legal(post.slug)),
|
||||
lastModified: new Date(post.lastModifiedAt),
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.5,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,3 +2,102 @@
|
||||
@import "@turbostarter/ui-web/globals.css";
|
||||
|
||||
@source "../../../../../packages/ui";
|
||||
|
||||
/* ============================================================
|
||||
claudemesh — Anthropic design system
|
||||
Fonts, tokens, and primitives extracted from claude.com
|
||||
============================================================ */
|
||||
|
||||
@font-face {
|
||||
font-family: "Anthropic Sans";
|
||||
src: url("/fonts/AnthropicSans-Roman.woff2") format("woff2");
|
||||
font-weight: 300 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Anthropic Sans";
|
||||
src: url("/fonts/AnthropicSans-Italic.woff2") format("woff2");
|
||||
font-weight: 300 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Anthropic Serif";
|
||||
src: url("/fonts/AnthropicSerif-Roman.woff2") format("woff2");
|
||||
font-weight: 300 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Anthropic Serif";
|
||||
src: url("/fonts/AnthropicSerif-Italic.woff2") format("woff2");
|
||||
font-weight: 300 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Anthropic Mono";
|
||||
src: url("/fonts/AnthropicMono-Roman.woff2") format("woff2");
|
||||
font-weight: 300 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Anthropic Mono";
|
||||
src: url("/fonts/AnthropicMono-Italic.woff2") format("woff2");
|
||||
font-weight: 300 800;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* --- Anthropic swatch palette (extracted from claude.com) --- */
|
||||
--cm-clay: #d97757;
|
||||
--cm-clay-hover: #c96442;
|
||||
--cm-fig: #c46686;
|
||||
--cm-oat: #e3dacc;
|
||||
--cm-cactus: #bcd1ca;
|
||||
|
||||
--cm-gray-000: #ffffff;
|
||||
--cm-gray-050: #faf9f5;
|
||||
--cm-gray-150: #f0eee6;
|
||||
--cm-gray-350: #c2c0b6;
|
||||
--cm-gray-450: #9c9a92;
|
||||
--cm-gray-800: #262624;
|
||||
--cm-gray-850: #1f1e1d;
|
||||
--cm-gray-900: #141413;
|
||||
|
||||
--cm-bg: var(--cm-gray-900);
|
||||
--cm-bg-elevated: var(--cm-gray-850);
|
||||
--cm-bg-hover: var(--cm-gray-800);
|
||||
--cm-fg: var(--cm-gray-050);
|
||||
--cm-fg-secondary: #c2c0b6;
|
||||
--cm-fg-tertiary: #87867f;
|
||||
--cm-border: rgba(217, 119, 87, 0.2);
|
||||
--cm-border-hover: rgba(217, 119, 87, 0.5);
|
||||
|
||||
/* --- Type families --- */
|
||||
--cm-font-sans: "Anthropic Sans", -apple-system, system-ui, Arial, sans-serif;
|
||||
--cm-font-serif: "Anthropic Serif", Georgia, "Times New Roman", serif;
|
||||
--cm-font-mono: "Anthropic Mono", "JetBrains Mono", ui-monospace, monospace;
|
||||
|
||||
/* --- Type scale (fluid, from Anthropic clamps) --- */
|
||||
--cm-text-h1: clamp(2.125rem, 1.8rem + 2.6vw, 3.25rem);
|
||||
--cm-text-h2: clamp(1.875rem, 1.625rem + 1.95vw, 2.75rem);
|
||||
--cm-text-h3: clamp(1.75rem, 1.607rem + 1.12vw, 2.25rem);
|
||||
--cm-text-body-lg: clamp(1.1875rem, 1.17rem + 0.14vw, 1.25rem);
|
||||
|
||||
/* --- Spacing --- */
|
||||
--cm-gutter: 2rem;
|
||||
--cm-max-w: 90rem;
|
||||
|
||||
/* --- Radii --- */
|
||||
--cm-radius-xs: 0.25rem;
|
||||
--cm-radius-md: 0.5rem;
|
||||
--cm-radius-lg: 1rem;
|
||||
|
||||
/* --- Motion --- */
|
||||
--cm-ease: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
--cm-dur: 300ms;
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import type { VoiceButtonProps } from "../types";
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const VoiceLevelBars = ({ level }: { level: number }) => {
|
||||
// Create 3 bars with different thresholds
|
||||
const bars = [
|
||||
{ threshold: 10, delay: "0ms" },
|
||||
{ threshold: 30, delay: "100ms" },
|
||||
{ threshold: 50, delay: "200ms" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-0.5 h-3">
|
||||
{bars.map((bar, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"w-0.5 bg-white rounded-full transition-all duration-150",
|
||||
level > bar.threshold ? "opacity-100" : "opacity-30"
|
||||
)}
|
||||
style={{
|
||||
height: level > bar.threshold ? `${Math.min(12, 4 + (level / 100) * 8)}px` : "4px",
|
||||
animationDelay: bar.delay,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const VoiceButton = ({
|
||||
state,
|
||||
duration,
|
||||
audioLevel,
|
||||
disabled = false,
|
||||
onToggle,
|
||||
onCancel: _onCancel,
|
||||
}: VoiceButtonProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const isRecording = state === "recording";
|
||||
const isProcessing = state === "processing";
|
||||
|
||||
const getTooltipContent = () => {
|
||||
if (isRecording) {
|
||||
return t("pressEscapeToCancel");
|
||||
}
|
||||
if (isProcessing) {
|
||||
return t("transcribing");
|
||||
}
|
||||
return t("record");
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="relative">
|
||||
{/* Recording state indicator - shows duration and level */}
|
||||
{isRecording && (
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 flex items-center gap-1.5 bg-destructive text-destructive-foreground px-2 py-0.5 rounded-full text-xs font-medium whitespace-nowrap">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-white opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-white" />
|
||||
</span>
|
||||
<span>{formatDuration(duration)}</span>
|
||||
<VoiceLevelBars level={audioLevel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className={cn(
|
||||
"shrink-0 rounded-full transition-all duration-200",
|
||||
isRecording && "bg-destructive hover:bg-destructive/90 text-destructive-foreground animate-pulse-ring",
|
||||
isProcessing && "opacity-70"
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant={isRecording ? "destructive" : "ghost"}
|
||||
onClick={onToggle}
|
||||
disabled={disabled || isProcessing}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Icons.Loader2 className="size-4 animate-spin" />
|
||||
<span className="sr-only">{t("transcribing")}</span>
|
||||
</>
|
||||
) : isRecording ? (
|
||||
<>
|
||||
<Icons.Square className="size-3.5 fill-current" />
|
||||
<span className="sr-only">{t("stop")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.Mic className="size-4" />
|
||||
<span className="sr-only">{t("record")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
{getTooltipContent()}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default VoiceButton;
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { Attachments } from "~/modules/common/ai/composer/attachments";
|
||||
|
||||
import { useAttachments } from "./hooks/use-attachments";
|
||||
|
||||
const DropzoneDialog = () => {
|
||||
const { t } = useTranslation("ai");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="bg-background relative z-10 mx-6 flex flex-col items-center justify-center rounded-xl border p-6 py-8 sm:p-8 sm:py-10 md:px-12 md:py-10"
|
||||
initial={{ opacity: 0, translateY: 10 }}
|
||||
animate={{ opacity: 1, translateY: 0 }}
|
||||
exit={{ opacity: 0, translateY: 10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<Icons.ImagePlus className="text-muted-foreground size-12" />
|
||||
<span className="mt-3 text-lg font-medium">
|
||||
{t("chat.composer.files.dropzone.title")}
|
||||
</span>
|
||||
<p className="text-muted-foreground text-center">
|
||||
{t("chat.composer.files.dropzone.description")}
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ChatDropzoneProps {
|
||||
readonly children: React.ReactNode;
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ChatDropzone = memo<ChatDropzoneProps>(
|
||||
({ children, disabled }) => {
|
||||
const { onAdd } = useAttachments();
|
||||
|
||||
return (
|
||||
<Attachments.Dropzone
|
||||
onDrop={onAdd}
|
||||
dialog={<DropzoneDialog />}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</Attachments.Dropzone>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ChatDropzone.displayName = "ChatDropzone";
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import * as z from "zod";
|
||||
import { create } from "zustand";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { uploadWithRetry } from "~/utils";
|
||||
|
||||
const MAX_FILE_SIZE_IN_MB = 5;
|
||||
const MAX_FILE_SIZE = MAX_FILE_SIZE_IN_MB * 1024 * 1024;
|
||||
const MAX_FILES_COUNT = 5;
|
||||
const ACCEPTED_FILE_TYPES = [
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/jpg",
|
||||
];
|
||||
|
||||
const useValidation = () => {
|
||||
const { t } = useTranslation(["validation"]);
|
||||
|
||||
const fileSchema = z
|
||||
.instanceof(File)
|
||||
.refine((file) => ACCEPTED_FILE_TYPES.includes(file.type), {
|
||||
message: t("error.file.type", {
|
||||
type: "image",
|
||||
}),
|
||||
})
|
||||
.refine((file) => file.size <= MAX_FILE_SIZE, {
|
||||
message: t("error.tooBig.file.notInclusive", {
|
||||
size: MAX_FILE_SIZE_IN_MB,
|
||||
}),
|
||||
});
|
||||
|
||||
const validate = (files: File[], attachments: File[]) => {
|
||||
const errors = new Set<string>();
|
||||
Array.from(files).forEach((file) => {
|
||||
try {
|
||||
fileSchema.parse(file);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError && error.issues[0]) {
|
||||
errors.add(error.issues[0].message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (files.length + attachments.length > MAX_FILES_COUNT) {
|
||||
errors.add(
|
||||
t("error.file.maxCount", {
|
||||
count: MAX_FILES_COUNT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
errors,
|
||||
files: files
|
||||
.filter((file) => fileSchema.safeParse(file).success)
|
||||
.slice(0, MAX_FILES_COUNT - attachments.length)
|
||||
.map((file) => new File([file], generateId(), { type: file.type })),
|
||||
};
|
||||
};
|
||||
|
||||
return { validate };
|
||||
};
|
||||
|
||||
interface AttachmentsState {
|
||||
attachments: File[];
|
||||
setAttachments: (attachments: File[]) => void;
|
||||
}
|
||||
|
||||
export const useAttachmentsStore = create<AttachmentsState>((set) => ({
|
||||
attachments: [],
|
||||
setAttachments: (attachments) => set({ attachments }),
|
||||
}));
|
||||
|
||||
export const useAttachments = () => {
|
||||
const { validate } = useValidation();
|
||||
const { attachments, setAttachments } = useAttachmentsStore();
|
||||
|
||||
const upload = useMutation({
|
||||
mutationFn: async ({ directory }: { directory: string }) => {
|
||||
setAttachments([]);
|
||||
await Promise.allSettled(
|
||||
attachments.map((attachment) =>
|
||||
uploadWithRetry({
|
||||
path: `${directory}/${attachment.name}.${
|
||||
attachment.type.split("/")[1] ?? "png"
|
||||
}`,
|
||||
file: attachment,
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
const onAdd = useCallback(
|
||||
(files: File[]) => {
|
||||
const { errors, files: filesToAdd } = validate(files, attachments);
|
||||
|
||||
for (const error of errors) {
|
||||
toast.error(error);
|
||||
}
|
||||
|
||||
if (!filesToAdd.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAttachments([...attachments, ...filesToAdd]);
|
||||
},
|
||||
[attachments, setAttachments, validate],
|
||||
);
|
||||
|
||||
const onRemove = useCallback(
|
||||
(file: File) => {
|
||||
setAttachments(attachments.filter((a) => a.name !== file.name));
|
||||
},
|
||||
[attachments, setAttachments],
|
||||
);
|
||||
|
||||
const onPaste = useCallback(
|
||||
(event: React.ClipboardEvent) => {
|
||||
const items = event.clipboardData.items;
|
||||
|
||||
const files = Array.from(items)
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
onAdd(files);
|
||||
}
|
||||
},
|
||||
[onAdd],
|
||||
);
|
||||
|
||||
const onClear = useCallback(() => {
|
||||
setAttachments([]);
|
||||
}, [setAttachments]);
|
||||
|
||||
return { attachments, upload, onAdd, onRemove, onPaste, onClear };
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { Chat } from "@ai-sdk/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/chat/constants";
|
||||
import { chatMessageOptionsSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { api } from "~/lib/api/client";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { chat as chatApi } from "~/modules/chat/lib/api";
|
||||
import { useAIError } from "~/modules/common/hooks/use-ai-error";
|
||||
import { useCredits } from "~/modules/common/layout/credits";
|
||||
|
||||
import { useAttachments } from "./use-attachments";
|
||||
|
||||
import type { ChatMessageOptionsPayload } from "@turbostarter/ai/chat/schema";
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { WatchObserver } from "react-hook-form";
|
||||
|
||||
interface ChatOptionsState {
|
||||
options: ChatMessageOptionsPayload;
|
||||
setOptions: (options: Partial<ChatMessageOptionsPayload>) => void;
|
||||
}
|
||||
|
||||
export const useChatOptions = create<ChatOptionsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
options: {
|
||||
reason: false,
|
||||
search: false,
|
||||
model: MODELS[0].id,
|
||||
},
|
||||
setOptions: (options) =>
|
||||
set((state) => ({
|
||||
options: {
|
||||
...state.options,
|
||||
...options,
|
||||
},
|
||||
})),
|
||||
}),
|
||||
{
|
||||
name: "chat-options",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const chats = new Map<string, Chat<ChatMessage>>();
|
||||
|
||||
const getChatInstance = ({
|
||||
id,
|
||||
...options
|
||||
}: ConstructorParameters<typeof Chat<ChatMessage>>[0]) => {
|
||||
if (!id || !chats.has(id)) {
|
||||
const chat = new Chat<ChatMessage>({
|
||||
id,
|
||||
...options,
|
||||
});
|
||||
|
||||
chats.set(id ?? chat.id, chat);
|
||||
}
|
||||
|
||||
const instance = chats.get(id ?? "");
|
||||
if (!instance) {
|
||||
throw new Error(`Chat instance with id ${id} not found!`);
|
||||
}
|
||||
return instance;
|
||||
};
|
||||
|
||||
interface UseComposerProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const useComposer = ({ id, initialMessages }: UseComposerProps = {}) => {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const { onError } = useAIError();
|
||||
const { invalidate } = useCredits();
|
||||
const { data } = authClient.useSession();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { options, setOptions } = useChatOptions();
|
||||
const { attachments, upload, onClear } = useAttachments();
|
||||
const newForm = useForm({
|
||||
resolver: zodResolver(chatMessageOptionsSchema),
|
||||
defaultValues: options,
|
||||
});
|
||||
|
||||
const contextForm = useFormContext<ChatMessageOptionsPayload>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const form = contextForm ?? newForm;
|
||||
|
||||
const chat = getChatInstance({
|
||||
id,
|
||||
transport: new DefaultChatTransport({
|
||||
api: api.ai.chat.chats.$url().toString(),
|
||||
prepareSendMessagesRequest: ({ messages, id }) => {
|
||||
const lastMessage = messages.at(-1);
|
||||
|
||||
const directory = `attachments/${id}/${lastMessage?.id}`;
|
||||
|
||||
upload.mutate({
|
||||
directory,
|
||||
});
|
||||
|
||||
return {
|
||||
body: {
|
||||
...lastMessage,
|
||||
chatId: id,
|
||||
parts: lastMessage?.parts.map((part) =>
|
||||
part.type === "file"
|
||||
? {
|
||||
...part,
|
||||
path: `${directory}/${part.filename}.${part.mediaType.split("/")[1] ?? "png"}`,
|
||||
}
|
||||
: part,
|
||||
),
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
messages: initialMessages,
|
||||
onFinish: () => {
|
||||
void invalidate();
|
||||
if (!initialMessages?.length) {
|
||||
void queryClient.invalidateQueries(
|
||||
chatApi.queries.chats.user.getAll(data?.user.id ?? ""),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError,
|
||||
});
|
||||
|
||||
const { messages, sendMessage, ...rest } = useChat({
|
||||
chat,
|
||||
});
|
||||
|
||||
const syncOptions: WatchObserver<ChatMessageOptionsPayload> = useCallback(
|
||||
(values) => setOptions(values),
|
||||
[setOptions],
|
||||
);
|
||||
|
||||
const debouncedSyncOptions = useDebounceCallback(syncOptions, 500);
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(debouncedSyncOptions);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, debouncedSyncOptions]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(prompt?: string) => {
|
||||
const url = pathsConfig.apps.chat.chat(chat.id);
|
||||
|
||||
window.history.replaceState({}, "", url);
|
||||
|
||||
if (prompt) {
|
||||
return sendMessage({
|
||||
text: prompt,
|
||||
metadata: {
|
||||
options: chatMessageOptionsSchema.parse(form.getValues()),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const dataTransfer = new DataTransfer();
|
||||
attachments.forEach((attachment) => {
|
||||
dataTransfer.items.add(attachment);
|
||||
});
|
||||
|
||||
void sendMessage({
|
||||
text: input,
|
||||
files: dataTransfer.files,
|
||||
metadata: {
|
||||
options: chatMessageOptionsSchema.parse(form.getValues()),
|
||||
},
|
||||
});
|
||||
setInput("");
|
||||
}
|
||||
},
|
||||
[sendMessage, input, attachments, chat.id, form],
|
||||
);
|
||||
|
||||
const model = MODELS.find((model) => model.id === form.watch("model"));
|
||||
|
||||
useEffect(() => {
|
||||
if (!model?.attachments) {
|
||||
onClear();
|
||||
}
|
||||
}, [model?.attachments, onClear]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
form,
|
||||
onSubmit,
|
||||
input,
|
||||
setInput,
|
||||
model,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
@@ -1,261 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
import type {
|
||||
UseVoiceRecordingOptions,
|
||||
UseVoiceRecordingReturn,
|
||||
VoiceRecordingState,
|
||||
} from "../types";
|
||||
|
||||
export const useVoiceRecording = (
|
||||
options: UseVoiceRecordingOptions = {}
|
||||
): UseVoiceRecordingReturn => {
|
||||
const { onTranscription, onError, onStateChange } = options;
|
||||
|
||||
const [state, setState] = useState<VoiceRecordingState>("idle");
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Update state and notify
|
||||
const updateState = useCallback(
|
||||
(newState: VoiceRecordingState) => {
|
||||
setState(newState);
|
||||
onStateChange?.(newState);
|
||||
},
|
||||
[onStateChange]
|
||||
);
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = useCallback(() => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
void audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
mediaRecorderRef.current = null;
|
||||
chunksRef.current = [];
|
||||
setDuration(0);
|
||||
setAudioLevel(0);
|
||||
}, []);
|
||||
|
||||
// Monitor audio levels
|
||||
const monitorAudioLevel = useCallback(() => {
|
||||
if (!analyserRef.current) return;
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
// Calculate average volume (0-100)
|
||||
const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length;
|
||||
const normalizedLevel = Math.min(100, Math.round((average / 255) * 100 * 2));
|
||||
setAudioLevel(normalizedLevel);
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(monitorAudioLevel);
|
||||
}, []);
|
||||
|
||||
// Start recording
|
||||
const startRecording = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
cleanup();
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
|
||||
// Setup audio analysis
|
||||
const audioContext = new AudioContext();
|
||||
audioContextRef.current = audioContext;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const analyser = audioContext.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
// Start audio level monitoring
|
||||
monitorAudioLevel();
|
||||
|
||||
// Setup media recorder
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: MediaRecorder.isTypeSupported("audio/webm")
|
||||
? "audio/webm"
|
||||
: "audio/mp4",
|
||||
});
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
chunksRef.current = [];
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
chunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
// Stop level monitoring
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
|
||||
console.log("[Voice] Recording stopped, chunks:", chunksRef.current.length);
|
||||
|
||||
if (chunksRef.current.length === 0) {
|
||||
console.log("[Voice] No chunks recorded, aborting");
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
return;
|
||||
}
|
||||
|
||||
updateState("processing");
|
||||
|
||||
try {
|
||||
const audioBlob = new Blob(chunksRef.current, {
|
||||
type: mediaRecorder.mimeType,
|
||||
});
|
||||
|
||||
console.log("[Voice] Audio blob:", audioBlob.size, "bytes,", mediaRecorder.mimeType);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append(
|
||||
"audio",
|
||||
audioBlob,
|
||||
`recording.${mediaRecorder.mimeType.includes("webm") ? "webm" : "mp4"}`
|
||||
);
|
||||
|
||||
const url = api.ai.stt.$url().toString();
|
||||
console.log("[Voice] Sending to:", url);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
console.log("[Voice] Response status:", response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error("[Voice] Error response:", errorData);
|
||||
throw new Error(
|
||||
(errorData as { message?: string }).message ??
|
||||
"Transcription failed"
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await response.json()) as { text: string };
|
||||
console.log("[Voice] Transcription result:", result.text);
|
||||
onTranscription?.(result.text);
|
||||
} catch (err) {
|
||||
console.error("[Voice] Error:", err);
|
||||
const transcriptionError =
|
||||
err instanceof Error ? err : new Error("Transcription failed");
|
||||
setError(transcriptionError);
|
||||
onError?.(transcriptionError);
|
||||
} finally {
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
updateState("recording");
|
||||
|
||||
// Start duration timer
|
||||
setDuration(0);
|
||||
timerRef.current = setInterval(() => {
|
||||
setDuration((prev) => prev + 1);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
const accessError =
|
||||
err instanceof Error
|
||||
? err
|
||||
: new Error("Failed to access microphone");
|
||||
setError(accessError);
|
||||
onError?.(accessError);
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}
|
||||
}, [cleanup, monitorAudioLevel, onTranscription, onError, updateState]);
|
||||
|
||||
// Stop recording (will trigger transcription)
|
||||
const stopRecording = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cancel recording (no transcription)
|
||||
const cancelRecording = useCallback(() => {
|
||||
cleanup();
|
||||
updateState("idle");
|
||||
}, [cleanup, updateState]);
|
||||
|
||||
// Toggle recording
|
||||
const toggleRecording = useCallback(() => {
|
||||
if (state === "recording") {
|
||||
stopRecording();
|
||||
} else if (state === "idle") {
|
||||
void startRecording();
|
||||
}
|
||||
}, [state, startRecording, stopRecording]);
|
||||
|
||||
// Escape key handler
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && state === "recording") {
|
||||
e.preventDefault();
|
||||
cancelRecording();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [state, cancelRecording]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanup();
|
||||
};
|
||||
}, [cleanup]);
|
||||
|
||||
return {
|
||||
state,
|
||||
duration,
|
||||
audioLevel,
|
||||
error,
|
||||
isRecording: state === "recording",
|
||||
isProcessing: state === "processing",
|
||||
startRecording,
|
||||
stopRecording,
|
||||
cancelRecording,
|
||||
toggleRecording,
|
||||
};
|
||||
};
|
||||
@@ -1,185 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/chat/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Toggle } from "@turbostarter/ui-web/toggle";
|
||||
|
||||
import { Composer } from "~/modules/common/ai/composer";
|
||||
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
|
||||
|
||||
import { VoiceButton } from "./components/voice-button";
|
||||
import { useAttachments } from "./hooks/use-attachments";
|
||||
import { useComposer } from "./hooks/use-composer";
|
||||
import { useVoiceRecording } from "./hooks/use-voice-recording";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatComposerProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const ChatComposer = ({
|
||||
id,
|
||||
initialMessages,
|
||||
}: ChatComposerProps = {}) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const { status, stop, form, onSubmit, model, input, setInput } = useComposer({
|
||||
id,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
const { attachments, onRemove, onPaste } = useAttachments();
|
||||
|
||||
const {
|
||||
state: voiceState,
|
||||
duration,
|
||||
audioLevel,
|
||||
toggleRecording,
|
||||
cancelRecording,
|
||||
} = useVoiceRecording({
|
||||
onTranscription: (text) => {
|
||||
setInput((prev) => (prev ? `${prev} ${text}` : text));
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = error.message.includes("microphone")
|
||||
? t("microphoneDenied", { ns: "common" })
|
||||
: t("transcriptionFailed", { ns: "common" });
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const isSubmitting = ["submitted", "streaming"].includes(status);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<Composer.Form onSubmit={form.handleSubmit(() => onSubmit())}>
|
||||
<Composer.Input className="pb-12">
|
||||
<Composer.Attachments.Preview
|
||||
attachments={attachments}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
|
||||
<Composer.Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.currentTarget.value)}
|
||||
maxLength={5_000}
|
||||
placeholder={t("chat.composer.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!isSubmitting) {
|
||||
return form.handleSubmit(() => onSubmit())();
|
||||
}
|
||||
}
|
||||
}}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex w-full gap-1.5 overflow-hidden border-2 border-transparent p-2 @[480px]/input:p-3">
|
||||
<Composer.Attachments.Input disabled={!model?.attachments} />
|
||||
|
||||
<div className="flex max-w-full grow gap-1.5">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="search"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Toggle
|
||||
variant="outline"
|
||||
className="text-muted-foreground w-9 gap-1.5 rounded-full p-0 @lg:w-auto @lg:px-3.5"
|
||||
pressed={model?.tools && !!field.value}
|
||||
onPressedChange={field.onChange}
|
||||
disabled={!model?.tools}
|
||||
>
|
||||
<Icons.Globe className="size-4 shrink-0" />
|
||||
<span className="text-foreground hidden @lg:inline">
|
||||
{t("search.label")}
|
||||
</span>
|
||||
</Toggle>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Toggle
|
||||
variant="outline"
|
||||
className="text-muted-foreground w-9 gap-1.5 rounded-full p-0 @lg:w-auto @lg:px-3.5"
|
||||
pressed={model?.reason && !!field.value}
|
||||
onPressedChange={field.onChange}
|
||||
disabled={!model?.reason}
|
||||
>
|
||||
<Icons.Sparkle className="size-4" />
|
||||
<span className="text-foreground hidden @lg:inline">
|
||||
{t("reason")}
|
||||
</span>
|
||||
</Toggle>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
control={form.control}
|
||||
name="model"
|
||||
options={MODELS}
|
||||
/>
|
||||
|
||||
<VoiceButton
|
||||
state={voiceState}
|
||||
duration={duration}
|
||||
audioLevel={audioLevel}
|
||||
disabled={isSubmitting}
|
||||
onToggle={toggleRecording}
|
||||
onCancel={cancelRecording}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="shrink-0 rounded-full"
|
||||
disabled={!input.trim() && !isSubmitting}
|
||||
size="icon"
|
||||
type="submit"
|
||||
onClick={(e) => {
|
||||
if (isSubmitting) {
|
||||
e.preventDefault();
|
||||
return stop();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Icons.Square className="size-4 fill-current" />
|
||||
<span className="sr-only">{t("stop")}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icons.ArrowUp className="size-5" />
|
||||
<span className="sr-only">{t("send")}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Composer.Input>
|
||||
</Composer.Form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
// Voice Recording Types
|
||||
|
||||
export type VoiceRecordingState = "idle" | "recording" | "processing";
|
||||
|
||||
export interface VoiceRecordingData {
|
||||
state: VoiceRecordingState;
|
||||
duration: number; // seconds elapsed
|
||||
audioLevel: number; // 0-100 volume level
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export interface UseVoiceRecordingOptions {
|
||||
onTranscription?: (text: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onStateChange?: (state: VoiceRecordingState) => void;
|
||||
}
|
||||
|
||||
export interface UseVoiceRecordingReturn {
|
||||
state: VoiceRecordingState;
|
||||
duration: number;
|
||||
audioLevel: number;
|
||||
error: Error | null;
|
||||
isRecording: boolean;
|
||||
isProcessing: boolean;
|
||||
startRecording: () => Promise<void>;
|
||||
stopRecording: () => void;
|
||||
cancelRecording: () => void;
|
||||
toggleRecording: () => void;
|
||||
}
|
||||
|
||||
export interface VoiceButtonProps {
|
||||
state: VoiceRecordingState;
|
||||
duration: number;
|
||||
audioLevel: number;
|
||||
disabled?: boolean;
|
||||
onToggle: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { CommandGroup, CommandItem } from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
interface ChatActionsProps {
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatActions = ({ onSelect }: ChatActionsProps) => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
|
||||
return (
|
||||
<CommandGroup heading={t("actions")}>
|
||||
<CommandItem asChild>
|
||||
<TurboLink href={pathsConfig.apps.chat.index} onClick={onSelect}>
|
||||
<Icons.SquarePen />
|
||||
<span>{t("chat.new")}</span>
|
||||
</TurboLink>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
);
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import duration from "dayjs/plugin/duration";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
} from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { ChatActions } from "./actions";
|
||||
import { ChatHistoryList } from "./list";
|
||||
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface CommandMenuProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const CommandMenu = ({ open, onOpenChange }: CommandMenuProps) => {
|
||||
const { t } = useTranslation("ai");
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<CommandInput placeholder={t("chat.command.search")} />
|
||||
<CommandList className="h-[420px]">
|
||||
<CommandEmpty className="py-10">{t("chat.command.empty")}</CommandEmpty>
|
||||
<ChatActions onSelect={() => onOpenChange(false)} />
|
||||
<ChatHistoryList onSelect={() => onOpenChange(false)} />
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChatHistory = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setIsOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group relative"
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Icons.TextSearch className="text-muted-foreground group-hover:text-foreground size-5" />
|
||||
<span className="sr-only">{t("history")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{t("history")}</span>
|
||||
<kbd className="text-muted-foreground pointer-events-none inline-flex items-center gap-0.5 pl-1 font-mono select-none">
|
||||
{/* eslint-disable-next-line i18next/no-literal-string */}
|
||||
<span className="">⌘</span>K
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<CommandMenu open={isOpen} onOpenChange={setIsOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { useDateGroups } from "@turbostarter/shared/hooks";
|
||||
import { CommandGroup } from "@turbostarter/ui-web/command";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { chat } from "../../lib/api";
|
||||
|
||||
import { ChatHistoryListItem } from "./item";
|
||||
|
||||
interface ChatHistoryListProps {
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatHistoryList = ({ onSelect }: ChatHistoryListProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { data: session } = authClient.useSession();
|
||||
const userChats = useQuery(
|
||||
chat.queries.chats.user.getAll(session?.user.id ?? ""),
|
||||
);
|
||||
|
||||
const groups = useDateGroups(userChats.data ?? []);
|
||||
|
||||
if (userChats.isLoading) {
|
||||
return (
|
||||
<CommandGroup heading={t("history")} className="w-full">
|
||||
<Skeleton className="mb-2 h-11 w-3/4 rounded-xl" />
|
||||
<Skeleton className="mb-2 h-11 w-full rounded-xl" />
|
||||
<Skeleton className="h-11 w-1/2 rounded-xl" />
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{groups.map(
|
||||
(group) =>
|
||||
group.items.length > 0 && (
|
||||
<CommandGroup heading={group.label} key={group.label}>
|
||||
{group.items.map((chat) => (
|
||||
<ChatHistoryListItem
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</CommandGroup>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,167 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import dayjs from "dayjs";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { CommandItem } from "@turbostarter/ui-web/command";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { chat as chatApi } from "../../lib/api";
|
||||
|
||||
import type { Chat } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatHistoryListItemProps {
|
||||
readonly chat: Chat;
|
||||
readonly onSelect: () => void;
|
||||
}
|
||||
|
||||
export const ChatHistoryListItem = ({
|
||||
chat,
|
||||
onSelect,
|
||||
}: ChatHistoryListItemProps) => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={chat.id}
|
||||
value={`${chat.id}-${chat.name}`}
|
||||
asChild
|
||||
onSelect={() => {
|
||||
router.push(pathsConfig.apps.chat.chat(chat.id));
|
||||
onSelect();
|
||||
}}
|
||||
className="group"
|
||||
>
|
||||
<div>
|
||||
<TurboLink
|
||||
href={pathsConfig.apps.chat.chat(chat.id)}
|
||||
onClick={onSelect}
|
||||
className="flex min-w-0 grow items-center justify-start gap-3"
|
||||
>
|
||||
<Icons.MessagesSquare />
|
||||
<span className="min-w-0 truncate">{chat.name}</span>
|
||||
{pathname.includes(chat.id) && (
|
||||
<Badge variant="outline">{t("current")}</Badge>
|
||||
)}
|
||||
</TurboLink>
|
||||
<Controls chat={chat} />
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
const Controls = ({ chat }: { chat: Chat }) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
const userId = session?.user.id ?? "";
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate } = useMutation({
|
||||
...chatApi.mutations.chats.delete,
|
||||
onMutate: async (data) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
});
|
||||
|
||||
const previousChats = queryClient.getQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
);
|
||||
|
||||
queryClient.setQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
(old: Chat[]) => old.filter((chat) => chat.id !== data.id),
|
||||
);
|
||||
|
||||
if (pathname.includes(chat.id)) {
|
||||
router.push(pathsConfig.apps.chat.index);
|
||||
}
|
||||
|
||||
return { previousChats };
|
||||
},
|
||||
onError: (error, _, context) => {
|
||||
toast.error(error.message);
|
||||
queryClient.setQueryData(
|
||||
chatApi.queries.chats.user.getAll(userId).queryKey,
|
||||
context?.previousChats,
|
||||
);
|
||||
},
|
||||
onSettled: async () => {
|
||||
await queryClient.invalidateQueries(
|
||||
chatApi.queries.chats.user.getAll(userId),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="text-muted-foreground ml-auto whitespace-nowrap group-data-[selected=true]:hidden">
|
||||
{dayjs(chat.createdAt).fromNow()}
|
||||
</span>
|
||||
|
||||
<div className="-my-2 ml-auto hidden items-center gap-2 group-data-[selected=true]:flex">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(pathsConfig.apps.chat.chat(chat.id), "_blank");
|
||||
}}
|
||||
>
|
||||
<Icons.ExternalLink className="text-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("newTab")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="hover:bg-muted-foreground/10 size-7 rounded-lg"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
mutate({ id: chat.id });
|
||||
}}
|
||||
>
|
||||
<Icons.Trash className="text-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("delete")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,78 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
|
||||
|
||||
const examples = [
|
||||
{
|
||||
icon: Icons.FileText,
|
||||
label: "chat.example.summarize.label",
|
||||
prompt: "chat.example.summarize.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.ChartNoAxesColumn,
|
||||
label: "chat.example.analyze.label",
|
||||
prompt: "chat.example.analyze.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.Code,
|
||||
label: "chat.example.code.label",
|
||||
prompt: "chat.example.code.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.Zap,
|
||||
label: "chat.example.brainstorm.label",
|
||||
prompt: "chat.example.brainstorm.prompt",
|
||||
},
|
||||
{
|
||||
icon: Icons.PackageOpen,
|
||||
label: "chat.example.surprise.label",
|
||||
prompt: "chat.example.surprise.prompt",
|
||||
},
|
||||
] as const;
|
||||
|
||||
interface ExamplesProps {
|
||||
readonly id?: string;
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
export const Examples = memo<ExamplesProps>(({ className, id }) => {
|
||||
const { t } = useTranslation("ai");
|
||||
const { onSubmit } = useComposer({ id });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full flex-row flex-wrap items-center justify-center gap-2 px-3 @sm:gap-2",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{examples.map(({ icon: Icon, label, prompt }, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
|
||||
initial={{ opacity: 0, y: 3, filter: "blur(4px)" }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
key={label}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-muted-foreground gap-2 rounded-full"
|
||||
onClick={() => onSubmit(t(prompt))}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
<span>{t(label)}</span>
|
||||
</Button>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Examples.displayName = "Examples";
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { getGreeting } from "@turbostarter/shared/utils";
|
||||
|
||||
export const Headline = () => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
const { text, emoji } = getGreeting();
|
||||
|
||||
return (
|
||||
<h1 className="leading-tighter flex w-full flex-col items-center justify-center text-center text-2xl tracking-tight @sm:text-3xl @md:text-4xl">
|
||||
{t(`greeting.${text}`)} {emoji}
|
||||
<span className="text-muted-foreground">{t("ai:chat.headline")}</span>
|
||||
</h1>
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { ChatComposer } from "~/modules/chat/composer";
|
||||
import { ChatDropzone } from "~/modules/chat/composer/dropzone";
|
||||
import { useComposer } from "~/modules/chat/composer/hooks/use-composer";
|
||||
import { Examples } from "~/modules/chat/layout/examples";
|
||||
import { Headline } from "~/modules/chat/layout/headline";
|
||||
|
||||
interface NewChatProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const NewChat = memo<NewChatProps>(({ id }) => {
|
||||
const { model } = useComposer({ id });
|
||||
return (
|
||||
<ChatDropzone disabled={!model?.attachments}>
|
||||
<div className="mx-auto flex h-full w-full flex-col items-center justify-between gap-6 md:justify-center md:gap-9 md:p-2">
|
||||
<div className="flex w-full grow items-end">
|
||||
<Headline />
|
||||
</div>
|
||||
<div className="flex w-full grow flex-col items-center justify-between md:flex-col-reverse md:justify-end md:gap-5">
|
||||
<Examples className="flex" id={id} />
|
||||
<div className="relative w-full px-3 pb-3">
|
||||
<ChatComposer id={id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatDropzone>
|
||||
);
|
||||
});
|
||||
|
||||
NewChat.displayName = "NewChat";
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo } from "react";
|
||||
|
||||
import { ChatComposer } from "~/modules/chat/composer";
|
||||
import { ChatDropzone } from "~/modules/chat/composer/dropzone";
|
||||
import { Chat } from "~/modules/chat/thread";
|
||||
|
||||
import { useComposer } from "../composer/hooks/use-composer";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ViewChatProps {
|
||||
readonly id: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
export const ViewChat = memo<ViewChatProps>(({ id, initialMessages }) => {
|
||||
const { model } = useComposer({ id, initialMessages });
|
||||
|
||||
return (
|
||||
<ChatDropzone disabled={!model?.attachments}>
|
||||
<Chat id={id} initialMessages={initialMessages} />
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 z-50 mx-auto max-w-[50rem]">
|
||||
<div className="relative z-40 flex w-full flex-col items-center px-3 pb-3">
|
||||
<ChatComposer id={id} initialMessages={initialMessages} />
|
||||
</div>
|
||||
</div>
|
||||
</ChatDropzone>
|
||||
);
|
||||
});
|
||||
|
||||
ViewChat.displayName = "ViewChat";
|
||||
@@ -1,38 +0,0 @@
|
||||
import * as z from "zod";
|
||||
|
||||
import { chatSchema } from "@turbostarter/ai/chat/schema";
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
const KEY = "chat";
|
||||
|
||||
const queries = {
|
||||
chats: {
|
||||
user: {
|
||||
getAll: (userId: string) => ({
|
||||
queryKey: [KEY, "chats", userId],
|
||||
queryFn: handle(api.ai.chat.chats.$get, {
|
||||
schema: z.array(chatSchema),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mutations = {
|
||||
chats: {
|
||||
delete: {
|
||||
mutationKey: [KEY, "chats", "delete"],
|
||||
mutationFn: ({ id }: { id: string }) =>
|
||||
handle(api.ai.chat.chats[":id"].$delete)({
|
||||
param: { id },
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const chat = {
|
||||
queries,
|
||||
mutations,
|
||||
} as const;
|
||||
@@ -1,39 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
|
||||
import { Thread } from "../../common/ai/thread";
|
||||
import { useComposer } from "../composer/hooks/use-composer";
|
||||
|
||||
import { AssistantMessage } from "./message/assistant";
|
||||
import { UserMessage } from "./message/user";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
|
||||
interface ChatProps {
|
||||
readonly id?: string;
|
||||
readonly initialMessages?: ChatMessage[];
|
||||
}
|
||||
|
||||
const components = {
|
||||
[Role.USER]: UserMessage,
|
||||
[Role.ASSISTANT]: AssistantMessage,
|
||||
};
|
||||
|
||||
export const Chat = ({ id, initialMessages }: ChatProps = {}) => {
|
||||
const { messages, regenerate, error, status } = useComposer({
|
||||
id,
|
||||
initialMessages,
|
||||
});
|
||||
|
||||
return (
|
||||
<Thread
|
||||
messages={messages}
|
||||
initialMessages={initialMessages}
|
||||
status={status}
|
||||
components={components}
|
||||
error={error}
|
||||
regenerate={regenerate}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
import { memo } from "react";
|
||||
|
||||
import { WebSearch } from "~/modules/chat/thread/message/assistant/tools/web-search";
|
||||
import { ThreadMessage } from "~/modules/common/ai/thread/message";
|
||||
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
|
||||
import { Prose } from "~/modules/common/prose";
|
||||
|
||||
import { ReasoningMessagePart } from "./reasoning";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
|
||||
|
||||
export const AssistantMessage = memo<ThreadMessageProps<ChatMessage>>(
|
||||
({ message, ref, status }) => {
|
||||
return (
|
||||
<ThreadMessage.Layout className="items-start" ref={ref}>
|
||||
<Prose className="w-full max-w-none">
|
||||
{message.parts.map((part, partIndex) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<MemoizedMarkdown
|
||||
key={`${message.id}-${partIndex}`}
|
||||
content={part.text}
|
||||
id={`text-${partIndex}`}
|
||||
/>
|
||||
);
|
||||
case "reasoning":
|
||||
return (
|
||||
<ReasoningMessagePart
|
||||
key={`${message.id}-${partIndex}`}
|
||||
part={part}
|
||||
reasoning={
|
||||
status === "streaming" &&
|
||||
partIndex === message.parts.length - 1
|
||||
}
|
||||
defaultExpanded={status === "streaming"}
|
||||
/>
|
||||
);
|
||||
|
||||
case "tool-web-search":
|
||||
switch (part.state) {
|
||||
case "input-available":
|
||||
case "output-available":
|
||||
return (
|
||||
<WebSearch
|
||||
key={`${message.id}-${partIndex}`}
|
||||
{...part}
|
||||
annotations={message.parts.filter(
|
||||
(p) => p.type === "data-query_completion",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</Prose>
|
||||
|
||||
{!["submitted", "streaming"].includes(status) && (
|
||||
<ThreadMessage.Controls message={message} />
|
||||
)}
|
||||
</ThreadMessage.Layout>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AssistantMessage.displayName = "AssistantMessage";
|
||||
@@ -1,86 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { MemoizedMarkdown } from "~/modules/common/markdown/memoized-markdown";
|
||||
|
||||
import type { ReasoningUIPart } from "ai";
|
||||
|
||||
interface ReasoningMessagePartProps {
|
||||
part: ReasoningUIPart;
|
||||
reasoning: boolean;
|
||||
defaultExpanded?: boolean;
|
||||
}
|
||||
|
||||
export function ReasoningMessagePart({
|
||||
part,
|
||||
reasoning,
|
||||
defaultExpanded = false,
|
||||
}: ReasoningMessagePartProps) {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!part.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue={defaultExpanded ? "reasoning" : undefined}
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="reasoning" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"not-prose border-border bg-background rounded-xl border p-3 pr-4 shadow-xs hover:no-underline",
|
||||
"data-[state=open]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1 md:p-1.5">
|
||||
{reasoning ? (
|
||||
<Icons.Loader className="text-muted-foreground size-3.5 animate-spin md:size-4" />
|
||||
) : (
|
||||
<Icons.Sparkle className="text-muted-foreground size-3.5 md:size-4" />
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{reasoning
|
||||
? t("reasoning.inProgress")
|
||||
: t("reasoning.completed")}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="rounded-b-xl border border-t-0 px-5 py-3 shadow-xs">
|
||||
<div className="text-muted-foreground prose-p:my-1.5 text-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<MemoizedMarkdown id={part.type} content={part.text} />
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
|
||||
import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image";
|
||||
|
||||
import type { SearchResult } from ".";
|
||||
|
||||
type SearchImage = SearchResult["images"][number];
|
||||
|
||||
export const PREVIEW_IMAGE_COUNT = {
|
||||
MOBILE: 4,
|
||||
DESKTOP: 5,
|
||||
};
|
||||
|
||||
interface ImageGridProps {
|
||||
images: SearchImage[];
|
||||
showAll?: boolean;
|
||||
}
|
||||
|
||||
const ImageThumbnail = ({
|
||||
image,
|
||||
index,
|
||||
onClick,
|
||||
isLast,
|
||||
hasMore,
|
||||
moreCount,
|
||||
}: {
|
||||
image: SearchImage;
|
||||
index: number;
|
||||
onClick: () => void;
|
||||
isLast: boolean;
|
||||
hasMore: boolean;
|
||||
moreCount: number;
|
||||
}) => (
|
||||
<Thumbnail onClick={onClick} index={index}>
|
||||
<ThumbnailImage src={image.url} alt={image.description} />
|
||||
{image.description && (!isLast || !hasMore) && (
|
||||
<div className="absolute inset-0 flex items-end bg-black/60 px-3 py-4 opacity-0 transition-opacity duration-200 group-hover/thumbnail:opacity-100">
|
||||
<p className="line-clamp-3 text-xs text-white">{image.description}</p>
|
||||
</div>
|
||||
)}
|
||||
{isLast && hasMore && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/60">
|
||||
<span className="text-sm font-medium text-white">+{moreCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</Thumbnail>
|
||||
);
|
||||
|
||||
export const ImageGrid = ({ images, showAll = false }: ImageGridProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const isDesktop = useBreakpoint("md");
|
||||
|
||||
const displayImages = showAll
|
||||
? images
|
||||
: images.slice(
|
||||
0,
|
||||
isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE,
|
||||
);
|
||||
const hasMore =
|
||||
images.length >
|
||||
(isDesktop ? PREVIEW_IMAGE_COUNT.DESKTOP : PREVIEW_IMAGE_COUNT.MOBILE);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2",
|
||||
"grid-cols-2",
|
||||
displayImages.length === 1 && "grid-cols-1",
|
||||
"sm:grid-cols-3",
|
||||
"lg:grid-cols-4",
|
||||
"*:aspect-4/3",
|
||||
"[&>*:first-child]:col-span-1 [&>*:first-child]:row-span-1",
|
||||
isDesktop &&
|
||||
displayImages.length > 1 &&
|
||||
"[&>*:first-child]:col-span-2 [&>*:first-child]:row-span-2",
|
||||
displayImages.length === 1 &&
|
||||
"grid-cols-1! [&>*:first-child]:col-span-1! [&>*:first-child]:row-span-2!",
|
||||
)}
|
||||
>
|
||||
{displayImages.map((image, index) => (
|
||||
<ImageThumbnail
|
||||
key={index}
|
||||
image={image}
|
||||
index={index}
|
||||
onClick={() => {
|
||||
setSelectedImage(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
isLast={index === displayImages.length - 1}
|
||||
hasMore={!showAll && hasMore}
|
||||
moreCount={images.length - displayImages.length}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={images}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,187 +0,0 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
import { motion } from "motion/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { ImageGrid } from "./images";
|
||||
import { SearchLoading } from "./loading";
|
||||
|
||||
import type {
|
||||
ChatDataParts,
|
||||
ChatTools,
|
||||
Tool,
|
||||
} from "@turbostarter/ai/chat/types";
|
||||
import type { DataUIPart } from "ai";
|
||||
|
||||
const ResultCard = ({
|
||||
result,
|
||||
}: {
|
||||
result: SearchResult["results"][number];
|
||||
}) => {
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="border-border bg-background h-full w-[300px] shrink-0 rounded-xl border shadow-xs">
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<div className="bg-muted relative flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg">
|
||||
{!imageLoaded && (
|
||||
<div className="bg-muted-foreground/10 absolute inset-0 animate-pulse" />
|
||||
)}
|
||||
<img
|
||||
src={`https://www.google.com/s2/favicons?sz=128&domain=${new URL(result.url).hostname}`}
|
||||
alt=""
|
||||
className={cn("size-8 object-cover", !imageLoaded && "opacity-0")}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={(e) => {
|
||||
setImageLoaded(true);
|
||||
e.currentTarget.src =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cline x1='12' y1='8' x2='12' y2='16'/%3E%3Cline x1='8' y1='12' x2='16' y2='12'/%3E%3C/svg%3E";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="line-clamp-1 text-sm font-medium">{result.title}</h3>
|
||||
<a
|
||||
href={result.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground flex items-center gap-1 text-xs"
|
||||
>
|
||||
{new URL(result.url).hostname}
|
||||
<Icons.ExternalLink className="size-2.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground line-clamp-3 text-sm">
|
||||
{result.content}
|
||||
</p>
|
||||
|
||||
{result.publishedDate && (
|
||||
<div className="pt-2">
|
||||
<time className="text-muted-foreground flex items-center gap-1.5 text-xs">
|
||||
<Icons.Calendar className="size-3" />
|
||||
{new Date(result.publishedDate).toLocaleDateString()}
|
||||
</time>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type SearchResult = NonNullable<
|
||||
ChatTools[typeof Tool.WEB_SEARCH]["output"]
|
||||
>["searches"][number];
|
||||
|
||||
export const WebSearch = (
|
||||
props: Partial<ChatTools[typeof Tool.WEB_SEARCH]> & {
|
||||
annotations: DataUIPart<ChatDataParts>[];
|
||||
},
|
||||
) => {
|
||||
const { input, output, annotations } = props;
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
if (!output) {
|
||||
return (
|
||||
<SearchLoading queries={input?.queries ?? []} annotations={annotations} />
|
||||
);
|
||||
}
|
||||
|
||||
const allImages = output.searches.reduce<SearchResult["images"]>(
|
||||
(acc, search) => {
|
||||
return [...acc, ...search.images];
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const totalResults = output.searches.reduce(
|
||||
(sum, search) => sum + search.results.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full space-y-4 pb-2">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue="search"
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="search" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"border-border bg-background rounded-xl border p-3 pr-4 shadow-xs hover:no-underline",
|
||||
"[&[data-state=open]]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1.5">
|
||||
<Icons.Globe className="text-muted-foreground size-4" />
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{t("search.completed")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-muted rounded-full px-3 py-1"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{totalResults} {t("results")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="border-border bg-background rounded-b-xl border border-t-0 px-4 py-3 shadow-xs">
|
||||
<div className="no-scrollbar mb-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{output.searches.map((search, i) => (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="secondary"
|
||||
className="bg-muted flex-shrink-0 rounded-full px-3 py-1.5"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{search.query.q}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar flex gap-3 overflow-x-auto">
|
||||
{output.searches.map((search) =>
|
||||
search.results.map((result, resultIndex) => (
|
||||
<motion.div
|
||||
key={`${search.query.q}-${resultIndex}`}
|
||||
initial={{ opacity: 0, x: 20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.3, delay: resultIndex * 0.1 }}
|
||||
>
|
||||
<ResultCard result={result} />
|
||||
</motion.div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
{allImages.length > 0 && <ImageGrid images={allImages} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { useBreakpoint } from "@turbostarter/ui-web";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@turbostarter/ui-web/accordion";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
|
||||
import { PREVIEW_IMAGE_COUNT } from "./images";
|
||||
|
||||
import type {
|
||||
ChatTools,
|
||||
ChatDataParts,
|
||||
Tool,
|
||||
} from "@turbostarter/ai/chat/types";
|
||||
import type { DataUIPart } from "ai";
|
||||
|
||||
export const SearchLoading = ({
|
||||
queries,
|
||||
annotations,
|
||||
}: {
|
||||
queries: ChatTools[typeof Tool.WEB_SEARCH]["input"]["queries"];
|
||||
annotations: DataUIPart<ChatDataParts>[];
|
||||
}) => {
|
||||
const isDesktop = useBreakpoint("md");
|
||||
const { t } = useTranslation("common");
|
||||
const totalResults = annotations.reduce(
|
||||
(sum, a) => sum + a.data.resultsCount,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="not-prose w-full space-y-4 pb-2">
|
||||
<Accordion
|
||||
type="single"
|
||||
collapsible
|
||||
defaultValue="search"
|
||||
className="w-full"
|
||||
>
|
||||
<AccordionItem value="search" className="border-none [&_h3]:my-0">
|
||||
<AccordionTrigger
|
||||
className={cn(
|
||||
"border-border bg-background rounded-xl border p-3 shadow-xs hover:no-underline",
|
||||
"data-[state=open]:rounded-b-none",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-muted rounded-lg p-1.5">
|
||||
<Icons.Loader className="text-muted-foreground size-4 animate-spin" />
|
||||
</div>
|
||||
<h2 className="text-left font-medium">
|
||||
{t("search.inProgress")}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mr-2 flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-muted rounded-full px-3 py-1"
|
||||
>
|
||||
<Icons.Search className="mr-1.5 size-3" />
|
||||
{totalResults || "0"} {t("results")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
|
||||
<AccordionContent className="mt-0 border-0 py-0">
|
||||
<div className="border-border bg-background rounded-b-xl border border-t-0 px-4 py-3 shadow-xs">
|
||||
<div className="no-scrollbar mb-3 flex gap-2 overflow-x-auto pb-1">
|
||||
{queries.map((query, i) => {
|
||||
const annotation = annotations.find(
|
||||
(a) =>
|
||||
a.data.query.q === query.q &&
|
||||
a.data.status === "completed",
|
||||
);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
key={i}
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"shrink-0 gap-1.5 rounded-full px-3 py-1.5",
|
||||
!annotation && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{annotation ? (
|
||||
<Icons.Check className="size-3" />
|
||||
) : (
|
||||
<Icons.Loader2 className="size-3 animate-spin stroke-[3px]" />
|
||||
)}
|
||||
{query.q}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="no-scrollbar flex gap-3 overflow-x-auto pb-1">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="border-border bg-background w-[300px] shrink-0 rounded-xl border shadow-xs"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="mb-3 flex items-center gap-2.5">
|
||||
<Skeleton className="h-8 w-8 rounded-lg" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-5/6" />
|
||||
<Skeleton className="h-3 w-4/6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{Array.from({
|
||||
length: isDesktop
|
||||
? PREVIEW_IMAGE_COUNT.DESKTOP
|
||||
: PREVIEW_IMAGE_COUNT.MOBILE,
|
||||
}).map((_, i) => (
|
||||
<Skeleton
|
||||
key={i}
|
||||
className={cn(
|
||||
"aspect-4/3 rounded-xl",
|
||||
i === 0 && "sm:col-span-2 sm:row-span-2",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,85 +0,0 @@
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { ThreadMessage } from "~/modules/common/ai/thread/message";
|
||||
import { Thumbnail, ThumbnailImage, Viewer } from "~/modules/common/image";
|
||||
import { Prose } from "~/modules/common/prose";
|
||||
|
||||
import type { ChatMessage } from "@turbostarter/ai/chat/types";
|
||||
import type { FileUIPart } from "ai";
|
||||
import type { ThreadMessageProps } from "~/modules/common/ai/thread/message";
|
||||
|
||||
const Attachments = ({ attachments }: { attachments: FileUIPart[] }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-1 flex max-w-full flex-row flex-wrap items-center justify-end gap-1.5">
|
||||
{attachments
|
||||
.filter((attachment) => attachment.mediaType.includes("image/"))
|
||||
.map((attachment, index) => {
|
||||
return (
|
||||
<Thumbnail
|
||||
key={attachment.url}
|
||||
index={index}
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setSelectedImage(index);
|
||||
}}
|
||||
className="aspect-square h-24 w-24 border bg-transparent shadow-none sm:h-32 sm:w-32 dark:bg-transparent"
|
||||
>
|
||||
<ThumbnailImage
|
||||
src={attachment.url}
|
||||
alt=""
|
||||
key={attachment.url}
|
||||
/>
|
||||
</Thumbnail>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={attachments}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const UserMessage = memo<ThreadMessageProps<ChatMessage>>(
|
||||
({ message, ref }) => {
|
||||
const attachments = message.parts.filter((part) => part.type === "file");
|
||||
return (
|
||||
<ThreadMessage.Layout className="items-end" ref={ref}>
|
||||
{attachments.length > 0 && (
|
||||
<Attachments
|
||||
key={`${message.id}-attachments`}
|
||||
attachments={attachments}
|
||||
/>
|
||||
)}
|
||||
{message.parts.map((part, index) => {
|
||||
switch (part.type) {
|
||||
case "text":
|
||||
return (
|
||||
<Prose
|
||||
key={`${message.id}-${index}`}
|
||||
className="bg-muted min-h-7 max-w-full rounded-3xl rounded-br-lg border px-4 py-2.5 sm:max-w-[90%]"
|
||||
>
|
||||
{part.text}
|
||||
</Prose>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</ThreadMessage.Layout>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
UserMessage.displayName = "UserMessage";
|
||||
@@ -1,227 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
import { useState } from "react";
|
||||
import { useDropzone } from "react-dropzone";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@turbostarter/ui-web/avatar";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { Viewer } from "~/modules/common/image";
|
||||
|
||||
import type { DropzoneOptions, DropzoneState } from "react-dropzone";
|
||||
|
||||
const DropzoneContext = createContext<{
|
||||
dropzone: DropzoneState;
|
||||
} | null>(null);
|
||||
|
||||
interface DropzoneProps extends DropzoneOptions {
|
||||
children: React.ReactNode;
|
||||
dialog?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Dropzone = ({ children, dialog, ...options }: DropzoneProps) => {
|
||||
const dropzone = useDropzone({
|
||||
accept: {
|
||||
"image/*": [".png", ".gif", ".jpeg", ".webp", ".jpg"],
|
||||
},
|
||||
onError: (error) => toast.error(error.message),
|
||||
noClick: true,
|
||||
noKeyboard: true,
|
||||
multiple: true,
|
||||
...options,
|
||||
});
|
||||
|
||||
return (
|
||||
<DropzoneContext.Provider value={{ dropzone }}>
|
||||
<div {...dropzone.getRootProps()} className="relative h-full w-full">
|
||||
{children}
|
||||
|
||||
<AnimatePresence>
|
||||
{dropzone.isDragActive && dialog && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
<motion.div
|
||||
className="bg-background/50 absolute inset-0 backdrop-blur-sm md:rounded-lg"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
/>
|
||||
|
||||
{dialog}
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</DropzoneContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = memo<React.ButtonHTMLAttributes<HTMLButtonElement>>((props) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const context = useContext(DropzoneContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
{...context?.dropzone.getInputProps()}
|
||||
disabled={props.disabled ?? false}
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
{...props}
|
||||
className={cn(
|
||||
"text-muted-foreground shrink-0 rounded-full dark:bg-transparent",
|
||||
props.className,
|
||||
)}
|
||||
onClick={(event) => {
|
||||
context?.dropzone.open();
|
||||
props.onClick?.(event);
|
||||
}}
|
||||
>
|
||||
<Icons.Paperclip className="size-4" />
|
||||
<span className="sr-only">{t("chat.composer.files.add")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{t("chat.composer.files.add")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = "Input";
|
||||
|
||||
interface PreviewProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
attachments: File[];
|
||||
onRemove: (file: File) => void;
|
||||
}
|
||||
|
||||
export const Preview = memo<PreviewProps>(
|
||||
({ attachments, onRemove, className, ...props }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
|
||||
if (!attachments.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"-mb-2.5 flex w-full flex-wrap gap-3 px-2 pt-4 @[480px]/input:px-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{attachments.map((attachment, index) => (
|
||||
<Thumbnail
|
||||
key={attachment.name}
|
||||
attachment={attachment}
|
||||
onRemove={() => onRemove(attachment)}
|
||||
onClick={() => {
|
||||
setSelectedImage(index);
|
||||
setIsOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={attachments.map((attachment) => ({
|
||||
url: URL.createObjectURL(attachment),
|
||||
}))}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Preview.displayName = "Preview";
|
||||
|
||||
interface ThumbnailProps extends React.HTMLAttributes<HTMLButtonElement> {
|
||||
attachment: File;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
const Thumbnail = memo<ThumbnailProps>(({ attachment, onRemove, ...props }) => {
|
||||
const { t } = useTranslation(["ai"]);
|
||||
const preview = useMemo(() => URL.createObjectURL(attachment), [attachment]);
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<button {...props} type="button">
|
||||
<Avatar className="size-16 shrink-0 rounded-xl">
|
||||
<AvatarImage
|
||||
src={preview}
|
||||
alt={`Preview of ${attachment.name}`}
|
||||
className="rounded-xl border object-cover"
|
||||
/>
|
||||
<AvatarFallback className="rounded-xl">
|
||||
<Icons.Image className="text-muted-foreground size-8" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<span className="sr-only">{t("chat.composer.files.preview")}</span>
|
||||
</button>
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="bg-card dark:bg-card absolute top-0 right-0 size-5 translate-x-1/3 -translate-y-1/3 p-1"
|
||||
onClick={onRemove}
|
||||
type="button"
|
||||
>
|
||||
<Icons.X className="size-full" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
className="rounded-md px-2 py-1 text-xs"
|
||||
>
|
||||
<span>{t("chat.composer.files.remove")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
Thumbnail.displayName = "Thumbnail";
|
||||
|
||||
export const Attachments = {
|
||||
Input,
|
||||
Dropzone,
|
||||
Preview,
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { TextareaAutosize } from "@turbostarter/ui-web/textarea";
|
||||
|
||||
import { Attachments } from "./attachments";
|
||||
|
||||
const Form = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLFormElement>) => {
|
||||
const ref = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry) return;
|
||||
ref.current
|
||||
?.closest("main")
|
||||
?.style.setProperty(
|
||||
"--composer-height",
|
||||
`${entry.contentRect.height}px`,
|
||||
);
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative bottom-0 z-10 flex w-full flex-col items-center justify-center gap-2 text-base",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const Input = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card/65 ring-border/75 focus-within:ring-input hover:ring-input hover:focus-within:ring-input @container/input relative w-full max-w-200 rounded-2xl px-2 pb-2 ring-1 backdrop-blur-xl duration-100 ring-inset focus-within:ring-1 @lg:rounded-3xl @lg:shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const Textarea = ({
|
||||
className,
|
||||
...props
|
||||
}: Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, "style">) => {
|
||||
return (
|
||||
<TextareaAutosize
|
||||
dir="auto"
|
||||
className={cn(
|
||||
"text-foreground mb-3 min-h-20 w-full resize-none bg-transparent px-2 pt-5 align-bottom focus:outline-none @[480px]/input:px-3",
|
||||
className,
|
||||
)}
|
||||
spellCheck={false}
|
||||
maxRows={6}
|
||||
autoFocus
|
||||
maxLength={5_000}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const Composer = {
|
||||
Form,
|
||||
Input,
|
||||
Textarea,
|
||||
Attachments,
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import { ProviderIcons } from "~/modules/common/ai/icons";
|
||||
|
||||
import type { Provider } from "@turbostarter/ai";
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
interface ModelSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly options: readonly {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly provider: Provider;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const ModelSelector = <T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
options,
|
||||
}: ModelSelectorProps<T>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0">
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent align="end">
|
||||
{options.map((option) => {
|
||||
const Icon = ProviderIcons[option.provider];
|
||||
|
||||
return (
|
||||
<SelectItem key={option.id} value={option.id}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="text-foreground size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import { Provider } from "@turbostarter/ai";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
export const ProviderIcons = {
|
||||
[Provider.OPENAI]: Icons.OpenAI,
|
||||
[Provider.GEMINI]: Icons.Gemini,
|
||||
[Provider.CLAUDE]: Icons.Claude,
|
||||
[Provider.GROK]: Icons.Grok,
|
||||
[Provider.DEEPSEEK]: Icons.DeepSeek,
|
||||
[Provider.REPLICATE]: Icons.Replicate,
|
||||
[Provider.LUMA]: Icons.Luma,
|
||||
[Provider.STABILITY_AI]: Icons.StabilityAI,
|
||||
[Provider.RECRAFT]: Icons.Recraft,
|
||||
[Provider.ELEVEN_LABS]: Icons.ElevenLabs,
|
||||
[Provider.NVIDIA]: Icons.Nvidia,
|
||||
};
|
||||
@@ -1,149 +0,0 @@
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { TextShimmer } from "@turbostarter/ui-web/text-shimmer";
|
||||
|
||||
import type { Transition } from "motion/react";
|
||||
|
||||
const transition: Transition = {
|
||||
duration: 2.5,
|
||||
ease: [0.175, 0.885, 0.32, 1],
|
||||
times: [0, 0.6, 0.6, 1],
|
||||
repeat: Infinity,
|
||||
repeatType: "mirror",
|
||||
repeatDelay: 0.2,
|
||||
};
|
||||
|
||||
export const AnalyzingImage = () => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="relative isolate flex items-center justify-center">
|
||||
<motion.div
|
||||
initial={{
|
||||
clipPath: "inset(0px 0px 0px 0px)",
|
||||
}}
|
||||
animate={{
|
||||
clipPath: [
|
||||
"inset(0px 0px 0px 0px)",
|
||||
"inset(0px 24px 0px 0px)",
|
||||
"inset(0px 24px 0px 0px)",
|
||||
"inset(0px 0px 0px 0px)",
|
||||
],
|
||||
}}
|
||||
transition={transition}
|
||||
className="bg-background z-10"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/65"
|
||||
>
|
||||
<rect width="20" height="20" fill="hsl(var(--background))" />
|
||||
<path
|
||||
d="M4.27209 20.7279L10.8686 14.1314C11.2646 13.7354 11.4627 13.5373 11.691 13.4632C11.8918 13.3979 12.1082 13.3979 12.309 13.4632C12.5373 13.5373 12.7354 13.7354 13.1314 14.1314L19.6839 20.6839M14 15L16.8686 12.1314C17.2646 11.7354 17.4627 11.5373 17.691 11.4632C17.8918 11.3979 18.1082 11.3979 18.309 11.4632C18.5373 11.5373 18.7354 11.7354 19.1314 12.1314L22 15M10 9C10 10.1046 9.10457 11 8 11C6.89543 11 6 10.1046 6 9C6 7.89543 6.89543 7 8 7C9.10457 7 10 7.89543 10 9ZM6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ transform: "translateX(10px)" }}
|
||||
animate={{
|
||||
transform: [
|
||||
"translateX(10px)",
|
||||
"translateX(-10px)",
|
||||
"translateX(-10px)",
|
||||
"translateX(10px)",
|
||||
],
|
||||
}}
|
||||
transition={transition}
|
||||
className="bg-muted-foreground/65 absolute z-10 h-full w-[3px] rounded-full"
|
||||
/>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-muted-foreground/65 absolute"
|
||||
>
|
||||
<rect width="20" height="20" fill="hsl(var(--background))" />
|
||||
<path
|
||||
d="M6.8 21H17.2C18.8802 21 19.7202 21 20.362 20.673C20.9265 20.3854 21.3854 19.9265 21.673 19.362C22 18.7202 22 17.8802 22 16.2V7.8C22 6.11984 22 5.27976 21.673 4.63803C21.3854 4.07354 20.9265 3.6146 20.362 3.32698C19.7202 3 18.8802 3 17.2 3H6.8C5.11984 3 4.27976 3 3.63803 3.32698C3.07354 3.6146 2.6146 4.07354 2.32698 4.63803C2 5.27976 2 6.11984 2 7.8V16.2C2 17.8802 2 18.7202 2.32698 19.362C2.6146 19.9265 3.07354 20.3854 3.63803 20.673C4.27976 21 5.11984 21 6.8 21Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<rect x="6" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="19" width="3" height="1" fill="currentColor" />
|
||||
<rect x="9" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="14" y="19" width="3" height="1" fill="currentColor" />
|
||||
<rect x="15" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="5" y="18" width="2" height="1" fill="currentColor" />
|
||||
<rect x="5" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="18" width="1" height="1" fill="currentColor" />
|
||||
<rect x="17" y="19" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="4" width="2" height="1" fill="currentColor" />
|
||||
<rect x="3" y="9" width="1" height="3" fill="currentColor" />
|
||||
<rect x="4" y="10" width="1" height="2" fill="currentColor" />
|
||||
<rect x="6" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="5" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="8" width="1" height="3" fill="currentColor" />
|
||||
<rect x="19" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="13" width="1" height="1" fill="currentColor" />
|
||||
<rect x="9" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="16" y="12" width="1" height="2" fill="currentColor" />
|
||||
<rect x="13" y="14" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="10" y="13" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="13" y="10" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="14" width="1" height="1" fill="currentColor" />
|
||||
<rect x="5" y="4" width="3" height="1" fill="currentColor" />
|
||||
<rect x="6" y="5" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="14" width="1" height="2" fill="currentColor" />
|
||||
<rect x="6" y="14" width="3" height="1" fill="currentColor" />
|
||||
<rect x="16" y="8" width="1" height="1" fill="currentColor" />
|
||||
<rect x="8" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="16" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="12" width="1" height="1" fill="currentColor" />
|
||||
<rect x="8" y="8" width="1" height="1" fill="currentColor" />
|
||||
<rect x="14" y="12" width="1" height="1" fill="currentColor" />
|
||||
<rect x="17" y="16" width="2" height="1" fill="currentColor" />
|
||||
<rect x="14" y="17" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="5" width="3" height="1" fill="currentColor" />
|
||||
<rect x="12" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="7" width="1" height="1" fill="currentColor" />
|
||||
<rect x="7" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="15" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="11" y="11" width="1" height="1" fill="currentColor" />
|
||||
<rect x="13" y="9" width="1" height="1" fill="currentColor" />
|
||||
<rect x="12" y="15" width="1" height="1" fill="currentColor" />
|
||||
<rect x="9" y="12" width="2" height="1" fill="currentColor" />
|
||||
<rect x="19" y="13" width="2" height="1" fill="currentColor" />
|
||||
<rect x="9" y="6" width="1" height="1" fill="currentColor" />
|
||||
<rect x="20" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="19" y="4" width="1" height="1" fill="currentColor" />
|
||||
<rect x="3" y="15" width="1" height="2" fill="currentColor" />
|
||||
<rect x="3" y="19" width="1" height="1" fill="currentColor" />
|
||||
</svg>
|
||||
</div>
|
||||
<TextShimmer className="text-sm font-medium" duration={1.5}>
|
||||
{t("analyzingImage")}
|
||||
</TextShimmer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
|
||||
import { getMessageTextContent } from "@turbostarter/ai";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { useCopy } from "~/modules/common/hooks/use-copy";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
const transition = {
|
||||
initial: { opacity: 0, scale: 0.8 },
|
||||
animate: { opacity: 1, scale: 1 },
|
||||
exit: { opacity: 0, scale: 0.8 },
|
||||
transition: { duration: 0.1, ease: "easeInOut" as const },
|
||||
};
|
||||
|
||||
interface ThreadMessageCopyProps<MESSAGE extends UIMessage = UIMessage> {
|
||||
message: MESSAGE;
|
||||
}
|
||||
|
||||
export const ThreadMessageCopy = <MESSAGE extends UIMessage = UIMessage>({
|
||||
message,
|
||||
}: ThreadMessageCopyProps<MESSAGE>) => {
|
||||
const { t } = useTranslation("common");
|
||||
const { copied, copy } = useCopy();
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => copy(getMessageTextContent(message))}
|
||||
>
|
||||
<div className="relative size-3.5">
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{copied ? (
|
||||
<motion.div
|
||||
key="check"
|
||||
{...transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Icons.Check className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="copy"
|
||||
{...transition}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<Icons.Copy className="text-muted-foreground group-hover/button:text-foreground size-3.5" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<span className="sr-only">{t("copy")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("copy")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { ThreadMessageCopy } from "./copy";
|
||||
import { ThreadMessageLikes } from "./likes";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface ControlsProps {
|
||||
message: UIMessage;
|
||||
}
|
||||
|
||||
export const Controls = ({ message }: ControlsProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background start-0 -ml-4 flex w-max items-center gap-px rounded-lg px-2 pb-2 text-xs opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 md:start-3",
|
||||
)}
|
||||
>
|
||||
{message.parts.some(
|
||||
(part) => part.type === "text" && part.text.length > 0,
|
||||
) && <ThreadMessageCopy message={message} />}
|
||||
<ThreadMessageLikes />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,71 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
export const ThreadMessageLikes = () => {
|
||||
const { t } = useTranslation("common");
|
||||
const [likeState, setLikeState] = useState<-1 | 0 | 1>(0);
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => setLikeState(likeState === 1 ? 0 : 1)}
|
||||
>
|
||||
<Icons.ThumbsUp
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
likeState === 1
|
||||
? "text-primary fill-current"
|
||||
: "text-muted-foreground group-hover/button:text-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t("like")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("like")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="group/button size-8 rounded-full"
|
||||
onClick={() => setLikeState(likeState === -1 ? 0 : -1)}
|
||||
>
|
||||
<Icons.ThumbsDown
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
likeState === -1
|
||||
? "text-primary fill-current"
|
||||
: "text-muted-foreground group-hover/button:text-foreground",
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">{t("dislike")}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{t("dislike")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface UseThreadLayoutProps<MESSAGE extends UIMessage> {
|
||||
readonly messages: MESSAGE[];
|
||||
readonly initialMessages?: MESSAGE[];
|
||||
}
|
||||
|
||||
export const useThreadLayout = <MESSAGE extends UIMessage>({
|
||||
messages,
|
||||
initialMessages,
|
||||
}: UseThreadLayoutProps<MESSAGE>) => {
|
||||
const [scrolledByUser, setScrolledByUser] = useState(false);
|
||||
|
||||
const lastMessage = messages.at(-1);
|
||||
const lastMessageRef = useRef<HTMLDivElement>(null);
|
||||
const isChatActive = initialMessages?.length !== messages.length;
|
||||
|
||||
const lastUserMessageIndex = [...messages]
|
||||
.reverse()
|
||||
.findIndex((m) => m.role === Role.USER);
|
||||
const lastResponseMessages = messages.slice(
|
||||
lastUserMessageIndex !== 0 ? -2 : -1,
|
||||
);
|
||||
const previousMessages = messages.slice(0, -lastResponseMessages.length);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current) return;
|
||||
|
||||
const parent = lastMessageRef.current.parentElement;
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
|
||||
const handleScroll = () => {
|
||||
setScrolledByUser(true);
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => {
|
||||
setScrolledByUser(false);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
parent?.addEventListener("scroll", handleScroll);
|
||||
|
||||
return () => {
|
||||
parent?.removeEventListener("scroll", handleScroll);
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [lastMessageRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lastMessageRef.current) return;
|
||||
|
||||
const parent = lastMessageRef.current.parentElement;
|
||||
|
||||
const isAtBottom = () => {
|
||||
const container = parent?.closest("[data-radix-scroll-area-viewport]");
|
||||
|
||||
if (!container) return false;
|
||||
|
||||
const scrollBottom = container.scrollTop + container.clientHeight;
|
||||
return Math.abs(container.scrollHeight - scrollBottom) < 150;
|
||||
};
|
||||
|
||||
if (isChatActive) {
|
||||
if (lastMessage?.role === Role.USER) {
|
||||
requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
} else if (isAtBottom() && !scrolledByUser) {
|
||||
requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "instant",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const animationFrameId = requestAnimationFrame(() => {
|
||||
parent?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "end",
|
||||
});
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(animationFrameId);
|
||||
}, [lastMessage, scrolledByUser, isChatActive]);
|
||||
|
||||
return {
|
||||
lastMessage,
|
||||
lastMessageRef,
|
||||
isChatActive,
|
||||
lastResponseMessages,
|
||||
previousMessages,
|
||||
};
|
||||
};
|
||||
@@ -1,134 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { Role } from "@turbostarter/ai/chat/types";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
|
||||
|
||||
import { AnalyzingImage } from "./analyzing-image";
|
||||
import { useThreadLayout } from "./hooks/use-thread-layout";
|
||||
import { ThreadMessage } from "./message";
|
||||
|
||||
import type { ThreadMessageComponents } from "./message";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
interface ThreadProps<MESSAGE extends UIMessage> {
|
||||
readonly messages: MESSAGE[];
|
||||
readonly initialMessages?: MESSAGE[];
|
||||
readonly status: string;
|
||||
readonly error?: Error | null;
|
||||
readonly regenerate?: () => Promise<void>;
|
||||
readonly className?: string;
|
||||
readonly components: ThreadMessageComponents<MESSAGE>;
|
||||
readonly footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Thread = <MESSAGE extends UIMessage>({
|
||||
messages,
|
||||
initialMessages,
|
||||
status,
|
||||
error,
|
||||
regenerate,
|
||||
className,
|
||||
components,
|
||||
footer,
|
||||
}: ThreadProps<MESSAGE>) => {
|
||||
const { t } = useTranslation("common");
|
||||
const isReloading = useRef(false);
|
||||
|
||||
const {
|
||||
lastMessage,
|
||||
lastMessageRef,
|
||||
isChatActive,
|
||||
previousMessages,
|
||||
lastResponseMessages,
|
||||
} = useThreadLayout({ messages, initialMessages });
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
messages.at(-1)?.role === Role.USER &&
|
||||
status === "ready" &&
|
||||
!isReloading.current
|
||||
) {
|
||||
isReloading.current = true;
|
||||
void regenerate?.().finally(() => {
|
||||
isReloading.current = false;
|
||||
});
|
||||
}
|
||||
}, [regenerate, messages, status]);
|
||||
|
||||
const renderMessage = useCallback(
|
||||
(message: MESSAGE) => {
|
||||
return (
|
||||
<ThreadMessage.Message
|
||||
message={message}
|
||||
key={message.id}
|
||||
status={status}
|
||||
components={components}
|
||||
{...(message.id === lastMessage?.id && { ref: lastMessageRef })}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[lastMessage?.id, lastMessageRef, status, components],
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"@container/thread h-full w-full pt-12 pb-4 md:pt-14",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="px-5">
|
||||
{previousMessages.map(renderMessage)}
|
||||
<div
|
||||
className={cn("mx-auto flex w-full max-w-3xl flex-col", {
|
||||
"min-h-[calc(100vh-4rem)] md:min-h-[calc(100vh-5.5rem)]":
|
||||
isChatActive,
|
||||
})}
|
||||
>
|
||||
{lastResponseMessages.map(renderMessage)}
|
||||
{["submitted", "streaming"].includes(status) && (
|
||||
<div className="relative py-4 md:px-4">
|
||||
{status === "submitted" &&
|
||||
messages.at(-1)?.role === Role.USER &&
|
||||
messages
|
||||
.at(-1)
|
||||
?.parts.some(
|
||||
(part) =>
|
||||
part.type === "file" && part.mediaType.startsWith("image"),
|
||||
) ? (
|
||||
<AnalyzingImage />
|
||||
) : (
|
||||
<Icons.Loader className="text-muted-foreground size-5 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{footer}
|
||||
{error && (
|
||||
<div className="relative pb-4 @lg/thread:px-2 @xl/thread:px-4">
|
||||
<div className="bg-destructive/10 dark:bg-destructive/40 flex w-fit flex-wrap items-center gap-3 rounded-xl p-5 py-3">
|
||||
<p className="text-destructive dark:text-foreground">
|
||||
{t("error.general")}
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="h-auto gap-2"
|
||||
onClick={() => regenerate?.()}
|
||||
>
|
||||
<Icons.RotateCw className="size-4" />
|
||||
{t("reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full pb-[calc(var(--composer-height)+20px)]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
import { Controls } from "./controls";
|
||||
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
|
||||
export type ThreadMessageComponents<MESSAGE extends UIMessage> = Record<
|
||||
string,
|
||||
React.ComponentType<ThreadMessageProps<MESSAGE>>
|
||||
>;
|
||||
|
||||
export interface ThreadMessageProps<T extends UIMessage = UIMessage> {
|
||||
readonly status: string;
|
||||
readonly message: T;
|
||||
readonly ref?: React.RefObject<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
const Message = <MESSAGE extends UIMessage>(
|
||||
props: ThreadMessageProps<MESSAGE> & {
|
||||
components: ThreadMessageComponents<MESSAGE>;
|
||||
},
|
||||
) => {
|
||||
const role = props.message.role;
|
||||
|
||||
const isSupportedRole = (
|
||||
role: string,
|
||||
): role is keyof typeof props.components => {
|
||||
return role in props.components;
|
||||
};
|
||||
|
||||
if (!isSupportedRole(role)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Component = props.components[role];
|
||||
|
||||
if (!Component) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Component {...props} />;
|
||||
};
|
||||
|
||||
const Layout = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative mx-auto flex w-full max-w-3xl scroll-mb-[calc(var(--composer-height,140px)+36px)] flex-col justify-center gap-1 py-4 @md/thread:px-1 @lg/thread:px-2 @xl/thread:px-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreadMessage = {
|
||||
Layout,
|
||||
Message,
|
||||
Controls,
|
||||
};
|
||||
@@ -1,11 +1,9 @@
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/client";
|
||||
|
||||
// AI credits were backed by the removed @turbostarter/ai package.
|
||||
// claudemesh does not meter AI credits, so this stubs the query to return null.
|
||||
export const queries = {
|
||||
get: (params: { id: string }) => ({
|
||||
queryKey: ["credits", params.id],
|
||||
queryFn: () => handle(api.ai.credits.$get)(),
|
||||
queryFn: () => Promise.resolve(null as number | null),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@ import NumberFlow from "@number-flow/react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import {
|
||||
getCreditsLevel,
|
||||
getCreditsProgress,
|
||||
} from "@turbostarter/ai/credits/utils";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
|
||||
@@ -13,6 +9,15 @@ import { authClient } from "~/lib/auth/client";
|
||||
|
||||
import { credits } from "./api";
|
||||
|
||||
// Local replacements — @turbostarter/ai package removed in claudemesh fork.
|
||||
// claudemesh does not meter AI credits (not an AI consumption product), but
|
||||
// the surrounding UI still calls these with a number.
|
||||
type CreditsLevel = "high" | "medium" | "low";
|
||||
const getCreditsLevel = (n: number): CreditsLevel =>
|
||||
n > 500 ? "high" : n > 100 ? "medium" : "low";
|
||||
const getCreditsProgress = (n: number): number =>
|
||||
Math.max(0, Math.min(1, n / 1000));
|
||||
|
||||
export const useCredits = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import { handle } from "@turbostarter/api/utils";
|
||||
|
||||
import { api } from "~/lib/api/server";
|
||||
import { getQueryClient } from "~/lib/query/server";
|
||||
|
||||
import { credits } from "./api";
|
||||
|
||||
export const prefetchCredits = async (id: string) => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
...credits.queries.get({ id }),
|
||||
queryFn: handle(api.ai.credits.$get),
|
||||
});
|
||||
|
||||
await queryClient.prefetchQuery(credits.queries.get({ id }));
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { MDXContent } from "@content-collections/mdx/react";
|
||||
|
||||
interface MdxProps {
|
||||
readonly mdx: string;
|
||||
}
|
||||
|
||||
export const Mdx = ({ mdx }: MdxProps) => {
|
||||
return (
|
||||
<div className="prose dark:prose-invert prose-headings:font-semibold py-6">
|
||||
<MDXContent code={mdx} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { AspectRatio } from "@turbostarter/ai/image/types";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
const icons = {
|
||||
[AspectRatio.SQUARE]: Icons.Square,
|
||||
[AspectRatio.STANDARD]: Icons.Square,
|
||||
[AspectRatio.LANDSCAPE]: Icons.RectangleHorizontal,
|
||||
[AspectRatio.PORTRAIT]: Icons.RectangleVertical,
|
||||
};
|
||||
|
||||
interface AspectSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly options: readonly {
|
||||
readonly id: AspectRatio;
|
||||
readonly value: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export const AspectSelector = <T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
options,
|
||||
}: AspectSelectorProps<T>) => {
|
||||
const { t } = useTranslation("common");
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="max-w-24 min-w-0 @sm:max-w-none">
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger size="sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent align="end">
|
||||
{options.map(({ id, value }) => {
|
||||
const Icon = icons[id];
|
||||
|
||||
return (
|
||||
<SelectItem key={id} value={id}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Icon className="size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
<span className="hidden @lg:inline">
|
||||
{t(
|
||||
id.toLowerCase() as Lowercase<
|
||||
keyof typeof AspectRatio
|
||||
>,
|
||||
)}{" "}
|
||||
</span>
|
||||
<span>{`(${value})`}</span>
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { FormControl, FormField, FormItem } from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectPortal,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@turbostarter/ui-web/select";
|
||||
|
||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||
|
||||
interface ImageCountSelectorProps<T extends FieldValues> {
|
||||
readonly control: Control<T>;
|
||||
readonly name: Path<T>;
|
||||
readonly min?: number;
|
||||
readonly max?: number;
|
||||
}
|
||||
|
||||
export const ImageCountSelector = <T extends FieldValues>({
|
||||
name,
|
||||
control,
|
||||
min = 1,
|
||||
max = 5,
|
||||
}: ImageCountSelectorProps<T>) => {
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="min-w-0 gap-0">
|
||||
<FormControl>
|
||||
<Select
|
||||
value={`${field.value}`}
|
||||
onValueChange={(value) => field.onChange(parseInt(value, 10))}
|
||||
>
|
||||
<SelectTrigger size="sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icons.Image className="size-4 shrink-0" />
|
||||
<SelectValue />
|
||||
</div>
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent align="end">
|
||||
{Array.from({ length: max - min + 1 }, (_, i) => i + min).map(
|
||||
(count) => (
|
||||
<SelectItem key={count} value={count.toString()}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span className="min-w-0 truncate font-medium">
|
||||
{count}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -1,103 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/image/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
} from "@turbostarter/ui-web/form";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
|
||||
import { Composer } from "~/modules/common/ai/composer";
|
||||
import { ModelSelector } from "~/modules/common/ai/composer/model-selector";
|
||||
|
||||
import { AspectSelector } from "./aspect-selector";
|
||||
import { ImageCountSelector } from "./image-count-selector";
|
||||
import { useComposer } from "./use-composer";
|
||||
|
||||
interface ImageComposerProps {
|
||||
id?: string;
|
||||
prompt?: string;
|
||||
reset?: () => void;
|
||||
}
|
||||
|
||||
export const ImageComposer = ({
|
||||
id,
|
||||
prompt: initialPrompt,
|
||||
reset,
|
||||
}: ImageComposerProps) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const { form, model, onSubmit } = useComposer({ id });
|
||||
|
||||
const prompt = form.watch("prompt");
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPrompt) {
|
||||
form.setValue("prompt", initialPrompt);
|
||||
form.setFocus("prompt");
|
||||
reset?.();
|
||||
}
|
||||
}, [initialPrompt, form, reset]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<Composer.Form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<Composer.Input className="pb-12">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="prompt"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Composer.Textarea
|
||||
{...field}
|
||||
autoFocus
|
||||
maxLength={5_000}
|
||||
placeholder={t("image.composer.placeholder")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
return form.handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 flex w-full gap-1.5 overflow-hidden border-2 border-transparent p-2 @[480px]/input:p-3">
|
||||
<div className="flex max-w-full grow gap-px">
|
||||
<ImageCountSelector control={form.control} name="options.count" />
|
||||
<AspectSelector
|
||||
control={form.control}
|
||||
name="options.aspectRatio"
|
||||
options={MODELS.find((m) => m.id === model)?.dimensions ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ModelSelector
|
||||
control={form.control}
|
||||
name="options.model"
|
||||
options={MODELS}
|
||||
/>
|
||||
|
||||
<Button
|
||||
className="ml-auto shrink-0 rounded-full"
|
||||
disabled={!prompt.trim()}
|
||||
size="icon"
|
||||
type="submit"
|
||||
>
|
||||
<Icons.ImagePlay className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</Composer.Input>
|
||||
</Composer.Form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
@@ -1,121 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { useForm, useFormContext } from "react-hook-form";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/image/constants";
|
||||
import { imageGenerationSchema } from "@turbostarter/ai/image/schema";
|
||||
import { useDebounceCallback } from "@turbostarter/shared/hooks";
|
||||
import { generateId } from "@turbostarter/shared/utils";
|
||||
|
||||
import { useImageGeneration } from "~/modules/image/use-image-generation";
|
||||
|
||||
import type {
|
||||
ImageGenerationOptionsPayload,
|
||||
ImageGenerationPayload,
|
||||
} from "@turbostarter/ai/image/schema";
|
||||
import type { WatchObserver } from "react-hook-form";
|
||||
|
||||
interface ImageComposerState {
|
||||
prompt: string;
|
||||
options: ImageGenerationOptionsPayload;
|
||||
setPrompt: (prompt: string) => void;
|
||||
setOptions: (options: Partial<ImageGenerationOptionsPayload>) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS = {
|
||||
model: MODELS[0].id,
|
||||
aspectRatio: MODELS[0].dimensions[0].id,
|
||||
count: 1,
|
||||
};
|
||||
|
||||
const useImageComposerStore = create<ImageComposerState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
prompt: "",
|
||||
options: DEFAULT_OPTIONS,
|
||||
setPrompt: (prompt) => set({ prompt }),
|
||||
setOptions: (options) =>
|
||||
set((state) => ({
|
||||
options: { ...state.options, ...options },
|
||||
})),
|
||||
reset: () =>
|
||||
set({
|
||||
prompt: "",
|
||||
options: DEFAULT_OPTIONS,
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: "image-options",
|
||||
partialize: (state) => ({ options: state.options }),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
interface UseComposerProps {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export const useComposer = ({ id: passedId }: UseComposerProps = {}) => {
|
||||
const { prompt, options, setPrompt, setOptions, reset } =
|
||||
useImageComposerStore();
|
||||
const currentId = useRef(passedId);
|
||||
|
||||
const { createGeneration } = useImageGeneration({
|
||||
id: currentId.current,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (currentId.current !== passedId) {
|
||||
reset();
|
||||
currentId.current = passedId ?? generateId();
|
||||
}
|
||||
}, [passedId, reset]);
|
||||
|
||||
const newForm = useForm({
|
||||
resolver: zodResolver(imageGenerationSchema),
|
||||
defaultValues: {
|
||||
prompt,
|
||||
options,
|
||||
},
|
||||
});
|
||||
const contextForm = useFormContext<ImageGenerationPayload>();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const form = contextForm ?? newForm;
|
||||
|
||||
const model = form.watch("options.model");
|
||||
|
||||
const sync: WatchObserver<ImageGenerationPayload> = useCallback(
|
||||
(values) => {
|
||||
setPrompt(values.prompt ?? "");
|
||||
setOptions(values.options ?? DEFAULT_OPTIONS);
|
||||
},
|
||||
[setOptions, setPrompt],
|
||||
);
|
||||
|
||||
const debouncedSync = useDebounceCallback(sync, 500, {
|
||||
leading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = form.watch(debouncedSync);
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, debouncedSync]);
|
||||
|
||||
const onSubmit = (input: ImageGenerationPayload) => {
|
||||
form.resetField("prompt");
|
||||
createGeneration.mutate(input);
|
||||
};
|
||||
|
||||
return {
|
||||
form,
|
||||
model,
|
||||
prompt,
|
||||
onSubmit,
|
||||
reset,
|
||||
};
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
|
||||
import { ImageComposer } from "../composer";
|
||||
import { BackgroundGrid } from "../layout/background-grid";
|
||||
import { Examples } from "../layout/examples";
|
||||
import { Headline } from "../layout/headline";
|
||||
|
||||
interface NewGenerationProps {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
export const NewGeneration = memo<NewGenerationProps>(({ id }) => {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
return (
|
||||
<>
|
||||
<BackgroundGrid />
|
||||
<div className="absolute inset-0 z-10 mx-auto flex h-full w-full flex-col items-center justify-between gap-6 md:justify-center md:gap-9 md:p-2">
|
||||
<div className="flex w-full grow items-end justify-center">
|
||||
<Headline />
|
||||
</div>
|
||||
<div className="flex w-full grow flex-col items-center justify-between md:flex-col-reverse md:justify-end md:gap-5">
|
||||
<Examples className="flex" onSelect={setPrompt} />
|
||||
<div className="relative w-full px-3 pb-3">
|
||||
<ImageComposer
|
||||
id={id}
|
||||
prompt={prompt}
|
||||
reset={() => setPrompt("")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
NewGeneration.displayName = "NewImageGeneration";
|
||||
@@ -1,75 +0,0 @@
|
||||
import { MODELS } from "@turbostarter/ai/image/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@turbostarter/ui-web/popover";
|
||||
|
||||
import type { ImageGeneration } from "../../use-image-generation";
|
||||
|
||||
interface DetailsProps {
|
||||
readonly generation: ImageGeneration;
|
||||
}
|
||||
|
||||
export const Details = ({ generation }: DetailsProps) => {
|
||||
const { t, i18n } = useTranslation("common");
|
||||
|
||||
const model = MODELS.find(
|
||||
(model) => model.id === generation.input?.options.model,
|
||||
);
|
||||
|
||||
if (!model) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aspectRatio = model.dimensions.find(
|
||||
(dimension) => dimension.id === generation.input?.options.aspectRatio,
|
||||
);
|
||||
|
||||
const data = [
|
||||
{
|
||||
label: t("model"),
|
||||
value: model.name,
|
||||
},
|
||||
{
|
||||
label: t("aspectRatio"),
|
||||
value: generation.input?.options.aspectRatio
|
||||
? `${t(generation.input.options.aspectRatio)} (${aspectRatio?.value})`
|
||||
: "---",
|
||||
},
|
||||
{
|
||||
label: t("count"),
|
||||
value: generation.input?.options.count,
|
||||
},
|
||||
{
|
||||
label: t("createdAt"),
|
||||
value: generation.createdAt?.toLocaleString(i18n.language),
|
||||
},
|
||||
{
|
||||
label: t("completedAt"),
|
||||
value: generation.completedAt?.toLocaleString(i18n.language) ?? "---",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="gap-2">
|
||||
<Icons.Info className="size-4" />
|
||||
<span className="hidden @lg:block">{t("details")}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="flex w-64 flex-col gap-3 p-4">
|
||||
{data.map((item) => (
|
||||
<div key={item.label} className="flex flex-col items-start gap-1">
|
||||
<span className="text-muted-foreground text-xs">{item.label}</span>
|
||||
<span className="font-medium">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,317 +0,0 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AspectRatio } from "@turbostarter/ai/image/types";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { splitArray } from "@turbostarter/shared/utils";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Badge } from "@turbostarter/ui-web/badge";
|
||||
import { Button, buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { GridPattern } from "@turbostarter/ui-web/grid-pattern";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { Skeleton } from "@turbostarter/ui-web/skeleton";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import {
|
||||
getImageSource,
|
||||
Thumbnail,
|
||||
ThumbnailImage,
|
||||
Viewer,
|
||||
} from "~/modules/common/image";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
import { shareOrDownload } from "~/utils";
|
||||
|
||||
import type { ImageGenerationImage } from "../../use-image-generation";
|
||||
|
||||
const getAspectRatioClass = (aspectRatio?: AspectRatio) => {
|
||||
switch (aspectRatio) {
|
||||
case AspectRatio.SQUARE:
|
||||
return "aspect-square";
|
||||
case AspectRatio.STANDARD:
|
||||
return "aspect-[4/3]";
|
||||
case AspectRatio.PORTRAIT:
|
||||
return "aspect-[9/16]";
|
||||
case AspectRatio.LANDSCAPE:
|
||||
return "aspect-[16/9]";
|
||||
default:
|
||||
return "aspect-square";
|
||||
}
|
||||
};
|
||||
|
||||
const ColumnsContext = createContext<number>(0);
|
||||
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
const [columns, setColumns] = useState(0);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const getColumnsCount = () => {
|
||||
if (!ref.current) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
setColumns(
|
||||
window
|
||||
.getComputedStyle(ref.current)
|
||||
.getPropertyValue("grid-template-columns")
|
||||
.split(" ").length,
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
getColumnsCount();
|
||||
const resizeObserver = new ResizeObserver(getColumnsCount);
|
||||
resizeObserver.observe(ref.current);
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ColumnsContext.Provider value={columns}>
|
||||
<div
|
||||
className="grid w-full grid-cols-[repeat(auto-fill,minmax(min(20rem,100%),1fr))] gap-4"
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ColumnsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface GridProps {
|
||||
readonly images: (ImageGenerationImage & {
|
||||
generationId: string;
|
||||
description?: string;
|
||||
aspectRatio?: AspectRatio;
|
||||
model?: string;
|
||||
})[];
|
||||
readonly fetching?: boolean;
|
||||
readonly withDetails?: boolean;
|
||||
}
|
||||
|
||||
const Grid = ({ images, fetching, withDetails }: GridProps) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState(0);
|
||||
const columns = useContext(ColumnsContext);
|
||||
|
||||
const share = useMutation({
|
||||
mutationFn: (image: (typeof images)[number]) =>
|
||||
shareOrDownload({
|
||||
url: getImageSource(image),
|
||||
filename: `${image.model ?? "image"}-${Date.now()}.png`,
|
||||
}),
|
||||
onError: () => toast.error(t("error.general")),
|
||||
});
|
||||
|
||||
const chunks = useMemo(() => splitArray(images, columns), [images, columns]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{chunks.map((chunk, chunkIndex) => (
|
||||
<div key={chunkIndex} className="flex w-full flex-col gap-2.5">
|
||||
{chunk.map((image, imageIndex) => {
|
||||
const index = images.findIndex(
|
||||
(img) =>
|
||||
(img.url && img.url === image.url) ??
|
||||
(img.base64 && img.base64 === image.base64),
|
||||
);
|
||||
return (
|
||||
<div className="group relative" key={imageIndex}>
|
||||
<Thumbnail
|
||||
index={index}
|
||||
onClick={() => {
|
||||
setIsOpen(true);
|
||||
setSelectedImage(index);
|
||||
}}
|
||||
className={getAspectRatioClass(image.aspectRatio)}
|
||||
>
|
||||
{withDetails && (
|
||||
<Badge
|
||||
className="bg-background/75 absolute top-3 left-3 backdrop-blur-md"
|
||||
variant="secondary"
|
||||
>
|
||||
{image.model}
|
||||
</Badge>
|
||||
)}
|
||||
<ThumbnailImage
|
||||
src={getImageSource(image)}
|
||||
alt={image.description ?? ""}
|
||||
/>
|
||||
</Thumbnail>
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="bg-background/75 absolute bottom-5 left-3 opacity-0 backdrop-blur-md transition-all duration-200 group-hover:opacity-100 focus:opacity-100 disabled:opacity-0 hover:disabled:opacity-50 [@media(hover:none)]:opacity-100"
|
||||
onClick={() => share.mutate(image)}
|
||||
disabled={share.isPending}
|
||||
>
|
||||
{share.isPending ? (
|
||||
<Icons.Loader className="size-4 animate-spin" />
|
||||
) : (
|
||||
<Icons.Download className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="center" sideOffset={5}>
|
||||
<span>{t("download")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{withDetails && (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<TurboLink
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost", size: "icon" }),
|
||||
"bg-background/75 absolute right-3 bottom-5 opacity-0 backdrop-blur-md transition-all duration-200 group-hover:opacity-100 focus:opacity-100 disabled:opacity-0 hover:disabled:opacity-50 [@media(hover:none)]:opacity-100",
|
||||
)}
|
||||
href={pathsConfig.apps.image.generation(
|
||||
image.generationId,
|
||||
)}
|
||||
>
|
||||
<Icons.ExternalLink className="size-4" />
|
||||
</TurboLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="center"
|
||||
sideOffset={5}
|
||||
>
|
||||
<span>{t("image.generation.goTo")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{fetching && (
|
||||
<Skeleton className={cn("rounded-lg", getAspectRatioClass())} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Viewer
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
images={images}
|
||||
selectedImage={selectedImage}
|
||||
setSelectedImage={setSelectedImage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Empty = () => {
|
||||
const { t } = useTranslation(["ai"]);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-1 flex-col items-center justify-center gap-4 rounded-lg border-2 border-dashed p-4">
|
||||
<GridPattern
|
||||
width={50}
|
||||
height={50}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className="mask-[radial-gradient(white,transparent)]"
|
||||
/>
|
||||
|
||||
<Icons.ImageOff className="size-20" />
|
||||
<span className="text-2xl font-medium @lg:text-3xl">
|
||||
{t("image.generation.empty.title")}
|
||||
</span>
|
||||
<p className="text-muted-foreground max-w-md text-center text-pretty @lg:text-lg">
|
||||
{t("image.generation.empty.description")}
|
||||
</p>
|
||||
|
||||
<TurboLink
|
||||
href={pathsConfig.apps.image.index}
|
||||
className={cn(buttonVariants({ variant: "secondary" }), "mt-3 gap-2")}
|
||||
>
|
||||
<Icons.Plus className="size-5" />
|
||||
{t("image.generation.new")}
|
||||
</TurboLink>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Loading = ({
|
||||
aspectRatio,
|
||||
count,
|
||||
}: {
|
||||
aspectRatio?: AspectRatio;
|
||||
count?: number;
|
||||
}) => {
|
||||
const columns = useContext(ColumnsContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count ?? columns * 2 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className={cn("rounded-lg", getAspectRatioClass(aspectRatio))}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Error = ({ onRetry }: { onRetry: () => void }) => {
|
||||
const { t } = useTranslation(["ai", "common"]);
|
||||
|
||||
return (
|
||||
<div className="border-destructive relative flex h-full w-full flex-1 flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed p-4">
|
||||
<GridPattern
|
||||
width={50}
|
||||
height={50}
|
||||
x={-1}
|
||||
y={-1}
|
||||
strokeDasharray={"4 2"}
|
||||
className="mask-[radial-gradient(white,transparent)]"
|
||||
/>
|
||||
<Icons.CircleX className="text-destructive size-20" />
|
||||
<span className="text-destructive text-2xl font-medium @lg:text-3xl">
|
||||
{t("error.title")}
|
||||
</span>
|
||||
<p className="text-muted-foreground max-w-md py-1 text-center text-pretty @lg:text-lg">
|
||||
{t("error.general")}
|
||||
</p>
|
||||
<Button variant="outline" className="mt-2" onClick={onRetry}>
|
||||
<Icons.RefreshCcw className="mr-2 size-4" />
|
||||
{t("regenerate")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Images = {
|
||||
Layout,
|
||||
Grid,
|
||||
Empty,
|
||||
Loading,
|
||||
Error,
|
||||
};
|
||||
@@ -1,117 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MODELS } from "@turbostarter/ai/image/constants";
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { Button } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
|
||||
|
||||
import { ProviderIcons } from "~/modules/common/ai/icons";
|
||||
import { Stopwatch } from "~/modules/common/stopwatch";
|
||||
|
||||
import { useImageGeneration } from "../../use-image-generation";
|
||||
|
||||
import { Details } from "./details";
|
||||
import { Images } from "./images";
|
||||
|
||||
import type { ImageGeneration } from "../../use-image-generation";
|
||||
|
||||
interface ViewGenerationProps {
|
||||
id: string;
|
||||
initialGeneration?: ImageGeneration;
|
||||
}
|
||||
|
||||
export const ViewGeneration = ({
|
||||
id,
|
||||
initialGeneration,
|
||||
}: ViewGenerationProps) => {
|
||||
const { t } = useTranslation(["common", "ai"]);
|
||||
const { generation, stop, reload } = useImageGeneration({
|
||||
id,
|
||||
initialGeneration,
|
||||
});
|
||||
|
||||
if (!generation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const model = MODELS.find(
|
||||
(model) => model.id === generation.input?.options.model,
|
||||
);
|
||||
|
||||
const Icon = model ? ProviderIcons[model.provider] : null;
|
||||
|
||||
return (
|
||||
<ScrollArea className="w-full grow">
|
||||
<div className="flex h-full w-full flex-1 flex-col gap-8 px-5 pt-16 pb-5 md:px-6 md:pt-18 md:pb-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="ml-px flex items-center gap-3.5">
|
||||
{Icon && <Icon className="size-5 shrink-0" />}
|
||||
<span className="text-lg font-medium">{model?.name}</span>
|
||||
</div>
|
||||
<span className="text-5xl font-semibold">
|
||||
{generation.createdAt && !generation.completedAt && (
|
||||
<Stopwatch startTime={generation.createdAt} key={id} />
|
||||
)}
|
||||
{generation.completedAt &&
|
||||
(
|
||||
(generation.completedAt.getTime() -
|
||||
(generation.createdAt?.getTime() ?? 0)) /
|
||||
1000
|
||||
).toFixed(1)}
|
||||
{`s`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Details generation={generation} />
|
||||
|
||||
{generation.completedAt ? (
|
||||
<Button className="gap-2" onClick={reload}>
|
||||
<Icons.RefreshCcw className="size-4" />
|
||||
<span className="hidden @lg:block">{t("regenerate")}</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button className="gap-2" onClick={stop}>
|
||||
<Icons.Square className="size-4 fill-current" />
|
||||
<span className="hidden @lg:block">{t("stop")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground text-2xl italic @xl:text-3xl">
|
||||
“{generation.input?.prompt}”
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{["created", "loading"].includes(generation.status ?? "") ? (
|
||||
<Images.Layout>
|
||||
<Images.Loading
|
||||
aspectRatio={generation.input?.options.aspectRatio}
|
||||
count={generation.input?.options.count}
|
||||
/>
|
||||
</Images.Layout>
|
||||
) : generation.status === "error" ? (
|
||||
<Images.Error onRetry={reload} />
|
||||
) : generation.images?.length ? (
|
||||
<Images.Layout>
|
||||
<Images.Grid
|
||||
images={generation.images.map((image) => ({
|
||||
...image,
|
||||
generationId: id,
|
||||
description: generation.input?.prompt,
|
||||
aspectRatio: generation.input?.options.aspectRatio,
|
||||
model: generation.input?.options.model,
|
||||
}))}
|
||||
/>
|
||||
</Images.Layout>
|
||||
) : (
|
||||
<Images.Empty />
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -1,44 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "node_modules/@turbostarter/i18n/src/client";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@turbostarter/ui-web/tooltip";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
export const HistoryCta = () => {
|
||||
const { t } = useTranslation("common");
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={100}>
|
||||
<TooltipTrigger asChild>
|
||||
<TurboLink
|
||||
href={pathsConfig.apps.image.history}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: "ghost",
|
||||
size: "icon",
|
||||
className: "group relative",
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<Icons.TextSearch className="text-muted-foreground group-hover:text-foreground size-5" />
|
||||
</TurboLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<span>{t("history")}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
@@ -1,127 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useTranslation } from "@turbostarter/i18n";
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { buttonVariants } from "@turbostarter/ui-web/button";
|
||||
import { Icons } from "@turbostarter/ui-web/icons";
|
||||
import { ScrollArea } from "@turbostarter/ui-web/scroll-area";
|
||||
|
||||
import { pathsConfig } from "~/config/paths";
|
||||
import { authClient } from "~/lib/auth/client";
|
||||
import { useIntersectionObserver } from "~/modules/common/hooks/use-intersection-observer";
|
||||
import { TurboLink } from "~/modules/common/turbo-link";
|
||||
|
||||
import { Images } from "../generation/view/images";
|
||||
import { image } from "../lib/api";
|
||||
|
||||
const Headline = () => {
|
||||
const { t } = useTranslation("ai");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex w-full items-start justify-between gap-3">
|
||||
<h1 className="text-4xl font-semibold">{t("image.history.title")}</h1>
|
||||
|
||||
<TurboLink
|
||||
href={pathsConfig.apps.image.index}
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
"h-9 w-9 gap-2 p-0 @lg:h-10 @lg:w-auto @lg:px-4 @lg:py-2",
|
||||
)}
|
||||
>
|
||||
<Icons.Plus className="size-5" />
|
||||
<span className="hidden @lg:inline">{t("image.generation.new")}</span>
|
||||
</TurboLink>
|
||||
</div>
|
||||
<p className="text-muted-foreground max-w-lg leading-snug @lg:text-lg">
|
||||
{t("image.history.description")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex h-full w-full flex-1 flex-col gap-8 px-5 pt-16 pb-5 md:px-6 md:pt-18 md:pb-6">
|
||||
{children}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
|
||||
const Content = () => {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const { isIntersecting, ref } = useIntersectionObserver({
|
||||
threshold: 0.5,
|
||||
});
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
isError,
|
||||
hasNextPage,
|
||||
refetch,
|
||||
} = useInfiniteQuery({
|
||||
...image.queries.images.user.getAll(session?.user.id ?? ""),
|
||||
getNextPageParam: (lastPage) => lastPage.at(-1)?.createdAt,
|
||||
initialPageParam: undefined,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||
void fetchNextPage();
|
||||
}
|
||||
}, [isIntersecting, hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
|
||||
const images = data?.pages.flatMap((page) => page) ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Images.Layout>
|
||||
<Images.Loading />
|
||||
</Images.Layout>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Images.Error onRetry={() => refetch()} />;
|
||||
}
|
||||
|
||||
if (!images.length) {
|
||||
return <Images.Empty />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Images.Layout>
|
||||
<Images.Grid
|
||||
images={images.map((image) => ({
|
||||
...image,
|
||||
...image.generation,
|
||||
description: image.generation.prompt,
|
||||
}))}
|
||||
fetching={isFetchingNextPage}
|
||||
withDetails
|
||||
/>
|
||||
</Images.Layout>
|
||||
|
||||
<div ref={ref} className="-mt-8 h-5 @lg:h-6" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const History = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<Headline />
|
||||
<Content />
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import { cn } from "@turbostarter/ui";
|
||||
import { Marquee } from "@turbostarter/ui-web/marquee";
|
||||
|
||||
const images = [
|
||||
"https://images.unsplash.com/photo-1493612276216-ee3925520721",
|
||||
"https://images.unsplash.com/photo-1731964877414-217cdc9b5b37",
|
||||
"https://images.unsplash.com/photo-1513542789411-b6a5d4f31634",
|
||||
"https://images.unsplash.com/photo-1485550409059-9afb054cada4",
|
||||
"https://images.unsplash.com/photo-1459411552884-841db9b3cc2a",
|
||||
"https://images.unsplash.com/photo-1726455083595-fb3d23fa3d2d",
|
||||
"https://images.unsplash.com/photo-1494059980473-813e73ee784b",
|
||||
"https://images.unsplash.com/photo-1741515277598-64b4da5d212a",
|
||||
"https://images.unsplash.com/photo-1524856949007-80db29955b17",
|
||||
"https://images.unsplash.com/photo-1605142859862-978be7eba909",
|
||||
"https://images.unsplash.com/photo-1500530855697-b586d89ba3ee",
|
||||
"https://images.unsplash.com/photo-1536697246787-1f7ae568d89a",
|
||||
"https://images.unsplash.com/photo-1501426026826-31c667bdf23d",
|
||||
"https://images.unsplash.com/photo-1554570731-63bcddda4dcd",
|
||||
"https://images.unsplash.com/photo-1504275107627-0c2ba7a43dba",
|
||||
"https://images.unsplash.com/photo-1741533699135-b3ef83e27215",
|
||||
"https://images.unsplash.com/photo-1740532501882-5766c265f637",
|
||||
"https://images.unsplash.com/photo-1560963619-c9e49c9380bd",
|
||||
"https://images.unsplash.com/photo-1624239408355-7b06ee576e95",
|
||||
"https://images.unsplash.com/photo-1468971050039-be99497410af",
|
||||
];
|
||||
|
||||
const chunkSize = Math.ceil(images.length / 4);
|
||||
const firstRow = images.slice(0, chunkSize);
|
||||
const secondRow = images.slice(chunkSize, chunkSize * 2);
|
||||
const thirdRow = images.slice(chunkSize * 2, chunkSize * 3);
|
||||
const fourthRow = images.slice(chunkSize * 3);
|
||||
|
||||
const ImageCard = ({ src }: { src: string }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative aspect-square w-80 cursor-pointer overflow-hidden rounded-xl border",
|
||||
)}
|
||||
>
|
||||
<Image className="object-cover" alt="" src={src} fill />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function BackgroundGrid() {
|
||||
return (
|
||||
<div className="relative flex h-full w-full flex-col items-center justify-center overflow-hidden rounded-xl">
|
||||
<div className="bg-background/50 absolute inset-0 z-10 backdrop-blur-md"></div>
|
||||
<div className="absolute -top-20 left-0 w-full rotate-[-5deg]">
|
||||
<Marquee>
|
||||
{firstRow.map((src, index) => (
|
||||
<ImageCard key={`first-row-${index}`} src={src} />
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
<div className="absolute top-[20%] left-0 w-full rotate-[3deg]">
|
||||
<Marquee reverse>
|
||||
{secondRow.map((src, index) => (
|
||||
<ImageCard key={`second-row-${index}`} src={src} />
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
<div className="absolute top-[calc(50%-5rem)] left-0 w-full rotate-[-4deg]">
|
||||
<Marquee>
|
||||
{thirdRow.map((src, index) => (
|
||||
<ImageCard key={`third-row-${index}`} src={src} />
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
<div className="absolute -bottom-10 left-0 w-full rotate-[6deg]">
|
||||
<Marquee reverse>
|
||||
{fourthRow.map((src, index) => (
|
||||
<ImageCard key={`fourth-row-${index}`} src={src} />
|
||||
))}
|
||||
</Marquee>
|
||||
</div>
|
||||
<div className="from-background pointer-events-none absolute inset-y-0 left-0 w-2/5 bg-gradient-to-r"></div>
|
||||
<div className="from-background pointer-events-none absolute inset-y-0 right-0 w-2/5 bg-gradient-to-l"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||