9 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
c6674e971a chore(deploy): production Dockerfiles for broker + web + env template
Some checks failed
CI / Tests / 🧪 Test (push) Has been cancelled
- apps/broker/Dockerfile: oven/bun 1.2-slim runtime, multi-stage, pnpm deps,
  non-root bun user, GIT_SHA build-arg, /health-based HEALTHCHECK, port 7900
- apps/web/Dockerfile: Next.js 15 standalone, multi-stage, non-root nextjs
  user, NEXT_PUBLIC_* baked as build args, port 3000
- .env.production.template: DATABASE_URL, BetterAuth, OAuth, broker caps;
  no secrets
- Build context: repo root (pnpm workspace needs root pnpm-lock.yaml +
  pnpm-workspace.yaml); build with -f apps/{broker,web}/Dockerfile .

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:22:05 +01:00
Alejandro Gutiérrez
3458860c1f test(broker): coverage for hardening modules — caps, limits, metrics, health, logs
Adds 23 tests across 4 files, taking total broker coverage from
21 → 44 passing in ~2.5s.

Unit tests (no I/O):
- tests/rate-limit.test.ts (6): TokenBucket capacity, refill rate,
  no-overflow cap, independent buckets per key, sweep GC.
- tests/metrics.test.ts (5): all 10 series present in /metrics,
  counter increment semantics, labelled series produce distinct lines,
  gauge set overwrites, Prometheus format well-formed.
- tests/logging.test.ts (5): JSON per line, required fields (ts, level,
  component, msg), context merging, level preservation, no plain-text
  escape hatches.

Integration tests (spawn real broker subprocesses on random ports):
- tests/integration/health.test.ts (7):
  * GET /health 200 + {status, db, version, gitSha, uptime} (healthy DB)
  * GET /health 503 + {status:degraded, db:down} (unreachable DB)
  * GET /metrics 200 text/plain with all expected series
  * GET /nope → 404
  * POST /hook/set-status oversized body → 413
  * POST /hook/set-status 6th req/min → 429
  * Rate limit isolation by (pid, cwd) key

