diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..cf88b5c1a24122c8d1228839f4fc75759fef5a58 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,15 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +_greenlet.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +_psycopg.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +_pydantic_core.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +collections.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +immutabledict.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +pgproto.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +processors.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +protocol.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +python3.9 filter=lfs diff=lfs merge=lfs -text +resultproxy.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text +t64.exe filter=lfs diff=lfs merge=lfs -text +util.cpython-39-darwin.so filter=lfs diff=lfs merge=lfs -text diff --git a/22dc0511fe69_add_toolsource_table.cpython-311.pyc b/22dc0511fe69_add_toolsource_table.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23f8c1898f444f774898487f7f8aae4fa507fb22 Binary files /dev/null and b/22dc0511fe69_add_toolsource_table.cpython-311.pyc differ diff --git a/2ea570019b8f_add_apikey_table.cpython-311.pyc b/2ea570019b8f_add_apikey_table.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e4866820c5231932a4611ed67755e718b683a5c1 Binary files /dev/null and b/2ea570019b8f_add_apikey_table.cpython-311.pyc differ diff --git a/2ea570019b8f_add_apikey_table.py b/2ea570019b8f_add_apikey_table.py new file mode 100644 index 0000000000000000000000000000000000000000..680d779311de7ea7d9fef85667c7f447fe531d8d --- /dev/null +++ b/2ea570019b8f_add_apikey_table.py @@ -0,0 +1,58 @@ +"""Add ApiKey table + +Revision ID: 2ea570019b8f +Revises: 4af13678b83c +Create Date: 2025-05-03 18:56:32.989446 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '2ea570019b8f' +down_revision: Union[str, None] = '4af13678b83c' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - adjusted ### + op.create_table('api_keys', + sa.Column('id', sa.UUID(), nullable=False), + sa.Column('user_id', sa.UUID(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('key_prefix', sa.String(length=8), nullable=False), + sa.Column('hashed_key', sa.String(), nullable=False), + sa.Column('scopes', postgresql.ARRAY(sa.String()), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_api_keys_user_id_users_id')), + sa.PrimaryKeyConstraint('id', name=op.f('pk_api_keys')) + ) + with op.batch_alter_table('api_keys', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_api_keys_hashed_key'), ['hashed_key'], unique=False) + batch_op.create_index(batch_op.f('ix_api_keys_key_prefix'), ['key_prefix'], unique=True) + batch_op.create_index(batch_op.f('ix_api_keys_user_id'), ['user_id'], unique=False) + + # Removed incorrect drop/alter table commands for other tables + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - adjusted ### + with op.batch_alter_table('api_keys', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_api_keys_user_id')) + batch_op.drop_index(batch_op.f('ix_api_keys_key_prefix')) + batch_op.drop_index(batch_op.f('ix_api_keys_hashed_key')) + + op.drop_table('api_keys') + # Removed incorrect create/alter table commands for other tables + # ### end Alembic commands ### + diff --git a/4af13678b83c_add_toolsource_table.cpython-311.pyc b/4af13678b83c_add_toolsource_table.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cc7f9efc3a6f55a3c8072eb5e0a88179bb367657 Binary files /dev/null and b/4af13678b83c_add_toolsource_table.cpython-311.pyc differ diff --git a/4af13678b83c_add_toolsource_table.py b/4af13678b83c_add_toolsource_table.py new file mode 100644 index 0000000000000000000000000000000000000000..a7637123b7bbbab03504350917da2351ee466325 --- /dev/null +++ b/4af13678b83c_add_toolsource_table.py @@ -0,0 +1,50 @@ +"""Add ToolSource table + +Revision ID: 4af13678b83c +Revises: e2ca2546bf71 +Create Date: 2025-05-03 18:51:11.601728 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '4af13678b83c' +down_revision: Union[str, None] = 'e2ca2546bf71' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + # Manually corrected: Remove incorrect drop commands and add create_table for tool_sources + op.create_table('tool_sources', + sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False), + sa.Column('github_url', sa.String(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(), nullable=False, server_default='active'), # Match default from model + sa.Column('last_checked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_tool_sources')) + ) + with op.batch_alter_table('tool_sources', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_tool_sources_github_url'), ['github_url'], unique=True) + batch_op.create_index(batch_op.f('ix_tool_sources_status'), ['status'], unique=False) + + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('tool_sources', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_tool_sources_status')) + batch_op.drop_index(batch_op.f('ix_tool_sources_github_url')) + + op.drop_table('tool_sources') + # ### end Alembic commands ### + diff --git a/APIKeyManager.tsx b/APIKeyManager.tsx new file mode 100644 index 0000000000000000000000000000000000000000..92263363fac08188fe40a3e4f4aca722c05d4b9f --- /dev/null +++ b/APIKeyManager.tsx @@ -0,0 +1,169 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { IconButton } from '~/components/ui/IconButton'; +import type { ProviderInfo } from '~/types/model'; +import Cookies from 'js-cookie'; + +interface APIKeyManagerProps { + provider: ProviderInfo; + apiKey: string; + setApiKey: (key: string) => void; + getApiKeyLink?: string; + labelForGetApiKey?: string; +} + +// cache which stores whether the provider's API key is set via environment variable +const providerEnvKeyStatusCache: Record = {}; + +const apiKeyMemoizeCache: { [k: string]: Record } = {}; + +export function getApiKeysFromCookies() { + const storedApiKeys = Cookies.get('apiKeys'); + let parsedKeys: Record = {}; + + if (storedApiKeys) { + parsedKeys = apiKeyMemoizeCache[storedApiKeys]; + + if (!parsedKeys) { + parsedKeys = apiKeyMemoizeCache[storedApiKeys] = JSON.parse(storedApiKeys); + } + } + + return parsedKeys; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention +export const APIKeyManager: React.FC = ({ provider, apiKey, setApiKey }) => { + const [isEditing, setIsEditing] = useState(false); + const [tempKey, setTempKey] = useState(apiKey); + const [isEnvKeySet, setIsEnvKeySet] = useState(false); + + // Reset states and load saved key when provider changes + useEffect(() => { + // Load saved API key from cookies for this provider + const savedKeys = getApiKeysFromCookies(); + const savedKey = savedKeys[provider.name] || ''; + + setTempKey(savedKey); + setApiKey(savedKey); + setIsEditing(false); + }, [provider.name]); + + const checkEnvApiKey = useCallback(async () => { + // Check cache first + if (providerEnvKeyStatusCache[provider.name] !== undefined) { + setIsEnvKeySet(providerEnvKeyStatusCache[provider.name]); + return; + } + + try { + const response = await fetch(`/api/check-env-key?provider=${encodeURIComponent(provider.name)}`); + const data = await response.json(); + const isSet = (data as { isSet: boolean }).isSet; + + // Cache the result + providerEnvKeyStatusCache[provider.name] = isSet; + setIsEnvKeySet(isSet); + } catch (error) { + console.error('Failed to check environment API key:', error); + setIsEnvKeySet(false); + } + }, [provider.name]); + + useEffect(() => { + checkEnvApiKey(); + }, [checkEnvApiKey]); + + const handleSave = () => { + // Save to parent state + setApiKey(tempKey); + + // Save to cookies + const currentKeys = getApiKeysFromCookies(); + const newKeys = { ...currentKeys, [provider.name]: tempKey }; + Cookies.set('apiKeys', JSON.stringify(newKeys)); + + setIsEditing(false); + }; + + return ( +
+
+
+ {provider?.name} API Key: + {!isEditing && ( +
+ {apiKey ? ( + <> +
+ Set via UI + + ) : isEnvKeySet ? ( + <> +
+ Set via environment variable + + ) : ( + <> +
+ Not Set (Please set via UI or ENV_VAR) + + )} +
+ )} +
+
+ +
+ {isEditing ? ( +
+ setTempKey(e.target.value)} + className="w-[300px] px-3 py-1.5 text-sm rounded border border-bolt-elements-borderColor + bg-bolt-elements-prompt-background text-bolt-elements-textPrimary + focus:outline-none focus:ring-2 focus:ring-bolt-elements-focus" + /> + +
+ + setIsEditing(false)} + title="Cancel" + className="bg-red-500/10 hover:bg-red-500/20 text-red-500" + > +
+ +
+ ) : ( + <> + { + setIsEditing(true)} + title="Edit API Key" + className="bg-blue-500/10 hover:bg-blue-500/20 text-blue-500" + > +
+ + } + {provider?.getApiKeyLink && !apiKey && ( + window.open(provider?.getApiKeyLink)} + title="Get API Key" + className="bg-purple-500/10 hover:bg-purple-500/20 text-purple-500 flex items-center gap-2" + > + {provider?.labelForGetApiKey || 'Get API Key'} +
+ + )} + + )} +
+
+ ); +}; diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 0000000000000000000000000000000000000000..64bc938f68869605db8d6fe02d91635ea610fb73 --- /dev/null +++ b/AUTHORS @@ -0,0 +1,6 @@ +Main contributors +================= + +MagicStack Inc.: + Elvis Pranskevichus + Yury Selivanov diff --git a/Activate.ps1 b/Activate.ps1 new file mode 100644 index 0000000000000000000000000000000000000000..2fb3852c3cf1a565ccf813f876a135ecf6f99712 --- /dev/null +++ b/Activate.ps1 @@ -0,0 +1,241 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virutal environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/Artifact.tsx b/Artifact.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5f0c99106df922b915de3bf5a64d49d94c2a0864 --- /dev/null +++ b/Artifact.tsx @@ -0,0 +1,263 @@ +import { useStore } from '@nanostores/react'; +import { AnimatePresence, motion } from 'framer-motion'; +import { computed } from 'nanostores'; +import { memo, useEffect, useRef, useState } from 'react'; +import { createHighlighter, type BundledLanguage, type BundledTheme, type HighlighterGeneric } from 'shiki'; +import type { ActionState } from '~/lib/runtime/action-runner'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { classNames } from '~/utils/classNames'; +import { cubicEasingFn } from '~/utils/easings'; +import { WORK_DIR } from '~/utils/constants'; + +const highlighterOptions = { + langs: ['shell'], + themes: ['light-plus', 'dark-plus'], +}; + +const shellHighlighter: HighlighterGeneric = + import.meta.hot?.data.shellHighlighter ?? (await createHighlighter(highlighterOptions)); + +if (import.meta.hot) { + import.meta.hot.data.shellHighlighter = shellHighlighter; +} + +interface ArtifactProps { + messageId: string; +} + +export const Artifact = memo(({ messageId }: ArtifactProps) => { + const userToggledActions = useRef(false); + const [showActions, setShowActions] = useState(false); + const [allActionFinished, setAllActionFinished] = useState(false); + + const artifacts = useStore(workbenchStore.artifacts); + const artifact = artifacts[messageId]; + + const actions = useStore( + computed(artifact.runner.actions, (actions) => { + return Object.values(actions); + }), + ); + + const toggleActions = () => { + userToggledActions.current = true; + setShowActions(!showActions); + }; + + useEffect(() => { + if (actions.length && !showActions && !userToggledActions.current) { + setShowActions(true); + } + + if (actions.length !== 0 && artifact.type === 'bundled') { + const finished = !actions.find((action) => action.status !== 'complete'); + + if (allActionFinished !== finished) { + setAllActionFinished(finished); + } + } + }, [actions]); + + return ( +
+
+ +
+ + {actions.length && artifact.type !== 'bundled' && ( + +
+
+
+
+ )} +
+
+ + {artifact.type !== 'bundled' && showActions && actions.length > 0 && ( + +
+ +
+ +
+ + )} + +
+ ); +}); + +interface ShellCodeBlockProps { + classsName?: string; + code: string; +} + +function ShellCodeBlock({ classsName, code }: ShellCodeBlockProps) { + return ( +
+ ); +} + +interface ActionListProps { + actions: ActionState[]; +} + +const actionVariants = { + hidden: { opacity: 0, y: 20 }, + visible: { opacity: 1, y: 0 }, +}; + +function openArtifactInWorkbench(filePath: any) { + if (workbenchStore.currentView.get() !== 'code') { + workbenchStore.currentView.set('code'); + } + + workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`); +} + +const ActionList = memo(({ actions }: ActionListProps) => { + return ( + +
    + {actions.map((action, index) => { + const { status, type, content } = action; + const isLast = index === actions.length - 1; + + return ( + +
    +
    + {status === 'running' ? ( + <> + {type !== 'start' ? ( +
    + ) : ( +
    + )} + + ) : status === 'pending' ? ( +
    + ) : status === 'complete' ? ( +
    + ) : status === 'failed' || status === 'aborted' ? ( +
    + ) : null} +
    + {type === 'file' ? ( +
    + Create{' '} + openArtifactInWorkbench(action.filePath)} + > + {action.filePath} + +
    + ) : type === 'shell' ? ( +
    + Run command +
    + ) : type === 'start' ? ( + { + e.preventDefault(); + workbenchStore.currentView.set('preview'); + }} + className="flex items-center w-full min-h-[28px]" + > + Start Application + + ) : null} +
    + {(type === 'shell' || type === 'start') && ( + + )} +
    + ); + })} +
+
+ ); +}); + +function getIconColor(status: ActionState['status']) { + switch (status) { + case 'pending': { + return 'text-bolt-elements-textTertiary'; + } + case 'running': { + return 'text-bolt-elements-loader-progress'; + } + case 'complete': { + return 'text-bolt-elements-icon-success'; + } + case 'aborted': { + return 'text-bolt-elements-textSecondary'; + } + case 'failed': { + return 'text-bolt-elements-icon-error'; + } + default: { + return undefined; + } + } +} diff --git a/AssistantMessage.tsx b/AssistantMessage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1e3ed2d98b5b4e5afe525bf4f99979993f93011b --- /dev/null +++ b/AssistantMessage.tsx @@ -0,0 +1,113 @@ +import { memo } from 'react'; +import { Markdown } from './Markdown'; +import type { JSONValue } from 'ai'; +import Popover from '~/components/ui/Popover'; +import { workbenchStore } from '~/lib/stores/workbench'; +import { WORK_DIR } from '~/utils/constants'; + +interface AssistantMessageProps { + content: string; + annotations?: JSONValue[]; +} + +function openArtifactInWorkbench(filePath: string) { + filePath = normalizedFilePath(filePath); + + if (workbenchStore.currentView.get() !== 'code') { + workbenchStore.currentView.set('code'); + } + + workbenchStore.setSelectedFile(`${WORK_DIR}/${filePath}`); +} + +function normalizedFilePath(path: string) { + let normalizedPath = path; + + if (normalizedPath.startsWith(WORK_DIR)) { + normalizedPath = path.replace(WORK_DIR, ''); + } + + if (normalizedPath.startsWith('/')) { + normalizedPath = normalizedPath.slice(1); + } + + return normalizedPath; +} + +export const AssistantMessage = memo(({ content, annotations }: AssistantMessageProps) => { + const filteredAnnotations = (annotations?.filter( + (annotation: JSONValue) => annotation && typeof annotation === 'object' && Object.keys(annotation).includes('type'), + ) || []) as { type: string; value: any } & { [key: string]: any }[]; + + let chatSummary: string | undefined = undefined; + + if (filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')) { + chatSummary = filteredAnnotations.find((annotation) => annotation.type === 'chatSummary')?.summary; + } + + let codeContext: string[] | undefined = undefined; + + if (filteredAnnotations.find((annotation) => annotation.type === 'codeContext')) { + codeContext = filteredAnnotations.find((annotation) => annotation.type === 'codeContext')?.files; + } + + const usage: { + completionTokens: number; + promptTokens: number; + totalTokens: number; + } = filteredAnnotations.find((annotation) => annotation.type === 'usage')?.value; + + return ( +
+ <> +
+ {(codeContext || chatSummary) && ( + }> + {chatSummary && ( +
+
+

Summary

+
+ {chatSummary} +
+
+ {codeContext && ( +
+

Context

+
+ {codeContext.map((x) => { + const normalized = normalizedFilePath(x); + return ( + <> + { + e.preventDefault(); + e.stopPropagation(); + openArtifactInWorkbench(normalized); + }} + > + {normalized} + + + ); + })} +
+
+ )} +
+ )} +
+
+ )} + {usage && ( +
+ Tokens: {usage.totalTokens} (prompt: {usage.promptTokens}, completion: {usage.completionTokens}) +
+ )} +
+ + {content} +
+ ); +}); diff --git a/AvatarDropdown.tsx b/AvatarDropdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6adfd31d3cb51f850d981d5efe8110cc0d0f223b --- /dev/null +++ b/AvatarDropdown.tsx @@ -0,0 +1,158 @@ +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; +import { motion } from 'framer-motion'; +import { useStore } from '@nanostores/react'; +import { classNames } from '~/utils/classNames'; +import { profileStore } from '~/lib/stores/profile'; +import type { TabType, Profile } from './types'; + +const BetaLabel = () => ( + + BETA + +); + +interface AvatarDropdownProps { + onSelectTab: (tab: TabType) => void; +} + +export const AvatarDropdown = ({ onSelectTab }: AvatarDropdownProps) => { + const profile = useStore(profileStore) as Profile; + + return ( + + + + {profile?.avatar ? ( + {profile?.username + ) : ( +
+
+
+ )} + + + + + +
+
+ {profile?.avatar ? ( + {profile?.username + ) : ( +
+ ? +
+ )} +
+
+
+ {profile?.username || 'Guest User'} +
+ {profile?.bio &&
{profile.bio}
} +
+
+ + onSelectTab('profile')} + > +
+ Edit Profile + + + onSelectTab('settings')} + > +
+ Settings + + +
+ + onSelectTab('task-manager')} + > +
+ Task Manager + + + + onSelectTab('service-status')} + > +
+ Service Status + + + + + + ); +}; diff --git a/BaseChat.module.scss b/BaseChat.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..4908e34e05d90eb08c7da48946631d5a68ee7e2e --- /dev/null +++ b/BaseChat.module.scss @@ -0,0 +1,47 @@ +.BaseChat { + &[data-chat-visible='false'] { + --workbench-inner-width: 100%; + --workbench-left: 0; + + .Chat { + --at-apply: bolt-ease-cubic-bezier; + transition-property: transform, opacity; + transition-duration: 0.3s; + will-change: transform, opacity; + transform: translateX(-50%); + opacity: 0; + } + } +} + +.Chat { + opacity: 1; +} + +.PromptEffectContainer { + --prompt-container-offset: 50px; + --prompt-line-stroke-width: 1px; + position: absolute; + pointer-events: none; + inset: calc(var(--prompt-container-offset) / -2); + width: calc(100% + var(--prompt-container-offset)); + height: calc(100% + var(--prompt-container-offset)); +} + +.PromptEffectLine { + width: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width)); + height: calc(100% - var(--prompt-container-offset) + var(--prompt-line-stroke-width)); + x: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2); + y: calc(var(--prompt-container-offset) / 2 - var(--prompt-line-stroke-width) / 2); + rx: calc(8px - var(--prompt-line-stroke-width)); + fill: transparent; + stroke-width: var(--prompt-line-stroke-width); + stroke: url(#line-gradient); + stroke-dasharray: 35px 65px; + stroke-dashoffset: 10; +} + +.PromptShine { + fill: url(#shine-gradient); + mix-blend-mode: overlay; +} diff --git a/BaseChat.tsx b/BaseChat.tsx new file mode 100644 index 0000000000000000000000000000000000000000..12929b10fb420f35bf12ae39ba3797757abd2463 --- /dev/null +++ b/BaseChat.tsx @@ -0,0 +1,630 @@ +/* + * @ts-nocheck + * Preventing TS checks with files presented in the video for a better presentation. + */ +import type { JSONValue, Message } from 'ai'; +import React, { type RefCallback, useEffect, useState } from 'react'; +import { ClientOnly } from 'remix-utils/client-only'; +import { Menu } from '~/components/sidebar/Menu.client'; +import { IconButton } from '~/components/ui/IconButton'; +import { Workbench } from '~/components/workbench/Workbench.client'; +import { classNames } from '~/utils/classNames'; +import { PROVIDER_LIST } from '~/utils/constants'; +import { Messages } from './Messages.client'; +import { SendButton } from './SendButton.client'; +import { APIKeyManager, getApiKeysFromCookies } from './APIKeyManager'; +import Cookies from 'js-cookie'; +import * as Tooltip from '@radix-ui/react-tooltip'; + +import styles from './BaseChat.module.scss'; +import { ExportChatButton } from '~/components/chat/chatExportAndImport/ExportChatButton'; +import { ImportButtons } from '~/components/chat/chatExportAndImport/ImportButtons'; +import { ExamplePrompts } from '~/components/chat/ExamplePrompts'; +import GitCloneButton from './GitCloneButton'; + +import FilePreview from './FilePreview'; +import { ModelSelector } from '~/components/chat/ModelSelector'; +import { SpeechRecognitionButton } from '~/components/chat/SpeechRecognition'; +import type { ProviderInfo } from '~/types/model'; +import { ScreenshotStateManager } from './ScreenshotStateManager'; +import { toast } from 'react-toastify'; +import StarterTemplates from './StarterTemplates'; +import type { ActionAlert } from '~/types/actions'; +import ChatAlert from './ChatAlert'; +import type { ModelInfo } from '~/lib/modules/llm/types'; +import ProgressCompilation from './ProgressCompilation'; +import type { ProgressAnnotation } from '~/types/context'; +import type { ActionRunner } from '~/lib/runtime/action-runner'; +import { LOCAL_PROVIDERS } from '~/lib/stores/settings'; + +const TEXTAREA_MIN_HEIGHT = 76; + +interface BaseChatProps { + textareaRef?: React.RefObject | undefined; + messageRef?: RefCallback | undefined; + scrollRef?: RefCallback | undefined; + showChat?: boolean; + chatStarted?: boolean; + isStreaming?: boolean; + onStreamingChange?: (streaming: boolean) => void; + messages?: Message[]; + description?: string; + enhancingPrompt?: boolean; + promptEnhanced?: boolean; + input?: string; + model?: string; + setModel?: (model: string) => void; + provider?: ProviderInfo; + setProvider?: (provider: ProviderInfo) => void; + providerList?: ProviderInfo[]; + handleStop?: () => void; + sendMessage?: (event: React.UIEvent, messageInput?: string) => void; + handleInputChange?: (event: React.ChangeEvent) => void; + enhancePrompt?: () => void; + importChat?: (description: string, messages: Message[]) => Promise; + exportChat?: () => void; + uploadedFiles?: File[]; + setUploadedFiles?: (files: File[]) => void; + imageDataList?: string[]; + setImageDataList?: (dataList: string[]) => void; + actionAlert?: ActionAlert; + clearAlert?: () => void; + data?: JSONValue[] | undefined; + actionRunner?: ActionRunner; +} + +export const BaseChat = React.forwardRef( + ( + { + textareaRef, + messageRef, + scrollRef, + showChat = true, + chatStarted = false, + isStreaming = false, + onStreamingChange, + model, + setModel, + provider, + setProvider, + providerList, + input = '', + enhancingPrompt, + handleInputChange, + + // promptEnhanced, + enhancePrompt, + sendMessage, + handleStop, + importChat, + exportChat, + uploadedFiles = [], + setUploadedFiles, + imageDataList = [], + setImageDataList, + messages, + actionAlert, + clearAlert, + data, + actionRunner, + }, + ref, + ) => { + const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200; + const [apiKeys, setApiKeys] = useState>(getApiKeysFromCookies()); + const [modelList, setModelList] = useState([]); + const [isModelSettingsCollapsed, setIsModelSettingsCollapsed] = useState(false); + const [isListening, setIsListening] = useState(false); + const [recognition, setRecognition] = useState(null); + const [transcript, setTranscript] = useState(''); + const [isModelLoading, setIsModelLoading] = useState('all'); + const [progressAnnotations, setProgressAnnotations] = useState([]); + useEffect(() => { + if (data) { + const progressList = data.filter( + (x) => typeof x === 'object' && (x as any).type === 'progress', + ) as ProgressAnnotation[]; + setProgressAnnotations(progressList); + } + }, [data]); + useEffect(() => { + console.log(transcript); + }, [transcript]); + + useEffect(() => { + onStreamingChange?.(isStreaming); + }, [isStreaming, onStreamingChange]); + + useEffect(() => { + if (typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window)) { + const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; + const recognition = new SpeechRecognition(); + recognition.continuous = true; + recognition.interimResults = true; + + recognition.onresult = (event) => { + const transcript = Array.from(event.results) + .map((result) => result[0]) + .map((result) => result.transcript) + .join(''); + + setTranscript(transcript); + + if (handleInputChange) { + const syntheticEvent = { + target: { value: transcript }, + } as React.ChangeEvent; + handleInputChange(syntheticEvent); + } + }; + + recognition.onerror = (event) => { + console.error('Speech recognition error:', event.error); + setIsListening(false); + }; + + setRecognition(recognition); + } + }, []); + + useEffect(() => { + if (typeof window !== 'undefined') { + let parsedApiKeys: Record | undefined = {}; + + try { + parsedApiKeys = getApiKeysFromCookies(); + setApiKeys(parsedApiKeys); + } catch (error) { + console.error('Error loading API keys from cookies:', error); + Cookies.remove('apiKeys'); + } + + setIsModelLoading('all'); + fetch('/api/models') + .then((response) => response.json()) + .then((data) => { + const typedData = data as { modelList: ModelInfo[] }; + setModelList(typedData.modelList); + }) + .catch((error) => { + console.error('Error fetching model list:', error); + }) + .finally(() => { + setIsModelLoading(undefined); + }); + } + }, [providerList, provider]); + + const onApiKeysChange = async (providerName: string, apiKey: string) => { + const newApiKeys = { ...apiKeys, [providerName]: apiKey }; + setApiKeys(newApiKeys); + Cookies.set('apiKeys', JSON.stringify(newApiKeys)); + + setIsModelLoading(providerName); + + let providerModels: ModelInfo[] = []; + + try { + const response = await fetch(`/api/models/${encodeURIComponent(providerName)}`); + const data = await response.json(); + providerModels = (data as { modelList: ModelInfo[] }).modelList; + } catch (error) { + console.error('Error loading dynamic models for:', providerName, error); + } + + // Only update models for the specific provider + setModelList((prevModels) => { + const otherModels = prevModels.filter((model) => model.provider !== providerName); + return [...otherModels, ...providerModels]; + }); + setIsModelLoading(undefined); + }; + + const startListening = () => { + if (recognition) { + recognition.start(); + setIsListening(true); + } + }; + + const stopListening = () => { + if (recognition) { + recognition.stop(); + setIsListening(false); + } + }; + + const handleSendMessage = (event: React.UIEvent, messageInput?: string) => { + if (sendMessage) { + sendMessage(event, messageInput); + + if (recognition) { + recognition.abort(); // Stop current recognition + setTranscript(''); // Clear transcript + setIsListening(false); + + // Clear the input by triggering handleInputChange with empty value + if (handleInputChange) { + const syntheticEvent = { + target: { value: '' }, + } as React.ChangeEvent; + handleInputChange(syntheticEvent); + } + } + } + }; + + const handleFileUpload = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = 'image/*'; + + input.onchange = async (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + + if (file) { + const reader = new FileReader(); + + reader.onload = (e) => { + const base64Image = e.target?.result as string; + setUploadedFiles?.([...uploadedFiles, file]); + setImageDataList?.([...imageDataList, base64Image]); + }; + reader.readAsDataURL(file); + } + }; + + input.click(); + }; + + const handlePaste = async (e: React.ClipboardEvent) => { + const items = e.clipboardData?.items; + + if (!items) { + return; + } + + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + + const file = item.getAsFile(); + + if (file) { + const reader = new FileReader(); + + reader.onload = (e) => { + const base64Image = e.target?.result as string; + setUploadedFiles?.([...uploadedFiles, file]); + setImageDataList?.([...imageDataList, base64Image]); + }; + reader.readAsDataURL(file); + } + + break; + } + } + }; + + const baseChat = ( +
+ {() => } +
+
+ {!chatStarted && ( +
+

+ Where ideas begin +

+

+ Bring ideas to life in seconds or get help on existing projects. +

+
+ )} +
+ + {() => { + return chatStarted ? ( + + ) : null; + }} + +
+
+ {actionAlert && ( + clearAlert?.()} + postMessage={(message) => { + sendMessage?.({} as any, message); + clearAlert?.(); + }} + /> + )} +
+ {progressAnnotations && } +
+ + + + + + + + + + + + + + + + + + +
+ + {() => ( +
+ + {(providerList || []).length > 0 && provider && !LOCAL_PROVIDERS.includes(provider.name) && ( + { + onApiKeysChange(provider.name, key); + }} + /> + )} +
+ )} +
+
+ { + setUploadedFiles?.(uploadedFiles.filter((_, i) => i !== index)); + setImageDataList?.(imageDataList.filter((_, i) => i !== index)); + }} + /> + + {() => ( + + )} + +
+