diff --git a/.claude/rules/emcn-components.md b/.claude/rules/emcn-components.md index c5548d327df..a1ab8b5b93a 100644 --- a/.claude/rules/emcn-components.md +++ b/.claude/rules/emcn-components.md @@ -33,6 +33,16 @@ The menu surface intentionally diverges from the pill: `dropdown-menu.tsx` items - **`ChipTimePicker`** — minute-granular time sibling of `ChipDatePicker`, a `ChipInput` that leniently parses typed input (`9:47`, `947`, `2:05pm`, `14:30`), commits on Enter/blur, and re-renders the canonical `9:47 AM` label. - **`DropdownMenu`** — the canonical context/action menu (Radix-backed). Not a chip, but the standard menu for command/action lists; reach for it instead of a hand-rolled popover. Its surface intentionally diverges from the chip pill (`text-small`, `gap-2`) — keep them distinct. For a pill that opens a value picker, use `ChipDropdown`/`ChipSelect` instead. +## Modal keyboard defaults + +Declare keyboard intent on the action-owning primitive; never add document-level or per-callsite Enter listeners. + +- `ChipModalFooter` defaults to `defaultAction='primary'`. A plain Enter in a canonical single-line field or a custom plain input invokes the enabled primary action. Use `'none'` when submission must require an explicit click, such as an irreversible destructive action or an editor whose nested control owns Enter. Use `'dismiss'` only when dismissal is genuinely the modal's default decision. +- `ChipConfirmModal` fails safe with `defaultAction='dismiss'`. Opt into `'confirm'` only for an audited, low-impact reversible or non-destructive decision. Deleting an aggregate resource such as a workflow, table, knowledge base, or folder remains `'dismiss'` even when it can be restored, because the action takes a broad dependent graph offline. Use `'none'` for typed confirmations and severe account, ownership, or access changes. Button color never determines keyboard behavior. +- Textareas, native forms, buttons, links, comboboxes, menus, listboxes, tag/email inputs, IME composition, modified Enter, and disabled or pending actions retain their native behavior. A native form remains the sole submission path so browser validation is not bypassed. +- A custom field containing a search, token editor, or another input that owns Enter must set `submitOnEnter={false}` on `ChipModalField`. Do not attach a duplicate `onKeyDown` handler merely to call the footer action. +- Initial focus goes to the first visible editable text control. With no text control, the declared real button receives focus; `'none'` focuses the dialog surface. A safe dismiss default never turns Enter in a text field into data loss—the field simply does not publish a submit action. + ## Authoring principles - **One source of truth for shared chrome.** Compose from `chip-chrome.ts` / `chipVariants`; never duplicate the chrome string. diff --git a/.claude/rules/sim-react-performance.md b/.claude/rules/sim-react-performance.md index e64c8544ee0..2d77b324f24 100644 --- a/.claude/rules/sim-react-performance.md +++ b/.claude/rules/sim-react-performance.md @@ -90,6 +90,19 @@ 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). +## Prefetch dynamic destination lists on intent + +For long lists of dynamic destinations, do not viewport-prefetch every row and do not assume +`router.prefetch()` warms the full route: in Next 16 it uses the automatic/PPR strategy. Gate +`` behind deliberate hover or keyboard focus, and prefetch destination +server state with the consumer's shared React Query options. A short, cancelable hover dwell +avoids drive-by downloads. Do not treat `touchstart` as intent because it also begins scrolling; +let the actual unmodified click start the data request. + +If a continuity-focused surface intentionally omits `loading.tsx` so the current view remains +mounted until its peer is ready, the intent path must warm both the full route and its critical +data. Otherwise keep the loading boundary so dynamic navigation remains responsive. + ## 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/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md index 2cff0545957..b65deabafe3 100644 --- a/.claude/rules/sim-settings-pages.md +++ b/.claude/rules/sim-settings-pages.md @@ -13,8 +13,9 @@ The Next.js `settings/[section]/layout.tsx` owns all settings page chrome via `SettingsHeaderShell` — a fixed header bar (a left back chip + right-aligned action chips), a scroll region, and a centered `max-w-[48rem]` content column led by a **title + description from navigation metadata**. The chrome stays mounted -across section navigation (it never re-renders or re-lays-out). Each section -renders through the **`SettingsPanel`** registrar +across section navigation. Its routed title and description are available before +the section body resolves. Each section renders through the **`SettingsPanel`** +registrar (`@/app/workspace/[workspaceId]/settings/components/settings-panel`), which feeds the shell its header data and renders only the section body. Sections supply **data**, never chrome. @@ -82,6 +83,9 @@ return ( `children` instead and omit the prop. - `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a detail sub-view that needs a different heading; normal pages never pass these. + A top-level page's header identity must remain stable while its data loads: + never replace navigation metadata with client-fetched copy after first paint. + Put data-dependent context in the page body instead. - `scrollContainerRef?: React.Ref` — forwards a ref to the scroll region (e.g. programmatic scroll-to-bottom). diff --git a/.claude/rules/sim-url-state.md b/.claude/rules/sim-url-state.md index 9312fe72c9b..8a859e8a547 100644 --- a/.claude/rules/sim-url-state.md +++ b/.claude/rules/sim-url-state.md @@ -143,6 +143,11 @@ import KnowledgeBaseLoading from '@/app/workspace/[workspaceId]/knowledge/[id]/l Reference: `apps/sim/app/workspace/[workspaceId]/knowledge/[id]/page.tsx`. +The narrow exception is a continuity-focused peer switch that deliberately keeps the current +view mounted and follows the full-route plus critical-data intent-prefetch rule in +`sim-react-performance.md`. It still needs a real in-page Suspense fallback; it only omits the +route-level `loading.tsx` that would replace the current peer before the destination is ready. + This applies to **page entries**. An inner `` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels". ## Debounced text inputs diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 007df411aa5..690905622d2 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -14,8 +14,18 @@ jobs: timeout-minutes: 15 steps: + # The diff-based audits below need a base commit to read, and the default + # depth of 1 clones a single commit with no parent. They normally fetch + # their base by SHA (see "Resolve base ref"), so this depth only covers the + # `HEAD~1` fallback — but without it that fallback resolves to nothing. + # + # Worth stating because the failure was invisible for so long: the migration + # audit read the resulting `git diff` failure as "no migrations changed" and + # exited 0, so it had never actually run on a push build. - name: Checkout code uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 2 - name: Setup Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 @@ -104,15 +114,40 @@ jobs: echo "✅ All env flags are properly configured" - - name: Check block registry invariants + # One fetch for both base-ref audits, and no `|| true`: a swallowed fetch leaves + # the base ref absent, which neither audit can tell apart from a branch that + # changed nothing. The block-registry check at least degrades to a visible + # `⚠ … skipping` line; the migration audit printed `✓ No new migrations to + # check` and exited 0, clearing the only guard on production DDL. + # + # Depth stays at 1 — without a merge-base the migration audit diffs the two + # tips, which under `--diff-filter=AM` is exactly the migrations new here. + # Resolved once for both diff-based audits, and never with `|| true`: a + # swallowed fetch leaves the base absent, which neither audit can tell apart + # from a branch that changed nothing. + # + # On push the base is `github.event.before`, the tip the branch had before + # this push — not `HEAD~1`, which names only the last commit and would let a + # multi-commit push slip every earlier commit's migrations past the audit. + # It is fetched by SHA at depth 1; the audits diff two tips and need no + # common ancestry. An all-zero `before` means the branch is new and has no + # predecessor to diff, so `HEAD~1` remains the fallback there. + - name: Resolve base ref for diff-based audits + id: audit_base run: | if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE_REF="origin/${{ github.base_ref }}" - git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true + git fetch --depth=1 origin "${{ github.base_ref }}" + echo "ref=origin/${{ github.base_ref }}" >> "$GITHUB_OUTPUT" + elif [ -n "${{ github.event.before }}" ] && + [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then + git fetch --depth=1 origin "${{ github.event.before }}" + echo "ref=${{ github.event.before }}" >> "$GITHUB_OUTPUT" else - BASE_REF="HEAD~1" + echo "ref=HEAD~1" >> "$GITHUB_OUTPUT" fi - bun run apps/sim/scripts/check-block-registry.ts "$BASE_REF" + + - name: Check block registry invariants + run: bun run apps/sim/scripts/check-block-registry.ts "${{ steps.audit_base.outputs.ref }}" - name: Lint code run: bun run lint:check @@ -127,14 +162,7 @@ jobs: run: bun run docs-manifest:check - name: Migration safety (zero-downtime) audit - run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - BASE_REF="origin/${{ github.base_ref }}" - git fetch --depth=1 origin "${{ github.base_ref }}" 2>/dev/null || true - else - BASE_REF="HEAD~1" - fi - bun run check:migrations "$BASE_REF" + run: bun run check:migrations "${{ steps.audit_base.outputs.ref }}" # Every workspace, not just realtime. packages/emcn, packages/utils, # apps/desktop and apps/docs had no type check in CI at all; apps/sim's diff --git a/README.md b/README.md index 2d9b86ca5db..7b2a05134aa 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,7 @@ npx sim-setup add sandbox npx sim-setup add jobs npx sim-setup add cache npx sim-setup add knowledge +npx sim-setup add chat npx sim-setup add llm npx sim-setup add integration slack ``` diff --git a/apps/docs/components/workflow-preview/docs-container-node.tsx b/apps/docs/components/workflow-preview/docs-container-node.tsx index 85d098979d3..b0d27d22ed4 100644 --- a/apps/docs/components/workflow-preview/docs-container-node.tsx +++ b/apps/docs/components/workflow-preview/docs-container-node.tsx @@ -8,6 +8,7 @@ interface DocsContainerData { name: string blockType: string size?: { width: number; height: number } + parentId?: string } /** @@ -24,6 +25,7 @@ export const DocsContainerNode = memo(function DocsContainerNode({ name: data.name, width: data.size?.width, height: data.size?.height, + parentId: data.parentId, isPreview: true, } diff --git a/apps/docs/components/workflow-preview/workflow-data.test.ts b/apps/docs/components/workflow-preview/workflow-data.test.ts new file mode 100644 index 00000000000..7dbe6e51b01 --- /dev/null +++ b/apps/docs/components/workflow-preview/workflow-data.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { BLOCK_Z_BASE, CONTAINER_CHILD_Z_BASE, getEdgeZIndex } from '@sim/workflow-renderer' +import { describe, expect, it } from 'vitest' +import { type PreviewBlock, type PreviewWorkflow, toReactFlowElements } from './workflow-data' + +const block = ( + overrides: Partial & Pick +): PreviewBlock => ({ + name: overrides.id, + bgColor: '#000000', + rows: [], + position: { x: 0, y: 0 }, + ...overrides, +}) + +const workflow: PreviewWorkflow = { + id: 'nested-subflows', + name: 'Nested subflows', + blocks: [ + block({ id: 'start', type: 'starter' }), + block({ id: 'loop', type: 'loop', size: { width: 500, height: 300 } }), + block({ + id: 'parallel', + type: 'parallel', + parentId: 'loop', + position: { x: 24, y: 64 }, + size: { width: 400, height: 200 }, + }), + block({ id: 'agent', type: 'agent', parentId: 'loop', position: { x: 24, y: 140 } }), + ], + edges: [ + { id: 'start-loop', source: 'start', target: 'loop' }, + { id: 'loop-parallel', source: 'loop', target: 'parallel' }, + { id: 'loop-agent', source: 'loop', target: 'agent' }, + ], +} + +describe('toReactFlowElements layering', () => { + it('places incoming edges on their container target layer', () => { + const { nodes, edges } = toReactFlowElements(workflow, false, { + highlightEdge: 'loop-parallel', + }) + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + + expect(nodeById.get('loop')?.zIndex).toBe(0) + expect(nodeById.get('parallel')?.zIndex).toBe(1) + expect(edgeById.get('start-loop')?.zIndex).toBe(0) + expect(edgeById.get('loop-parallel')?.zIndex).toBe(1) + }) + + it('keeps ordinary cards above normally layered edges', () => { + const { nodes, edges } = toReactFlowElements(workflow) + const nodeById = new Map(nodes.map((node) => [node.id, node])) + const edgeById = new Map(edges.map((edge) => [edge.id, edge])) + + expect(nodeById.get('start')?.zIndex).toBe(BLOCK_Z_BASE) + expect(nodeById.get('agent')?.zIndex).toBe(CONTAINER_CHILD_Z_BASE) + expect(edgeById.get('loop-agent')?.zIndex).toBe(getEdgeZIndex(0)) + }) +}) diff --git a/apps/docs/components/workflow-preview/workflow-data.ts b/apps/docs/components/workflow-preview/workflow-data.ts index a15b99a4759..4148fccaa70 100644 --- a/apps/docs/components/workflow-preview/workflow-data.ts +++ b/apps/docs/components/workflow-preview/workflow-data.ts @@ -1,3 +1,9 @@ +import { + BLOCK_Z_BASE, + CONTAINER_CHILD_Z_BASE, + getEdgeZIndex, + getEdgeZIndexForTarget, +} from '@sim/workflow-renderer' import { type Edge, type Node, Position } from 'reactflow' /** @@ -61,6 +67,24 @@ export interface HighlightOptions { selectedBlock?: string } +/** Semantic container depth used for z-order while docs positions stay flattened. */ +function getNestingDepth(block: PreviewBlock, blocksById: Map): number { + let depth = 0 + let parentId = block.parentId + const visited = new Set() + + while (parentId && !visited.has(parentId)) { + const parent = blocksById.get(parentId) + if (!parent) break + + visited.add(parentId) + depth += 1 + parentId = parent.parentId + } + + return depth +} + /** * Converts a {@link PreviewWorkflow} to React Flow nodes and edges. * @@ -81,6 +105,7 @@ export function toReactFlowElements( const nodes: Node[] = workflow.blocks.map((block, index) => { const isContainer = Boolean(block.size) + const nestingDepth = getNestingDepth(block, blocksById) // Nested blocks are authored relative to their container; render them at // absolute coordinates (not React Flow parentNode children) so the edges // between a container and its nested blocks render reliably and on top. @@ -92,7 +117,7 @@ export function toReactFlowElements( id: block.id, type: isContainer ? 'previewContainer' : 'previewBlock', position, - zIndex: isContainer ? 0 : 1, + zIndex: isContainer ? nestingDepth : block.parentId ? CONTAINER_CHILD_Z_BASE : BLOCK_Z_BASE, ...(block.size ? { style: { width: block.size.width, height: block.size.height } } : {}), data: { name: block.name, @@ -103,6 +128,7 @@ export function toReactFlowElements( tools: block.tools, hideTargetHandle: block.hideTargetHandle, size: block.size, + parentId: block.parentId, index, animate, isHighlighted: highlightBlock === block.id || selectedBlock === block.id, @@ -127,6 +153,14 @@ export function toReactFlowElements( // so edges into and out of Loop/Parallel containers still connect. const sourceBlock = blocksById.get(e.source) const targetBlock = blocksById.get(e.target) + const parentContainer = blocksById.get(sourceBlock?.parentId ?? targetBlock?.parentId ?? '') + const baseZIndex = getEdgeZIndex( + parentContainer ? getNestingDepth(parentContainer, blocksById) : undefined, + { isHighlighted: isEdgeHighlight } + ) + const targetContainerZIndex = targetBlock?.size + ? getNestingDepth(targetBlock, blocksById) + : undefined const sourceHandle = e.sourceHandle ?? (sourceBlock?.size ? `${sourceBlock.type}-end-source` : 'source') const targetHandle = targetBlock?.size ? undefined : 'target' @@ -142,6 +176,7 @@ export function toReactFlowElements( }, sourceHandle, targetHandle, + zIndex: getEdgeZIndexForTarget(baseZIndex, targetContainerZIndex), data: { animate, delay: animate ? sourceIndex * BLOCK_STAGGER + BLOCK_STAGGER : 0, diff --git a/apps/docs/content/docs/en/platform/credentials.mdx b/apps/docs/content/docs/en/platform/credentials.mdx index 2584a353ac1..387450643f8 100644 --- a/apps/docs/content/docs/en/platform/credentials.mdx +++ b/apps/docs/content/docs/en/platform/credentials.mdx @@ -119,12 +119,27 @@ Click **Details** on any secret row to open its detail view. From here you can: - View the **Key** and edit the **Value** +- Toggle **Visibility** — show the value unmasked in run output; see [Visibility](#visibility) - Edit the **Description** — an optional note telling teammates what the secret is for. Workspace secrets only; a personal secret is not shared, so it has none - Manage **Members** — invite teammates by email and assign them an **Admin** or **Member** role - Open **See usage** — where this secret has actually been used Click **Save** to apply changes, or **Back** to return to the list. +### Visibility + +By default, a secret's resolved value is masked everywhere Sim shows run output (see [Execution log protection](#execution-log-protection)). For values that aren't actually sensitive — a staging key, a shared base URL — that masking makes your own logs harder to read. + +**Show value in logs and Chat** turns masking off for one workspace secret. With it on: + +- Run logs, Chat, and code output show the real value instead of `{{KEY}}` +- Files a run writes with the value in them stay readable and attachable +- The Secrets API list includes the value for this secret, so external agents can read it directly instead of scraping logs + +The value becomes visible to **anyone who can see this workspace's runs** — including publicly shared log links and log exports, and regardless of member restrictions on the secret itself. Only turn it on for values you'd be comfortable printing in a log. + +The switch applies to future runs only. Logs written while the secret was masked stay masked, and anything written while it was visible keeps the value even if you turn masking back on. If another secret holds the same value, that value stays masked — masking always wins a conflict. Workspace secrets only; the same people who can edit the description can flip it. + ### See usage **See usage** lists the runs that resolved this secret: when it was last used, what used it (a workflow, the Sim agent, or an MCP server), how it was triggered, who it resolved under, and a link to the most recent run in Logs. Rows are grouped by day, so a workflow on a schedule reads as one row per day rather than thousands. diff --git a/apps/docs/content/docs/en/platform/self-hosting/docker.mdx b/apps/docs/content/docs/en/platform/self-hosting/docker.mdx index 0974cfb8e41..0343aee10c8 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/docker.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/docker.mdx @@ -134,6 +134,26 @@ OLLAMA_URL=http://192.168.1.100:11434 docker compose -f docker-compose.prod.yml Inside Docker, `localhost` refers to the container, not your host. Use `host.docker.internal` or your host's IP. +### LM Studio + +[LM Studio exposes an OpenAI-compatible API](https://lmstudio.ai/docs/developer/openai-compat). Start its local server, load a model, and enable **Serve on Local Network** so the Docker container can reach it. [Enable API authentication](https://lmstudio.ai/docs/developer/core/authentication), then set the endpoint and token in the `.env` file next to your Compose file: + +```bash +# macOS/Windows +VLLM_BASE_URL=http://host.docker.internal:1234 + +# Linux - use your host IP instead +# VLLM_BASE_URL=http://192.168.1.100:1234 + +VLLM_API_KEY=your_lm_studio_api_token +``` + +Both the server root shown above and a URL ending in `/v1` are accepted. After recreating the `simstudio` service, its models appear in the model picker with a `vllm/` prefix; Sim removes that prefix before sending the model identifier to LM Studio. + +```bash +docker compose -f docker-compose.ollama.yml up -d --force-recreate simstudio +``` + ## Commands ```bash diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx index 35f9162f061..b90835505b2 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx @@ -81,8 +81,8 @@ import { Callout } from 'fumadocs-ui/components/callout' | Variable | Description | |----------|-------------| -| `VLLM_BASE_URL` | vLLM server URL, **without** a `/v1` suffix (e.g. `http://localhost:8000`) — Sim appends `/v1` itself | -| `VLLM_API_KEY` | Optional bearer token for vLLM | +| `VLLM_BASE_URL` | OpenAI-compatible vLLM or LM Studio URL. Both the server root (`http://localhost:8000`) and versioned API URL (`http://localhost:8000/v1`) are accepted | +| `VLLM_API_KEY` | Optional bearer token for the vLLM or LM Studio endpoint | | `LITELLM_BASE_URL` | LiteLLM proxy base URL | | `LITELLM_API_KEY` | Optional bearer token for LiteLLM | diff --git a/apps/docs/content/docs/en/platform/self-hosting/index.mdx b/apps/docs/content/docs/en/platform/self-hosting/index.mdx index 3e7af31abca..287de10dde9 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/index.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/index.mdx @@ -116,7 +116,7 @@ Sim is self-contained for the core editor and execution engine. A few features r | Feature | Requires | Notes | |---|---|---| | **Knowledge bases** | An OpenAI, Azure OpenAI, or Gemini API key | Embeddings are generated by a hosted provider, selected with `KB_EMBEDDING_MODEL` (`text-embedding-3-small` by default). There is no local embedding backend — knowledge bases are unavailable without one of these keys. | -| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, or LiteLLM. | +| **Agent blocks** | An API key for at least one model provider | Or a self-hosted OpenAI-compatible endpoint: Ollama, vLLM, LM Studio, or LiteLLM. | | **Chat module** | `COPILOT_API_KEY` from sim.ai | Set `NEXT_PUBLIC_CHAT_DISABLED=true` to hide the module instead. | | **Integrations** | Your own OAuth app per service | See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). | | **Remote Function / Pi execution** | Optional E2B or Daytona key | Without one, JavaScript Function code that has no `import` or `require` still runs in the in-process isolated VM. Python, Shell, JavaScript with external imports, custom Function Sandboxes, and Pi require a configured remote provider. See [Security](/platform/self-hosting/security). | @@ -126,4 +126,3 @@ Sim is self-contained for the core editor and execution engine. A few features r { question: "What are the required environment variables for production?", answer: "Three secrets are required: BETTER_AUTH_SECRET (authentication), ENCRYPTION_KEY (data encryption), and INTERNAL_API_SECRET (service-to-service auth). Generate each with openssl rand -hex 32. You also need to set NEXT_PUBLIC_APP_URL and BETTER_AUTH_URL to your domain."}, { question: "Can I use Sim with local AI models?", answer: "Yes. Sim supports Ollama for local model inference. Use docker-compose.ollama.yml instead of docker-compose.prod.yml. It offers both GPU (with NVIDIA support) and CPU-only profiles, and automatically pulls gemma3:4b as a starter model." }, ]} /> - diff --git a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx index c4b8406ad5d..6e00170b57f 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx +++ b/apps/docs/content/docs/en/platform/self-hosting/troubleshooting.mdx @@ -25,6 +25,21 @@ OLLAMA_URL=http://host.docker.internal:11434 # macOS/Windows OLLAMA_URL=http://192.168.1.x:11434 # Linux (use actual IP) ``` +## LM Studio Requests Route to Ollama + +Sim identifies dynamically discovered LM Studio and vLLM models by their `vllm/` prefix. If the endpoint is unavailable and you manually enter the raw LM Studio model identifier, Sim treats that unknown identifier as an Ollama model. + +1. Confirm `VLLM_BASE_URL` is available inside the app container: + + ```bash + docker compose -f docker-compose.ollama.yml exec simstudio printenv VLLM_BASE_URL + ``` + +2. In LM Studio, enable **Serve on Local Network** and API authentication so the container can connect safely. +3. From Docker on macOS or Windows, use `http://host.docker.internal:1234` rather than `localhost`. On Linux, use the host IP. +4. The server root and a URL ending in `/v1` are both accepted. +5. Recreate `simstudio`, reload the workspace, and select the discovered `vllm/` option from the model picker. + ## WebSocket/Realtime Not Working 1. Verify reverse proxy routes `/socket.io` to the realtime service (default port 3002). `NEXT_PUBLIC_SOCKET_URL` is only needed if realtime is on a separate host. diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index a1d045f0cc5..e2a260d2f5e 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2447,7 +2447,7 @@ "get": { "operationId": "listSecrets", "summary": "List Secrets", - "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Only names, scope, role, and timestamps are returned; secret values are never returned. A workspace API key is rejected with `403`; use a personal API key.", + "description": "List workspace and caller-owned personal secret metadata with opaque cursor pagination. Rows for workspace secrets marked visible (unredacted) include the stored value; every other row is metadata-only and no other response ever carries a value. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { @@ -5581,7 +5581,7 @@ } ] }, - "V2Secret": { + "V2SecretWithValue": { "type": "object", "properties": { "name": { @@ -5607,6 +5607,10 @@ ], "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." }, + "unredacted": { + "type": "boolean", + "description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret." + }, "role": { "type": "string", "enum": ["admin", "member"], @@ -5623,12 +5627,24 @@ "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "description": "ISO 8601 timestamp when the secret was last updated." + }, + "value": { + "description": "The stored secret value. Present only when the workspace secret is marked visible (unredacted); omitted for every other secret.", + "type": "string" } }, - "required": ["name", "scope", "description", "role", "createdAt", "updatedAt"], + "required": [ + "name", + "scope", + "description", + "unredacted", + "role", + "createdAt", + "updatedAt" + ], "additionalProperties": false, - "title": "Secret metadata", - "description": "Public secret metadata without the stored secret value." + "title": "Secret metadata with visible value", + "description": "Secret metadata; the stored value is included only for a workspace secret marked visible (unredacted)." }, "ListSecretsResponse": { "type": "object", @@ -5636,7 +5652,7 @@ "data": { "type": "array", "items": { - "$ref": "#/components/schemas/V2Secret" + "$ref": "#/components/schemas/V2SecretWithValue" }, "description": "Items in the current page." }, @@ -5655,7 +5671,7 @@ "required": ["data", "nextCursor"], "additionalProperties": false, "title": "List secrets response", - "description": "Secret metadata visible to the caller without stored values.", + "description": "Secret metadata visible to the caller; visible (unredacted) workspace secrets carry their value.", "examples": [ { "data": [ @@ -5663,15 +5679,87 @@ "name": "STRIPE_API_KEY", "scope": "workspace", "description": "Production billing key — rotate quarterly.", + "unredacted": false, "role": "admin", "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" + }, + { + "name": "STAGING_BASE_URL", + "scope": "workspace", + "description": "Staging environment base URL.", + "unredacted": true, + "role": "member", + "createdAt": "2026-06-03T11:30:00.000Z", + "updatedAt": "2026-06-21T08:45:09.000Z", + "value": "https://staging.example.com" } ], "nextCursor": null } ] }, + "V2Secret": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_]+$", + "description": "Secret name containing only letters, numbers, and underscores." + }, + "scope": { + "type": "string", + "enum": ["workspace", "personal"], + "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "What the secret is for, as set on the workspace secret. Always null for a personal secret, which has no shared audience." + }, + "unredacted": { + "type": "boolean", + "description": "Whether the workspace secret opts out of redaction, so its value appears in plaintext in run logs and model-visible content. Always false for a personal secret." + }, + "role": { + "type": "string", + "enum": ["admin", "member"], + "description": "Caller role for the secret." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was created." + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the secret was last updated." + } + }, + "required": [ + "name", + "scope", + "description", + "unredacted", + "role", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Secret metadata", + "description": "Public secret metadata without the stored secret value." + }, "SetSecretResponse": { "type": "object", "properties": { @@ -5690,6 +5778,7 @@ "name": "STRIPE_API_KEY", "scope": "workspace", "description": "Production billing key — rotate quarterly.", + "unredacted": false, "role": "admin", "createdAt": "2026-06-01T09:14:00.000Z", "updatedAt": "2026-06-20T14:02:11.000Z" @@ -5729,6 +5818,10 @@ "type": "null" } ] + }, + "unredacted": { + "description": "Opt the workspace secret out of redaction: its value then appears in plaintext in run logs, model-visible content, and files, including publicly shared log links. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave the current setting untouched.", + "type": "boolean" } }, "required": ["workspaceId", "scope", "value"], diff --git a/apps/docs/package.json b/apps/docs/package.json index 56a6a98107a..700fdfecbc6 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -10,6 +10,7 @@ "build": "fumadocs-mdx && NODE_OPTIONS='--max-old-space-size=8192' next build", "start": "next start", "postinstall": "fumadocs-mdx", + "test": "vitest run", "type-check": "fumadocs-mdx && tsc --noEmit", "lint": "biome check --write --unsafe .", "lint:check": "biome check .", @@ -47,6 +48,7 @@ "@types/react-dom": "^19.0.4", "postcss": "^8.5.3", "tailwindcss": "^4.0.12", - "typescript": "^7.0.2" + "typescript": "^7.0.2", + "vitest": "^4.1.0" } } diff --git a/apps/sim/.env.example b/apps/sim/.env.example index c7313cbaa07..0f71427e700 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -90,8 +90,8 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # Local AI Models (Optional) # OLLAMA_URL=http://localhost:11434 # URL for local Ollama server - uncomment if using local models -# VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible) -# VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth +# VLLM_BASE_URL=http://localhost:8000 # vLLM or LM Studio OpenAI-compatible URL; a trailing /v1 is optional +# VLLM_API_KEY= # Optional bearer token if the endpoint requires auth # LITELLM_BASE_URL=http://localhost:4000 # Base URL for your LiteLLM proxy (OpenAI-compatible) # LITELLM_API_KEY= # Optional bearer token if your LiteLLM proxy requires auth # OPENROUTER_API_KEY= # Optional self-hosted fallback for OpenAI knowledge-base embeddings diff --git a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx index ee2f4ede8a3..a8ba38dcfb4 100644 --- a/apps/sim/app/(auth)/components/oauth-provider-checker.tsx +++ b/apps/sim/app/(auth)/components/oauth-provider-checker.tsx @@ -3,7 +3,6 @@ import { isGithubAuthDisabled, isGoogleAuthDisabled, isMicrosoftAuthDisabled, - isProd, } from '@/lib/core/config/env-flags' export async function getOAuthProviderStatus() { @@ -16,5 +15,5 @@ export async function getOAuthProviderStatus() { const microsoftAvailable = !!(env.MICROSOFT_CLIENT_ID && env.MICROSOFT_CLIENT_SECRET) && !isMicrosoftAuthDisabled - return { githubAvailable, googleAvailable, microsoftAvailable, isProduction: isProd } + return { githubAvailable, googleAvailable, microsoftAvailable } } diff --git a/apps/sim/app/(auth)/components/social-login-buttons.tsx b/apps/sim/app/(auth)/components/social-login-buttons.tsx index c2156e7dd3f..c200d86bd11 100644 --- a/apps/sim/app/(auth)/components/social-login-buttons.tsx +++ b/apps/sim/app/(auth)/components/social-login-buttons.tsx @@ -15,7 +15,6 @@ interface SocialLoginButtonsProps { googleAvailable: boolean microsoftAvailable: boolean callbackURL?: string - isProduction: boolean children?: ReactNode } @@ -24,7 +23,6 @@ export function SocialLoginButtons({ googleAvailable, microsoftAvailable, callbackURL = '/workspace', - isProduction, children, }: SocialLoginButtonsProps) { const [isGithubLoading, setIsGithubLoading] = useState(false) diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx index 380aeb89a42..cfe0b1403b8 100644 --- a/apps/sim/app/(auth)/login/login-form.tsx +++ b/apps/sim/app/(auth)/login/login-form.tsx @@ -87,13 +87,11 @@ export default function LoginPage({ githubAvailable, googleAvailable, microsoftAvailable, - isProduction, registrationDisabled, }: { githubAvailable: boolean googleAvailable: boolean microsoftAvailable: boolean - isProduction: boolean /** DISABLE_REGISTRATION. Hides the signup cross-link, which `/signup` blocks. */ registrationDisabled: boolean }) { @@ -430,7 +428,6 @@ export default function LoginPage({ googleAvailable={googleAvailable} githubAvailable={githubAvailable} microsoftAvailable={microsoftAvailable} - isProduction={isProduction} callbackURL={callbackUrl} > {ssoEnabled && !hasOnlySSO && ( @@ -464,9 +461,6 @@ export default function LoginPage({ title='Email' value={forgotPasswordEmail} onChange={(value) => setForgotPasswordEmail(value)} - onSubmit={() => { - if (!isSubmittingReset) void handleForgotPassword() - }} required placeholder='you@example.com' /> diff --git a/apps/sim/app/(auth)/login/page.tsx b/apps/sim/app/(auth)/login/page.tsx index 3b0c3f6a96a..726727b1435 100644 --- a/apps/sim/app/(auth)/login/page.tsx +++ b/apps/sim/app/(auth)/login/page.tsx @@ -12,8 +12,7 @@ export const metadata: Metadata = { export const dynamic = 'force-dynamic' export default async function LoginPage() { - const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } = - await getOAuthProviderStatus() + const { githubAvailable, googleAvailable, microsoftAvailable } = await getOAuthProviderStatus() return ( }> @@ -21,7 +20,6 @@ export default async function LoginPage() { githubAvailable={githubAvailable} googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} - isProduction={isProduction} registrationDisabled={isRegistrationDisabled} /> diff --git a/apps/sim/app/(auth)/signup/page.tsx b/apps/sim/app/(auth)/signup/page.tsx index d43dc7c0475..496f779b7fb 100644 --- a/apps/sim/app/(auth)/signup/page.tsx +++ b/apps/sim/app/(auth)/signup/page.tsx @@ -36,15 +36,13 @@ export default async function SignupPage({ ) } - const { githubAvailable, googleAvailable, microsoftAvailable, isProduction } = - await getOAuthProviderStatus() + const { githubAvailable, googleAvailable, microsoftAvailable } = await getOAuthProviderStatus() return ( diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index ad0d5213b97..61ad48a8328 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -5,7 +5,9 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' +import { trackGoogleEvent } from '@/lib/analytics/google' import { client, useSession } from '@/lib/auth/auth-client' +import { useTrackingConsent } from '@/lib/consent/tracking-consent' import { getEnv, isFalsy } from '@/lib/core/config/env' import { isSsoEnabled } from '@/lib/core/config/env-flags' import { validateCallbackUrl } from '@/lib/core/security/input-validation' @@ -91,7 +93,6 @@ interface SignupFormProps { githubAvailable: boolean googleAvailable: boolean microsoftAvailable: boolean - isProduction: boolean emailSignupEnabled: boolean /** Server-derived: verification is enabled AND a mail provider is configured. */ emailVerificationEnabled: boolean @@ -101,7 +102,6 @@ function SignupFormContent({ githubAvailable, googleAvailable, microsoftAvailable, - isProduction, emailSignupEnabled, emailVerificationEnabled, }: SignupFormProps) { @@ -109,6 +109,7 @@ function SignupFormContent({ const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const posthog = usePostHog() + const { measurement } = useTrackingConsent() const [isLoading, setIsLoading] = useState(false) useEffect(() => { @@ -346,6 +347,8 @@ function SignupFormContent({ return } + if (measurement) trackGoogleEvent('sign_up', { method: 'email' }) + try { await refetchSession() logger.info('Session refreshed after successful signup') @@ -484,7 +487,6 @@ function SignupFormContent({ googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} callbackURL={redirectUrl || '/workspace'} - isProduction={isProduction} > {ssoEnabled && !hasOnlySSO && ( @@ -507,7 +509,6 @@ export default function SignupPage({ githubAvailable, googleAvailable, microsoftAvailable, - isProduction, emailSignupEnabled, emailVerificationEnabled, }: SignupFormProps) { @@ -519,7 +520,6 @@ export default function SignupPage({ githubAvailable={githubAvailable} googleAvailable={googleAvailable} microsoftAvailable={microsoftAvailable} - isProduction={isProduction} emailSignupEnabled={emailSignupEnabled} emailVerificationEnabled={emailVerificationEnabled} /> diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index f7a7872c093..da7f1e76324 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -48,6 +48,13 @@ const FALLBACK_STATUS: ProviderStatus = { const SOCIAL_BTN = 'relative flex h-[32px] w-full items-center justify-center rounded-[5px] border border-[var(--border-1)] text-[13.5px] text-[var(--text-primary)] transition-colors hover:bg-[var(--surface-hover)] disabled:cursor-not-allowed disabled:opacity-50' +/** Auth providers are peer choices, so opening the dialog must not arm one or dismissal. */ +function focusAuthDialog(event: Event): void { + event.preventDefault() + const content = event.currentTarget as HTMLElement | null + content?.focus() +} + function fetchProviderStatus(): Promise { if (fetchPromise) return fetchPromise fetchPromise = requestJson(getAuthProvidersContract, {}) @@ -155,7 +162,11 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal return ( {children} - + {effectiveView === 'login' ? 'Log in' : 'Create account'} diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 1fedf3b0226..5c74a5a7e8f 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -1,4 +1,5 @@ import Link from 'next/link' +import { ConsentPreferencesTrigger } from '@/app/_shell/consent/consent-preferences-trigger' import { ALL_COMPETITORS } from '@/app/(landing)/comparisons/utils' import { SimWordmark } from '@/app/(landing)/components/navbar/components/sim-wordmark' import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils' @@ -19,14 +20,25 @@ import { MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils' */ const LINK_CLASS = - 'text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]' + 'text-left text-sm text-[var(--text-muted)] transition-colors hover:text-[var(--text-primary)]' -interface FooterItem { +interface FooterLinkItem { label: string href: string external?: boolean } +interface FooterConsentItem { + label: string + consentPreferences: true +} + +type FooterItem = FooterLinkItem | FooterConsentItem + +interface FooterProps { + showConsentPreferences?: boolean +} + /** * Platform modules link to their local landing pages (internal link equity * stays on the ranking pages); docs-only surfaces (MCP, API, Self Hosting) @@ -108,27 +120,37 @@ const SOCIAL_LINKS: FooterItem[] = [ const LEGAL_LINKS: FooterItem[] = [ { label: 'Terms of Service', href: '/terms' }, { label: 'Privacy Policy', href: '/privacy' }, + { label: 'Cookie Policy', href: '/cookie-policy' }, ] +const CONSENT_PREFERENCES_LINK: FooterConsentItem = { + label: 'Cookie preferences', + consentPreferences: true, +} + function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) { return (

{title}

- {items.map(({ label, href, external }) => - external ? ( + {items.map((item) => + 'consentPreferences' in item ? ( + + {item.label} + + ) : item.external ? ( - {label} + {item.label} ) : ( - - {label} + + {item.label} ) )} @@ -137,7 +159,7 @@ function FooterColumn({ title, items }: { title: string; items: FooterItem[] }) ) } -export function Footer() { +export function Footer({ showConsentPreferences = false }: FooterProps) { return (