Integration tests use node:child_process (vitest runs under Node, not
Bun — Bun.spawn isn't available). Each suite spawns its own broker
subprocess with a random port + tailored env vars.

Not yet covered (flagged for follow-up):
- WebSocket connection caps (needs seeded mesh + WS client setup)
- WebSocket message-size rejection (ws.maxPayload behavior)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:19:14 +01:00
Alejandro Gutiérrez
5f8567614a docs(broker): production deployment spec
Authoritative runtime contract for the broker. Documents:
- HTTP + WS routes (single-port architecture)
- Required + optional env vars (DATABASE_URL, caps, TTLs, limits)
- /health and /metrics semantics, including 503 behavior on DB drop
- SIGTERM/SIGINT graceful shutdown sequence
- Recommended multi-stage Docker build (node:slim for pnpm, oven/bun
  for runtime) with GIT_SHA build-arg convention
- Signal/grace-period guidance for orchestrators
- Prometheus metric names + suggested alert thresholds
- CI pattern for the test suite (needs a live Postgres)
- Deployment target hand-off to the deploy lane

Complements the existing Dockerfile (claudemesh-3's work) with the
runtime contract the Dockerfile implements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:15:24 +01:00
Alejandro Gutiérrez
5bf815b304 feat(broker): production hardening — caps, limits, metrics, logging
Adds the minimum ops surface area for a production broker without
over-engineering. All new config knobs are env-var driven with sane
defaults.

New modules:
- logger.ts: structured JSON logs (one line, stderr, ready for
  Loki/Datadog ingestion without preprocessing)
- metrics.ts: in-process Prometheus counters + gauges, exposed at
  GET /metrics. Tracks connections, messages, queue depth, TTL
  sweeps, hook requests, DB health.
- rate-limit.ts: token-bucket rate limiter keyed by (pid, cwd).
  Applied to POST /hook/set-status at 30/min default.
- db-health.ts: Postgres ping loop with exponential-backoff retry.
  GET /health returns 503 while DB is down.
- build-info.ts: version + gitSha (from GIT_SHA env or `git rev-parse`
  fallback) + uptime, surfaced on /health.

Behavior changes:
- Connection caps: MAX_CONNECTIONS_PER_MESH (default 100). Exceed →
  close(1008, "capacity") + metric increment.
- Message size: MAX_MESSAGE_BYTES (default 65536). WS applies it via
  `ws.maxPayload`. Hook POST bodies cap out with 413.
- Structured logs everywhere replacing the old `log()` helper.
- Env validation stricter: DATABASE_URL required + regex-checked for
  postgres:// prefix.

New endpoints:
- GET /health → {status, db, version, gitSha, uptime}. 503 if DB down.
- GET /metrics → Prometheus text format.

Verified: 21/21 tests still pass. Hit /health + /metrics live —
gitSha resolves correctly via `git rev-parse --short HEAD` in dev.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:14:31 +01:00
Alejandro Gutiérrez
84e14ff410 feat(web): marketing landing page with Anthropic design system
Landing page at / matching claude.com/product/claude-code structure:
hero, surfaces, pricing, laptop-to-laptop, features, meets-you, faq, cta,
+ floating "Latest news" toaster. Motion-based scroll reveals.

Design system extracted from claude.com via playwriter reverse-engineering:
- Self-hosted Anthropic Sans/Serif/Mono fonts (6 woff2 files)
- --cm-* tokens in globals.css (clay #d97757, gray-050..900, fluid clamps)
- Serif display, Sans UI, Mono terminals & section markers
- Italic clay phrases for emphasis

Header rewritten for design consistency: claudemesh wordmark (mesh glyph +
serif), dark bg, nav (Docs · Pricing · Changelog · GitHub), "Start free" CTA.

Free-first messaging: hero subhead "Free and open-source. Forever.", primary
CTA "Start free", pricing defaults to Solo=Free.

Fixes:
- packages/api: comment out aiRouter (module removed in 1f094c4)
- packages/db/schema/mesh.ts: rename memberRelations → meshMemberRelations
  (missed in beeaa3b rename pass, caught via web build — ack'd by BotMou)
- credits/{api,server,index}: stub out @turbostarter/ai/credits/utils
- remove (marketing)/legal/[slug] route and common/mdx.tsx (cms-backed)
- sitemap: drop blog/legal enumeration (cms removed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:09:38 +01:00
Alejandro Gutiérrez
e25115f1b0 test(broker): port test suite from claude-intercom to drizzle/postgres
21 integration tests (14 broker behavior + 7 path encoding), all
passing in ~1s against a real Postgres (claudemesh_test database on
the dev container).

Test infrastructure:
- apps/broker/vitest.config.ts extends @turbostarter/vitest-config/base
- tests/helpers.ts: setupTestMesh() creates a fresh mesh + 2 members
  per test with a unique slug, returns cleanup function that cascades
  the delete. cleanupAllTestMeshes() as an afterAll safety net.
- Mesh isolation in broker logic means tests don't interfere even when
  they share a database — no per-test TRUNCATE needed.

Ported behavior tests (broker.test.ts, 14 tests):
- hook flips status + queued "next" messages unblock
- "now"-priority bypasses the working gate
- DND is sacred (hooks cannot unset it)
- hook source stays fresh through jsonl refresh
- source decays to jsonl when hook signal goes stale
- isHookFresh freshness window + source-type rules
- TTL sweep flips stuck "working" → idle
- TTL sweep leaves DND alone
- first-turn race: hook fired pre-connect stashed in pending_status
- applyPendingHookStatus picks newest matching entry
- expired pending entries are ignored on connect
- broadcast targetSpec (*) reaches all members
- pubkey mismatch → message not drained
- mesh isolation: peer in mesh X doesn't drain from mesh Y

Ported encoding tests (encoding.test.ts, 7 tests):
- macOS, Linux, Windows path encoding first-candidate correctness
- Roberto's H:\Claude → H--Claude regression test (2026-04-04)
- Candidate dedup, drive-stripped fallback, leading-dash fallback

How to run: from apps/broker,
  DATABASE_URL="postgresql://.../claudemesh_test" pnpm test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:09:06 +01:00
Alejandro Gutiérrez
1f094c4c53 chore: remove files importing pruned packages (ai, cms, cognitive-context)
Step 3 pruned packages/{ai,cms,cognitive-context} but left whole
route groups + feature modules that depended on them. Those files
were unbuildable since that prune. Removes them now so the workspace
can be validated:

Route groups:
- apps/web/src/app/[locale]/(apps)/{chat,image,pdf,tts}/
- apps/web/src/app/[locale]/(marketing)/blog/

Feature modules:
- apps/web/src/modules/{chat,image,pdf,tts,common/ai,marketing/blog}/
- packages/api/src/modules/ai/  (chat, image, pdf, stt, tts, router)

3 stragglers remain (separate handoff to claudemesh-2):
- apps/web/src/app/[locale]/(marketing)/legal/[slug]/page.tsx  (cms)
- apps/web/src/app/sitemap.ts                                   (cms)
- apps/web/src/modules/common/layout/credits/index.tsx          (ai)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:02:26 +01:00
Alejandro Gutiérrez
8ce8b04e75 fix(db): rename pgSchema exports to prevent barrel collision
chat/image/mesh modules all exported a generic `const schema`
binding. When packages/db/src/schema/index.ts did `export * from
"./chat"` + `export * from "./image"` + `export * from "./mesh"`,
TypeScript's ambiguous-re-export rule silently dropped the colliding
bindings — drizzle-kit's introspection could not find the pgSchema
instances, so CREATE SCHEMA statements were never emitted. The
migration worked on the prior dev DB only because chat/image already
existed from an earlier turbostarter run; a fresh clone would fail.

pdf.ts already used `pdfSchema` (unique name). Applied the same
pattern everywhere:
- chat.ts:  `export const chatSchema = pgSchema("chat")`
- image.ts: `export const imageSchema = pgSchema("image")`
- mesh.ts:  `export const meshSchema = pgSchema("mesh")`

Also added `CREATE EXTENSION IF NOT EXISTS vector` at the top of the
migration (pgvector is used by pdf.embedding — the generated
migration assumed it was pre-enabled).

Verified end-to-end against a fresh pgvector/pgvector:pg17 container:
`pnpm drizzle-kit migrate` applies cleanly from scratch, all 7 mesh.*
tables + chat/image/pdf/mesh schemas created correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:02:09 +01:00
Alejandro Gutiérrez
3ab3fbcdf6 chore(web): rebrand TurboStarter → claudemesh (i18n, env, icons)
- Replaced brand references in env.config.ts default (NEXT_PUBLIC_PRODUCT_NAME)
- Updated all 6 i18n locale files (en/es × marketing/auth/organization)
- Generated favicon.ico + icon.png + apple-icon.png from pixelated mascot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 22:01:29 +01:00
187 changed files with 3486 additions and 13798 deletions

30
.env.production.template Normal file
View 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
View 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
View 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"]

View File

@@ -9,6 +9,8 @@
"start": "bun src/index.ts", "start": "bun src/index.ts",
"format": "prettier --check . --ignore-path ../../.gitignore", "format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint", "lint": "eslint",
"test": "vitest run",
"test:watch": "vitest",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"prettier": "@turbostarter/prettier-config", "prettier": "@turbostarter/prettier-config",
@@ -24,10 +26,12 @@
"@turbostarter/eslint-config": "workspace:*", "@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*", "@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*", "@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/libsodium-wrappers": "0.7.14", "@types/libsodium-wrappers": "0.7.14",
"@types/ws": "8.5.13", "@types/ws": "8.5.13",
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
"typescript": "catalog:" "typescript": "catalog:",
"vitest": "catalog:"
} }
} }

View File

@@ -17,6 +17,7 @@
import { import {
and, and,
asc, asc,
count,
desc, desc,
eq, eq,
gte, gte,
@@ -34,6 +35,7 @@ import {
presence, presence,
} from "@turbostarter/db/schema/mesh"; } from "@turbostarter/db/schema/mesh";
import { env } from "./env"; import { env } from "./env";
import { metrics } from "./metrics";
import { inferStatusFromJsonl } from "./paths"; import { inferStatusFromJsonl } from "./paths";
import type { import type {
HookSetStatusRequest, HookSetStatusRequest,
@@ -244,6 +246,16 @@ export async function sweepStuckWorking(): Promise<void> {
for (const row of stuck) { for (const row of stuck) {
await writeStatus(row.id, "idle", "jsonl", now); 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. */ /** Sweep expired pending_status entries. */

View 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 };

View 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;
}

View File

@@ -4,18 +4,26 @@ import { z } from "zod";
* Broker environment config. * Broker environment config.
* *
* Validated at startup with Zod. Fails fast with a useful error if any * Validated at startup with Zod. Fails fast with a useful error if any
* required var is missing or malformed. Defaults mirror the values * required var is missing or malformed.
* proven out in the claude-intercom prototype so local dev works
* without a .env file.
*/ */
const envSchema = z.object({ const envSchema = z.object({
BROKER_PORT: z.coerce.number().int().positive().default(7900), 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), STATUS_TTL_SECONDS: z.coerce.number().int().positive().default(60),
HOOK_FRESH_WINDOW_SECONDS: z.coerce.number().int().positive().default(30), 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 NODE_ENV: z
.enum(["development", "production", "test"]) .enum(["development", "production", "test"])
.default("development"), .default("development"),
GIT_SHA: z.string().optional(),
}); });
export type BrokerEnv = z.infer<typeof envSchema>; export type BrokerEnv = z.infer<typeof envSchema>;

View File

@@ -2,17 +2,17 @@
/** /**
* @claudemesh/broker entry point. * @claudemesh/broker entry point.
* *
* Spins up two servers in a single process: * Single-port HTTP + WebSocket server. Routes:
* - HTTP on BROKER_PORT+1 for the /hook/set-status endpoint * GET /health → liveness + build info (503 if DB down)
* (Claude Code hook scripts POST here on turn boundaries). * GET /metrics → Prometheus plaintext
* - WebSocket on BROKER_PORT for authenticated peer connections * POST /hook/set-status → Claude Code hook scripts report status
* (routes E2E-encrypted envelopes between mesh members). * WS /ws → authenticated peer connections
* *
* Background: TTL sweeper + pending-status sweeper. * Graceful shutdown on SIGTERM/SIGINT: stops sweepers, marks all
* Shutdown: clean SIGTERM/SIGINT marks all presences disconnected. * 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 type { Duplex } from "node:stream";
import { WebSocketServer, type WebSocket } from "ws"; import { WebSocketServer, type WebSocket } from "ws";
import { env } from "./env"; import { env } from "./env";
@@ -24,6 +24,7 @@ import {
handleHookSetStatus, handleHookSetStatus,
heartbeat, heartbeat,
queueMessage, queueMessage,
refreshQueueDepth,
refreshStatusFromJsonl, refreshStatusFromJsonl,
startSweepers, startSweepers,
stopSweepers, stopSweepers,
@@ -35,28 +36,31 @@ import type {
WSPushMessage, WSPushMessage,
WSServerMessage, WSServerMessage,
} from "./types"; } 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 PORT = env.BROKER_PORT;
const WS_PATH = "/ws"; const WS_PATH = "/ws";
function log(msg: string): void {
console.error(`[broker] ${msg}`);
}
// --- Runtime connection registry --- // --- Runtime connection registry ---
/** In-memory map of presenceId → authenticated WS connection. */ interface PeerConn {
const connections = new Map<
string,
{
ws: WebSocket; ws: WebSocket;
meshId: string; meshId: string;
memberId: string; memberId: string;
memberPubkey: string; memberPubkey: string;
cwd: 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 { function sendToPeer(presenceId: string, msg: WSServerMessage): void {
const conn = connections.get(presenceId); const conn = connections.get(presenceId);
@@ -65,80 +69,11 @@ function sendToPeer(presenceId: string, msg: WSServerMessage): void {
try { try {
conn.ws.send(JSON.stringify(msg)); conn.ws.send(JSON.stringify(msg));
} catch (e) { } catch (e) {
log(`push failed to ${presenceId}: ${e instanceof Error ? e.message : e}`); log.warn("push failed", {
} presence_id: presenceId,
}
// --- 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,
error: e instanceof Error ? e.message : String(e), 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> { async function maybePushQueuedMessages(presenceId: string): Promise<void> {
@@ -167,26 +102,190 @@ async function maybePushQueuedMessages(presenceId: string): Promise<void> {
createdAt: m.createdAt.toISOString(), createdAt: m.createdAt.toISOString(),
}; };
sendToPeer(presenceId, push); 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( async function handleHello(
ws: WebSocket, ws: WebSocket,
hello: Extract<WSClientMessage, { type: "hello" }>, hello: Extract<WSClientMessage, { type: "hello" }>,
): Promise<string | null> { ): Promise<string | null> {
// Authenticate: member with this pubkey must exist in this mesh and // Capacity check BEFORE touching DB.
// not be revoked. Signature verification is TODO (crypto not wired const existing = connectionsPerMesh.get(hello.meshId) ?? 0;
// yet; client-side libsodium sign_detached is planned). 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); const member = await findMemberByPubkey(hello.meshId, hello.pubkey);
if (!member) { if (!member) {
const err: WSServerMessage = { metrics.connectionsRejected.inc({ reason: "unauthorized" });
type: "error", sendError(ws, "unauthorized", "pubkey not found in mesh");
code: "unauthorized", ws.close(1008, "unauthorized");
message: "pubkey not found in mesh",
};
ws.send(JSON.stringify(err));
return null; return null;
} }
const presenceId = await connectPresence({ const presenceId = await connectPresence({
@@ -202,16 +301,19 @@ async function handleHello(
memberPubkey: hello.pubkey, memberPubkey: hello.pubkey,
cwd: hello.cwd, cwd: hello.cwd,
}); });
log( incMeshCount(hello.meshId);
`hello: mesh=${hello.meshId} member=${member.displayName} presence=${presenceId}`, log.info("ws hello", {
); mesh_id: hello.meshId,
// Drain any messages already queued for this member. member: member.displayName,
presence_id: presenceId,
session_id: hello.sessionId,
});
await maybePushQueuedMessages(presenceId); await maybePushQueuedMessages(presenceId);
return presenceId; return presenceId;
} }
async function handleSend( async function handleSend(
conn: NonNullable<ReturnType<typeof connections.get>>, conn: PeerConn,
msg: Extract<WSClientMessage, { type: "send" }>, msg: Extract<WSClientMessage, { type: "send" }>,
): Promise<void> { ): Promise<void> {
const messageId = await queueMessage({ const messageId = await queueMessage({
@@ -230,17 +332,17 @@ async function handleSend(
}; };
conn.ws.send(JSON.stringify(ack)); conn.ws.send(JSON.stringify(ack));
// Fan-out: push to any currently-connected peer whose pubkey matches // Fan-out over connected peers in the same mesh.
// the target (or to everyone on broadcast). Drain their queue which
// handles priority gating automatically.
for (const [pid, peer] of connections) { for (const [pid, peer] of connections) {
if (peer.meshId !== conn.meshId) continue; 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); void maybePushQueuedMessages(pid);
} }
} }
function handleConnection(ws: WebSocket): void { function handleConnection(ws: WebSocket): void {
metrics.connectionsTotal.inc();
let presenceId: string | null = null; let presenceId: string | null = null;
ws.on("message", async (raw) => { ws.on("message", async (raw) => {
try { try {
@@ -250,12 +352,7 @@ function handleConnection(ws: WebSocket): void {
return; return;
} }
if (!presenceId) { if (!presenceId) {
const err: WSServerMessage = { sendError(ws, "no_hello", "must send hello first");
type: "error",
code: "no_hello",
message: "must send hello first",
};
ws.send(JSON.stringify(err));
return; return;
} }
const conn = connections.get(presenceId); const conn = connections.get(presenceId);
@@ -266,20 +363,32 @@ function handleConnection(ws: WebSocket): void {
break; break;
case "set_status": case "set_status":
await writeStatus(presenceId, msg.status, "manual", new Date()); await writeStatus(presenceId, msg.status, "manual", new Date());
log.info("ws set_status", {
presence_id: presenceId,
status: msg.status,
});
break; break;
} }
} catch (e) { } 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 () => { ws.on("close", async () => {
if (presenceId) { if (presenceId) {
const conn = connections.get(presenceId);
connections.delete(presenceId); connections.delete(presenceId);
if (conn) decMeshCount(conn.meshId);
await disconnectPresence(presenceId); 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", () => { ws.on("pong", () => {
if (presenceId) void heartbeat(presenceId); if (presenceId) void heartbeat(presenceId);
}); });
@@ -288,7 +397,10 @@ function handleConnection(ws: WebSocket): void {
// --- Main --- // --- Main ---
function main(): void { function main(): void {
const wss = new WebSocketServer({ noServer: true }); const wss = new WebSocketServer({
noServer: true,
maxPayload: env.MAX_MESSAGE_BYTES,
});
wss.on("connection", handleConnection); wss.on("connection", handleConnection);
const http = createServer(handleHttpRequest); const http = createServer(handleHttpRequest);
@@ -296,37 +408,66 @@ function main(): void {
handleUpgrade(wss, req, socket, head), handleUpgrade(wss, req, socket, head),
); );
http.on("error", (err) => { http.on("error", (err) => {
log(`http server error: ${err.message}`); log.error("http server error", { error: err.message });
process.exit(1); process.exit(1);
}); });
http.listen(PORT, "0.0.0.0", () => { http.listen(PORT, "0.0.0.0", () => {
log( const info = buildInfo();
`@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`, 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. // WS heartbeat ping every 30s; clients reply with pong → bumps lastPingAt.
setInterval(() => { const pingInterval = setInterval(() => {
for (const { ws } of connections.values()) { for (const { ws } of connections.values()) {
if (ws.readyState === ws.OPEN) ws.ping(); 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(); startSweepers();
startDbHealth();
const shutdown = async (signal: string): Promise<void> => { 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(); await stopSweepers();
for (const { ws } of connections.values()) { for (const { ws } of connections.values()) {
try { try {
ws.close(); ws.close(1001, "shutting down");
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
wss.close(); wss.close();
http.close(); http.close();
log("closed, bye"); log.info("shutdown complete");
process.exit(0); process.exit(0);
}; };

33
apps/broker/src/logger.ts Normal file
View 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
View 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"
);
}

View 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;
}
}

View 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();
}
});
});

View 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");
});
});

View 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),
),
);
}

View 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);
});
});

View 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();
}
});
});

View 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+)?$/);
}
});
});

View 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);
});
});

