mirror of
https://github.com/simstudioai/sim.git
synced 2026-08-29 02:27:35 +08:00
feat(setup): add incremental Chat setup (#7043)
* feat(setup): add incremental Chat setup * fix(setup): retain existing Compose Chat config
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
@@ -630,7 +630,7 @@
|
||||
},
|
||||
"packages/sim-setup": {
|
||||
"name": "sim-setup",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"bin": {
|
||||
"sim-setup": "dist/index.js",
|
||||
},
|
||||
|
||||
@@ -1339,9 +1339,12 @@ export const DEPLOYMENT_CONFIGURATION_KEYS: readonly string[] = [
|
||||
...new Set([
|
||||
...CORE_CONFIGURATION_KEYS,
|
||||
...ENV_CAPABILITIES.flatMap(capabilityKeys),
|
||||
'COPILOT_API_KEY',
|
||||
'EMAIL_VERIFICATION_ENABLED',
|
||||
'NEXT_PUBLIC_CHAT_DISABLED',
|
||||
'NEXT_PUBLIC_E2B_ENABLED',
|
||||
'NEXT_PUBLIC_SANDBOXES_ENABLED',
|
||||
'SIM_AGENT_API_URL',
|
||||
...Object.values(LLM_KEY_POOLS).flatMap((pool) => [
|
||||
...pool.keys,
|
||||
...('fallbackKey' in pool ? [pool.fallbackKey] : []),
|
||||
|
||||
@@ -9,3 +9,9 @@ npx sim-setup
|
||||
Outside a Sim source checkout, the command creates a Docker Compose installation using published
|
||||
images. Inside a Sim source checkout, use `bun run sim-setup` to expose the complete development
|
||||
and deployment wizard.
|
||||
|
||||
To connect or replace the Chat API key without rerunning the full wizard:
|
||||
|
||||
```bash
|
||||
npx sim-setup add chat
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sim-setup",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"description": "Set up and manage a self-hosted Sim installation",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
@@ -847,13 +847,14 @@ if (missingCapabilitySetups.length > 0) {
|
||||
}
|
||||
|
||||
export type CapabilitySetupId = (typeof CAPABILITY_SETUPS)[number]['definition']['id']
|
||||
export type SetupFeatureId = CapabilitySetupId | 'llm' | 'integration'
|
||||
export type SetupFeatureId = CapabilitySetupId | 'chat' | 'llm' | 'integration'
|
||||
|
||||
export const SETUP_FEATURES: readonly { id: SetupFeatureId; label: string }[] = [
|
||||
...CAPABILITY_SETUPS.map((setup) => ({
|
||||
id: setup.definition.id,
|
||||
label: setup.label,
|
||||
})),
|
||||
{ id: 'chat', label: 'Chat' },
|
||||
{ id: 'llm', label: 'LLM API keys' },
|
||||
{ id: 'integration', label: 'OAuth integration' },
|
||||
]
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from '@sim/deployment-config/env-capabilities'
|
||||
import { SETUP_FEATURES, type SetupFeatureId } from './capability-config'
|
||||
|
||||
export type SetupStatusFeatureId = Exclude<SetupFeatureId, 'integration'>
|
||||
export type SetupStatusFeatureId = Exclude<SetupFeatureId, 'chat' | 'integration'>
|
||||
export type CapabilityStatusState = 'default' | 'configured' | 'missing' | 'partial' | 'invalid'
|
||||
|
||||
export interface CapabilityStatusIssue {
|
||||
|
||||
@@ -86,6 +86,29 @@ describe('discoverConfigurationSources', () => {
|
||||
expect(sources[0].values?.get('RESEND_API_KEY')).toBe('current')
|
||||
})
|
||||
|
||||
it('retains Chat configuration from a prepared Compose .env file', () => {
|
||||
const root = temporaryDirectory()
|
||||
writeFileSync(
|
||||
path.join(root, '.env'),
|
||||
[
|
||||
'COPILOT_API_KEY=existing-chat-key',
|
||||
'NEXT_PUBLIC_CHAT_DISABLED=false',
|
||||
'SIM_AGENT_API_URL=https://copilot.example.com',
|
||||
].join('\n')
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(root, 'docker-compose.prod.yml'),
|
||||
'services:\n simstudio:\n image: ghcr.io/simstudioai/simstudio:latest\n env_file: .env\n'
|
||||
)
|
||||
|
||||
const sources = discoverConfigurationSources({ root, runner: () => commandResult(1) })
|
||||
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(sources[0].values?.get('COPILOT_API_KEY')).toBe('existing-chat-key')
|
||||
expect(sources[0].values?.get('NEXT_PUBLIC_CHAT_DISABLED')).toBe('false')
|
||||
expect(sources[0].values?.get('SIM_AGENT_API_URL')).toBe('https://copilot.example.com')
|
||||
})
|
||||
|
||||
it('uses the effective environment of a stopped Compose app container', () => {
|
||||
const parent = temporaryDirectory()
|
||||
const root = path.join(parent, 'checkout')
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
mockDiscoverConfigurationSources,
|
||||
mockReconcileEnvValues,
|
||||
mockPromptCopilotKey,
|
||||
mockMothershipOverride,
|
||||
mockChatFlagValues,
|
||||
mockOutro,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDiscoverConfigurationSources: vi.fn(),
|
||||
mockReconcileEnvValues: vi.fn(),
|
||||
mockPromptCopilotKey: vi.fn(),
|
||||
mockMothershipOverride: vi.fn(),
|
||||
mockChatFlagValues: vi.fn(),
|
||||
mockOutro: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./configuration-sources', () => ({
|
||||
discoverConfigurationSources: mockDiscoverConfigurationSources,
|
||||
}))
|
||||
|
||||
vi.mock('./env-files', () => ({
|
||||
reconcileEnvValues: mockReconcileEnvValues,
|
||||
}))
|
||||
|
||||
vi.mock('./prompter', () => ({
|
||||
outro: mockOutro,
|
||||
}))
|
||||
|
||||
vi.mock('./steps', () => ({
|
||||
chatFlagValues: mockChatFlagValues,
|
||||
mothershipOverride: mockMothershipOverride,
|
||||
promptCopilotKey: mockPromptCopilotKey,
|
||||
}))
|
||||
|
||||
vi.mock('./theme', () => ({
|
||||
theme: { accent: (value: string) => value },
|
||||
}))
|
||||
|
||||
import { runFeatureSetup } from './feature-setup'
|
||||
|
||||
describe('Chat feature setup', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockDiscoverConfigurationSources.mockReturnValue([
|
||||
{
|
||||
kind: 'compose',
|
||||
label: 'Docker Compose',
|
||||
location: '.env',
|
||||
values: new Map([['COPILOT_API_KEY', 'existing-key']]),
|
||||
managedByCurrentCheckout: true,
|
||||
},
|
||||
])
|
||||
mockMothershipOverride.mockReturnValue({
|
||||
SIM_AGENT_API_URL: 'https://copilot.example.com',
|
||||
})
|
||||
mockChatFlagValues.mockReturnValue({ NEXT_PUBLIC_CHAT_DISABLED: 'false' })
|
||||
})
|
||||
|
||||
it('writes only the Chat configuration to the detected install', async () => {
|
||||
mockPromptCopilotKey.mockResolvedValue('new-key')
|
||||
|
||||
await runFeatureSetup('chat', [])
|
||||
|
||||
expect(mockPromptCopilotKey).toHaveBeenCalledWith('existing-key')
|
||||
expect(mockReconcileEnvValues).toHaveBeenCalledWith('root', [], {
|
||||
COPILOT_API_KEY: 'new-key',
|
||||
NEXT_PUBLIC_CHAT_DISABLED: 'false',
|
||||
SIM_AGENT_API_URL: 'https://copilot.example.com',
|
||||
})
|
||||
expect(mockOutro).toHaveBeenCalledWith(
|
||||
'Chat written to .env. Recreate the app container for it to take effect.'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails without changing configuration when no key is received', async () => {
|
||||
mockPromptCopilotKey.mockResolvedValue(null)
|
||||
|
||||
await expect(runFeatureSetup('chat', [])).rejects.toThrow(
|
||||
'Chat setup did not receive an API key. No configuration was changed.'
|
||||
)
|
||||
expect(mockReconcileEnvValues).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import { promptCapabilitySetup } from './capability-setup'
|
||||
import { type ConfigurationSource, discoverConfigurationSources } from './configuration-sources'
|
||||
import { type EnvTarget, reconcileEnvValues } from './env-files'
|
||||
import * as p from './prompter'
|
||||
import { chatFlagValues, mothershipOverride, promptCopilotKey } from './steps'
|
||||
import { theme } from './theme'
|
||||
|
||||
function isSetupFeatureId(value: string): value is SetupFeatureId {
|
||||
@@ -113,6 +114,19 @@ async function setupLlm(vars: Map<string, string>): Promise<LlmSetupResult> {
|
||||
return reconcileLlmSetup(provider, values)
|
||||
}
|
||||
|
||||
async function setupChat(vars: Map<string, string>): Promise<Record<string, string>> {
|
||||
const overrides = mothershipOverride()
|
||||
const copilotKey = await promptCopilotKey(vars.get('COPILOT_API_KEY'))
|
||||
if (!copilotKey) {
|
||||
throw new Error('Chat setup did not receive an API key. No configuration was changed.')
|
||||
}
|
||||
return {
|
||||
...overrides,
|
||||
COPILOT_API_KEY: copilotKey,
|
||||
...chatFlagValues(copilotKey),
|
||||
}
|
||||
}
|
||||
|
||||
export function setupFeatureUsage(): string {
|
||||
return SETUP_FEATURES.map((feature) =>
|
||||
feature.id === 'integration' ? 'integration <slug>' : feature.id
|
||||
@@ -182,6 +196,9 @@ export async function runFeatureSetup(feature: string, args: readonly string[]):
|
||||
})
|
||||
values = result.values
|
||||
remove = result.remove
|
||||
} else if (feature === 'chat') {
|
||||
values = await setupChat(vars)
|
||||
remove = []
|
||||
} else if (feature === 'integration') {
|
||||
values = await setupIntegration(args[0], vars)
|
||||
remove = []
|
||||
|
||||
@@ -7,7 +7,7 @@ import { theme } from './theme'
|
||||
import { SETUP_VERSION } from './version'
|
||||
|
||||
const SETUP_FEATURES =
|
||||
'email | storage | sandbox | jobs | cache | knowledge | knowledge-embeddings | llm | integration <slug>'
|
||||
'email | storage | sandbox | jobs | cache | knowledge | knowledge-embeddings | chat | llm | integration <slug>'
|
||||
|
||||
const USAGE = `Usage:
|
||||
sim-setup [--quick] [--dir <path>] [--mode compose|dev|k8s]
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface SetupStatusReport {
|
||||
const SECRET_KEYS = new Set(['BETTER_AUTH_SECRET', 'ENCRYPTION_KEY', 'INTERNAL_API_SECRET'])
|
||||
const URL_KEYS = new Set(['DATABASE_URL', 'BETTER_AUTH_URL', 'NEXT_PUBLIC_APP_URL'])
|
||||
const FEATURE_ORDER: readonly SetupStatusFeatureId[] = SETUP_FEATURES.flatMap((feature) =>
|
||||
feature.id === 'integration' ? [] : [feature.id]
|
||||
feature.id === 'chat' || feature.id === 'integration' ? [] : [feature.id]
|
||||
)
|
||||
|
||||
function readString(values: EnvCapabilityValues, key: string): string | undefined {
|
||||
|
||||
Reference in New Issue
Block a user