diff --git a/.claude/rules/sim-components.md b/.claude/rules/sim-components.md index 78b8f127a68..183fbaac822 100644 --- a/.claude/rules/sim-components.md +++ b/.claude/rules/sim-components.md @@ -12,3 +12,22 @@ Component authoring rules — structure order (refs → external hooks → store - `'use client'` only when using React hooks or browser-only APIs. - Prefer semantic HTML (`aside`, `nav`, `article`). - Optional-chain callbacks: `onAction?.(id)`. + +## List-render performance + +When rendering or sorting a list of rows against a lookup collection (members, folders, tags), keep the per-row work O(1): + +- **Precompute a lookup `Map` once**, never `array.find(...)` per row. Build `const byId = useMemo(() => { const m = new Map(); for (const x of items ?? []) m.set(x.id, x); return m }, [items])` and read `byId.get(id)` in the sort comparator, `.map(...)`, and cell builders. A `.find` inside a sort comparator is O(n²·log n) — the worst offender. Depend memos on the derived `Map`, not the raw array. +- **Sort with `[...array].sort(cmp)` in client code — NOT `array.toSorted(cmp)`.** SWC (Next's compiler) transforms syntax but does not polyfill prototype methods, and the repo sets no core-js/browserslist target, so `toSorted`/`toReversed`/`toSpliced`/`Array.prototype.with` ship as-is and throw `TypeError` on Safari <16 / iOS 15 (they landed in Safari 16). `tsconfig` `lib: ES2023` only affects type-checking, never the runtime. The `[...arr].sort()` spread copy is the intended cost — it keeps the sort non-mutating without an unpolyfilled builtin. These methods are only safe in server-only modules (no `'use client'`, executed on Node ≥20). react-doctor's `js-tosorted-immutable` is therefore a won't-fix in client components. +- **Partition in a single pass** — when splitting one collection into several (`fileIds`/`folderIds`), do one `for…of` pushing into each bucket and return `{ a, b }` from a single `useMemo`, not two memos that each `map→filter→map` the same source twice. + +## react-doctor (`npx react-doctor`) — apply the wins, skip the false positives + +react-doctor diagnostics are hypotheses, not verdicts — confirm against the code before acting, and preserve behavior. Known repo-specific false positives to NOT "fix": + +- `no-barrel-import` — barrel imports are the repo convention (see sim-imports.md, "Barrel Exports"). Keep them. +- `js-tosorted-immutable` — in `'use client'` code, keep `[...arr].sort(cmp)`; `toSorted` is unpolyfilled and crashes Safari <16 / iOS 15 (see "List-render performance" above). Only apply it in server-only modules. +- `rerender-state-only-in-handlers` / "state set but never rendered" — a false positive when the `useState` is consumed by a `useEffect`/`useLayoutEffect` dependency (the effect must re-run on change). Only convert to a ref when nothing reads the value reactively. +- `no-render-in-render` — a helper *called inline* (`{renderRow()}`) is reconciled by position and does **not** remount, so extracting it to a component is usually pure churn and can regress behavior (prop-drilling many closures, focus/scroll loss on the inner ``). Apply it only when the helper is genuinely a *component defined during render*, or when the move is mechanical (a stateless, ref-free helper whose closures become a small, explicit prop set). +- `async-await-in-loop` on an upload/progress loop where sequential execution is intentional (per-item progress, server backpressure) — leave it. +- Broad refactors (`prefer-useReducer` for many `useState`, `no-giant-component` splits) — out of scope for a perf pass; note, don't churn. diff --git a/.claude/rules/sim-hooks.md b/.claude/rules/sim-hooks.md index 6f600dfd946..36b96827c82 100644 --- a/.claude/rules/sim-hooks.md +++ b/.claude/rules/sim-hooks.md @@ -51,16 +51,24 @@ export function useFeature({ id, onSelect }: UseFeatureProps) { ## State shape -Never mirror a prop into state with `useState(prop)` + a syncing `useEffect` — a prop change clobbers in-progress local edits. Use the prop directly, reset via a remount `key`, or — when you must seed local state from a prop only on a transition (e.g. a modal opening) — reset during render with the `prevX` ref idiom: +Never mirror a prop into state with `useState(prop)` + a syncing `useEffect` — a prop change clobbers in-progress local edits. Use the prop directly, reset via a remount `key`, or — when you must seed local state from a prop only on a transition (e.g. a modal opening) — adjust it **during render** with a `prev`-value tracker held in `useState`: ```typescript -const prevOpenRef = useRef(open) -if (prevOpenRef.current !== open) { - prevOpenRef.current = open +const [prevOpen, setPrevOpen] = useState(open) +if (prevOpen !== open) { + setPrevOpen(open) if (open) setName(initialName) // closed → open only } ``` +React re-renders immediately with the corrected state without committing the stale value. Rules: the `if (prev !== current)` guard is mandatory (an unconditional `setState` in render loops forever), the tracker is set **inside** the guard, and you may only set the currently-rendering component's state this way. Hold the tracker in `useState`, **not a `useRef`** — React forbids reading/writing `ref.current` during render (react.dev, useRef → "Do not write _or read_ `ref.current` during rendering"; the `react-hooks` `refs` lint flags it), and a `useState` tracker is concurrent-safe where a mutated ref is not (a discarded render rolls state back, not a ref). + +**The tracker's initial value decides mount behavior — choose it deliberately.** The example seeds `useState(open)` because the modal mounts closed, so the first render's guard is `false` and nothing resets on mount (correct — `name` is already at its initial value). When the effect you're replacing did real work **on mount** — opening a panel because a prop already matches, seeding editable state from an already-present value, or a component that can mount in the active state — seed a **sentinel** the live value can't equal (e.g. `useState(null)`), otherwise the guard is `false` on the first render and that mount action is silently dropped. Place the block before any early `return`. + +> Some existing components use a `useRef` prev-tracker (`if (prevRef.current !== x) { prevRef.current = x; … }`). It works but reads/writes a ref during render — prefer the `useState` form above for new code. + +Only convert a `useState` to a `useRef` when the value is **never read during render/JSX and is never a hook dependency** — a value in a `useEffect`/`useMemo`/`useCallback` dep array must re-run the hook on change, so it stays state (see also `rerender-state-only-in-handlers` in `sim-components.md`). Convert only set-only values read solely inside handlers or effect bodies (e.g. a prompt-history index, a pending-upload URL). If a ref feeds render, mutating it won't re-render and the UI goes stale. + Model mutually-exclusive flags as ONE `status` enum, not several contradictory booleans. `isLoading`/`isVerified`/`isInvalidOtp` describing one machine collapse to `status: 'idle' | 'verifying' | 'verified' | 'error'` (+ `errorMessage`); derive any boolean a consumer still needs (`status === 'error'`). Derive busy/success from the mutation object — never duplicate `mutation.isPending`/`mutation.isSuccess` into local `useState`. Read them directly (`mutation.isSuccess`) and reset with `mutation.reset()`. A distinct phase the mutation doesn't cover — e.g. a pre-submit captcha/Turnstile gate that runs before `mutate()` — is not a duplicate; keep that flag. diff --git a/.claude/rules/sim-imports.md b/.claude/rules/sim-imports.md index 74bf556db4d..0449d96e11e 100644 --- a/.claude/rules/sim-imports.md +++ b/.claude/rules/sim-imports.md @@ -31,6 +31,20 @@ import { Dashboard, Sidebar } from '@/app/workspace/[workspaceId]/logs/component import { Dashboard } from '@/app/workspace/[workspaceId]/logs/components/dashboard/dashboard' ``` +## Code-splitting through barrels + +When you `lazy(() => import(...))` a component to keep it out of a route's initial bundle, import the **deep module path** (`./components/foo/foo`), never the barrel — and **delete the now-dead barrel re-export** of that component. This app has no `"sideEffects": false` in `apps/sim/package.json`, so when any sibling still imports that barrel, webpack can conservatively keep the barrel's re-export edge to the heavy module. A leftover `export { Foo } from './foo'` line can therefore drag `Foo` (and its transitive deps) back into the initial chunk and silently defeat the split. Removing the dead re-export is the guaranteed fix; verify with a production bundle diff, not by eyeballing the `lazy()` call. + +```typescript +// ✓ Good — deep lazy import + no barrel edge left behind +const MothershipView = lazy(() => + import('./components/mothership-view/mothership-view').then((m) => ({ default: m.MothershipView })) +) +// (and remove `export { MothershipView } from './mothership-view'` from components/index.ts) +``` + +Wrap the lazy component in a **local ``** so its suspension resolves at the nearest boundary instead of bubbling to the page-level fallback (which would flash the whole route). `React.lazy(memo(forwardRef(...)))` forwards a DOM `ref` correctly in React 19 — but during the fallback window `ref.current` is `null`, so every consumer must guard it (`if (!el) return` / `el?.`). + ## No Re-exports Do not re-export from non-barrel files. Import directly from the source. diff --git a/.claude/rules/sim-react-performance.md b/.claude/rules/sim-react-performance.md new file mode 100644 index 00000000000..e64c8544ee0 --- /dev/null +++ b/.claude/rules/sim-react-performance.md @@ -0,0 +1,95 @@ +# React & Render Performance + +Behavior-preserving performance idioms for components, hooks, and hot render paths. These are safe defaults — apply them freely. For the render-causing *effect/state* anti-patterns (derived state in effects, effect chains, state synced to a prop), use the dedicated skills: `/you-might-not-need-an-effect`, `/you-might-not-need-state`, `/you-might-not-need-a-memo`, `/you-might-not-need-a-callback`. Those refactors change render timing — verify them against the running UI, never mass-apply blind. + +## Lazy-init refs that hold objects + +`useRef(new Map())` / `useRef(new Set())` / `useRef({...})` allocates a fresh object on **every render** and throws it away — only the first is ever kept. Lazy-init instead so the allocation happens once. + +```typescript +// ✗ Bad — allocates a new Map each render, discards all but the first +const cacheRef = useRef>(new Map()) + +// ✓ Good — allocated once, stable identity thereafter +const cacheRef = useRef | null>(null) +cacheRef.current ??= new Map() +``` + +Read `cacheRef.current` directly inside effects/handlers — refs are stable and never belong in a dependency array. A cheap primitive (`useRef(0)`, `useRef('')`, `useRef(null)`) needs no lazy init. + +## Hoist static values and closure-free functions to module scope + +A value or function declared inside a component is rebuilt every render. If it captures **nothing** from component scope (no props/state/refs), move it above the component at module scope. This skips the per-render allocation and keeps a stable identity so memoized children don't re-render. + +```typescript +// ✗ Bad — rebuilt every render, new identity each time +function Toolbar({ mode }: ToolbarProps) { + const TITLES = { create: 'Add', edit: 'Configure' } as const + const handleWheel = (e: React.WheelEvent) => e.currentTarget.scrollBy(e.deltaX, e.deltaY) + // ... +} + +// ✓ Good — allocated once at module load +const TITLES = { create: 'Add', edit: 'Configure' } as const +function handleWheel(e: React.WheelEvent) { + e.currentTarget.scrollBy(e.deltaX, e.deltaY) +} +function Toolbar({ mode }: ToolbarProps) { /* ... */ } +``` + +A closure-free function that IS wired through a ref sink or intentionally kept for stable identity may stay inline — hoisting a one-line `preventDefault` handler is churn, not a win. Hoist when it removes a real per-render allocation or unblocks child memoization. + +## Pre-index with Map/Set for repeated lookups + +`array.find()` / `array.includes()` / `array.indexOf()` scan the whole list each call. Inside a loop or a hot render path over a non-trivial list, that is O(n·m). Build a `Map` (for lookup-by-key) or `Set` (for membership) **once before** the loop, then look up in O(1). + +```typescript +// ✗ Bad — find() re-scans outputs for every column +for (const child of columns) { + const output = group.outputs.find((o) => o.columnName === getColumnId(child)) +} + +// ✓ Good — index once, then O(1) lookups +const outputByName = new Map() +for (const o of group.outputs) { + if (!outputByName.has(o.columnName)) outputByName.set(o.columnName, o) // first wins, matches find() +} +for (const child of columns) { + const output = outputByName.get(getColumnId(child)) +} +``` + +Preserve `.find()`'s **first-match** semantics when duplicate keys are possible: `new Map(arr.map(...))` keeps the *last* entry, so guard with `if (!map.has(key))` when replacing a `.find()`. Skip this for tiny, cold arrays (a handful of items in an event handler) where the Map build costs more than it saves. + +## Never mutate a shared array in place + +The real bug to avoid is `array.sort()` / `array.reverse()` on an array you don't own — sorting a React Query cache array in place corrupts shared state. Always sort a copy: + +```typescript +// ✗ Bad — mutates the (possibly shared) source array in place +return items.sort(compare) + +// ✓ Good — sorts a throwaway copy, source untouched +return [...items].sort(compare) +``` + +**Do NOT reach for `toSorted()` / `toReversed()` / `with()` / `toSpliced()` on client render paths.** They are ES2023 *runtime* methods — and a tsconfig `"lib": ["ES2023"]` only makes them **type-check**, it does not make them **run**. Next/SWC compiles syntax but does **not** polyfill prototype methods, and the default browserslist still includes browsers without them (`toSorted` landed in Safari 16 / iOS 16, so any device capped at iOS 15 throws `TypeError: x.toSorted is not a function` and crashes the page). The perf difference vs `[...arr].sort()` is negligible (both allocate one array), so the copy-then-sort form is the correct default everywhere client code runs. Only consider the immutable methods in Node-only code (server routes, scripts) on Node ≥20, where the runtime is known. + +## Run independent awaits in parallel + +Sequential `await`s that don't consume each other's result serialize latency for nothing — in an async Server Component or a route handler this directly delays the response. Kick them off together with `Promise.all` and destructure. + +```typescript +// ✗ Bad — waits for params, then separately waits for searchParams +const { id } = await params +const { kbName } = await searchParams + +// ✓ Good — one combined wait +const [{ id }, { kbName }] = await Promise.all([params, searchParams]) +``` + +Only keep awaits sequential when a later call genuinely uses an earlier result, or when the ordering is deliberate (rate-limited batches, retry loops, write-then-read). + +## Local feature barrels are the convention — do not "fix" them + +Tooling (e.g. react-doctor's `no-barrel-import`) will flag imports from local `index.ts` barrels as a bundle cost. In this repo that is a **false positive**: barrel imports for 3+ export folders are mandated by `.claude/rules/sim-imports.md`. Leave them. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c62cd2f2d1e..25d8348d8c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,7 +92,7 @@ jobs: ecr_repo_secret: ECR_PII steps: - name: Checkout code - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6 @@ -130,6 +130,49 @@ jobs: provenance: false sbom: false + # Dev: deploy Trigger.dev background tasks to the preview "dev-sim" branch. + # Gated after migrate-dev for the same reason as build-dev — the new task + # code runs against the dev DB, so the schema must be pushed first. + deploy-trigger-dev: + name: Deploy Trigger.dev (Dev) + needs: [migrate-dev] + if: github.event_name == 'push' && github.ref == 'refs/heads/dev' + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.13 + + - name: Cache Bun dependencies + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 + with: + path: | + ~/.bun/install/cache + node_modules + **/node_modules + key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Deploy to Trigger.dev + working-directory: ./apps/sim + env: + TRIGGER_ACCESS_TOKEN: ${{ secrets.DEV_TRIGGER_ACCESS_TOKEN }} + TRIGGER_PROJECT_ID: ${{ secrets.TRIGGER_PROJECT_ID }} + run: | + if [ -z "$TRIGGER_ACCESS_TOKEN" ] || [ -z "$TRIGGER_PROJECT_ID" ]; then + echo "ERROR: DEV_TRIGGER_ACCESS_TOKEN and TRIGGER_PROJECT_ID repo secrets must both be set" >&2 + exit 1 + fi + bunx trigger.dev@4.4.3 deploy --env preview --branch dev-sim + # Main/staging: build AMD64 images and push to ECR + GHCR build-amd64: name: Build AMD64 @@ -359,7 +402,7 @@ jobs: steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 with: - fetch-depth: 2 # Need at least 2 commits to detect changes + fetch-depth: 2 # Need at least 2 commits to detect changes - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4 id: filter with: diff --git a/.github/workflows/migrations.yml b/.github/workflows/migrations.yml index f789ec32627..7965eaf5e6d 100644 --- a/.github/workflows/migrations.yml +++ b/.github/workflows/migrations.yml @@ -69,7 +69,16 @@ jobs: if [ "${ENVIRONMENT}" = "dev" ]; then echo "Dev environment — pushing schema directly (db:push)" - bun run db:push --force + # drizzle-kit push needs a TTY to resolve ambiguous renames (--force only + # covers data-loss). In CI it throws "Interactive prompts require a TTY + # terminal" but still exits 0, so the job goes green without applying the + # change. tee keeps the output live in the log; we then fail on drizzle's + # own TTY error. A genuine non-zero exit already fails via `set -e`. + bun run db:push --force < /dev/null 2>&1 | tee /tmp/db-push.log + if grep -q "Interactive prompts require a TTY terminal" /tmp/db-push.log; then + echo "ERROR: db:push needs an interactive rename decision; land it as a versioned migration instead of relying on push." >&2 + exit 1 + fi else echo "Applying versioned migrations (db:migrate)" bun run ./scripts/migrate.ts diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 29c1058b0b0..53a18cf16fd 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -128,6 +128,9 @@ jobs: - name: Bare-icon theme-safety audit run: bun run check:bare-icons + - name: Icon SVG path validity audit + run: bun run check:icon-paths + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/CLAUDE.md b/CLAUDE.md index 704a4446179..df1f7fb9826 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -127,6 +127,8 @@ export function Component({ requiredProp, optionalProp = false }: ComponentProps Extract when: 50+ lines, used in 2+ files, or has own state/logic. Keep inline when: < 10 lines, single use, purely presentational. +Behavior-preserving render-performance idioms — lazy-init object refs, hoist closure-free values/functions to module scope, pre-index repeated lookups with `Map`/`Set`, and never mutating a shared array in place — are in `.claude/rules/sim-react-performance.md` (which also explains why `toSorted`/`toReversed` are unsafe on client render paths despite the ES2023 tsconfig lib — SWC does not polyfill prototype methods, so use `[...arr].sort()`). For the render-timing effect/state anti-patterns use the `/you-might-not-need-*` skills and verify against the running UI. + ## API Contracts Boundary HTTP request and response shapes for all routes under `apps/sim/app/api/**` live in `apps/sim/lib/api/contracts/**` (one file per resource family — `folders.ts`, `chats.ts`, `knowledge.ts`, etc.). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract. diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 165a6db7556..58d2124d7b3 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -1142,7 +1142,7 @@ export function NotionIcon(props: SVGProps) { ) @@ -1357,7 +1357,7 @@ export function GoogleIcon(props: SVGProps) { /> ) @@ -1980,7 +1980,7 @@ export function SquareIcon(props: SVGProps) { return ( @@ -2086,7 +2086,7 @@ export function ContextDevIcon(props: SVGProps) { return ( @@ -2328,7 +2328,7 @@ export function BrexIcon(props: SVGProps) { return ( @@ -2584,7 +2584,7 @@ export function LinkupIcon(props: SVGProps) { ) @@ -2665,7 +2665,7 @@ export function LangsmithIcon(props: SVGProps) { export function LatexIcon(props: SVGProps) { return ( - + ) { ) @@ -3107,7 +3107,7 @@ export function OutlookIcon(props: SVGProps) { /> ) => ( xmlns='http://www.w3.org/2000/svg' > Groq - + ) @@ -3444,7 +3444,7 @@ export const DeepseekIcon = (props: SVGProps) => ( DeepSeek @@ -3545,7 +3545,7 @@ export const CerebrasIcon = (props: SVGProps) => ( Cerebras @@ -4175,7 +4175,7 @@ export function PostgresIcon(props: SVGProps) { xmlns='http://www.w3.org/2000/svg' preserveAspectRatio='xMinYMin meet' > - + ) { width='1em' xmlns='http://www.w3.org/2000/svg' > - + ) } @@ -4311,7 +4311,7 @@ export function MongoDBIcon(props: SVGProps) { fillRule='evenodd' clipRule='evenodd' fill='currentColor' - d='M88.04 42.81c1.61 4.64 2.76 9.38 3.14 14.3.472 6.1.256 12.15-1.03 18.14-.35.17-.109.32-.164.48-.403-.814-.049-1.21.012-3.33.523-6.65 1.07-9.98 1.6-3.44.557-6.88 1.09-10.31 1.69-1.22.21-2.72-.041-3.21 1.64-.14.05-.154.05-.235.08l.166-10.05-.169-24.25 1.6-.275c2.62-.429 5.24-.864 7.86-1.28 3.13-.497 6.26-.98 9.39-1.46 1.38-.215 2.76-.412 4.15-.618z' + d='M88.038 42.812c1.605 4.643 2.761 9.383 3.141 14.296.472 6.095.256 12.147-1.029 18.142-.035.165-.109.32-.164.48-.403.001-.814-.049-1.208.012-3.329.523-6.655 1.065-9.981 1.604-3.438.557-6.881 1.092-10.313 1.687-1.216.21-2.721-.041-3.212 1.641-.014.046-.154.054-.235.08l.166-10.051-.169-24.252 1.602-.275c2.62-.429 5.24-.864 7.862-1.281 3.129-.497 6.261-.98 9.392-1.465 1.381-.215 2.764-.412 4.148-.618z' /> ) { fillRule='evenodd' clipRule='evenodd' fill='#409433' - d='M65.04 80.75c.081-.26.22-.34.24-.8.49-1.68 2-1.43 3.21-1.64 3.43-.594 6.88-1.13 10.31-1.69 3.33-.539 6.65-1.08 9.98-1.6.394-.62.81-.011 1.21-.012-.622 2.22-1.11 4.49-1.9 6.65-.896 2.45-1.98 4.84-3.13 7.18a49.14 49.14 0 01-6.35 9.76c-1.92 2.31-4.06 4.44-6.2 6.55-1.18 1.17-2.58 2.11-3.88 3.16l-.337-.23-1.21-1.04-1.26-2.75a41.4 41.4 0 01-1.39-9.84l.023-.561.17-2.43c.057-.828.13-1.65.168-2.48.129-2.98.241-5.96.359-8.95z' + d='M65.036 80.753c.081-.026.222-.034.235-.08.491-1.682 1.996-1.431 3.212-1.641 3.432-.594 6.875-1.13 10.313-1.687 3.326-.539 6.652-1.081 9.981-1.604.394-.062.805-.011 1.208-.012-.622 2.22-1.112 4.488-1.901 6.647-.896 2.449-1.98 4.839-3.131 7.182a49.142 49.142 0 01-6.353 9.763c-1.919 2.308-4.058 4.441-6.202 6.548-1.185 1.165-2.582 2.114-3.882 3.161l-.337-.23-1.214-1.038-1.256-2.753a41.402 41.402 0 01-1.394-9.838l.023-.561.171-2.426c.057-.828.133-1.655.168-2.485.129-2.982.241-5.964.359-8.946z' /> ) { fillRule='evenodd' clipRule='evenodd' fill='#A9AA88' - d='M62.6 107.09c.263-1.31.609-2.62.77-3.95.325-2.65.548-5.31.814-7.97l.066-.1.07.011a41.4 41.4 0 1.39 9.84c-.176.23-.425.44-.518.7-.727 2.05-1.41 4.12-2.14 6.17-.1.28-.378.5-.574.74l-.747-2.57.87-2.97z' + d='M62.598 107.085c.263-1.315.609-2.62.772-3.947.325-2.649.548-5.312.814-7.968l.066-.01.066.011a41.402 41.402 0 001.394 9.838c-.176.232-.425.439-.518.701-.727 2.05-1.412 4.116-2.143 6.166-.1.28-.378.498-.574.744l-.747-2.566.87-2.969z' /> ) { ) @@ -5119,7 +5119,7 @@ export function IntercomIcon(props: SVGProps) { @@ -5160,7 +5160,7 @@ export function MailchimpIcon(props: SVGProps) { y='0px' viewBox='0 0 230.81 244.96' xmlSpace='preserve' - fill='#000000' + fill='currentColor' > @@ -5957,7 +5957,7 @@ export function TemporalIcon(props: SVGProps) { ) @@ -5989,31 +5989,31 @@ export function DatadogIcon(props: SVGProps) { export function DaytonaIcon(props: SVGProps) { return ( - - + + ) { width='20.6556' height='8.54718' transform='rotate(90 22.1582 12.9094)' - fill='#000000' + fill='currentColor' /> ) { width='25.6415' height='8.54718' transform='rotate(90 52.0732 42.825)' - fill='#000000' + fill='currentColor' /> ) @@ -6213,7 +6213,7 @@ export function KetchIcon(props: SVGProps) { diff --git a/apps/pii/server.py b/apps/pii/server.py index 3fbd9859e45..4029bac1019 100644 --- a/apps/pii/server.py +++ b/apps/pii/server.py @@ -10,7 +10,13 @@ from typing import Any from fastapi import FastAPI -from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer, RecognizerResult +from presidio_analyzer import ( + AnalyzerEngine, + BatchAnalyzerEngine, + Pattern, + PatternRecognizer, + RecognizerResult, +) from presidio_analyzer.nlp_engine import NlpEngineProvider from presidio_analyzer.predefined_recognizers import ( AuAbnRecognizer, @@ -133,6 +139,7 @@ def build_analyzer() -> AnalyzerEngine: analyzer = build_analyzer() +batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer) anonymizer = AnonymizerEngine() # Propagates to uvicorn's root handler, so timing lands in the container log stream. @@ -149,6 +156,13 @@ class AnalyzeRequest(BaseModel): return_decision_process: bool = False +class AnalyzeBatchRequest(BaseModel): + texts: list[str] + language: str = "en" + entities: list[str] | None = None + score_threshold: float | None = None + + class AnonymizeRequest(BaseModel): text: str analyzer_results: list[dict[str, Any]] = [] @@ -156,6 +170,51 @@ class AnonymizeRequest(BaseModel): operators: dict[str, dict[str, Any]] | None = None +class AnonymizeBatchItem(BaseModel): + text: str + analyzer_results: list[dict[str, Any]] = [] + + +class AnonymizeBatchRequest(BaseModel): + items: list[AnonymizeBatchItem] = [] + anonymizers: dict[str, dict[str, Any]] | None = None + operators: dict[str, dict[str, Any]] | None = None + + +def build_operators( + raw_operators: dict[str, dict[str, Any]] | None, +) -> dict[str, OperatorConfig] | None: + if not raw_operators: + return None + operators: dict[str, OperatorConfig] = {} + for entity, raw_cfg in raw_operators.items(): + op_cfg = dict(raw_cfg) + op_type = op_cfg.pop("type", "replace") + operators[entity] = OperatorConfig(op_type, op_cfg) + return operators + + +def run_anonymize( + text: str, + raw_results: list[dict[str, Any]], + operators: dict[str, OperatorConfig] | None, +): + analyzer_results = [ + RecognizerResult( + entity_type=r["entity_type"], + start=r["start"], + end=r["end"], + score=r.get("score", 1.0), + ) + for r in raw_results + ] + return anonymizer.anonymize( + text=text, + analyzer_results=analyzer_results, + operators=operators, + ) + + @app.get("/health") def health() -> dict[str, str]: return {"status": "ok"} @@ -186,35 +245,28 @@ def analyze(req: AnalyzeRequest) -> list[dict[str, Any]]: return [r.to_dict() for r in results] +@app.post("/analyze_batch") +def analyze_batch(req: AnalyzeBatchRequest) -> list[list[dict[str, Any]]]: + """Analyze many texts in one pass (spaCy nlp.pipe), returning one span list + per input in request order — the batched counterpart to /analyze.""" + results = batch_analyzer.analyze_iterator( + texts=req.texts, + language=req.language, + entities=req.entities or None, + score_threshold=req.score_threshold, + ) + return [[r.to_dict() for r in per_text] for per_text in results] + + @app.post("/anonymize") def anonymize(req: AnonymizeRequest) -> dict[str, Any]: started = time.perf_counter() - analyzer_results = [ - RecognizerResult( - entity_type=r["entity_type"], - start=r["start"], - end=r["end"], - score=r.get("score", 1.0), - ) - for r in req.analyzer_results - ] - raw_operators = req.anonymizers or req.operators - operators = None - if raw_operators: - operators = {} - for entity, raw_cfg in raw_operators.items(): - op_cfg = dict(raw_cfg) - op_type = op_cfg.pop("type", "replace") - operators[entity] = OperatorConfig(op_type, op_cfg) - result = anonymizer.anonymize( - text=req.text, - analyzer_results=analyzer_results, - operators=operators, - ) + operators = build_operators(req.anonymizers or req.operators) + result = run_anonymize(req.text, req.analyzer_results, operators) logger.info( "anonymize chars=%d spans=%d duration_ms=%.1f", len(req.text), - len(analyzer_results), + len(req.analyzer_results), (time.perf_counter() - started) * 1000, ) return { @@ -230,3 +282,17 @@ def anonymize(req: AnonymizeRequest) -> dict[str, Any]: for item in result.items ], } + + +@app.post("/anonymize_batch") +def anonymize_batch(req: AnonymizeBatchRequest) -> dict[str, list[str]]: + """Mask many texts in one pass, returning masked text per item in request + order — the batched counterpart to /anonymize. Anonymization is pure string + work (no NLP), so callers should send only items with detected spans.""" + operators = build_operators(req.anonymizers or req.operators) + return { + "texts": [ + run_anonymize(item.text, item.analyzer_results, operators).text + for item in req.items + ] + } diff --git a/apps/sim/.env.example b/apps/sim/.env.example index d26ff64e52f..4083be6cdb0 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -12,6 +12,9 @@ BETTER_AUTH_URL=http://localhost:3000 # Authentication Bypass (Optional - for self-hosted deployments behind private networks) # DISABLE_AUTH=true # Uncomment to bypass authentication entirely. Creates an anonymous session for all requests. +# Private Database Hosts (Optional - for self-hosted deployments only) +# ALLOW_PRIVATE_DATABASE_HOSTS=true # Uncomment to let database/connector tools reach private/reserved/loopback hosts (e.g. Docker/K8s service names, localhost). Loosens the SSRF boundary; only enable on a trusted private network. + # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL diff --git a/apps/sim/app/(auth)/components/auth-background-svg.tsx b/apps/sim/app/(auth)/components/auth-background-svg.tsx deleted file mode 100644 index aebbe483e15..00000000000 --- a/apps/sim/app/(auth)/components/auth-background-svg.tsx +++ /dev/null @@ -1,93 +0,0 @@ -export default function AuthBackgroundSVG() { - return ( - <> - {/* Top-left card outline */} - - - {/* Top-right card outline */} - - - {/* Bottom-left card outline (mirrored) */} - - - {/* Bottom-right card outline (mirrored) */} - - - ) -} diff --git a/apps/sim/app/(auth)/components/auth-background.tsx b/apps/sim/app/(auth)/components/auth-background.tsx deleted file mode 100644 index fedca77f536..00000000000 --- a/apps/sim/app/(auth)/components/auth-background.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { cn } from '@sim/emcn' -import AuthBackgroundSVG from '@/app/(auth)/components/auth-background-svg' - -type AuthBackgroundProps = { - className?: string - children?: React.ReactNode -} - -export default function AuthBackground({ className, children }: AuthBackgroundProps) { - return ( -
-
- -
{children}
-
- ) -} diff --git a/apps/sim/app/(auth)/components/auth-button-classes.ts b/apps/sim/app/(auth)/components/auth-button-classes.ts index 95a3592fe96..6b392632de6 100644 --- a/apps/sim/app/(auth)/components/auth-button-classes.ts +++ b/apps/sim/app/(auth)/components/auth-button-classes.ts @@ -1,10 +1,3 @@ -/** Shared className for primary auth/status CTA buttons on dark auth surfaces. */ -export const AUTH_PRIMARY_CTA_BASE = - 'inline-flex h-[32px] items-center justify-center gap-2 rounded-[5px] border border-[var(--auth-primary-btn-border)] bg-[var(--auth-primary-btn-bg)] px-2.5 font-[430] font-season text-[var(--auth-primary-btn-text)] text-sm transition-colors hover:border-[var(--auth-primary-btn-hover-border)] hover:bg-[var(--auth-primary-btn-hover-bg)] hover:text-[var(--auth-primary-btn-hover-text)] disabled:cursor-not-allowed disabled:opacity-50' as const - -/** Full-width variant used for primary auth form submit buttons. */ -export const AUTH_SUBMIT_BTN = `${AUTH_PRIMARY_CTA_BASE} w-full` as const - -/** Shared className for inline auth action links on dark auth surfaces. */ +/** Shared className for inline auth action links. */ export const AUTH_TEXT_LINK = 'font-medium text-[var(--brand-accent)] underline-offset-4 transition hover:text-[var(--brand-accent-hover)] hover:underline disabled:cursor-not-allowed disabled:opacity-50' as const diff --git a/apps/sim/app/(auth)/components/auth-submit-button.tsx b/apps/sim/app/(auth)/components/auth-submit-button.tsx index c08cb3249a9..ae9004b8188 100644 --- a/apps/sim/app/(auth)/components/auth-submit-button.tsx +++ b/apps/sim/app/(auth)/components/auth-submit-button.tsx @@ -14,9 +14,8 @@ interface AuthSubmitButtonProps { /** * The canonical full-width primary auth action — a `primary`-variant {@link Chip} - * with the shared in-flight spinner. Replaces the legacy dark - * `AUTH_SUBMIT_BTN` class string for every in-scope auth submit (login, signup, - * verify, reset), so the primary CTA chrome lives in exactly one place. + * with the shared in-flight spinner, so the primary CTA chrome lives in + * exactly one place. */ export function AuthSubmitButton({ children, diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 6d4a9435a63..49f71d79272 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -426,7 +426,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { } return ( -
+
{/* Header component */} diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx index 9b032730a1c..8f55e4c5552 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/loading.tsx @@ -2,7 +2,7 @@ import { Skeleton } from '@sim/emcn' export default function ChatLoading() { return ( -
+
diff --git a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx index 23cbd1789e9..8d14e3a1ee9 100644 --- a/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx +++ b/apps/sim/app/(interfaces)/chat/components/error-state/error-state.tsx @@ -1,5 +1,6 @@ 'use client' +import { Button } from '@sim/emcn' import { useRouter } from 'next/navigation' interface ChatErrorStateProps { @@ -16,12 +17,13 @@ export function ChatErrorState({ error }: ChatErrorStateProps) { Chat Unavailable

{error}

- +
) diff --git a/apps/sim/app/(interfaces)/chat/components/header/header.tsx b/apps/sim/app/(interfaces)/chat/components/header/header.tsx index cbe6e74c2f3..014c103ca6b 100644 --- a/apps/sim/app/(interfaces)/chat/components/header/header.tsx +++ b/apps/sim/app/(interfaces)/chat/components/header/header.tsx @@ -3,6 +3,7 @@ import Image from 'next/image' import Link from 'next/link' import { GithubIcon } from '@/components/icons' +import { SimWordmark } from '@/app/(landing)/components/navbar/components' import { useBrandConfig } from '@/ee/whitelabeling' interface ChatHeaderProps { @@ -20,13 +21,12 @@ interface ChatHeaderProps { export function ChatHeader({ chatConfig, starCount }: ChatHeaderProps) { const brand = useBrandConfig() - const primaryColor = chatConfig?.customizations?.primaryColor || 'var(--brand)' const customImage = chatConfig?.customizations?.imageUrl || chatConfig?.customizations?.logoUrl return (