Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 3 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,12 @@
### Self-hosted

```bash
npx simstudio
git clone https://github.com/simstudioai/sim.git && cd sim
bun run setup
```

Open [http://localhost:3000](http://localhost:3000)

Docker must be installed and running. Use `-p, --port <port>` to run Sim on a different port, or `--no-pull` to skip pulling the latest Docker images.

<p align="center">
<img src="apps/sim/public/static/readme-platform.png" alt="The Sim platform — chat on the left, the visual workflow builder on the right" width="100%"/>
</p>
Expand Down Expand Up @@ -77,12 +76,6 @@ Docker must be installed and running. Use `-p, --port <port>` to run Sim on a di

**Requirements:** [Bun](https://bun.sh/) and [Docker](https://www.docker.com/).

```bash
git clone https://github.com/simstudioai/sim.git && cd sim
bun install
bun run setup
```

`bun run setup` is an interactive wizard: it provisions the database, generates secrets, writes your `.env` files, connects a Chat API key, and starts Sim the way you choose:

- **Local dev** — run from source to contribute or hack on Sim
Expand Down Expand Up @@ -110,7 +103,7 @@ Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https:

## Chat API Keys

Chat is a Sim-managed service. `bun run setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/account/settings/chat-keys](https://sim.ai/account/settings/chat-keys).
Chat is a Sim-managed service. `bun run setup` connects a Chat API key for you — sign in when it opens your browser and the key is stored automatically. To view, create, or revoke keys later, go to [sim.ai/selfhost/settings/chat-keys](https://sim.ai/selfhost/settings/chat-keys).

## Environment Variables

Expand Down
23 changes: 0 additions & 23 deletions apps/sim/app/account/settings/chat-keys/page.tsx

This file was deleted.

56 changes: 56 additions & 0 deletions apps/sim/app/selfhost/settings/[section]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { Suspense } from 'react'
import type { Metadata } from 'next'
import { notFound, redirect } from 'next/navigation'
import {
getSelfHostSettingsHref,
getSettingsSectionMeta,
parseSettingsPathSection,
SELFHOST_SETTINGS_ITEMS,
} from '@/components/settings/navigation'
import { SelfHostSettingsRenderer } from '@/components/settings/selfhost-settings-renderer'
import { getSession } from '@/lib/auth'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'

interface SelfHostSettingsSectionPageProps {
params: Promise<{ section: string }>
}

export async function generateMetadata({
params,
}: SelfHostSettingsSectionPageProps): Promise<Metadata> {
const { section } = await params
const parsed = parseSettingsPathSection({
path: section,
items: SELFHOST_SETTINGS_ITEMS,
defaultSection: null,
})
const meta = parsed ? getSettingsSectionMeta('selfhost', parsed) : null
return { title: meta ? `${meta.label} - Self-host settings` : 'Self-host settings' }
}

export default async function SelfHostSettingsSectionPage({
params,
}: SelfHostSettingsSectionPageProps) {
const session = await getSession()
if (!session?.user) redirect('/login')

const { section } = await params
const parsed = parseSettingsPathSection({
path: section,
items: SELFHOST_SETTINGS_ITEMS,
defaultSection: null,
})
if (!parsed) notFound()
if (parsed === 'billing' && !isBillingEnabled) redirect(getSelfHostSettingsHref('general'))
if (parsed === 'chat-keys' && !isHosted) redirect(getSelfHostSettingsHref('general'))

/**
* Sections read URL query params via nuqs (which uses `useSearchParams`
* internally), so the renderer must sit under a Suspense boundary.
*/
return (
<Suspense fallback={null}>
<SelfHostSettingsRenderer section={parsed} />
</Suspense>
)
Comment thread
cursor[bot] marked this conversation as resolved.
}
10 changes: 10 additions & 0 deletions apps/sim/app/selfhost/settings/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { redirect } from 'next/navigation'
import { StandaloneSettingsShell } from '@/components/settings/standalone-settings-shell'
import { getSession } from '@/lib/auth'

export default async function SelfHostSettingsLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
if (!session?.user) redirect('/login')

return <StandaloneSettingsShell plane='selfhost'>{children}</StandaloneSettingsShell>
}
6 changes: 6 additions & 0 deletions apps/sim/app/selfhost/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { redirect } from 'next/navigation'
import { getSelfHostSettingsHref } from '@/components/settings/navigation'

export default function SelfHostSettingsPage() {
redirect(getSelfHostSettingsHref('general'))
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,9 @@ import {
ChipModalHeader,
SecretReveal,
} from '@sim/emcn'
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { formatDate } from '@sim/utils/formatting'
import { Plus } from 'lucide-react'
import { useRouter } from 'next/navigation'
import { getAccountSettingsHref } from '@/components/settings/navigation'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import type { SettingsAction } from '@/app/workspace/[workspaceId]/settings/components/settings-header/settings-header'
import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel'
Expand All @@ -29,7 +26,7 @@ import {
useGenerateCopilotKey,
} from '@/hooks/queries/copilot-keys'

const logger = createLogger('ChatKeysSettings')
const logger = createLogger('CopilotSettings')

/** Formats a key's last-used timestamp, falling back to "Never" when unset. */
function formatLastUsed(dateString?: string | null): string {
Expand All @@ -38,11 +35,10 @@ function formatLastUsed(dateString?: string | null): string {
}

/**
* Standalone page for managing the Chat API keys used with the Chat feature.
* Provides functionality to create, view, and delete Chat API keys.
* Copilot Keys management component for handling API keys used with the Copilot feature.
* Provides functionality to create, view, and delete copilot API keys.
*/
export function ChatKeysView() {
const router = useRouter()
export function Copilot() {
const { data: keys = [], isLoading } = useCopilotKeys()
const generateKey = useGenerateCopilotKey()
const deleteKeyMutation = useDeleteCopilotKey()
Expand Down Expand Up @@ -88,7 +84,7 @@ export function ChatKeysView() {
setIsCreateDialogOpen(false)
}
} catch (error) {
logger.error('Failed to generate Chat API key', { error })
logger.error('Failed to generate copilot API key', { error })
setCreateError('Failed to create API key. Please check your connection and try again.')
}
}
Expand All @@ -102,7 +98,7 @@ export function ChatKeysView() {

await deleteKeyMutation.mutateAsync({ keyId: keyToDelete.id })
} catch (error) {
logger.error('Failed to delete Chat API key', { error })
logger.error('Failed to delete copilot API key', { error })
}
}

Expand All @@ -126,19 +122,12 @@ export function ChatKeysView() {
return (
<>
<SettingsPanel
back={{
text: 'Account',
icon: ArrowLeft,
onSelect: () => router.push(getAccountSettingsHref('general')),
}}
search={{
value: searchTerm,
onChange: setSearchTerm,
placeholder: 'Search API keys...',
}}
actions={actions}
title='Chat keys'
description='Create and manage the API keys that power Chat.'
>
{isLoading ? null : showEmptyState ? (
<SettingsEmptyState>Click "Create API key" above to get started</SettingsEmptyState>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@ describe('unified settings navigation', () => {
})

it('derives every unified item from exactly one registry entry', () => {
expect(allNavigationItems).toHaveLength(SETTINGS_SECTION_REGISTRY.length)
expect(allNavigationItems).toHaveLength(
SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified).length
)
for (const item of allNavigationItems) {
expect(
SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified.id === item.id)
SETTINGS_SECTION_REGISTRY.filter(({ unified }) => unified?.id === item.id)
).toHaveLength(1)
}
})
Expand Down
14 changes: 10 additions & 4 deletions apps/sim/components/settings/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
parseSettingsPathSection,
resolveOrganizationSectionAccess,
resolveWorkspaceNavigation,
SELFHOST_SETTINGS_ITEMS,
SETTINGS_SECTION_REGISTRY,
WORKSPACE_SETTINGS_ITEMS,
WORKSPACE_SETTINGS_PATH_ALIASES,
Expand Down Expand Up @@ -56,6 +57,7 @@ describe('settings navigation boundaries', () => {
'admin',
'mothership',
])
expect(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id)).toEqual(['general', 'billing', 'chat-keys'])
expect(ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id)).toEqual([
'members',
'billing',
Expand Down Expand Up @@ -83,20 +85,26 @@ describe('settings navigation boundaries', () => {
})

it('has one registry source for every unified and plane item', () => {
const unifiedIds = SETTINGS_SECTION_REGISTRY.map(({ unified }) => unified.id)
const unifiedIds = SETTINGS_SECTION_REGISTRY.flatMap(({ unified }) =>
unified ? [unified.id] : []
)
const accountIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) =>
planes?.account ? [planes.account.id] : []
)
const organizationIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) =>
planes?.organization ? [planes.organization.id] : []
)
const selfHostIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) =>
planes?.selfhost ? [planes.selfhost.id] : []
)
const workspaceIds = SETTINGS_SECTION_REGISTRY.flatMap(({ planes }) =>
planes?.workspace ? [planes.workspace.id] : []
)

expect(new Set(unifiedIds).size).toBe(unifiedIds.length)
expect(new Set(accountIds).size).toBe(accountIds.length)
expect(new Set(organizationIds).size).toBe(organizationIds.length)
expect(new Set(selfHostIds).size).toBe(selfHostIds.length)
expect(new Set(workspaceIds).size).toBe(workspaceIds.length)
expect([...unifiedIds].sort()).toEqual(
buildUnifiedSettingsNavigation()
Expand All @@ -107,6 +115,7 @@ describe('settings navigation boundaries', () => {
expect([...organizationIds].sort()).toEqual(
ORGANIZATION_SETTINGS_ITEMS.map(({ id }) => id).sort()
)
expect([...selfHostIds].sort()).toEqual(SELFHOST_SETTINGS_ITEMS.map(({ id }) => id).sort())
expect([...workspaceIds].sort()).toEqual(WORKSPACE_SETTINGS_ITEMS.map(({ id }) => id).sort())
})

Expand Down Expand Up @@ -179,9 +188,6 @@ describe('settings navigation boundaries', () => {
expect(parseAccountPath('/account/settings/apikeys', null)).toBe('api-keys')
expect(parseAccountPath('/account/settings/not-a-section', null)).toBeNull()
expect(parseAccountPath('/account/settings', 'general')).toBe('general')
// Chat keys is a real page but deliberately not a nav item — with a null
// default it must resolve to nothing so the sidebar highlights no sibling.
expect(parseAccountPath('/account/settings/chat-keys', null)).toBeNull()
})

it('parses canonical, aliased, and invalid organization settings paths', () => {
Expand Down
Loading
Loading