Files
windmill/frontend/src/lib/components/workspaceSettings/CreateWorkspace.svelte

454 lines
13 KiB
Svelte

<script lang="ts">
import { run } from 'svelte/legacy'
import { goto } from '$lib/navigation'
import { base } from '$lib/base'
import {
ResourceService,
SettingService,
UserService,
VariableService,
WorkspaceService,
type AIProvider
} from '$lib/gen'
import { validateUsername } from '$lib/utils'
import { logoutWithRedirect } from '$lib/logout'
import { page } from '$app/stores'
import { usersWorkspaceStore, workspaceStore } from '$lib/stores'
import CenteredModal from '$lib/components/CenteredModal.svelte'
import { Button } from '$lib/components/common'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { onMount } from 'svelte'
import { sendUserToast } from '$lib/toast'
import TestAIKey from '$lib/components/copilot/TestAIKey.svelte'
import { switchWorkspace } from '$lib/storeUtils'
import { isCloudHosted } from '$lib/cloud'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte'
import { AI_PROVIDERS } from '$lib/components/copilot/lib'
import { GitFork } from 'lucide-svelte'
import PrefixedInput from '../PrefixedInput.svelte'
import TextInput from '../text_input/TextInput.svelte'
interface Props {
isFork?: boolean
}
let { isFork = false }: Props = $props()
const rd = $page.url.searchParams.get('rd')
let id = $state('')
let name = $state('')
let username = $state('')
let errorId = $state('')
let errorUser = $state('')
let aiKey = $state('')
let codeCompletionEnabled = $state(true)
let checking = $state(false)
let workspaceColor: string | undefined = $state(undefined)
let colorEnabled = $state(false)
function generateRandomColor() {
const randomColor =
'#' +
Math.floor(Math.random() * 16777215)
.toString(16)
.padStart(6, '0')
workspaceColor = randomColor
}
async function validateName(id: string): Promise<void> {
checking = true
let exists = await WorkspaceService.existsWorkspace({ requestBody: { id } })
if (exists) {
errorId = 'ID already exists'
} else if (id != '' && !/^\w+(-\w+)*$/.test(id)) {
errorId = 'ID can only contain letters, numbers and dashes and must not finish by a dash'
} else {
errorId = ''
}
checking = false
}
const WM_FORK_PREFIX = 'wm-fork-'
async function createOrForkWorkspace() {
const prefixed_id = `${WM_FORK_PREFIX}${id}`
if (isFork) {
if ($workspaceStore) {
await WorkspaceService.createWorkspaceFork({
requestBody: {
id: prefixed_id,
name,
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
username: automateUsernameCreation ? undefined : username,
parent_workspace_id: $workspaceStore
}
})
sendUserToast(`Successfully forked workspace ${$workspaceStore} as: wm-fork-${id}`)
} else {
sendUserToast('No workspace selected, cannot fork non-existent workspace', true)
}
} else {
await createWorkspace()
}
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(isFork ? prefixed_id : id)
goto(rd ?? '/')
}
async function createWorkspace(): Promise<void> {
await WorkspaceService.createWorkspace({
requestBody: {
id,
name,
color: colorEnabled && workspaceColor ? workspaceColor : undefined,
username: automateUsernameCreation ? undefined : username
}
})
if (auto_invite) {
await WorkspaceService.editAutoInvite({
workspace: id,
requestBody: { operator: operatorOnly, invite_all: !isCloudHosted(), auto_add: true }
})
}
if (aiKey != '') {
let actualUsername = username
if (automateUsernameCreation) {
const user = await UserService.whoami({
workspace: id
})
actualUsername = user.username
}
let path = `u/${actualUsername}/${selected}_windmill_codegen`
await VariableService.createVariable({
workspace: id,
requestBody: {
path,
value: aiKey,
is_secret: true,
description: 'Ai token'
}
})
await ResourceService.createResource({
workspace: id,
requestBody: {
path,
value: {
api_key: '$var:' + path
},
resource_type: selected
}
})
await WorkspaceService.editCopilotConfig({
workspace: id,
requestBody: aiKey
? {
providers: {
[selected]: {
resource_path: path,
models: [AI_PROVIDERS[selected].defaultModels[0]]
}
},
default_model: {
model: AI_PROVIDERS[selected].defaultModels[0],
provider: selected
},
code_completion_model: codeCompletionEnabled
? { model: AI_PROVIDERS[selected].defaultModels[0], provider: selected }
: undefined
}
: {}
})
}
sendUserToast(`Created workspace id: ${id}`)
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
switchWorkspace(id)
goto(rd ?? '/')
}
function handleKeyUp(event: KeyboardEvent) {
const key = event.key
if (key === 'Enter') {
event.preventDefault()
createWorkspace()
}
}
async function loadWorkspaces() {
if (!$usersWorkspaceStore) {
try {
usersWorkspaceStore.set(await WorkspaceService.listUserWorkspaces())
} catch {}
}
if (!$usersWorkspaceStore) {
const url = $page.url
console.log('logout 2')
await logoutWithRedirect(url.href.replace(url.origin, ''))
}
}
let automateUsernameCreation = $state(false)
async function getAutomateUsernameCreationSetting() {
automateUsernameCreation =
((await SettingService.getGlobal({ key: 'automate_username_creation' })) as any) ?? false
if (!automateUsernameCreation) {
UserService.globalWhoami().then((x) => {
let uname = ''
if (x.name) {
uname = x.name.split(' ')[0]
} else {
uname = x.email.split('@')[0]
}
uname = uname.replace(/\./gi, '')
username = uname.toLowerCase()
})
}
}
getAutomateUsernameCreationSetting()
onMount(() => {
loadWorkspaces()
WorkspaceService.isDomainAllowed().then((x) => {
isDomainAllowed = x
})
})
let isDomainAllowed: undefined | boolean = $state(undefined)
let auto_invite = $state(false)
let operatorOnly = $state(false)
let selected: Exclude<AIProvider, 'customai'> = $state('openai')
run(() => {
id = name.toLowerCase().replace(/\s/gi, '-')
})
run(() => {
validateName(id)
})
run(() => {
errorUser = validateUsername(username)
})
run(() => {
colorEnabled && !workspaceColor && generateRandomColor()
})
let domain = $derived($usersWorkspaceStore?.email.split('@')[1])
</script>
<CenteredModal title="{isFork ? 'Forking' : 'New'} Workspace">
<div class="flex flex-col gap-8">
{#if isFork}
<div class="flex flex-block gap-2">
<GitFork size={16} />
<span class="text-xs text-normal">Forking </span>
<span class="text-xs text-emphasis font-semibold">
{$workspaceStore}
</span>
</div>
{/if}
<label class="flex flex-col gap-1">
{#if isFork}
<span class="text-xs font-semibold text-emphasis">Fork name</span>
<span class="text-xs text-secondary">Displayable name of the forked workspace</span>
{:else}
<span class="text-xs font-semibold text-emphasis">Workspace name</span>
<span class="text-xs text-secondary">Displayable name</span>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<TextInput inputProps={{ autofocus: true }} bind:value={name} />
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace ID</span>
{#if isFork}
<span class="text-2xs text-secondary"
>Slug to uniquely identify your fork (this will also set the branch name)</span
>
{:else}
<span class="text-2xs text-secondary">Slug to uniquely identify your workspace</span>
{/if}
{#if isFork}
<PrefixedInput
prefix={WM_FORK_PREFIX}
type="text"
bind:value={id}
placeholder="example.com"
class={errorId != '' ? 'input-error' : ''}
/>
{:else}
<TextInput bind:value={id} error={errorId} />
{/if}
{#if errorId}
<span class="text-red-500 text-2xs font-normal">{errorId}</span>
{/if}
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Workspace color</span>
<span class="text-xs text-secondary"
>Color to identify the current workspace in the list of workspaces</span
>
<div class="flex items-center gap-4">
<Toggle bind:checked={colorEnabled} options={{ right: 'Enable' }} />
{#if colorEnabled}
<div class="flex items-center gap-1 grow">
<input
class="grow min-w-10"
type="color"
bind:value={workspaceColor}
disabled={!colorEnabled}
/>
<TextInput
class="w-24"
bind:value={workspaceColor}
inputProps={{ disabled: !colorEnabled }}
/>
<Button
on:click={generateRandomColor}
size="xs"
variant="default"
disabled={!colorEnabled}>Random</Button
>
</div>
{/if}
</div>
</label>
{#if !automateUsernameCreation}
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Your username in that workspace</span>
<TextInput bind:value={username} inputProps={{ onkeyup: handleKeyUp }} error={errorUser} />
{#if errorUser}
<span class="text-red-500 text-2xs">{errorUser}</span>
{/if}
</label>
{/if}
{#if !isFork}
<div class="block">
<div class="flex flex-col gap-1">
<label for="ai-key" class="flex flex-row gap-2">
<span class="text-xs font-semibold text-emphasis">
AI key for Windmill AI
<Tooltip>
Find out how it can help you <a
href="https://www.windmill.dev/docs/core_concepts/ai_generation"
target="_blank"
rel="noopener noreferrer">in the docs</a
>
</Tooltip>
</span>
<span class="text-2xs text-secondary">(optional but recommended)</span>
</label>
<ToggleButtonGroup bind:selected>
{#snippet children({ item })}
<ToggleButton value="openai" label="OpenAI" {item} />
<ToggleButton value="anthropic" label="Anthropic" {item} />
<ToggleButton value="mistral" label="Mistral" {item} />
<ToggleButton value="deepseek" label="DeepSeek" {item} />
{/snippet}
</ToggleButtonGroup>
<div class="flex flex-row gap-1">
<input
id="ai-key"
type="password"
autocomplete="new-password"
bind:value={aiKey}
onkeyup={handleKeyUp}
/>
<TestAIKey
apiKey={aiKey}
disabled={!aiKey}
aiProvider={selected}
model={AI_PROVIDERS[selected].defaultModels[0]}
/>
</div>
</div>
{#if aiKey}
<div class="flex flex-col gap-2 mt-2">
<Toggle
disabled={!aiKey}
bind:checked={codeCompletionEnabled}
options={{ right: 'Enable code completion' }}
/>
</div>
{/if}
</div>
<div class="flex flex-col gap-1">
<label for="auto-invite" class="text-xs font-semibold text-emphasis"
>{isCloudHosted()
? `Auto-invite anyone from ${domain}`
: `Auto-invite anyone joining the instance`}</label
>
<Toggle
id="auto-invite"
disabled={isCloudHosted() && !isDomainAllowed}
bind:checked={auto_invite}
/>
{#if isCloudHosted() && isDomainAllowed == false}
<div class="text-secondary text-2xs">{domain} domain not allowed for auto-invite</div>
{/if}
{#if auto_invite}
<div class="bg-surface-tertiary p-4 rounded-md flex flex-col gap-8">
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Mode</span>
<span class="text-xs text-secondary font-normal"
>Whether to invite or add users directly to the workspace.</span
>
</label>
<label class="font-semibold flex flex-col gap-1">
<span class="text-xs font-semibold text-emphasis">Role</span>
<span class="text-xs text-secondary font-normal">Role of the auto-invited users</span>
<ToggleButtonGroup
selected={operatorOnly ? 'operator' : 'developer'}
on:selected={(e) => {
operatorOnly = e.detail == 'operator'
}}
>
{#snippet children({ item })}
<ToggleButton value="operator" label="Operator" {item} />
<ToggleButton value="developer" label="Developer" {item} />
{/snippet}
</ToggleButtonGroup>
</label>
</div>
{/if}
</div>
{/if}
<div class="flex flex-wrap flex-row justify-between pt-12 gap-4">
<Button variant="default" size="sm" href="{base}/user/workspaces"
>&leftarrow; Back to workspaces</Button
>
<Button
variant="accent"
disabled={checking ||
errorId != '' ||
!name ||
(!automateUsernameCreation && (errorUser != '' || !username)) ||
!id}
on:click={createOrForkWorkspace}
>
{#if isFork}
Fork workspace
{:else}
Create workspace
{/if}
</Button>
</div>
</div>
</CenteredModal>