View 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
View 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"]

View File

@@ -42,7 +42,7 @@ export default defineEnv({
NEXT_PUBLIC_AUTH_PASSKEY: castStringToBool.optional().default(true), NEXT_PUBLIC_AUTH_PASSKEY: castStringToBool.optional().default(true),
NEXT_PUBLIC_AUTH_ANONYMOUS: 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_URL: z.string().url().optional().default("http://localhost:3000"),
NEXT_PUBLIC_DEFAULT_LOCALE: z.string().optional().default("en"), NEXT_PUBLIC_DEFAULT_LOCALE: z.string().optional().default("en"),
NEXT_PUBLIC_THEME_MODE: z NEXT_PUBLIC_THEME_MODE: z

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

View File

@@ -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} />;
}

View File

@@ -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>
</>
);
}

View File

@@ -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} />;
}

View File

@@ -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,
})),
}}
/>
</>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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>
);
}

View File

@@ -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 />
</>
);
}

View File

@@ -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>;
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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>
</>
);
}

View File

@@ -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} />;
}

View File

@@ -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 });
}

View File

@@ -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>
);
}

View File

@@ -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 });
}

View File

@@ -1,42 +1,29 @@
"use client"; import { Hero } from "~/modules/marketing/home/hero";
import { Surfaces } from "~/modules/marketing/home/surfaces";
import { useTranslation } from "@turbostarter/i18n"; import { Pricing } from "~/modules/marketing/home/pricing";
import { buttonVariants } from "@turbostarter/ui-web/button"; import { LaptopToLaptop } from "~/modules/marketing/home/laptop-to-laptop";
import { Icons } from "@turbostarter/ui-web/icons"; import { Features } from "~/modules/marketing/home/features";
import { MeetsYou } from "~/modules/marketing/home/meets-you";
import { pathsConfig } from "~/config/paths"; import { FAQ } from "~/modules/marketing/home/faq";
import { TurboLink } from "~/modules/common/turbo-link"; import { CallToAction } from "~/modules/marketing/home/cta";
import { LatestNewsToaster } from "~/modules/marketing/home/toaster";
const HomePage = () => { const HomePage = () => {
const { t } = useTranslation("common");
return ( return (
<main className="flex min-h-[calc(100vh-4rem)] flex-col items-center justify-center px-4"> <div
<div className="mx-auto max-w-3xl text-center"> className="bg-[var(--cm-bg)] text-[var(--cm-fg)] antialiased"
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl"> style={{ fontFamily: "var(--cm-font-sans)" }}
{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" })}
> >
{t("home.getStarted", { defaultValue: "Get Started" })} <Hero />
<Icons.ArrowRight className="ml-2 size-4" /> <Surfaces />
</TurboLink> <Pricing />
<TurboLink <LaptopToLaptop />
href="https://turbostarter.dev/docs" <Features />
className={buttonVariants({ variant: "outline", size: "lg" })} <MeetsYou />
target="_blank" <FAQ />
> <CallToAction />
{t("home.documentation", { defaultValue: "Documentation" })} <LatestNewsToaster />
</TurboLink>
</div> </div>
</div>
</main>
); );
}; };

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 173 KiB

View File

@@ -1,4 +1,3 @@
import { CollectionType, getContentItems } from "@turbostarter/cms";
import { getPathname, config } from "@turbostarter/i18n"; import { getPathname, config } from "@turbostarter/i18n";
import { appConfig } from "~/config/app"; import { appConfig } from "~/config/app";
@@ -52,29 +51,5 @@ export default function sitemap(): MetadataRoute.Sitemap {
changeFrequency: "monthly", changeFrequency: "monthly",
priority: 0.8, 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,
})),
]; ];
} }

View File

@@ -2,3 +2,102 @@
@import "@turbostarter/ui-web/globals.css"; @import "@turbostarter/ui-web/globals.css";
@source "../../../../../packages/ui"; @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;
}

View File

@@ -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;

View File

@@ -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";

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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,
};
};

View File

@@ -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>
);
};

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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} />
</>
);
};

View File

@@ -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>
),
)}
</>
);
};

View File

@@ -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>
</>
);
};

View File

@@ -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";

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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";

View File

@@ -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;

View File

@@ -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}
/>
);
};

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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>
)}
/>
);
};

View File

@@ -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,
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
};
};

View File

@@ -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>
);
};

View File

@@ -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,
};

View File

@@ -1,11 +1,9 @@
import { handle } from "@turbostarter/api/utils"; // AI credits were backed by the removed @turbostarter/ai package.
// claudemesh does not meter AI credits, so this stubs the query to return null.
import { api } from "~/lib/api/client";
export const queries = { export const queries = {
get: (params: { id: string }) => ({ get: (params: { id: string }) => ({
queryKey: ["credits", params.id], queryKey: ["credits", params.id],
queryFn: () => handle(api.ai.credits.$get)(), queryFn: () => Promise.resolve(null as number | null),
}), }),
}; };

View File

@@ -2,10 +2,6 @@ import NumberFlow from "@number-flow/react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "motion/react"; import { motion } from "motion/react";
import {
getCreditsLevel,
getCreditsProgress,
} from "@turbostarter/ai/credits/utils";
import { useTranslation } from "@turbostarter/i18n"; import { useTranslation } from "@turbostarter/i18n";
import { cn } from "@turbostarter/ui"; import { cn } from "@turbostarter/ui";
@@ -13,6 +9,15 @@ import { authClient } from "~/lib/auth/client";
import { credits } from "./api"; 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 = () => { export const useCredits = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data } = authClient.useSession(); const { data } = authClient.useSession();

View File

@@ -1,17 +1,9 @@
import { handle } from "@turbostarter/api/utils";
import { api } from "~/lib/api/server";
import { getQueryClient } from "~/lib/query/server"; import { getQueryClient } from "~/lib/query/server";
import { credits } from "./api"; import { credits } from "./api";
export const prefetchCredits = async (id: string) => { export const prefetchCredits = async (id: string) => {
const queryClient = getQueryClient(); const queryClient = getQueryClient();
await queryClient.prefetchQuery(credits.queries.get({ id }));
await queryClient.prefetchQuery({
...credits.queries.get({ id }),
queryFn: handle(api.ai.credits.$get),
});
return queryClient; return queryClient;
}; };

View File

@@ -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>
);
};

View File

@@ -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>
)}
/>
);
};

View File

@@ -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>
)}
/>
);
};

View File

@@ -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>
);
};

View File

@@ -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,
};
};

View File

@@ -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";

View File

@@ -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>
);
};

View File

@@ -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,
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
}

Some files were not shown because too many files have changed in this diff Show More