chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,34 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { LEON_FILE_PATH } from '@/constants'
|
||||
import { Telemetry } from '@/telemetry'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
export default async () => {
|
||||
const status = createSetupStatus('Checking instance ID...').start()
|
||||
|
||||
try {
|
||||
const { instanceID, birthDate } = await Telemetry.postInstall()
|
||||
|
||||
if (!fs.existsSync(LEON_FILE_PATH)) {
|
||||
await fs.promises.writeFile(
|
||||
LEON_FILE_PATH,
|
||||
JSON.stringify(
|
||||
{
|
||||
instanceID,
|
||||
birthDate
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
|
||||
status.succeed('Instance ID: ready')
|
||||
} else {
|
||||
status.succeed('Instance ID: ready')
|
||||
}
|
||||
} catch (e) {
|
||||
status.warn(`Failed to create the instance ID: ${e}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
LLM_MANIFEST_PATH,
|
||||
PYTORCH_MANIFEST_PATH,
|
||||
PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH,
|
||||
PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH,
|
||||
PYTHON_TCP_SERVER_TTS_MODEL_PATH
|
||||
} from '@/constants'
|
||||
import {
|
||||
ASR_MODEL_FILES,
|
||||
TTS_BERT_BASE_MODEL_FILES
|
||||
} from '@/core/voice/voice-resource-state'
|
||||
|
||||
function readManifest(manifestPath) {
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function hasManifest(manifestPath) {
|
||||
return readManifest(manifestPath) !== null
|
||||
}
|
||||
|
||||
function hasAllFiles(directoryPath, fileNames) {
|
||||
return fileNames.every((fileName) =>
|
||||
fs.existsSync(path.join(directoryPath, fileName))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect whether Leon's default local LLM has already been installed.
|
||||
*/
|
||||
export function inspectLocalAISetupState() {
|
||||
const manifest = readManifest(LLM_MANIFEST_PATH)
|
||||
const defaultInstalledLLMPath = manifest?.defaultInstalledLLMPath
|
||||
|
||||
if (
|
||||
typeof defaultInstalledLLMPath !== 'string' ||
|
||||
defaultInstalledLLMPath.trim() === ''
|
||||
) {
|
||||
return {
|
||||
isInstalled: false,
|
||||
label: ''
|
||||
}
|
||||
}
|
||||
|
||||
const modelPath = path.resolve(defaultInstalledLLMPath)
|
||||
|
||||
return {
|
||||
isInstalled: fs.existsSync(modelPath),
|
||||
label:
|
||||
manifest?.name && manifest?.version
|
||||
? `${manifest.name} (${manifest.version})`
|
||||
: path.basename(modelPath)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect whether voice mode has already been installed at least once.
|
||||
* Version-specific updates are handled by the voice setup pass.
|
||||
*/
|
||||
export function inspectVoiceSetupState() {
|
||||
const hasVoiceModels =
|
||||
hasAllFiles(
|
||||
PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH,
|
||||
TTS_BERT_BASE_MODEL_FILES
|
||||
) &&
|
||||
fs.existsSync(PYTHON_TCP_SERVER_TTS_MODEL_PATH) &&
|
||||
hasAllFiles(PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH, ASR_MODEL_FILES)
|
||||
|
||||
return {
|
||||
isInstalled: hasManifest(PYTORCH_MANIFEST_PATH) && hasVoiceModels
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { CPUArchitectures } from '@/types'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
/**
|
||||
* Inspect whether this machine is a good fit for Leon's local AI setup.
|
||||
*/
|
||||
export default async function inspectLocalAICapability() {
|
||||
let llama = null
|
||||
|
||||
try {
|
||||
const { getLlama, LlamaLogLevel } = await Function(
|
||||
'return import("node-llama-cpp")'
|
||||
)()
|
||||
|
||||
llama = await getLlama({
|
||||
logLevel: LlamaLogLevel.disabled
|
||||
})
|
||||
} catch {
|
||||
llama = null
|
||||
}
|
||||
|
||||
const [hasGPU, gpuDeviceNames, graphicsComputeAPI, totalVRAM, canSupportLLM] =
|
||||
await Promise.all([
|
||||
SystemHelper.hasGPU(llama || undefined, { allowCoreImport: false }),
|
||||
SystemHelper.getGPUDeviceNames(llama || undefined, {
|
||||
allowCoreImport: false
|
||||
}),
|
||||
SystemHelper.getGraphicsComputeAPI(llama || undefined, {
|
||||
allowCoreImport: false
|
||||
}),
|
||||
SystemHelper.getTotalVRAM(llama || undefined, {
|
||||
allowCoreImport: false
|
||||
}),
|
||||
SystemHelper.canSupportLocalLLM(llama || undefined, {
|
||||
allowCoreImport: false
|
||||
})
|
||||
])
|
||||
|
||||
const isLinuxARM64 =
|
||||
SystemHelper.isLinux() &&
|
||||
SystemHelper.getInformation().cpuArchitecture === CPUArchitectures.ARM64
|
||||
|
||||
const canInstallLocalAI =
|
||||
(!isLinuxARM64 || (hasGPU && graphicsComputeAPI === 'cuda')) &&
|
||||
canSupportLLM
|
||||
|
||||
return {
|
||||
hasGPU,
|
||||
gpuDeviceNames,
|
||||
graphicsComputeAPI,
|
||||
totalVRAM,
|
||||
canInstallLocalAI
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
import { PNPM_RUNTIME_BIN_PATH } from '@/constants'
|
||||
import { RuntimeHelper } from '@/helpers/runtime-helper'
|
||||
|
||||
import { setupConsola } from './setup-ui'
|
||||
|
||||
const POST_SETUP_ACTIONS = [
|
||||
{
|
||||
label: 'Start me now',
|
||||
value: 'start-me-now'
|
||||
},
|
||||
{
|
||||
label: 'Finish',
|
||||
value: 'finish'
|
||||
}
|
||||
]
|
||||
|
||||
async function runLeonStartCommand() {
|
||||
const startCommand = process.platform === 'win32'
|
||||
? {
|
||||
command: 'cmd.exe',
|
||||
args: ['/c', PNPM_RUNTIME_BIN_PATH, 'start']
|
||||
}
|
||||
: {
|
||||
command: PNPM_RUNTIME_BIN_PATH,
|
||||
args: ['start']
|
||||
}
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(startCommand.command, startCommand.args, {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...RuntimeHelper.getManagedNodeEnvironment(process.env),
|
||||
LEON_OPEN_BROWSER: 'true'
|
||||
},
|
||||
stdio: 'inherit',
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
if ((code ?? 0) !== 0) {
|
||||
reject(new Error(`Leon exited with code ${code ?? 0}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolve(undefined)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default async function postSetup() {
|
||||
if (
|
||||
process.env['IS_DOCKER'] === 'true' ||
|
||||
!process.stdin.isTTY ||
|
||||
!process.stdout.isTTY
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextAction = await setupConsola.prompt('What do you want to do next?', {
|
||||
type: 'select',
|
||||
options: POST_SETUP_ACTIONS,
|
||||
initialValue: POST_SETUP_ACTIONS[1].value,
|
||||
cancel: 'default'
|
||||
})
|
||||
|
||||
if (nextAction !== 'start-me-now') {
|
||||
return
|
||||
}
|
||||
|
||||
await runLeonStartCommand()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
console.info('\x1b[36m➡ %s\x1b[0m', 'I\'m installing myself...')
|
||||
@@ -0,0 +1,98 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
const SETUP_ENTRY_PATH = path.join('scripts', 'setup', 'setup.js')
|
||||
const TSX_ENTRY_PATH = path.join(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'tsx',
|
||||
'dist',
|
||||
'cli.mjs'
|
||||
)
|
||||
const TTY_PATH = '/dev/tty'
|
||||
const WINDOWS_CONSOLE_IN_PATH = 'CONIN$'
|
||||
const WINDOWS_CONSOLE_OUT_PATH = 'CONOUT$'
|
||||
|
||||
// pnpm 11 reads project config from pnpm-workspace.yaml. That also makes root
|
||||
// lifecycle scripts run through pnpm's workspace runner, which does not expose
|
||||
// a TTY to the child process. Leon setup needs a TTY to ask setup questions, so
|
||||
// this wrapper reattaches the real setup process to the user's terminal.
|
||||
function openWindowsConsoleStdio() {
|
||||
try {
|
||||
const stdin = fs.openSync(WINDOWS_CONSOLE_IN_PATH, 'r')
|
||||
const stdout = fs.openSync(WINDOWS_CONSOLE_OUT_PATH, 'w')
|
||||
|
||||
return [stdin, stdout, stdout]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Use the controlling terminal when available. In CI, Docker, or other
|
||||
// non-interactive contexts there may be no terminal, so setup falls back to the
|
||||
// inherited stdio and keeps its existing non-interactive behavior.
|
||||
function getTTYStdio() {
|
||||
if (process.platform === 'win32') {
|
||||
return openWindowsConsoleStdio()
|
||||
}
|
||||
|
||||
if (!fs.existsSync(TTY_PATH)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const tty = fs.openSync(TTY_PATH, 'r+')
|
||||
|
||||
return [tty, tty, tty]
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function closeTTYStdio(stdio) {
|
||||
if (stdio === null) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const descriptor of new Set(stdio)) {
|
||||
fs.closeSync(descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
async function runSetup() {
|
||||
const ttyStdio = getTTYStdio()
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [TSX_ENTRY_PATH, SETUP_ENTRY_PATH], {
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
stdio: ttyStdio || 'inherit',
|
||||
windowsHide: false
|
||||
})
|
||||
|
||||
child.once('error', reject)
|
||||
child.once('exit', (code, signal) => {
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal)
|
||||
return
|
||||
}
|
||||
|
||||
if ((code ?? 0) !== 0) {
|
||||
reject(new Error(`Setup exited with code ${code ?? 0}`))
|
||||
return
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
} finally {
|
||||
closeTTYStdio(ttyStdio)
|
||||
}
|
||||
}
|
||||
|
||||
runSetup().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import { path as ffprobePath } from '@ffprobe-installer/ffprobe'
|
||||
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
export default async () => {
|
||||
const status = createSetupStatus('Checking ffprobe permissions...').start()
|
||||
|
||||
try {
|
||||
if (SystemHelper.isWindows()) {
|
||||
status.succeed('ffprobe: ready')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await fs.promises.chmod(ffprobePath, 0o755)
|
||||
|
||||
status.succeed('ffprobe: ready')
|
||||
} catch (e) {
|
||||
status.warn(`Failed to set ffprobe permissions: ${e}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
function isPlainObject(value) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge default settings into existing settings without overwriting user values.
|
||||
*/
|
||||
export function mergeMissingSettings(defaultSettings, existingSettings) {
|
||||
if (!isPlainObject(defaultSettings)) {
|
||||
return isPlainObject(existingSettings) ? existingSettings : {}
|
||||
}
|
||||
|
||||
if (!isPlainObject(existingSettings)) {
|
||||
return defaultSettings
|
||||
}
|
||||
|
||||
const mergedSettings = { ...existingSettings }
|
||||
|
||||
for (const [key, defaultValue] of Object.entries(defaultSettings)) {
|
||||
if (!Object.hasOwn(existingSettings, key)) {
|
||||
mergedSettings[key] = defaultValue
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
isPlainObject(defaultValue) &&
|
||||
isPlainObject(existingSettings[key])
|
||||
) {
|
||||
mergedSettings[key] = mergeMissingSettings(
|
||||
defaultValue,
|
||||
existingSettings[key]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return mergedSettings
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
const SETUP_BANNER = `
|
||||
██╗ ███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
|
||||
██║ ██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
|
||||
██║ █████╗ ██║ ██║██╔██╗ ██║ ███████║██║
|
||||
██║ ██╔══╝ ██║ ██║██║╚██╗██║ ██╔══██║██║
|
||||
███████╗███████╗╚██████╔╝██║ ╚████║ ██║ ██║██║
|
||||
╚══════╝╚══════╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝
|
||||
`.trim()
|
||||
|
||||
const GRADIENT_STOPS = [
|
||||
[28, 117, 219],
|
||||
[237, 41, 122]
|
||||
]
|
||||
|
||||
function getGradientColor(progress) {
|
||||
const scaledProgress = Math.min(
|
||||
GRADIENT_STOPS.length - 1,
|
||||
Math.max(0, progress) * (GRADIENT_STOPS.length - 1)
|
||||
)
|
||||
const startIndex = Math.floor(scaledProgress)
|
||||
const endIndex = Math.min(GRADIENT_STOPS.length - 1, startIndex + 1)
|
||||
const localProgress = scaledProgress - startIndex
|
||||
const startColor = GRADIENT_STOPS[startIndex]
|
||||
const endColor = GRADIENT_STOPS[endIndex]
|
||||
|
||||
return startColor.map((channel, index) =>
|
||||
Math.round(channel + (endColor[index] - channel) * localProgress)
|
||||
)
|
||||
}
|
||||
|
||||
function colorizeCharacter(character, progress) {
|
||||
if (character === ' ') {
|
||||
return character
|
||||
}
|
||||
|
||||
const [red, green, blue] = getGradientColor(progress)
|
||||
|
||||
return `\x1b[38;2;${red};${green};${blue}m${character}\x1b[0m`
|
||||
}
|
||||
|
||||
function colorizeBanner(banner) {
|
||||
const characters = [...banner]
|
||||
const visibleCharacterCount = characters.filter(
|
||||
(character) => character !== '\n' && character !== ' '
|
||||
).length
|
||||
let visibleIndex = 0
|
||||
|
||||
return characters
|
||||
.map((character) => {
|
||||
if (character === '\n') {
|
||||
return character
|
||||
}
|
||||
|
||||
const progress =
|
||||
visibleCharacterCount <= 1
|
||||
? 0
|
||||
: visibleIndex / (visibleCharacterCount - 1)
|
||||
|
||||
if (character !== ' ') {
|
||||
visibleIndex += 1
|
||||
}
|
||||
|
||||
return colorizeCharacter(character, progress)
|
||||
})
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the setup banner once at the beginning of postinstall.
|
||||
*/
|
||||
export function printSetupBanner() {
|
||||
console.log('')
|
||||
console.log(colorizeBanner(SETUP_BANNER))
|
||||
console.log('')
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { CPUArchitectures } from '@/types'
|
||||
import {
|
||||
CMAKE_PATH,
|
||||
CMAKE_INSTALL_PATH,
|
||||
CMAKE_BIN_PATH,
|
||||
CMAKE_MANIFEST_PATH,
|
||||
CMAKE_VERSION
|
||||
} from '@/constants'
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
/**
|
||||
* Download and set up Leon-managed CMake
|
||||
* 1. Resolve the pinned version from versions.json
|
||||
* 2. Download the matching Linux archive for the current architecture
|
||||
* 3. Extract it into bin/cmake/cmake/
|
||||
* 4. Always use this CMake binary for local source builds
|
||||
*/
|
||||
|
||||
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
|
||||
|
||||
function readManifest() {
|
||||
if (!fs.existsSync(CMAKE_MANIFEST_PATH)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CMAKE_MANIFEST_PATH, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanInstallDirectory() {
|
||||
await fs.promises.mkdir(CMAKE_PATH, { recursive: true })
|
||||
|
||||
const entries = await fs.promises.readdir(CMAKE_PATH, {
|
||||
withFileTypes: true
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.name !== 'versions.json')
|
||||
.map((entry) =>
|
||||
fs.promises.rm(path.join(CMAKE_PATH, entry.name), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getDownloadURL() {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return `https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-x86_64.tar.gz`
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return `https://github.com/Kitware/CMake/releases/download/v${CMAKE_VERSION}/cmake-${CMAKE_VERSION}-linux-aarch64.tar.gz`
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Linux architecture for CMake: ${CPU_ARCH}`)
|
||||
}
|
||||
|
||||
export default async function setupCMake() {
|
||||
if (!SystemHelper.isLinux()) {
|
||||
return
|
||||
}
|
||||
|
||||
const status = createSetupStatus('Downloading and setting up CMake...').start()
|
||||
|
||||
const manifest = readManifest()
|
||||
|
||||
if (manifest?.version === CMAKE_VERSION && fs.existsSync(CMAKE_BIN_PATH)) {
|
||||
status.succeed(`CMake: ${CMAKE_VERSION}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const archivePath = path.join(CMAKE_PATH, `cmake-${CMAKE_VERSION}.tar.gz`)
|
||||
|
||||
await cleanInstallDirectory()
|
||||
|
||||
try {
|
||||
status.pause()
|
||||
|
||||
await FileHelper.downloadFile(getDownloadURL(), archivePath, {
|
||||
cliProgress: true,
|
||||
parallelStreams: 3,
|
||||
skipExisting: false
|
||||
})
|
||||
status.text = 'Installing CMake...'
|
||||
status.start()
|
||||
|
||||
await FileHelper.extractArchive(archivePath, CMAKE_INSTALL_PATH, {
|
||||
stripComponents: 1
|
||||
})
|
||||
|
||||
if (!fs.existsSync(CMAKE_BIN_PATH)) {
|
||||
throw new Error(`Cannot find CMake binary at "${CMAKE_BIN_PATH}"`)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fs.promises.rm(archivePath, { force: true }),
|
||||
FileHelper.createManifestFile(CMAKE_MANIFEST_PATH, 'cmake', CMAKE_VERSION, {
|
||||
os: SystemHelper.getInformation().type,
|
||||
architecture: SystemHelper.getInformation().cpuArchitecture
|
||||
})
|
||||
])
|
||||
|
||||
status.succeed(`CMake: ${CMAKE_VERSION}`)
|
||||
} catch (error) {
|
||||
await fs.promises.rm(archivePath, { force: true })
|
||||
if (status.isSpinning) {
|
||||
status.fail('Failed to set up CMake')
|
||||
}
|
||||
throw new Error(`Failed to set up CMake: ${error}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import YAML, { isMap } from 'yaml'
|
||||
|
||||
import {
|
||||
LEON_PROFILE_PATH,
|
||||
PROFILE_CONFIG_PATH,
|
||||
PROFILE_DOT_ENV_PATH
|
||||
} from '@/leon-roots'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const CONFIG_PATH = PROFILE_CONFIG_PATH
|
||||
const CONFIG_SAMPLE_PATH = path.join(process.cwd(), 'config.sample.yml')
|
||||
const CONFIG_SCHEMA_PATH = path.join(
|
||||
process.cwd(),
|
||||
'schemas',
|
||||
'core-schemas',
|
||||
'config.json'
|
||||
)
|
||||
const PROFILE_CONFIG_SCHEMA_PATH = path.join(
|
||||
LEON_PROFILE_PATH,
|
||||
'schemas',
|
||||
'core-schemas',
|
||||
'config.json'
|
||||
)
|
||||
const LEGACY_DISABLED_PATH = path.join(LEON_PROFILE_PATH, 'disabled.json')
|
||||
const LEGACY_ALLOWED_PATH = path.join(LEON_PROFILE_PATH, 'allowed.json')
|
||||
const YAML_SCHEMA_COMMENT_PATTERN =
|
||||
/^# yaml-language-server: \$schema=.*(?:\r?\n)?/
|
||||
const PROFILE_YAML_SCHEMA_REFERENCE = './schemas/core-schemas/config.json'
|
||||
const ENV_LINE_SEPARATOR_PATTERN = /\r?\n/
|
||||
const OPTIONAL_STRING_CONFIG_PATHS = [
|
||||
['llm', 'default'],
|
||||
['llm', 'workflow'],
|
||||
['llm', 'agent'],
|
||||
['time_zone']
|
||||
]
|
||||
|
||||
function getPairKey(pair) {
|
||||
return String(pair.key?.value || pair.key || '').trim()
|
||||
}
|
||||
|
||||
function findPair(map, key) {
|
||||
return map.items.find((pair) => getPairKey(pair) === key) || null
|
||||
}
|
||||
|
||||
function mergeMissingMapPairs(targetMap, sampleMap, schema) {
|
||||
let addedCount = 0
|
||||
|
||||
for (const samplePair of sampleMap.items) {
|
||||
const key = getPairKey(samplePair)
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
|
||||
const targetPair = findPair(targetMap, key)
|
||||
if (!targetPair) {
|
||||
targetMap.items.push(samplePair.clone(schema))
|
||||
addedCount += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (isMap(targetPair.value) && isMap(samplePair.value)) {
|
||||
addedCount += mergeMissingMapPairs(
|
||||
targetPair.value,
|
||||
samplePair.value,
|
||||
schema
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return addedCount
|
||||
}
|
||||
|
||||
function withProfileSchemaReference(content) {
|
||||
const schemaComment = `# yaml-language-server: $schema=${PROFILE_YAML_SCHEMA_REFERENCE}\n`
|
||||
|
||||
if (YAML_SCHEMA_COMMENT_PATTERN.test(content)) {
|
||||
return content.replace(YAML_SCHEMA_COMMENT_PATTERN, schemaComment)
|
||||
}
|
||||
|
||||
return `${schemaComment}${content}`
|
||||
}
|
||||
|
||||
async function syncProfileConfigSchema() {
|
||||
if (!fs.existsSync(CONFIG_SCHEMA_PATH)) {
|
||||
return
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(PROFILE_CONFIG_SCHEMA_PATH), {
|
||||
recursive: true
|
||||
})
|
||||
await fs.promises.copyFile(CONFIG_SCHEMA_PATH, PROFILE_CONFIG_SCHEMA_PATH)
|
||||
}
|
||||
|
||||
function getEnvVariableName(line) {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (
|
||||
trimmedLine === '' ||
|
||||
trimmedLine.startsWith('#') ||
|
||||
!trimmedLine.includes('=')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const variableName = trimmedLine.slice(0, trimmedLine.indexOf('=')).trim()
|
||||
|
||||
return /^[A-Z0-9_]+$/.test(variableName) ? variableName : null
|
||||
}
|
||||
|
||||
async function readLegacyDotEnvVariables() {
|
||||
if (!fs.existsSync(PROFILE_DOT_ENV_PATH)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const dotEnvContent = await fs.promises.readFile(PROFILE_DOT_ENV_PATH, 'utf8')
|
||||
const values = {}
|
||||
|
||||
for (const line of dotEnvContent.split(ENV_LINE_SEPARATOR_PATTERN)) {
|
||||
const variableName = getEnvVariableName(line)
|
||||
|
||||
if (!variableName) {
|
||||
continue
|
||||
}
|
||||
|
||||
values[variableName] = line.slice(line.indexOf('=') + 1).replace(/\r$/, '')
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
function toBoolean(value) {
|
||||
const normalizedValue = String(value || '').trim().toLowerCase()
|
||||
|
||||
if (normalizedValue === 'true') {
|
||||
return true
|
||||
}
|
||||
|
||||
if (normalizedValue === 'false') {
|
||||
return false
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function toPort(value) {
|
||||
const port = Number(value)
|
||||
|
||||
return Number.isInteger(port) && port > 0 && port <= 65_535 ? port : null
|
||||
}
|
||||
|
||||
function toContextFileList(value) {
|
||||
return String(value || '')
|
||||
.split(/[,;\n]/)
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
function toOptionalString(value) {
|
||||
const normalizedValue = String(value || '').trim()
|
||||
|
||||
return normalizedValue === '' ? null : normalizedValue
|
||||
}
|
||||
|
||||
function toRoutingMode(value) {
|
||||
const normalizedValue = String(value || '').trim()
|
||||
|
||||
return normalizedValue === 'workflow' ? 'controlled' : normalizedValue
|
||||
}
|
||||
|
||||
function isEmptyList(value) {
|
||||
return !Array.isArray(value) || value.length === 0
|
||||
}
|
||||
|
||||
function readLegacyAccessConfig(configPath) {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {
|
||||
skills: [],
|
||||
tools: []
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'))
|
||||
|
||||
return {
|
||||
skills: Array.isArray(parsed?.skills) ? parsed.skills : [],
|
||||
tools: Array.isArray(parsed?.tools) ? parsed.tools : []
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
skills: [],
|
||||
tools: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAccessList(values) {
|
||||
return [...new Set(
|
||||
values
|
||||
.filter((value) => typeof value === 'string')
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0)
|
||||
)].sort((firstValue, secondValue) => firstValue.localeCompare(secondValue))
|
||||
}
|
||||
|
||||
function setDocumentValue(document, keyPath, value, shouldOverwrite) {
|
||||
if (!shouldOverwrite && document.getIn(keyPath) !== undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
document.setIn(keyPath, value)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizeOptionalStringValues(document) {
|
||||
let normalizedCount = 0
|
||||
|
||||
for (const keyPath of OPTIONAL_STRING_CONFIG_PATHS) {
|
||||
const value = document.getIn(keyPath)
|
||||
|
||||
if (typeof value !== 'string' || value.trim() !== '') {
|
||||
continue
|
||||
}
|
||||
|
||||
document.setIn(keyPath, null)
|
||||
normalizedCount += 1
|
||||
}
|
||||
|
||||
return normalizedCount
|
||||
}
|
||||
|
||||
async function migrateLegacyConfigValues(document, shouldOverwriteScalarValues) {
|
||||
let migrationCount = 0
|
||||
const envValues = await readLegacyDotEnvVariables()
|
||||
const mappings = [
|
||||
['LEON_LANG', ['language'], (value) => value.trim()],
|
||||
['LEON_HOST', ['server', 'host'], (value) => value.trim()],
|
||||
['LEON_PORT', ['server', 'port'], toPort],
|
||||
['LEON_HTTP_PLUGINS', ['http_plugins', 'enabled'], toBoolean],
|
||||
[
|
||||
'LEON_HTTP_PLUGIN_ROOT_ROUTES',
|
||||
['http_plugins', 'allow_root_routes'],
|
||||
toBoolean
|
||||
],
|
||||
[
|
||||
'LEON_HTTP_PLUGIN_AUTH',
|
||||
['http_plugins', 'auth', 'enabled'],
|
||||
toBoolean
|
||||
],
|
||||
['LEON_ROUTING_MODE', ['routing', 'mode'], toRoutingMode],
|
||||
['LEON_MOOD', ['mood', 'mode'], (value) => value.trim()],
|
||||
['LEON_LLM', ['llm', 'default'], toOptionalString],
|
||||
['LEON_WORKFLOW_LLM', ['llm', 'workflow'], toOptionalString],
|
||||
['LEON_AGENT_LLM', ['llm', 'agent'], toOptionalString],
|
||||
[
|
||||
'LEON_LLAMACPP_BASE_URL',
|
||||
['llm', 'providers', 'llamacpp', 'base_url'],
|
||||
(value) => value.trim()
|
||||
],
|
||||
[
|
||||
'LEON_SGLANG_BASE_URL',
|
||||
['llm', 'providers', 'sglang', 'base_url'],
|
||||
(value) => value.trim()
|
||||
],
|
||||
['LEON_WAKE_WORD', ['voice', 'wake_word_enabled'], toBoolean],
|
||||
['LEON_ASR', ['voice', 'asr', 'enabled'], toBoolean],
|
||||
['LEON_ASR_PROVIDER', ['voice', 'asr', 'provider'], (value) => value.trim()],
|
||||
['LEON_TTS', ['voice', 'tts', 'enabled'], toBoolean],
|
||||
['LEON_TTS_PROVIDER', ['voice', 'tts', 'provider'], (value) => value.trim()],
|
||||
['LEON_TIME_ZONE', ['time_zone'], toOptionalString],
|
||||
['LEON_AFTER_SPEECH', ['after_speech_enabled'], toBoolean],
|
||||
['LEON_TELEMETRY', ['telemetry_enabled'], toBoolean],
|
||||
[
|
||||
'LEON_PY_TCP_SERVER_HOST',
|
||||
['python_tcp_server', 'host'],
|
||||
(value) => value.trim()
|
||||
],
|
||||
['LEON_PY_TCP_SERVER_PORT', ['python_tcp_server', 'port'], toPort],
|
||||
['LEON_DISABLED_CONTEXT_FILES', ['context', 'disabled_files'], toContextFileList]
|
||||
]
|
||||
|
||||
for (const [envName, keyPath, normalizeValue] of mappings) {
|
||||
if (!Object.hasOwn(envValues, envName)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const normalizedValue = normalizeValue(envValues[envName])
|
||||
if (
|
||||
normalizedValue === null ||
|
||||
normalizedValue === undefined ||
|
||||
(typeof normalizedValue === 'string' && normalizedValue === '')
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (setDocumentValue(
|
||||
document,
|
||||
keyPath,
|
||||
normalizedValue,
|
||||
shouldOverwriteScalarValues
|
||||
)) {
|
||||
migrationCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
const legacyAllowed = readLegacyAccessConfig(LEGACY_ALLOWED_PATH)
|
||||
const legacyDisabled = readLegacyAccessConfig(LEGACY_DISABLED_PATH)
|
||||
const accessMigrations = [
|
||||
[
|
||||
['availability', 'skills', 'allowed'],
|
||||
normalizeAccessList(legacyAllowed.skills)
|
||||
],
|
||||
[
|
||||
['availability', 'tools', 'allowed'],
|
||||
normalizeAccessList(legacyAllowed.tools)
|
||||
],
|
||||
[
|
||||
['availability', 'skills', 'disabled'],
|
||||
normalizeAccessList(legacyDisabled.skills)
|
||||
],
|
||||
[
|
||||
['availability', 'tools', 'disabled'],
|
||||
normalizeAccessList(legacyDisabled.tools)
|
||||
]
|
||||
]
|
||||
|
||||
for (const [keyPath, values] of accessMigrations) {
|
||||
if (values.length === 0 || !isEmptyList(document.getIn(keyPath))) {
|
||||
continue
|
||||
}
|
||||
|
||||
document.setIn(keyPath, values)
|
||||
migrationCount += 1
|
||||
}
|
||||
|
||||
return migrationCount + normalizeOptionalStringValues(document)
|
||||
}
|
||||
|
||||
async function mergeMissingConfigKeys() {
|
||||
const sampleContent = await fs.promises.readFile(CONFIG_SAMPLE_PATH, 'utf8')
|
||||
const configContent = await fs.promises.readFile(CONFIG_PATH, 'utf8')
|
||||
const sampleDocument = YAML.parseDocument(sampleContent)
|
||||
const configDocument = YAML.parseDocument(configContent)
|
||||
|
||||
if (!isMap(sampleDocument.contents) || !isMap(configDocument.contents)) {
|
||||
return 'config.yml: skipped invalid YAML document'
|
||||
}
|
||||
|
||||
const migrationCount = await migrateLegacyConfigValues(configDocument, false)
|
||||
const addedCount = mergeMissingMapPairs(
|
||||
configDocument.contents,
|
||||
sampleDocument.contents,
|
||||
configDocument.schema
|
||||
)
|
||||
|
||||
if (addedCount === 0 && migrationCount === 0) {
|
||||
return 'config.yml: up-to-date'
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(
|
||||
CONFIG_PATH,
|
||||
withProfileSchemaReference(String(configDocument))
|
||||
)
|
||||
|
||||
return `config.yml: +${addedCount + migrationCount} setting${
|
||||
addedCount + migrationCount > 1 ? 's' : ''
|
||||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or merge the profile config.yml file.
|
||||
*/
|
||||
export default async () => {
|
||||
const status = createSetupStatus('Preparing config.yml...').start()
|
||||
|
||||
await syncProfileConfigSchema()
|
||||
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
await fs.promises.mkdir(path.dirname(CONFIG_PATH), { recursive: true })
|
||||
const sampleContent = await fs.promises.readFile(CONFIG_SAMPLE_PATH, 'utf8')
|
||||
const configDocument = YAML.parseDocument(
|
||||
withProfileSchemaReference(sampleContent)
|
||||
)
|
||||
await migrateLegacyConfigValues(configDocument, true)
|
||||
await fs.promises.writeFile(
|
||||
CONFIG_PATH,
|
||||
withProfileSchemaReference(String(configDocument))
|
||||
)
|
||||
status.succeed('config.yml: created')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
status.succeed(await mergeMissingConfigKeys())
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
/**
|
||||
* Set up Leon's core configuration
|
||||
*/
|
||||
export default () =>
|
||||
new Promise(async (resolve) => {
|
||||
const status = createSetupStatus('Configuring core...').start()
|
||||
let hasUpdatedCoreConfig = false
|
||||
|
||||
const dir = 'core/config'
|
||||
const list = async (dir) => {
|
||||
const entities = await fs.promises.readdir(dir)
|
||||
|
||||
// Browse core config entities
|
||||
for (let i = 0; i < entities.length; i += 1) {
|
||||
const file = `${entities[i].replace('.sample.json', '.json')}`
|
||||
// Recursive if the entity is a directory
|
||||
const way = path.join(dir, entities[i])
|
||||
if ((await fs.promises.stat(way)).isDirectory()) {
|
||||
await list(way)
|
||||
} else if (
|
||||
entities[i].indexOf('.sample.json') !== -1 &&
|
||||
!fs.existsSync(`${dir}/${file}`)
|
||||
) {
|
||||
// Clone config from sample in case there is no existing config file
|
||||
fs.createReadStream(`${dir}/${entities[i]}`).pipe(
|
||||
fs.createWriteStream(`${dir}/${file}`)
|
||||
)
|
||||
hasUpdatedCoreConfig = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await list(dir)
|
||||
status.succeed(
|
||||
hasUpdatedCoreConfig ? 'Core: updated' : 'Core: ready'
|
||||
)
|
||||
resolve()
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROFILE_DOT_ENV_PATH } from '@/constants'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const DOT_ENV_PATH = PROFILE_DOT_ENV_PATH
|
||||
const DOT_ENV_SAMPLE_PATH = path.join(process.cwd(), '.env.sample')
|
||||
const ENV_LINE_SEPARATOR_PATTERN = /\r?\n/
|
||||
|
||||
function splitEnvLines(content) {
|
||||
return content.split(ENV_LINE_SEPARATOR_PATTERN)
|
||||
}
|
||||
|
||||
function getEnvVariableName(line) {
|
||||
const trimmedLine = line.trim()
|
||||
|
||||
if (
|
||||
trimmedLine === '' ||
|
||||
trimmedLine.startsWith('#') ||
|
||||
!trimmedLine.includes('=')
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const variableName = trimmedLine.slice(0, trimmedLine.indexOf('=')).trim()
|
||||
|
||||
return /^[A-Z0-9_]+$/.test(variableName) ? variableName : null
|
||||
}
|
||||
|
||||
async function mergeMissingEnvVariables() {
|
||||
const sampleContent = await fs.promises.readFile(DOT_ENV_SAMPLE_PATH, 'utf8')
|
||||
const dotEnvContent = await fs.promises.readFile(DOT_ENV_PATH, 'utf8')
|
||||
const dotEnvLines = splitEnvLines(dotEnvContent)
|
||||
const existingVariableNames = new Set(
|
||||
dotEnvLines
|
||||
.map((line) => getEnvVariableName(line))
|
||||
.filter((variableName) => variableName !== null)
|
||||
)
|
||||
const missingSampleLines = sampleContent
|
||||
.split(ENV_LINE_SEPARATOR_PATTERN)
|
||||
.filter((line) => {
|
||||
const variableName = getEnvVariableName(line)
|
||||
|
||||
return variableName !== null && !existingVariableNames.has(variableName)
|
||||
})
|
||||
|
||||
if (missingSampleLines.length === 0) {
|
||||
return '.env: up-to-date'
|
||||
}
|
||||
|
||||
const normalizedDotEnvContent = dotEnvContent.endsWith('\n')
|
||||
? dotEnvContent
|
||||
: `${dotEnvContent}\n`
|
||||
const separator = normalizedDotEnvContent.trim() === '' ? '' : '\n'
|
||||
const mergedContent = `${normalizedDotEnvContent}${separator}${missingSampleLines.join('\n')}\n`
|
||||
|
||||
await fs.promises.writeFile(DOT_ENV_PATH, mergedContent)
|
||||
|
||||
return `.env: +${missingSampleLines.length} variable${
|
||||
missingSampleLines.length > 1 ? 's' : ''
|
||||
}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a single variable inside `.env`.
|
||||
*/
|
||||
export async function updateDotEnvVariable(variableName, value) {
|
||||
const dotEnvContent = fs.existsSync(DOT_ENV_PATH)
|
||||
? await fs.promises.readFile(DOT_ENV_PATH, 'utf8')
|
||||
: ''
|
||||
const dotEnvLines = dotEnvContent === '' ? [] : splitEnvLines(dotEnvContent)
|
||||
const nextLine = `${variableName}=${value}`
|
||||
let hasUpdatedLine = false
|
||||
|
||||
const updatedLines = dotEnvLines.map((line) => {
|
||||
if (getEnvVariableName(line) !== variableName) {
|
||||
return line
|
||||
}
|
||||
|
||||
hasUpdatedLine = true
|
||||
|
||||
return nextLine
|
||||
})
|
||||
|
||||
if (!hasUpdatedLine) {
|
||||
updatedLines.push(nextLine)
|
||||
}
|
||||
|
||||
const normalizedLines = updatedLines.filter(
|
||||
(line, index, lines) => !(index === lines.length - 1 && line === '')
|
||||
)
|
||||
|
||||
await fs.promises.mkdir(path.dirname(DOT_ENV_PATH), { recursive: true })
|
||||
|
||||
await fs.promises.writeFile(
|
||||
DOT_ENV_PATH,
|
||||
`${normalizedLines.join('\n')}\n`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate the .env.sample to .env file
|
||||
*/
|
||||
export default async () => {
|
||||
const status = createSetupStatus('Preparing .env...').start()
|
||||
|
||||
if (!fs.existsSync(DOT_ENV_PATH)) {
|
||||
await fs.promises.mkdir(path.dirname(DOT_ENV_PATH), { recursive: true })
|
||||
await fs.promises.copyFile(DOT_ENV_SAMPLE_PATH, DOT_ENV_PATH)
|
||||
status.succeed('.env: created')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
status.succeed(await mergeMissingEnvVariables())
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const GIT_PATH = path.join(process.cwd(), '.git')
|
||||
const HUSKY_BIN_PATH = path.join(
|
||||
process.cwd(),
|
||||
'node_modules',
|
||||
'.bin',
|
||||
process.platform === 'win32' ? 'husky.cmd' : 'husky'
|
||||
)
|
||||
|
||||
/**
|
||||
* Install git hooks through Husky when Leon is inside a git checkout.
|
||||
*/
|
||||
export default async function setupGitHooks() {
|
||||
if (process.env.GITHUB_ACTIONS === 'true' || !fs.existsSync(GIT_PATH)) {
|
||||
return
|
||||
}
|
||||
|
||||
const status = createSetupStatus('Setting up git hooks...').start()
|
||||
|
||||
try {
|
||||
await execa(HUSKY_BIN_PATH, [], {
|
||||
stdio: 'ignore'
|
||||
})
|
||||
|
||||
status.succeed('Git hooks: ready')
|
||||
} catch (error) {
|
||||
status.fail('Failed to set up git hooks')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { SetupUI } from './setup-ui'
|
||||
|
||||
const FIRST_JOKE_DELAY_MS = 8_000
|
||||
const MIN_REPEAT_JOKE_DELAY_MS = 42_000
|
||||
const MAX_REPEAT_JOKE_DELAY_MS = 120_000
|
||||
const DOWNLOAD_PROGRESS_START_EVENT = 'leon:setup-download-progress:start'
|
||||
const DOWNLOAD_PROGRESS_END_EVENT = 'leon:setup-download-progress:end'
|
||||
|
||||
const SETUP_JOKES = [
|
||||
'I promise this part is more organized than my early prototypes.',
|
||||
'Still working. No dramatic plot twist so far.',
|
||||
'I am wiring things up. Metaphorically. Please do not hand me a screwdriver.',
|
||||
'This is the quiet part where I become useful.',
|
||||
'I would say this is exciting, but I am trying to appear composed.',
|
||||
'Good software takes time. So do good noodles.',
|
||||
'I am installing the serious parts so I can say unserious things later.',
|
||||
'Everything is under control. That sounded more confident in my head.',
|
||||
'If progress bars had feelings, this one would be very motivated.',
|
||||
'I am moving at the speed of your internet and the patience of your storage drive.',
|
||||
'No worries, I also judge installers that ask too many questions.',
|
||||
'I checked. Turning it off and on again is not the current strategy.',
|
||||
'This setup has fewer mysteries than most Wi-Fi problems.',
|
||||
'I am building character. Also binaries.',
|
||||
'If anything here looks complicated, I am trying to keep it emotionally simple.',
|
||||
'This is going well. Suspiciously well, but still well.',
|
||||
'I am not procrastinating. I am compiling.',
|
||||
'The good news is I do not need coffee. The bad news is you still might.',
|
||||
'I could pretend this is instant, but honesty is part of my charm.',
|
||||
'Some assistants bring small talk. I bring dependencies.'
|
||||
]
|
||||
const SETUP_COMPLETION_JOKES = [
|
||||
'I am installed, calibrated, and only slightly too pleased with myself.',
|
||||
'That went well. I am trying not to make it my whole personality.',
|
||||
'Setup complete. I would bow, but I do not have knees.',
|
||||
'Everything is ready. Suspiciously ready, but ready.',
|
||||
'I am officially prepared to be useful and occasionally dramatic.',
|
||||
'Installation complete. Nobody had to jiggle the cable.',
|
||||
'I made it through setup without becoming folklore.',
|
||||
'That was the serious part. I can be charming again now.',
|
||||
'I am ready. Please admire how professional this looks.',
|
||||
'Setup finished. I would celebrate louder, but I respect your terminal.'
|
||||
]
|
||||
|
||||
function shuffleJokes(jokes) {
|
||||
const shuffledJokes = [...jokes]
|
||||
|
||||
for (let index = shuffledJokes.length - 1; index > 0; index -= 1) {
|
||||
const randomIndex = Math.floor(Math.random() * (index + 1))
|
||||
;[shuffledJokes[index], shuffledJokes[randomIndex]] = [
|
||||
shuffledJokes[randomIndex],
|
||||
shuffledJokes[index]
|
||||
]
|
||||
}
|
||||
|
||||
return shuffledJokes
|
||||
}
|
||||
|
||||
const remainingJokes = shuffleJokes(SETUP_JOKES)
|
||||
let areSetupJokesBoundToProgress = false
|
||||
let activeDownloadProgressCount = 0
|
||||
|
||||
function getRandomRepeatDelay() {
|
||||
return (
|
||||
MIN_REPEAT_JOKE_DELAY_MS +
|
||||
Math.floor(
|
||||
Math.random() *
|
||||
(MAX_REPEAT_JOKE_DELAY_MS - MIN_REPEAT_JOKE_DELAY_MS + 1)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getNextSetupJoke() {
|
||||
return remainingJokes.shift() || null
|
||||
}
|
||||
|
||||
function getRandomSetupCompletionJoke() {
|
||||
return (
|
||||
SETUP_COMPLETION_JOKES[
|
||||
Math.floor(Math.random() * SETUP_COMPLETION_JOKES.length)
|
||||
] || null
|
||||
)
|
||||
}
|
||||
|
||||
function bindSetupJokesToDownloadProgress() {
|
||||
if (areSetupJokesBoundToProgress) {
|
||||
return
|
||||
}
|
||||
|
||||
process.on(DOWNLOAD_PROGRESS_START_EVENT, () => {
|
||||
activeDownloadProgressCount += 1
|
||||
})
|
||||
|
||||
process.on(DOWNLOAD_PROGRESS_END_EVENT, () => {
|
||||
activeDownloadProgressCount = Math.max(0, activeDownloadProgressCount - 1)
|
||||
})
|
||||
|
||||
areSetupJokesBoundToProgress = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-task joke scheduler so setup jokes only appear
|
||||
* when a task has actually been running for a while.
|
||||
*/
|
||||
export function createSetupJokeScheduler() {
|
||||
bindSetupJokesToDownloadProgress()
|
||||
|
||||
let timerId = null
|
||||
let remainingDelayMs = FIRST_JOKE_DELAY_MS
|
||||
let isRunning = false
|
||||
let isFinished = false
|
||||
|
||||
const clearTimer = () => {
|
||||
if (!timerId) {
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timerId)
|
||||
timerId = null
|
||||
}
|
||||
|
||||
const scheduleNextJoke = () => {
|
||||
if (isFinished || !isRunning || timerId || remainingJokes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
timerId = setTimeout(() => {
|
||||
timerId = null
|
||||
|
||||
if (isFinished || !isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
const nextJoke = getNextSetupJoke()
|
||||
|
||||
if (!nextJoke) {
|
||||
return
|
||||
}
|
||||
|
||||
if (activeDownloadProgressCount > 0 && process.stdout.isTTY) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
|
||||
SetupUI.aside(nextJoke)
|
||||
remainingDelayMs = getRandomRepeatDelay()
|
||||
scheduleNextJoke()
|
||||
}, remainingDelayMs)
|
||||
}
|
||||
|
||||
const scheduler = {
|
||||
start() {
|
||||
if (isFinished) {
|
||||
return
|
||||
}
|
||||
|
||||
isRunning = true
|
||||
scheduleNextJoke()
|
||||
},
|
||||
stop() {
|
||||
if (isFinished || !isRunning) {
|
||||
return
|
||||
}
|
||||
|
||||
isRunning = false
|
||||
clearTimer()
|
||||
},
|
||||
finish() {
|
||||
isFinished = true
|
||||
isRunning = false
|
||||
clearTimer()
|
||||
}
|
||||
}
|
||||
|
||||
return scheduler
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell one install-complete joke before the final success message.
|
||||
*/
|
||||
export function tellSetupCompletionJoke() {
|
||||
const joke = getRandomSetupCompletionJoke()
|
||||
|
||||
if (!joke) {
|
||||
return
|
||||
}
|
||||
|
||||
SetupUI.aside(joke)
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
import { CPUArchitectures } from '@/types'
|
||||
import {
|
||||
LLAMACPP_BUILD_MANIFEST_PATH,
|
||||
CMAKE_BIN_PATH,
|
||||
LLAMACPP_BUILD_PATH,
|
||||
LLAMACPP_ROOT_MANIFEST_PATH,
|
||||
NINJA_BIN_PATH,
|
||||
LLAMACPP_SOURCE_BUILD_PATH,
|
||||
LLAMACPP_SOURCE_MANIFEST_PATH,
|
||||
LLAMACPP_SOURCE_PATH,
|
||||
LLAMACPP_PATH,
|
||||
LLAMACPP_RELEASE_VERSION
|
||||
} from '@/constants'
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
import { SetupUI } from './setup-ui'
|
||||
|
||||
/**
|
||||
* Download and set up llama.cpp
|
||||
* 1. Resolve the release version from versions.json
|
||||
* 2. Build from source on Linux + CUDA when required
|
||||
* 3. Otherwise download the matching prebuilt archive
|
||||
* 4. Keep the final binaries in their stable runtime directory
|
||||
*/
|
||||
|
||||
const MOVE_FALLBACK_ERROR_CODES = new Set(['EXDEV', 'EPERM', 'EBUSY', 'EACCES'])
|
||||
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
|
||||
const LLAMA_SERVER_BINARY_NAME = SystemHelper.isWindows()
|
||||
? 'llama-server.exe'
|
||||
: 'llama-server'
|
||||
const LLAMACPP_SOURCE_DOWNLOAD_MAX_ATTEMPTS = 2
|
||||
const LLAMACPP_SOURCE_ARCHIVE_SETTLE_DELAY_MS = 500
|
||||
const LLAMACPP_SOURCE_ARCHIVE_SETTLE_POLL_DELAY_MS = 250
|
||||
const LLAMACPP_SOURCE_ARCHIVE_SETTLE_MAX_POLLS = 6
|
||||
const LLAMACPP_RELEASE_BASE_URL = `https://github.com/ggml-org/llama.cpp/releases/download/${LLAMACPP_RELEASE_VERSION}`
|
||||
const LLAMACPP_SOURCE_URL = `https://github.com/ggml-org/llama.cpp/archive/refs/tags/${LLAMACPP_RELEASE_VERSION}.tar.gz`
|
||||
|
||||
function readManifest() {
|
||||
const manifestEntries = [
|
||||
{
|
||||
manifestPath: LLAMACPP_SOURCE_MANIFEST_PATH,
|
||||
runtimeBasePath: LLAMACPP_SOURCE_PATH
|
||||
},
|
||||
{
|
||||
manifestPath: LLAMACPP_BUILD_MANIFEST_PATH,
|
||||
runtimeBasePath: LLAMACPP_BUILD_PATH
|
||||
},
|
||||
// Keep compatibility with the previous root-level manifest layout.
|
||||
{
|
||||
manifestPath: LLAMACPP_ROOT_MANIFEST_PATH,
|
||||
runtimeBasePath: LLAMACPP_PATH
|
||||
}
|
||||
]
|
||||
|
||||
for (const manifestEntry of manifestEntries) {
|
||||
if (!fs.existsSync(manifestEntry.manifestPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(manifestEntry.manifestPath, 'utf8')
|
||||
)
|
||||
const runtimeDirectoryPath =
|
||||
typeof manifest.runtimePath === 'string' && manifest.runtimePath.trim()
|
||||
? path.join(manifestEntry.runtimeBasePath, manifest.runtimePath)
|
||||
: null
|
||||
|
||||
return {
|
||||
manifest,
|
||||
runtimeDirectoryPath
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function removePath(targetPath) {
|
||||
await fs.promises.rm(targetPath, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function wait(delayMs) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs)
|
||||
})
|
||||
}
|
||||
|
||||
function isMoveFallbackError(error) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
MOVE_FALLBACK_ERROR_CODES.has(error.code)
|
||||
)
|
||||
}
|
||||
|
||||
async function movePath(sourcePath, destinationPath) {
|
||||
await removePath(destinationPath)
|
||||
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, destinationPath)
|
||||
} catch (error) {
|
||||
if (!isMoveFallbackError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
await fs.promises.cp(sourcePath, destinationPath, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
await removePath(sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
function getBinaryPath(directoryPath) {
|
||||
return path.join(directoryPath, LLAMA_SERVER_BINARY_NAME)
|
||||
}
|
||||
|
||||
async function isExistingInstallationHealthy(runtimeDirectoryPath) {
|
||||
if (!runtimeDirectoryPath) {
|
||||
return false
|
||||
}
|
||||
|
||||
const binaryPath = getBinaryPath(runtimeDirectoryPath)
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await execa(binaryPath, ['--version'])
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function findDirectoryContainingBinary(rootPath, binaryName) {
|
||||
const entries = await fs.promises.readdir(rootPath, { withFileTypes: true })
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(rootPath, entry.name)
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
const maybeBinDir = await findDirectoryContainingBinary(
|
||||
entryPath,
|
||||
binaryName
|
||||
)
|
||||
|
||||
if (maybeBinDir) {
|
||||
return maybeBinDir
|
||||
}
|
||||
} else if (entry.isFile() && entry.name === binaryName) {
|
||||
return path.dirname(entryPath)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function cleanInstallDirectory() {
|
||||
await fs.promises.mkdir(LLAMACPP_PATH, { recursive: true })
|
||||
|
||||
const entries = await fs.promises.readdir(LLAMACPP_PATH, {
|
||||
withFileTypes: true
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.name !== 'versions.json')
|
||||
.map((entry) => removePath(path.join(LLAMACPP_PATH, entry.name)))
|
||||
)
|
||||
}
|
||||
|
||||
async function writeManifest(
|
||||
manifestPath,
|
||||
runtimeBasePath,
|
||||
runtimeDirectoryPath,
|
||||
extraData = {}
|
||||
) {
|
||||
await fs.promises.mkdir(path.dirname(manifestPath), { recursive: true })
|
||||
|
||||
await FileHelper.createManifestFile(
|
||||
manifestPath,
|
||||
'llama.cpp',
|
||||
LLAMACPP_RELEASE_VERSION,
|
||||
{
|
||||
runtimePath: path.relative(runtimeBasePath, runtimeDirectoryPath),
|
||||
os: SystemHelper.getInformation().type,
|
||||
architecture: SystemHelper.getInformation().cpuArchitecture,
|
||||
...extraData
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function pruneSourceTree() {
|
||||
const buildDirectoryPath = path.join(LLAMACPP_SOURCE_PATH, 'build')
|
||||
const temporaryRetainedPath = await fs.promises.mkdtemp(
|
||||
path.join(LLAMACPP_PATH, 'llama-cpp-build-bin-')
|
||||
)
|
||||
const retainedBuildBinPath = path.join(temporaryRetainedPath, 'bin')
|
||||
|
||||
await movePath(LLAMACPP_SOURCE_BUILD_PATH, retainedBuildBinPath)
|
||||
await removePath(LLAMACPP_SOURCE_PATH)
|
||||
await fs.promises.mkdir(buildDirectoryPath, { recursive: true })
|
||||
await movePath(retainedBuildBinPath, LLAMACPP_SOURCE_BUILD_PATH)
|
||||
await removePath(temporaryRetainedPath)
|
||||
}
|
||||
|
||||
async function waitForArchiveToSettle(archivePath) {
|
||||
// Give the download layer a short margin, then wait until the archive size
|
||||
// stops changing before extracting it.
|
||||
await wait(LLAMACPP_SOURCE_ARCHIVE_SETTLE_DELAY_MS)
|
||||
|
||||
let previousSize = -1
|
||||
|
||||
for (let poll = 0; poll < LLAMACPP_SOURCE_ARCHIVE_SETTLE_MAX_POLLS; poll += 1) {
|
||||
const currentSize = (await fs.promises.stat(archivePath)).size
|
||||
|
||||
if (currentSize > 0 && currentSize === previousSize) {
|
||||
return
|
||||
}
|
||||
|
||||
previousSize = currentSize
|
||||
await wait(LLAMACPP_SOURCE_ARCHIVE_SETTLE_POLL_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAndExtractSourceArchive(sourceArchivePath) {
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= LLAMACPP_SOURCE_DOWNLOAD_MAX_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
await Promise.all([
|
||||
removePath(sourceArchivePath),
|
||||
removePath(LLAMACPP_SOURCE_PATH)
|
||||
])
|
||||
|
||||
await FileHelper.downloadFile(LLAMACPP_SOURCE_URL, sourceArchivePath, {
|
||||
cliProgress: true,
|
||||
// Keep the source archive download conservative to avoid corrupted
|
||||
// segmented downloads before extraction.
|
||||
parallelStreams: 1,
|
||||
skipExisting: false
|
||||
})
|
||||
|
||||
await waitForArchiveToSettle(sourceArchivePath)
|
||||
|
||||
try {
|
||||
await FileHelper.extractArchive(sourceArchivePath, LLAMACPP_SOURCE_PATH, {
|
||||
stripComponents: 1
|
||||
})
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
if (attempt === LLAMACPP_SOURCE_DOWNLOAD_MAX_ATTEMPTS) {
|
||||
throw error
|
||||
}
|
||||
|
||||
await wait(LLAMACPP_SOURCE_ARCHIVE_SETTLE_POLL_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLinuxVulkanAssetName() {
|
||||
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-ubuntu-vulkan-x64.tar.gz`
|
||||
}
|
||||
|
||||
function getLinuxCPUAssetName() {
|
||||
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-ubuntu-x64.tar.gz`
|
||||
}
|
||||
|
||||
function getWindowsCPUAssetName() {
|
||||
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-win-cpu-x64.zip`
|
||||
}
|
||||
|
||||
function getPrebuiltAssetName(graphicsComputeAPI, hasGPU) {
|
||||
if (SystemHelper.isMacOS()) {
|
||||
return CPU_ARCH === CPUArchitectures.ARM64
|
||||
? `llama-${LLAMACPP_RELEASE_VERSION}-bin-macos-arm64.tar.gz`
|
||||
: `llama-${LLAMACPP_RELEASE_VERSION}-bin-macos-x64.tar.gz`
|
||||
}
|
||||
|
||||
if (SystemHelper.isWindows()) {
|
||||
if (hasGPU && graphicsComputeAPI === 'cuda') {
|
||||
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-win-cuda-12.4-x64.zip`
|
||||
}
|
||||
|
||||
if (hasGPU && graphicsComputeAPI === 'vulkan') {
|
||||
return `llama-${LLAMACPP_RELEASE_VERSION}-bin-win-vulkan-x64.zip`
|
||||
}
|
||||
|
||||
return getWindowsCPUAssetName()
|
||||
}
|
||||
|
||||
if (SystemHelper.isLinux() && CPU_ARCH === CPUArchitectures.X64) {
|
||||
return hasGPU && graphicsComputeAPI === 'vulkan'
|
||||
? getLinuxVulkanAssetName()
|
||||
: getLinuxCPUAssetName()
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported llama.cpp prebuilt platform: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
|
||||
)
|
||||
}
|
||||
|
||||
async function installPrebuilt(assetName, status, extraData = {}) {
|
||||
const archivePath = path.join(LLAMACPP_PATH, assetName)
|
||||
|
||||
try {
|
||||
await cleanInstallDirectory()
|
||||
status.pause()
|
||||
|
||||
await FileHelper.downloadFile(
|
||||
`${LLAMACPP_RELEASE_BASE_URL}/${assetName}`,
|
||||
archivePath,
|
||||
{
|
||||
cliProgress: true,
|
||||
parallelStreams: 3,
|
||||
skipExisting: false
|
||||
}
|
||||
)
|
||||
status.text = 'Installing llama.cpp...'
|
||||
status.start()
|
||||
|
||||
await FileHelper.extractArchive(archivePath, LLAMACPP_BUILD_PATH)
|
||||
|
||||
// Use the directory that actually contains llama-server so we do not rely
|
||||
// on a fixed archive layout across upstream release assets.
|
||||
const binaryDirectoryPath = await findDirectoryContainingBinary(
|
||||
LLAMACPP_BUILD_PATH,
|
||||
LLAMA_SERVER_BINARY_NAME
|
||||
)
|
||||
|
||||
if (!binaryDirectoryPath) {
|
||||
throw new Error(
|
||||
`Cannot find ${LLAMA_SERVER_BINARY_NAME} in extracted llama.cpp archive`
|
||||
)
|
||||
}
|
||||
|
||||
await writeManifest(
|
||||
LLAMACPP_BUILD_MANIFEST_PATH,
|
||||
LLAMACPP_BUILD_PATH,
|
||||
binaryDirectoryPath,
|
||||
{
|
||||
installType: 'prebuilt',
|
||||
...extraData
|
||||
}
|
||||
)
|
||||
status.succeed(`llama.cpp ${LLAMACPP_RELEASE_VERSION} ready`)
|
||||
} finally {
|
||||
await removePath(archivePath)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildFromSource(status) {
|
||||
const sourceArchivePath = path.join(
|
||||
LLAMACPP_PATH,
|
||||
`llama.cpp-${LLAMACPP_RELEASE_VERSION}.tar.gz`
|
||||
)
|
||||
|
||||
try {
|
||||
await cleanInstallDirectory()
|
||||
|
||||
status.pause()
|
||||
await downloadAndExtractSourceArchive(sourceArchivePath)
|
||||
status.text = 'Building llama.cpp from source...'
|
||||
status.start()
|
||||
|
||||
// Always use Leon-managed CMake for the source build.
|
||||
await execa(
|
||||
CMAKE_BIN_PATH,
|
||||
[
|
||||
'-B',
|
||||
'build',
|
||||
'-G',
|
||||
'Ninja',
|
||||
`-DCMAKE_MAKE_PROGRAM=${NINJA_BIN_PATH}`,
|
||||
'-DGGML_CUDA=ON',
|
||||
'-DLLAMA_BUILD_SERVER=ON',
|
||||
'-DLLAMA_BUILD_TESTS=OFF',
|
||||
'-DLLAMA_BUILD_EXAMPLES=OFF',
|
||||
'-DCMAKE_BUILD_TYPE=Release',
|
||||
'-DCMAKE_CUDA_ARCHITECTURES=native'
|
||||
],
|
||||
{
|
||||
cwd: LLAMACPP_SOURCE_PATH
|
||||
}
|
||||
)
|
||||
await execa(CMAKE_BIN_PATH, ['--build', 'build', '--target', 'llama-server', '-j'], {
|
||||
cwd: LLAMACPP_SOURCE_PATH
|
||||
})
|
||||
|
||||
if (
|
||||
!fs.existsSync(getBinaryPath(LLAMACPP_SOURCE_BUILD_PATH))
|
||||
) {
|
||||
throw new Error(
|
||||
`Cannot find ${LLAMA_SERVER_BINARY_NAME} after building llama.cpp`
|
||||
)
|
||||
}
|
||||
|
||||
// Retain only the runtime payload after a successful source build.
|
||||
await pruneSourceTree()
|
||||
await writeManifest(
|
||||
LLAMACPP_SOURCE_MANIFEST_PATH,
|
||||
LLAMACPP_SOURCE_PATH,
|
||||
LLAMACPP_SOURCE_BUILD_PATH,
|
||||
{
|
||||
installType: 'source'
|
||||
}
|
||||
)
|
||||
|
||||
status.succeed(`llama.cpp ${LLAMACPP_RELEASE_VERSION} ready`)
|
||||
} finally {
|
||||
await removePath(sourceArchivePath)
|
||||
}
|
||||
}
|
||||
|
||||
export default async function setupLlamaCPP() {
|
||||
const status = createSetupStatus('Setting up llama.cpp...').start()
|
||||
|
||||
const existingInstallation = readManifest()
|
||||
const manifest = existingInstallation?.manifest
|
||||
const runtimeDirectoryPath = existingInstallation?.runtimeDirectoryPath || null
|
||||
|
||||
if (
|
||||
manifest?.version === LLAMACPP_RELEASE_VERSION &&
|
||||
(await isExistingInstallationHealthy(runtimeDirectoryPath))
|
||||
) {
|
||||
status.succeed(`llama.cpp: ${LLAMACPP_RELEASE_VERSION}`)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (
|
||||
manifest?.version === LLAMACPP_RELEASE_VERSION &&
|
||||
runtimeDirectoryPath &&
|
||||
fs.existsSync(getBinaryPath(runtimeDirectoryPath))
|
||||
) {
|
||||
status.pause()
|
||||
SetupUI.warning('The current llama.cpp installation looks corrupted. Reinstalling it.')
|
||||
}
|
||||
|
||||
let hasGPU = false
|
||||
let graphicsComputeAPI = 'cpu'
|
||||
|
||||
try {
|
||||
const { getLlama, LlamaLogLevel } = await Function(
|
||||
'return import("node-llama-cpp")'
|
||||
)()
|
||||
const llama = await getLlama({
|
||||
logLevel: LlamaLogLevel.disabled
|
||||
})
|
||||
|
||||
hasGPU = await SystemHelper.hasGPU(llama)
|
||||
graphicsComputeAPI = await SystemHelper.getGraphicsComputeAPI(llama)
|
||||
} catch (error) {
|
||||
status.pause()
|
||||
SetupUI.warning(`Failed to inspect GPU support for llama.cpp setup: ${error}`)
|
||||
}
|
||||
|
||||
if (SystemHelper.isLinux() && CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
// Linux ARM64 only supports the local setup when a CUDA build is possible.
|
||||
if (!(hasGPU && graphicsComputeAPI === 'cuda')) {
|
||||
status.succeed(
|
||||
'Linux ARM64 local LLM support requires a CUDA GPU. Skipping llama.cpp setup.'
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
await buildFromSource(status)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
if (status.isSpinning) {
|
||||
status.fail('Failed to build llama.cpp from source')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isLinux() && hasGPU && graphicsComputeAPI === 'cuda') {
|
||||
try {
|
||||
await buildFromSource(status)
|
||||
|
||||
return true
|
||||
} catch {
|
||||
SetupUI.warning(
|
||||
'I could not build llama.cpp from source, so I will use the Vulkan binaries instead.'
|
||||
)
|
||||
await installPrebuilt(getLinuxVulkanAssetName(), status, {
|
||||
fallbackFromSourceBuild: true
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
await installPrebuilt(
|
||||
getPrebuiltAssetName(graphicsComputeAPI, hasGPU),
|
||||
status
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
LLM_DIR_PATH,
|
||||
LLM_MANIFEST_PATH,
|
||||
LLM_HIGH_TIER_MINIMUM_TOTAL_VRAM,
|
||||
LLM_MINIMUM_TOTAL_VRAM,
|
||||
LLAMACPP_RELEASE_VERSION
|
||||
} from '@/constants'
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { NetworkHelper } from '@/helpers/network-helper'
|
||||
|
||||
import inspectLocalAICapability from './local-ai-capability'
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
/**
|
||||
* Download and set up the default local LLM
|
||||
* 1. Check minimum hardware requirements
|
||||
* 2. Select the default model according to total VRAM
|
||||
* 3. Download the model from Hugging Face or mirror
|
||||
* 4. Create manifest file with the default installed model path
|
||||
*/
|
||||
|
||||
const DEFAULT_LLM_OPTIONS = [
|
||||
{
|
||||
minimumTotalVRAM: LLM_HIGH_TIER_MINIMUM_TOTAL_VRAM,
|
||||
name: 'Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive',
|
||||
version: 'Q4_K_M',
|
||||
fileName: 'Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf',
|
||||
downloadURL:
|
||||
'https://huggingface.co/HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive/resolve/main/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf?download=true'
|
||||
},
|
||||
{
|
||||
minimumTotalVRAM: LLM_MINIMUM_TOTAL_VRAM,
|
||||
name: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive',
|
||||
version: 'Q4_K_M',
|
||||
fileName: 'Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf',
|
||||
downloadURL:
|
||||
'https://huggingface.co/HauhauCS/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive/resolve/main/Qwen3.5-9B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf?download=true'
|
||||
}
|
||||
]
|
||||
const LOCAL_AI_CHECK_TEXT = 'Checking local AI requirements...'
|
||||
|
||||
function readManifest() {
|
||||
if (!fs.existsSync(LLM_MANIFEST_PATH)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(LLM_MANIFEST_PATH, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeModelPath(modelPath) {
|
||||
return path.resolve(modelPath)
|
||||
}
|
||||
|
||||
async function removePreviousDefaultModel(previousModelPath, nextModelPath) {
|
||||
if (!previousModelPath || previousModelPath === nextModelPath) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPreviousModelPath = normalizeModelPath(previousModelPath)
|
||||
|
||||
// Only delete the previous default model we installed under Leon's managed
|
||||
// shared models directory.
|
||||
if (!resolvedPreviousModelPath.startsWith(`${LLM_DIR_PATH}${path.sep}`)) {
|
||||
return
|
||||
}
|
||||
|
||||
await fs.promises.rm(resolvedPreviousModelPath, { force: true })
|
||||
}
|
||||
|
||||
function getSelectedModel(totalVRAM) {
|
||||
return (
|
||||
DEFAULT_LLM_OPTIONS.find(
|
||||
({ minimumTotalVRAM }) => totalVRAM >= minimumTotalVRAM
|
||||
) || null
|
||||
)
|
||||
}
|
||||
|
||||
async function downloadLLM(selectedModel) {
|
||||
const manifest = readManifest()
|
||||
const targetPath = path.join(LLM_DIR_PATH, selectedModel.fileName)
|
||||
const defaultInstalledLLMPath = normalizeModelPath(targetPath)
|
||||
const isCurrentModelInstalled =
|
||||
manifest?.name === selectedModel.name &&
|
||||
manifest?.version === selectedModel.version &&
|
||||
manifest?.defaultInstalledLLMPath === defaultInstalledLLMPath &&
|
||||
fs.existsSync(targetPath)
|
||||
|
||||
if (isCurrentModelInstalled) {
|
||||
return {
|
||||
installed: false,
|
||||
targetPath
|
||||
}
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(LLM_DIR_PATH, { recursive: true })
|
||||
await removePreviousDefaultModel(manifest?.defaultInstalledLLMPath, defaultInstalledLLMPath)
|
||||
await fs.promises.rm(targetPath, { force: true })
|
||||
|
||||
const llmDownloadURL = await NetworkHelper.setHuggingFaceURL(
|
||||
selectedModel.downloadURL
|
||||
)
|
||||
|
||||
await FileHelper.downloadFile(llmDownloadURL, targetPath)
|
||||
|
||||
await FileHelper.createManifestFile(
|
||||
LLM_MANIFEST_PATH,
|
||||
selectedModel.name,
|
||||
selectedModel.version,
|
||||
{
|
||||
llamaCPPVersion: LLAMACPP_RELEASE_VERSION,
|
||||
defaultInstalledLLMPath
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
targetPath
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalAISummary(selectedModel, hardware) {
|
||||
const gpuLabel = hardware.hasGPU ? hardware.gpuDeviceNames[0] : 'CPU'
|
||||
const computeAPILabel = hardware.hasGPU
|
||||
? String(hardware.graphicsComputeAPI).toUpperCase()
|
||||
: 'CPU'
|
||||
|
||||
return `${selectedModel.name} (${selectedModel.version}, ${gpuLabel}, ${computeAPILabel}, ${hardware.totalVRAM} GB VRAM)`
|
||||
}
|
||||
|
||||
export default async function setupLocalLLM(localAICapability) {
|
||||
const status = createSetupStatus(LOCAL_AI_CHECK_TEXT).start()
|
||||
|
||||
const hardware = localAICapability || (await inspectLocalAICapability())
|
||||
|
||||
if (!hardware.canInstallLocalAI) {
|
||||
status.succeed(
|
||||
`Local LLM support requires at least ${LLM_MINIMUM_TOTAL_VRAM} GB of total VRAM and a supported GPU setup. Current total VRAM is ${hardware.totalVRAM} GB. I will continue without installing a default local LLM.`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const selectedModel = getSelectedModel(hardware.totalVRAM)
|
||||
|
||||
if (!selectedModel) {
|
||||
status.succeed(
|
||||
`No default local LLM matches the current total VRAM (${hardware.totalVRAM} GB).`
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
status.pause()
|
||||
|
||||
const { installed } = await downloadLLM(selectedModel)
|
||||
status.text = 'Finalizing local AI...'
|
||||
status.start()
|
||||
|
||||
if (installed) {
|
||||
status.succeed(
|
||||
`Local AI: ready - ${getLocalAISummary(selectedModel, hardware)}`
|
||||
)
|
||||
} else {
|
||||
status.succeed(
|
||||
`Local AI: ready - ${getLocalAISummary(selectedModel, hardware)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
import {
|
||||
CACHE_PATH,
|
||||
NODE_VERSION,
|
||||
PNPM_RUNTIME_BIN_PATH
|
||||
} from '@/constants'
|
||||
import { RuntimeHelper } from '@/helpers/runtime-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const NODE_MODULES_PATH = path.join(process.cwd(), 'node_modules')
|
||||
const NATIVE_NODE_MODULE_REBUILD_PACKAGES = [
|
||||
'better-sqlite3'
|
||||
]
|
||||
const STAMP_FILE_PATH = path.join(
|
||||
CACHE_PATH,
|
||||
'setup',
|
||||
'.last-native-node-modules-rebuild'
|
||||
)
|
||||
|
||||
function getPackageManifestPath(packageName) {
|
||||
return path.join(NODE_MODULES_PATH, packageName, 'package.json')
|
||||
}
|
||||
|
||||
function getInstalledRebuildPackages() {
|
||||
return NATIVE_NODE_MODULE_REBUILD_PACKAGES.filter((packageName) =>
|
||||
fs.existsSync(getPackageManifestPath(packageName))
|
||||
)
|
||||
}
|
||||
|
||||
async function isRebuildCurrent(packageNames) {
|
||||
if (!fs.existsSync(STAMP_FILE_PATH)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const stampValue = (await fs.promises.readFile(STAMP_FILE_PATH, 'utf8')).trim()
|
||||
|
||||
if (stampValue !== NODE_VERSION) {
|
||||
return false
|
||||
}
|
||||
|
||||
const stampStat = await fs.promises.stat(STAMP_FILE_PATH)
|
||||
|
||||
for (const packageName of packageNames) {
|
||||
const packageManifestStat = await fs.promises.stat(
|
||||
getPackageManifestPath(packageName)
|
||||
)
|
||||
|
||||
if (packageManifestStat.mtimeMs > stampStat.mtimeMs) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild native modules that must match Leon's managed Node.js ABI.
|
||||
*/
|
||||
export default async function setupNativeNodeModules() {
|
||||
if (!fs.existsSync(NODE_MODULES_PATH)) {
|
||||
return
|
||||
}
|
||||
|
||||
const installedPackages = getInstalledRebuildPackages()
|
||||
|
||||
if (installedPackages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const status = createSetupStatus('Rebuilding native Node.js modules...').start()
|
||||
|
||||
if (await isRebuildCurrent(installedPackages)) {
|
||||
status.succeed('Native Node.js modules: up-to-date')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await execa(PNPM_RUNTIME_BIN_PATH, [
|
||||
'rebuild',
|
||||
...installedPackages
|
||||
], {
|
||||
cwd: process.cwd(),
|
||||
env: RuntimeHelper.getManagedNodeEnvironment()
|
||||
})
|
||||
|
||||
await fs.promises.mkdir(path.dirname(STAMP_FILE_PATH), { recursive: true })
|
||||
await fs.promises.writeFile(STAMP_FILE_PATH, NODE_VERSION)
|
||||
|
||||
status.succeed('Native Node.js modules: ready')
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { CPUArchitectures } from '@/types'
|
||||
import {
|
||||
NINJA_BIN_PATH,
|
||||
NINJA_INSTALL_PATH,
|
||||
NINJA_MANIFEST_PATH,
|
||||
NINJA_PATH,
|
||||
NINJA_VERSION
|
||||
} from '@/constants'
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
/**
|
||||
* Download and set up Leon-managed Ninja
|
||||
* 1. Resolve the pinned version from versions.json
|
||||
* 2. Download the matching Linux archive for the current architecture
|
||||
* 3. Extract it into bin/ninja/ninja/
|
||||
* 4. Always use this Ninja binary for local source builds
|
||||
*/
|
||||
|
||||
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
|
||||
|
||||
function readManifest() {
|
||||
if (!fs.existsSync(NINJA_MANIFEST_PATH)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(NINJA_MANIFEST_PATH, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanInstallDirectory() {
|
||||
await fs.promises.mkdir(NINJA_PATH, { recursive: true })
|
||||
|
||||
const entries = await fs.promises.readdir(NINJA_PATH, {
|
||||
withFileTypes: true
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.name !== 'versions.json')
|
||||
.map((entry) =>
|
||||
fs.promises.rm(path.join(NINJA_PATH, entry.name), {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function getDownloadURL() {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return `https://github.com/ninja-build/ninja/releases/download/v${NINJA_VERSION}/ninja-linux.zip`
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return `https://github.com/ninja-build/ninja/releases/download/v${NINJA_VERSION}/ninja-linux-aarch64.zip`
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported Linux architecture for Ninja: ${CPU_ARCH}`)
|
||||
}
|
||||
|
||||
export default async function setupNinja() {
|
||||
if (!SystemHelper.isLinux()) {
|
||||
return
|
||||
}
|
||||
|
||||
const status = createSetupStatus('Downloading and setting up Ninja...').start()
|
||||
|
||||
const manifest = readManifest()
|
||||
|
||||
if (manifest?.version === NINJA_VERSION && fs.existsSync(NINJA_BIN_PATH)) {
|
||||
status.succeed(`Ninja: ${NINJA_VERSION}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const archivePath = path.join(NINJA_PATH, `ninja-${NINJA_VERSION}.zip`)
|
||||
|
||||
await cleanInstallDirectory()
|
||||
|
||||
try {
|
||||
status.pause()
|
||||
|
||||
await FileHelper.downloadFile(getDownloadURL(), archivePath, {
|
||||
cliProgress: true,
|
||||
parallelStreams: 3,
|
||||
skipExisting: false
|
||||
})
|
||||
status.text = 'Installing Ninja...'
|
||||
status.start()
|
||||
|
||||
await FileHelper.extractArchive(archivePath, NINJA_INSTALL_PATH)
|
||||
await fs.promises.chmod(NINJA_BIN_PATH, 0o755)
|
||||
|
||||
if (!fs.existsSync(NINJA_BIN_PATH)) {
|
||||
throw new Error(`Cannot find Ninja binary at "${NINJA_BIN_PATH}"`)
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
fs.promises.rm(archivePath, { force: true }),
|
||||
FileHelper.createManifestFile(NINJA_MANIFEST_PATH, 'ninja', NINJA_VERSION, {
|
||||
os: SystemHelper.getInformation().type,
|
||||
architecture: SystemHelper.getInformation().cpuArchitecture
|
||||
})
|
||||
])
|
||||
|
||||
status.succeed(`Ninja: ${NINJA_VERSION}`)
|
||||
} catch (error) {
|
||||
await fs.promises.rm(archivePath, { force: true })
|
||||
if (status.isSpinning) {
|
||||
status.fail('Failed to set up Ninja')
|
||||
}
|
||||
throw new Error(`Failed to set up Ninja: ${error}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
NODE_INSTALL_PATH,
|
||||
NODE_MANIFEST_PATH,
|
||||
NODE_VERSION
|
||||
} from '@/constants'
|
||||
import { CPUArchitectures } from '@/types'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { setupRuntimeBinary } from './setup-runtime-binary'
|
||||
|
||||
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
|
||||
|
||||
function getBinaryPath() {
|
||||
return SystemHelper.isWindows()
|
||||
? path.join(NODE_INSTALL_PATH, 'node.exe')
|
||||
: path.join(NODE_INSTALL_PATH, 'bin', 'node')
|
||||
}
|
||||
|
||||
function getAssetFileName() {
|
||||
if (SystemHelper.isLinux()) {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return `node-v${NODE_VERSION}-linux-x64.tar.xz`
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return `node-v${NODE_VERSION}-linux-arm64.tar.xz`
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isMacOS()) {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return `node-v${NODE_VERSION}-darwin-x64.tar.xz`
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return `node-v${NODE_VERSION}-darwin-arm64.tar.xz`
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isWindows()) {
|
||||
return CPU_ARCH === CPUArchitectures.ARM64
|
||||
? `node-v${NODE_VERSION}-win-arm64.zip`
|
||||
: `node-v${NODE_VERSION}-win-x64.zip`
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported platform for Node.js: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
|
||||
)
|
||||
}
|
||||
|
||||
export default async function setupNode() {
|
||||
const assetFileName = getAssetFileName()
|
||||
|
||||
await setupRuntimeBinary({
|
||||
name: 'Node.js',
|
||||
version: NODE_VERSION,
|
||||
basePath: NODE_INSTALL_PATH,
|
||||
installPath: NODE_INSTALL_PATH,
|
||||
manifestPath: NODE_MANIFEST_PATH,
|
||||
binaryPath: getBinaryPath(),
|
||||
downloadURL: `https://nodejs.org/dist/v${NODE_VERSION}/${assetFileName}`,
|
||||
archiveFileName: assetFileName
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
import {
|
||||
NODEJS_BRIDGE_ROOT_PATH,
|
||||
CACHE_PATH,
|
||||
PNPM_RUNTIME_BIN_PATH
|
||||
} from '@/constants'
|
||||
import { RuntimeHelper } from '@/helpers/runtime-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const STAMP_FILE_PATH = path.join(
|
||||
CACHE_PATH,
|
||||
'setup',
|
||||
'.last-nodejs-bridge-deps-sync'
|
||||
)
|
||||
const PACKAGE_JSON_PATH = path.join(NODEJS_BRIDGE_ROOT_PATH, 'package.json')
|
||||
|
||||
/**
|
||||
* Re-sync the bridge only when its own dependency manifest changed.
|
||||
*/
|
||||
const isSyncCurrent = async () => {
|
||||
if (!fs.existsSync(STAMP_FILE_PATH) || !fs.existsSync(PACKAGE_JSON_PATH)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [stampStat, packageStat] = await Promise.all([
|
||||
fs.promises.stat(STAMP_FILE_PATH),
|
||||
fs.promises.stat(PACKAGE_JSON_PATH)
|
||||
])
|
||||
|
||||
return packageStat.mtimeMs <= stampStat.mtimeMs
|
||||
}
|
||||
|
||||
/**
|
||||
* The Node bridge still has its own SDK/runtime dependencies, so keep them in
|
||||
* sync once at setup time instead of installing them on every skill run.
|
||||
*/
|
||||
export default async function setupNodejsBridgeEnv() {
|
||||
if (!(fs.existsSync(PACKAGE_JSON_PATH))) {
|
||||
return
|
||||
}
|
||||
|
||||
const status = createSetupStatus('Setting up Node.js bridge...').start()
|
||||
|
||||
if (await isSyncCurrent()) {
|
||||
status.succeed('Node.js bridge: up-to-date')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await execa(PNPM_RUNTIME_BIN_PATH, [
|
||||
'install',
|
||||
'--ignore-workspace',
|
||||
'--config.confirmModulesPurge=false',
|
||||
'--lockfile=false'
|
||||
], {
|
||||
cwd: NODEJS_BRIDGE_ROOT_PATH,
|
||||
env: RuntimeHelper.getManagedNodeEnvironment()
|
||||
})
|
||||
|
||||
await fs.promises.mkdir(path.dirname(STAMP_FILE_PATH), { recursive: true })
|
||||
await fs.promises.writeFile(STAMP_FILE_PATH, `${Date.now()}`)
|
||||
|
||||
status.succeed('Node.js bridge: ready')
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
NVIDIA_LIBS_PATH,
|
||||
NVIDIA_CUBLAS_PATH,
|
||||
NVIDIA_CUDNN_PATH,
|
||||
NVIDIA_CUDA_CUDART_PATH,
|
||||
NVIDIA_CUDA_CUPTI_PATH,
|
||||
NVIDIA_CUSPARSE_PATH,
|
||||
NVIDIA_CUSPARSELT_PATH,
|
||||
NVIDIA_CUSPARSE_FULL_PATH,
|
||||
NVIDIA_NCCL_PATH,
|
||||
NVIDIA_NVSHMEM_PATH,
|
||||
NVIDIA_NVJITLINK_PATH,
|
||||
NVIDIA_CUBLAS_MANIFEST_PATH,
|
||||
NVIDIA_CUDNN_MANIFEST_PATH,
|
||||
NVIDIA_CUDA_CUDART_MANIFEST_PATH,
|
||||
NVIDIA_CUDA_CUPTI_MANIFEST_PATH,
|
||||
NVIDIA_CUSPARSE_MANIFEST_PATH,
|
||||
NVIDIA_CUSPARSE_FULL_MANIFEST_PATH,
|
||||
NVIDIA_NCCL_MANIFEST_PATH,
|
||||
NVIDIA_NVSHMEM_MANIFEST_PATH,
|
||||
NVIDIA_NVJITLINK_MANIFEST_PATH,
|
||||
NVIDIA_CUDA_VERSION,
|
||||
NVIDIA_CUBLAS_VERSION,
|
||||
NVIDIA_CUDNN_VERSION,
|
||||
NVIDIA_CUDA_CUDART_VERSION,
|
||||
NVIDIA_CUDA_CUPTI_VERSION,
|
||||
NVIDIA_CUSPARSE_VERSION,
|
||||
NVIDIA_CUSPARSE_FULL_VERSION,
|
||||
NVIDIA_NCCL_VERSION,
|
||||
NVIDIA_NVSHMEM_VERSION,
|
||||
NVIDIA_NVJITLINK_VERSION,
|
||||
PYTORCH_NVIDIA_PATH,
|
||||
PYTORCH_TORCH_PATH
|
||||
} from '@/constants'
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const { type: OS_TYPE, cpuArchitecture: CPU_ARCH } =
|
||||
SystemHelper.getInformation()
|
||||
const NVIDIA_LIBRARY_LABELS = {
|
||||
cublas: 'cuBLAS',
|
||||
cudnn: 'cuDNN',
|
||||
cuda_cudart: 'CUDA Runtime',
|
||||
cuda_cupti: 'CUDA CUPTI',
|
||||
cusparse: 'cuSPARSE Lt',
|
||||
cusparse_full: 'cuSPARSE',
|
||||
nccl: 'NCCL',
|
||||
nvshmem: 'NVSHMEM',
|
||||
nvjitlink: 'nvJitLink'
|
||||
}
|
||||
|
||||
/**
|
||||
* Map CPU architecture to NVIDIA's architecture naming convention
|
||||
*/
|
||||
function mapToNvidiaArch(cpuArch) {
|
||||
// Map Node.js process.arch values to NVIDIA naming
|
||||
if (cpuArch === 'arm64' || cpuArch === 'aarch64') {
|
||||
return 'aarch64'
|
||||
}
|
||||
if (cpuArch === 'x64' || cpuArch === 'x86_64') {
|
||||
return 'x86_64'
|
||||
}
|
||||
|
||||
return 'x86_64'
|
||||
}
|
||||
|
||||
async function ensureDirectoryLink(linkPath, targetPath) {
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
await fs.promises.rm(linkPath, { recursive: true, force: true })
|
||||
await fs.promises.mkdir(path.dirname(linkPath), { recursive: true })
|
||||
|
||||
const relativeTarget = path.relative(path.dirname(linkPath), targetPath)
|
||||
const linkType = SystemHelper.isWindows() ? 'junction' : 'dir'
|
||||
|
||||
await fs.promises.symlink(relativeTarget, linkPath, linkType)
|
||||
}
|
||||
|
||||
async function ensureCompatibilityLinks() {
|
||||
await ensureDirectoryLink(NVIDIA_CUSPARSELT_PATH, NVIDIA_CUSPARSE_PATH)
|
||||
await ensureDirectoryLink(
|
||||
path.join(NVIDIA_LIBS_PATH, 'cuda_runtime'),
|
||||
NVIDIA_CUDA_CUDART_PATH
|
||||
)
|
||||
|
||||
if (fs.existsSync(PYTORCH_TORCH_PATH)) {
|
||||
await ensureDirectoryLink(PYTORCH_NVIDIA_PATH, NVIDIA_LIBS_PATH)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read manifest file to get installed version
|
||||
*/
|
||||
function readManifest(manifestPath) {
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(manifestPath, 'utf-8')
|
||||
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get download URL for NVIDIA libraries
|
||||
*/
|
||||
function getNVIDIADownloadURL(library, version) {
|
||||
const ext = SystemHelper.isWindows() ? 'zip' : 'tar.xz'
|
||||
const arch = mapToNvidiaArch(CPU_ARCH)
|
||||
|
||||
// NVIDIA CDN URLs for CUDA libraries and more
|
||||
if (library === 'cublas') {
|
||||
return `https://developer.download.nvidia.com/compute/cuda/redist/libcublas/${OS_TYPE}-${arch}/libcublas-${OS_TYPE}-${arch}-${version}-archive.${ext}`
|
||||
} else if (library === 'cudnn') {
|
||||
return `https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/${OS_TYPE}-${arch}/cudnn-${OS_TYPE}-${arch}-${version}_cuda${NVIDIA_CUDA_VERSION}-archive.${ext}`
|
||||
} else if (library === 'cuda_cudart') {
|
||||
return `https://developer.download.nvidia.com/compute/cuda/redist/cuda_cudart/${OS_TYPE}-${arch}/cuda_cudart-${OS_TYPE}-${arch}-${version}-archive.${ext}`
|
||||
} else if (library === 'cuda_cupti') {
|
||||
return `https://developer.download.nvidia.com/compute/cuda/redist/cuda_cupti/${OS_TYPE}-${arch}/cuda_cupti-${OS_TYPE}-${arch}-${version}-archive.${ext}`
|
||||
} else if (library === 'cusparse') {
|
||||
return `https://developer.download.nvidia.com/compute/cusparselt/redist/libcusparse_lt/${OS_TYPE}-${arch}/libcusparse_lt-${OS_TYPE}-${arch}-${version}_cuda${NVIDIA_CUDA_VERSION}-archive.${ext}`
|
||||
} else if (library === 'cusparse_full') {
|
||||
return `https://developer.download.nvidia.com/compute/cuda/redist/libcusparse/${OS_TYPE}-${arch}/libcusparse-${OS_TYPE}-${arch}-${version}-archive.${ext}`
|
||||
} else if (library === 'nccl') {
|
||||
// NCCL is only available on Linux x86_64
|
||||
if (!SystemHelper.isLinux() || arch !== 'x86_64') {
|
||||
throw new Error('NCCL is only available on Linux x86_64')
|
||||
}
|
||||
|
||||
return `https://developer.download.nvidia.com/compute/nccl/redist/nccl/${OS_TYPE}-${arch}/nccl-${OS_TYPE}-${arch}-${version}-archive.${ext}`
|
||||
} else if (library === 'nvshmem') {
|
||||
// NVSHMEM is only available on Linux x86_64
|
||||
if (!SystemHelper.isLinux() || arch !== 'x86_64') {
|
||||
throw new Error('NVSHMEM is only available on Linux x86_64')
|
||||
}
|
||||
|
||||
return `https://developer.download.nvidia.com/compute/nvshmem/redist/libnvshmem/${OS_TYPE}-${arch}/libnvshmem-${OS_TYPE}-${arch}-${version}_cuda${NVIDIA_CUDA_VERSION}-archive.${ext}`
|
||||
} else if (library === 'nvjitlink') {
|
||||
return `https://developer.download.nvidia.com/compute/cuda/redist/libnvjitlink/${OS_TYPE}-${arch}/libnvjitlink-${OS_TYPE}-${arch}-${version}-archive.${ext}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install NVIDIA libraries if needed
|
||||
*/
|
||||
async function installNVIDIALibrary(
|
||||
library,
|
||||
requiredVersion,
|
||||
targetPath,
|
||||
manifestPath
|
||||
) {
|
||||
const libraryLabel = NVIDIA_LIBRARY_LABELS[library] || library
|
||||
const status = createSetupStatus(`Setting up ${libraryLabel}...`).start()
|
||||
const manifest = readManifest(manifestPath)
|
||||
|
||||
if (!manifest || manifest.version !== requiredVersion) {
|
||||
const ext = SystemHelper.isWindows() ? 'zip' : 'tar.xz'
|
||||
const archivePath = path.join(
|
||||
NVIDIA_LIBS_PATH,
|
||||
`${library}-${requiredVersion}.${ext}`
|
||||
)
|
||||
|
||||
// Clean up old version
|
||||
await fs.promises.rm(targetPath, { recursive: true, force: true })
|
||||
await fs.promises.rm(archivePath, { force: true })
|
||||
|
||||
// Create target directory
|
||||
await fs.promises.mkdir(targetPath, { recursive: true })
|
||||
|
||||
try {
|
||||
const downloadURL = getNVIDIADownloadURL(library, requiredVersion)
|
||||
status.pause()
|
||||
|
||||
await FileHelper.downloadFile(downloadURL, archivePath, {
|
||||
cliProgress: true,
|
||||
parallelStreams: 3,
|
||||
skipExisting: false
|
||||
})
|
||||
status.text = `Installing ${libraryLabel}...`
|
||||
status.start()
|
||||
|
||||
// Extract archive using unified method
|
||||
await FileHelper.extractArchive(archivePath, targetPath, {
|
||||
stripComponents: 1
|
||||
})
|
||||
|
||||
// Clean up and create manifest
|
||||
await Promise.all([
|
||||
fs.promises.rm(archivePath, { force: true }),
|
||||
FileHelper.createManifestFile(manifestPath, library, requiredVersion, {
|
||||
os: SystemHelper.getInformation().type,
|
||||
architecture: SystemHelper.getInformation().cpuArchitecture
|
||||
})
|
||||
])
|
||||
status.succeed(`${libraryLabel} ${requiredVersion} ready`)
|
||||
} catch (error) {
|
||||
if (status.isSpinning) {
|
||||
status.fail(`Failed to set up ${libraryLabel}`)
|
||||
}
|
||||
throw new Error(
|
||||
`${libraryLabel} may require manual download from https://developer.nvidia.com/cuda-downloads: ${error}`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
status.succeed(`${libraryLabel}: ${requiredVersion}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main setup function
|
||||
*/
|
||||
async function setupNVIDIALibs() {
|
||||
// Skip on macOS since there is no CUDA involved
|
||||
if (SystemHelper.isMacOS()) {
|
||||
return
|
||||
}
|
||||
const status = createSetupStatus('Checking CUDA runtime support...').start()
|
||||
|
||||
try {
|
||||
const { getLlama, LlamaLogLevel } = await Function(
|
||||
'return import("node-llama-cpp")'
|
||||
)()
|
||||
const llama = await getLlama({
|
||||
logLevel: LlamaLogLevel.disabled
|
||||
})
|
||||
|
||||
const hasGPU = await SystemHelper.hasGPU(llama)
|
||||
|
||||
if (!hasGPU) {
|
||||
status.succeed('CUDA runtime: skipped')
|
||||
return
|
||||
}
|
||||
|
||||
status.succeed('CUDA runtime: detected')
|
||||
|
||||
// Install/update cuBLAS
|
||||
await installNVIDIALibrary(
|
||||
'cublas',
|
||||
NVIDIA_CUBLAS_VERSION,
|
||||
NVIDIA_CUBLAS_PATH,
|
||||
NVIDIA_CUBLAS_MANIFEST_PATH
|
||||
)
|
||||
|
||||
// Install/update cuDNN
|
||||
await installNVIDIALibrary(
|
||||
'cudnn',
|
||||
NVIDIA_CUDNN_VERSION,
|
||||
NVIDIA_CUDNN_PATH,
|
||||
NVIDIA_CUDNN_MANIFEST_PATH
|
||||
)
|
||||
|
||||
// Install/update CUDA cudart runtime
|
||||
await installNVIDIALibrary(
|
||||
'cuda_cudart',
|
||||
NVIDIA_CUDA_CUDART_VERSION,
|
||||
NVIDIA_CUDA_CUDART_PATH,
|
||||
NVIDIA_CUDA_CUDART_MANIFEST_PATH
|
||||
)
|
||||
|
||||
// Install/update CUDA CUPTI
|
||||
await installNVIDIALibrary(
|
||||
'cuda_cupti',
|
||||
NVIDIA_CUDA_CUPTI_VERSION,
|
||||
NVIDIA_CUDA_CUPTI_PATH,
|
||||
NVIDIA_CUDA_CUPTI_MANIFEST_PATH
|
||||
)
|
||||
|
||||
// Install/update cuSPARSE-Lt (Linux only, both x86_64 and aarch64)
|
||||
if (SystemHelper.isLinux()) {
|
||||
try {
|
||||
await installNVIDIALibrary(
|
||||
'cusparse',
|
||||
NVIDIA_CUSPARSE_VERSION,
|
||||
NVIDIA_CUSPARSE_PATH,
|
||||
NVIDIA_CUSPARSE_MANIFEST_PATH
|
||||
)
|
||||
} catch (error) {
|
||||
status.warn(`cuSPARSE Lt skipped: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Install/update cuSPARSE (Linux only, both x86_64 and aarch64)
|
||||
if (SystemHelper.isLinux()) {
|
||||
try {
|
||||
await installNVIDIALibrary(
|
||||
'cusparse_full',
|
||||
NVIDIA_CUSPARSE_FULL_VERSION,
|
||||
NVIDIA_CUSPARSE_FULL_PATH,
|
||||
NVIDIA_CUSPARSE_FULL_MANIFEST_PATH
|
||||
)
|
||||
} catch (error) {
|
||||
status.warn(`cuSPARSE skipped: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Install/update nvJitLink (Linux only)
|
||||
if (SystemHelper.isLinux()) {
|
||||
try {
|
||||
await installNVIDIALibrary(
|
||||
'nvjitlink',
|
||||
NVIDIA_NVJITLINK_VERSION,
|
||||
NVIDIA_NVJITLINK_PATH,
|
||||
NVIDIA_NVJITLINK_MANIFEST_PATH
|
||||
)
|
||||
} catch (error) {
|
||||
status.warn(`nvJitLink skipped: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Install/update NCCL (Linux x86_64 only)
|
||||
if (SystemHelper.isLinux() && mapToNvidiaArch(CPU_ARCH) === 'x86_64') {
|
||||
try {
|
||||
await installNVIDIALibrary(
|
||||
'nccl',
|
||||
NVIDIA_NCCL_VERSION,
|
||||
NVIDIA_NCCL_PATH,
|
||||
NVIDIA_NCCL_MANIFEST_PATH
|
||||
)
|
||||
} catch (error) {
|
||||
status.warn(`NCCL skipped: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Install/update NVSHMEM (Linux x86_64 only)
|
||||
if (SystemHelper.isLinux() && mapToNvidiaArch(CPU_ARCH) === 'x86_64') {
|
||||
try {
|
||||
await installNVIDIALibrary(
|
||||
'nvshmem',
|
||||
NVIDIA_NVSHMEM_VERSION,
|
||||
NVIDIA_NVSHMEM_PATH,
|
||||
NVIDIA_NVSHMEM_MANIFEST_PATH
|
||||
)
|
||||
} catch (error) {
|
||||
status.warn(`NVSHMEM skipped: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
await ensureCompatibilityLinks()
|
||||
} catch (error) {
|
||||
if (status.isSpinning) {
|
||||
status.fail('Failed to inspect CUDA runtime support')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export default setupNVIDIALibs
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
PNPM_INSTALL_PATH,
|
||||
PNPM_MANIFEST_PATH,
|
||||
PNPM_VERSION
|
||||
} from '@/constants'
|
||||
import { CPUArchitectures } from '@/types'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { setupRuntimeBinary } from './setup-runtime-binary'
|
||||
|
||||
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
|
||||
|
||||
function getBinaryPath() {
|
||||
return SystemHelper.isWindows()
|
||||
? path.join(PNPM_INSTALL_PATH, 'pnpm.exe')
|
||||
: path.join(PNPM_INSTALL_PATH, 'pnpm')
|
||||
}
|
||||
|
||||
function getAssetFileName() {
|
||||
if (SystemHelper.isLinux()) {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return 'pnpm-linux-x64.tar.gz'
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return 'pnpm-linux-arm64.tar.gz'
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isMacOS()) {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
throw new Error('pnpm 11 no longer ships a standalone macOS x64 binary')
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return 'pnpm-darwin-arm64.tar.gz'
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isWindows()) {
|
||||
return CPU_ARCH === CPUArchitectures.ARM64
|
||||
? 'pnpm-win32-arm64.zip'
|
||||
: 'pnpm-win32-x64.zip'
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported platform for pnpm: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
|
||||
)
|
||||
}
|
||||
|
||||
export default async function setupPNPM() {
|
||||
const assetFileName = getAssetFileName()
|
||||
|
||||
await setupRuntimeBinary({
|
||||
name: 'pnpm',
|
||||
version: PNPM_VERSION,
|
||||
basePath: PNPM_INSTALL_PATH,
|
||||
installPath: PNPM_INSTALL_PATH,
|
||||
manifestPath: PNPM_MANIFEST_PATH,
|
||||
binaryPath: getBinaryPath(),
|
||||
downloadURL: `https://github.com/pnpm/pnpm/releases/download/v${PNPM_VERSION}/${assetFileName}`,
|
||||
archiveFileName: assetFileName
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { SetupUI, setupConsola } from './setup-ui'
|
||||
import setupRemoteLLM from './setup-remote-llm'
|
||||
|
||||
/**
|
||||
* Ask lightweight setup questions so users can skip optional downloads.
|
||||
*/
|
||||
export default async function setupPreferences(
|
||||
localAICapability,
|
||||
existingLLMChoice,
|
||||
localAISetupState,
|
||||
voiceSetupState
|
||||
) {
|
||||
const defaultPreferences = {
|
||||
setupLocalAI: localAICapability.canInstallLocalAI,
|
||||
setupVoice: false,
|
||||
remoteLLMProvider: '',
|
||||
remoteLLMModel: '',
|
||||
remoteLLMAPIKeyEnv: '',
|
||||
remoteLLMAPIKey: ''
|
||||
}
|
||||
|
||||
if (
|
||||
process.env.IS_DOCKER === 'true' ||
|
||||
!process.stdin.isTTY ||
|
||||
!process.stdout.isTTY
|
||||
) {
|
||||
if (!localAICapability.canInstallLocalAI) {
|
||||
SetupUI.info(
|
||||
'This computer is not a good fit for local AI or voice features, so I will set up the essentials.'
|
||||
)
|
||||
}
|
||||
|
||||
return defaultPreferences
|
||||
}
|
||||
|
||||
const hasConfiguredLocalAI =
|
||||
existingLLMChoice.hasResolvedChoice &&
|
||||
existingLLMChoice.setupLocalAI &&
|
||||
existingLLMChoice.targetType !== 'defaultLocal'
|
||||
const hasInstalledLocalAI =
|
||||
localAISetupState.isInstalled || hasConfiguredLocalAI
|
||||
const hasInstalledVoice = voiceSetupState.isInstalled
|
||||
const hasUsableExistingLLMChoice =
|
||||
existingLLMChoice.hasResolvedChoice &&
|
||||
(existingLLMChoice.targetType !== 'defaultLocal' || hasInstalledLocalAI)
|
||||
|
||||
if (hasUsableExistingLLMChoice) {
|
||||
SetupUI.info(
|
||||
`I found your current AI setup, so I will keep using it: ${existingLLMChoice.label}`
|
||||
)
|
||||
}
|
||||
|
||||
if (hasInstalledLocalAI) {
|
||||
SetupUI.info(
|
||||
`Local AI is already installed${
|
||||
localAISetupState.label ? `: ${localAISetupState.label}` : ''
|
||||
}`
|
||||
)
|
||||
}
|
||||
|
||||
if (hasInstalledVoice) {
|
||||
SetupUI.info('Voice is already installed, so I will keep it updated.')
|
||||
}
|
||||
|
||||
if (!localAICapability.canInstallLocalAI) {
|
||||
if (!hasUsableExistingLLMChoice) {
|
||||
return {
|
||||
...defaultPreferences,
|
||||
setupVoice: hasInstalledVoice,
|
||||
...(await setupRemoteLLM())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultPreferences,
|
||||
setupLocalAI: hasInstalledLocalAI,
|
||||
setupVoice: hasInstalledVoice
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasInstalledLocalAI || !hasInstalledVoice) {
|
||||
SetupUI.info(
|
||||
'I just have a few quick questions so I can set things up the way you want.'
|
||||
)
|
||||
}
|
||||
|
||||
const setupLocalAI = hasInstalledLocalAI
|
||||
? true
|
||||
: await setupConsola.prompt('Do you want me to set up local AI now?', {
|
||||
type: 'confirm',
|
||||
initial: true,
|
||||
cancel: 'default'
|
||||
})
|
||||
|
||||
let remoteLLMPreferences = {
|
||||
remoteLLMProvider: '',
|
||||
remoteLLMModel: '',
|
||||
remoteLLMAPIKeyEnv: '',
|
||||
remoteLLMAPIKey: ''
|
||||
}
|
||||
|
||||
if (!setupLocalAI && !hasUsableExistingLLMChoice) {
|
||||
remoteLLMPreferences = await setupRemoteLLM()
|
||||
}
|
||||
|
||||
const setupVoice = hasInstalledVoice
|
||||
? true
|
||||
: await setupConsola.prompt('Do you want to talk to me with your voice now?', {
|
||||
type: 'confirm',
|
||||
initial: false,
|
||||
cancel: 'default'
|
||||
})
|
||||
|
||||
const preferences = {
|
||||
...defaultPreferences,
|
||||
setupLocalAI,
|
||||
setupVoice,
|
||||
...remoteLLMPreferences
|
||||
}
|
||||
|
||||
return preferences
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { PYTHON_BRIDGE_SRC_PATH } from '@/constants'
|
||||
|
||||
import { setupPythonProjectEnv } from './setup-python-project-env'
|
||||
|
||||
/**
|
||||
* Sync the Python bridge runtime environment from its `pyproject.toml`.
|
||||
*/
|
||||
export default async function setupPythonBridgeEnv() {
|
||||
await setupPythonProjectEnv({
|
||||
name: 'Python bridge',
|
||||
projectPath: PYTHON_BRIDGE_SRC_PATH,
|
||||
stampFileName: '.last-python-bridge-deps-sync'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
import {
|
||||
PYTHON_RUNTIME_BIN_PATH,
|
||||
UV_RUNTIME_BIN_PATH
|
||||
} from '@/constants'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const PYPROJECT_FILE_NAME = 'pyproject.toml'
|
||||
|
||||
/**
|
||||
* Detect whether a command failed to spawn because the executable was missing.
|
||||
*/
|
||||
function isExecutableMissingError(error) {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make Python runtime resolution failures actionable during setup.
|
||||
*/
|
||||
async function ensurePythonRuntimeAvailable(name) {
|
||||
try {
|
||||
await execa(PYTHON_RUNTIME_BIN_PATH, ['--version'])
|
||||
} catch (error) {
|
||||
if (!isExecutableMissingError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${name}: unable to resolve Leon's managed Python runtime at "${PYTHON_RUNTIME_BIN_PATH}". Make sure the managed Python setup completed successfully, or set \`LEON_PYTHON_PATH\` to an explicit Python executable if you intentionally want to override it.`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make uv runtime resolution failures actionable during setup.
|
||||
*/
|
||||
async function ensureUVRuntimeAvailable(name) {
|
||||
try {
|
||||
await execa(UV_RUNTIME_BIN_PATH, ['--version'])
|
||||
} catch (error) {
|
||||
if (!isExecutableMissingError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${name}: unable to resolve Leon's managed uv runtime at "${UV_RUNTIME_BIN_PATH}". Make sure the managed uv setup completed successfully, or set \`LEON_UV_PATH\` to an explicit uv executable if you intentionally want to override it.`,
|
||||
{ cause: error }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the dependency list from a Python project's `pyproject.toml` using the
|
||||
* managed Python runtime and the standard-library `tomllib` parser.
|
||||
*/
|
||||
export async function getPyprojectDependencies(projectPath) {
|
||||
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
|
||||
|
||||
if (!fs.existsSync(pyprojectPath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const readerScript = [
|
||||
'import json',
|
||||
'import sys',
|
||||
'import tomllib',
|
||||
'from pathlib import Path',
|
||||
'data = tomllib.loads(Path(sys.argv[1]).read_text())',
|
||||
'deps = data.get("project", {}).get("dependencies", [])',
|
||||
'print(json.dumps(deps))'
|
||||
].join('; ')
|
||||
|
||||
const result = await execa(PYTHON_RUNTIME_BIN_PATH, [
|
||||
'-c',
|
||||
readerScript,
|
||||
pyprojectPath
|
||||
])
|
||||
|
||||
return JSON.parse(result.stdout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Python executable path inside a project-local `.venv`.
|
||||
*/
|
||||
export function getProjectVenvPythonPath(projectPath) {
|
||||
return path.join(
|
||||
projectPath,
|
||||
'.venv',
|
||||
SystemHelper.isWindows() ? 'Scripts' : 'bin',
|
||||
SystemHelper.isWindows() ? 'python.exe' : 'python'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a project dependency sync stamp is newer than its manifest.
|
||||
*/
|
||||
async function isSyncCurrent(projectPath, stampFileName) {
|
||||
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
|
||||
const stampPath = path.join(projectPath, stampFileName)
|
||||
|
||||
if (
|
||||
!fs.existsSync(pyprojectPath) ||
|
||||
!fs.existsSync(stampPath) ||
|
||||
!fs.existsSync(getProjectVenvPythonPath(projectPath))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [manifestStat, stampStat] = await Promise.all([
|
||||
fs.promises.stat(pyprojectPath),
|
||||
fs.promises.stat(stampPath)
|
||||
])
|
||||
|
||||
return manifestStat.mtimeMs <= stampStat.mtimeMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or refresh a project-local `.venv` and install dependencies declared
|
||||
* in `pyproject.toml` through Leon's managed `uv` and Python runtimes.
|
||||
*/
|
||||
export async function setupPythonProjectEnv({
|
||||
name,
|
||||
projectPath,
|
||||
stampFileName
|
||||
}) {
|
||||
const status = createSetupStatus(`Setting up ${name} dependencies...`).start()
|
||||
const pyprojectPath = path.join(projectPath, PYPROJECT_FILE_NAME)
|
||||
const stampPath = path.join(projectPath, stampFileName)
|
||||
const venvPath = path.join(projectPath, '.venv')
|
||||
|
||||
if (!fs.existsSync(pyprojectPath)) {
|
||||
status.pause()
|
||||
return
|
||||
}
|
||||
|
||||
if (await isSyncCurrent(projectPath, stampFileName)) {
|
||||
status.succeed(`${name}: up-to-date`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await ensurePythonRuntimeAvailable(name)
|
||||
await ensureUVRuntimeAvailable(name)
|
||||
|
||||
const dependencies = await getPyprojectDependencies(projectPath)
|
||||
|
||||
await fs.promises.rm(venvPath, { recursive: true, force: true })
|
||||
|
||||
await execa(UV_RUNTIME_BIN_PATH, [
|
||||
'venv',
|
||||
'--python',
|
||||
PYTHON_RUNTIME_BIN_PATH,
|
||||
venvPath
|
||||
], { cwd: projectPath })
|
||||
|
||||
if (dependencies.length > 0) {
|
||||
await execa(UV_RUNTIME_BIN_PATH, [
|
||||
'pip',
|
||||
'install',
|
||||
'--python',
|
||||
getProjectVenvPythonPath(projectPath),
|
||||
...dependencies
|
||||
], { cwd: projectPath })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(stampPath, `${Date.now()}`)
|
||||
|
||||
status.succeed(`${name}: ready`)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
PYTHON_INSTALL_PATH,
|
||||
PYTHON_MANIFEST_PATH,
|
||||
PYTHON_VERSION
|
||||
} from '@/constants'
|
||||
import { CPUArchitectures } from '@/types'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { setupRuntimeBinary } from './setup-runtime-binary'
|
||||
|
||||
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
|
||||
|
||||
function getBinaryPath() {
|
||||
return SystemHelper.isWindows()
|
||||
? path.join(PYTHON_INSTALL_PATH, 'python.exe')
|
||||
: path.join(PYTHON_INSTALL_PATH, 'bin', 'python')
|
||||
}
|
||||
|
||||
function getAssetFileName() {
|
||||
if (SystemHelper.isLinux()) {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return `cpython-${PYTHON_VERSION}-x86_64-unknown-linux-gnu-install_only.tar.gz`
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return `cpython-${PYTHON_VERSION}-aarch64-unknown-linux-gnu-install_only.tar.gz`
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isMacOS()) {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return `cpython-${PYTHON_VERSION}-x86_64-apple-darwin-install_only.tar.gz`
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return `cpython-${PYTHON_VERSION}-aarch64-apple-darwin-install_only.tar.gz`
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isWindows()) {
|
||||
return CPU_ARCH === CPUArchitectures.ARM64
|
||||
? `cpython-${PYTHON_VERSION}-aarch64-pc-windows-msvc-install_only.tar.gz`
|
||||
: `cpython-${PYTHON_VERSION}-x86_64-pc-windows-msvc-install_only.tar.gz`
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported platform for Python: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
|
||||
)
|
||||
}
|
||||
|
||||
export default async function setupPython() {
|
||||
const assetFileName = getAssetFileName()
|
||||
|
||||
await setupRuntimeBinary({
|
||||
name: 'Python',
|
||||
version: PYTHON_VERSION,
|
||||
basePath: PYTHON_INSTALL_PATH,
|
||||
installPath: PYTHON_INSTALL_PATH,
|
||||
manifestPath: PYTHON_MANIFEST_PATH,
|
||||
binaryPath: getBinaryPath(),
|
||||
downloadURL: `https://github.com/astral-sh/python-build-standalone/releases/download/20240415/${assetFileName}`,
|
||||
archiveFileName: assetFileName
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
NVIDIA_LIBS_PATH,
|
||||
PYTORCH_PATH,
|
||||
PYTORCH_TORCH_PATH,
|
||||
PYTORCH_NVIDIA_PATH,
|
||||
PYTORCH_VERSION,
|
||||
PYTORCH_MANIFEST_PATH
|
||||
} from '@/constants'
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const { type: OS_TYPE, cpuArchitecture: CPU_ARCH } =
|
||||
SystemHelper.getInformation()
|
||||
const PYTORCH_SETUP_TEXT = 'Setting up PyTorch...'
|
||||
|
||||
async function ensureDirectoryLink(linkPath, targetPath) {
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
await fs.promises.rm(linkPath, { recursive: true, force: true })
|
||||
await fs.promises.mkdir(path.dirname(linkPath), { recursive: true })
|
||||
|
||||
const relativeTarget = path.relative(path.dirname(linkPath), targetPath)
|
||||
const linkType = SystemHelper.isWindows() ? 'junction' : 'dir'
|
||||
|
||||
await fs.promises.symlink(relativeTarget, linkPath, linkType)
|
||||
}
|
||||
|
||||
/**
|
||||
* Map OS and architecture to PyTorch wheel platform identifiers
|
||||
*/
|
||||
function getPyTorchPlatform() {
|
||||
const isMacOS = SystemHelper.isMacOS()
|
||||
const isWindows = SystemHelper.isWindows()
|
||||
const isLinux = SystemHelper.isLinux()
|
||||
|
||||
if (isLinux) {
|
||||
if (CPU_ARCH === 'x64' || CPU_ARCH === 'x86_64') {
|
||||
return 'linux-x86_64'
|
||||
} else if (CPU_ARCH === 'arm64' || CPU_ARCH === 'aarch64') {
|
||||
return 'linux-aarch64'
|
||||
}
|
||||
} else if (isWindows) {
|
||||
return 'windows-x86_64'
|
||||
} else if (isMacOS) {
|
||||
if (CPU_ARCH === 'arm64') {
|
||||
return 'macos-arm64'
|
||||
} else {
|
||||
return 'macos-x86_64'
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported platform: ${OS_TYPE} ${CPU_ARCH}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get PyTorch wheel download URL based on platform
|
||||
*/
|
||||
function getPyTorchDownloadURL(version) {
|
||||
const platform = getPyTorchPlatform()
|
||||
|
||||
const urls = {
|
||||
'linux-x86_64': `https://download.pytorch.org/whl/cu129/torch-${version}%2Bcu129-cp311-cp311-manylinux_2_28_x86_64.whl`,
|
||||
'linux-aarch64': `https://download.pytorch.org/whl/cu129/torch-${version}%2Bcu129-cp311-cp311-manylinux_2_28_aarch64.whl`,
|
||||
'windows-x86_64': `https://download.pytorch.org/whl/cu129/torch-${version}%2Bcu129-cp311-cp311-win_amd64.whl`,
|
||||
'macos-arm64': `https://download.pytorch.org/whl/cpu/torch-${version}-cp311-none-macosx_11_0_arm64.whl`,
|
||||
// Use 2.2.0 as it is the latest available pre-built package for Python 3.11
|
||||
'macos-x86_64': 'https://download.pytorch.org/whl/cpu/torch-2.2.0-cp311-none-macosx_10_9_x86_64.whl'
|
||||
}
|
||||
|
||||
const url = urls[platform]
|
||||
if (!url) {
|
||||
throw new Error(`No PyTorch wheel available for platform: ${platform}`)
|
||||
}
|
||||
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* Read manifest file to get installed version
|
||||
*/
|
||||
function readManifest(manifestPath) {
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(manifestPath, 'utf-8')
|
||||
|
||||
return JSON.parse(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install PyTorch wheel if needed
|
||||
*/
|
||||
async function installPyTorch(requiredVersion, targetPath, manifestPath) {
|
||||
const manifest = readManifest(manifestPath)
|
||||
|
||||
if (!manifest || manifest.version !== requiredVersion) {
|
||||
const wheelPath = path.join(PYTORCH_PATH, `torch-${requiredVersion}.whl`)
|
||||
|
||||
// Clean up old version
|
||||
await fs.promises.rm(targetPath, { recursive: true, force: true })
|
||||
await fs.promises.rm(wheelPath, { force: true })
|
||||
|
||||
// Create target directory
|
||||
await fs.promises.mkdir(targetPath, { recursive: true })
|
||||
|
||||
try {
|
||||
const downloadURL = getPyTorchDownloadURL(requiredVersion)
|
||||
|
||||
await FileHelper.downloadFile(downloadURL, wheelPath, {
|
||||
cliProgress: true,
|
||||
parallelStreams: 3,
|
||||
skipExisting: false
|
||||
})
|
||||
|
||||
// Extract wheel (wheels are just ZIP files)
|
||||
await FileHelper.extractArchive(wheelPath, targetPath, {
|
||||
stripComponents: 0
|
||||
})
|
||||
|
||||
// Clean up and create manifest
|
||||
await Promise.all([
|
||||
fs.promises.rm(wheelPath, { force: true }),
|
||||
FileHelper.createManifestFile(manifestPath, 'torch', requiredVersion, {
|
||||
os: SystemHelper.getInformation().type,
|
||||
architecture: SystemHelper.getInformation().cpuArchitecture
|
||||
})
|
||||
])
|
||||
|
||||
if (!SystemHelper.isMacOS()) {
|
||||
await ensureDirectoryLink(PYTORCH_NVIDIA_PATH, NVIDIA_LIBS_PATH)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`PyTorch may require manual download from https://pytorch.org/get-started/locally/: ${error}`
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Main setup function
|
||||
*/
|
||||
async function setupPyTorch() {
|
||||
const status = createSetupStatus(PYTORCH_SETUP_TEXT).start()
|
||||
|
||||
try {
|
||||
const installed = await installPyTorch(
|
||||
PYTORCH_VERSION,
|
||||
PYTORCH_TORCH_PATH,
|
||||
PYTORCH_MANIFEST_PATH
|
||||
)
|
||||
|
||||
if (!SystemHelper.isMacOS()) {
|
||||
await ensureDirectoryLink(PYTORCH_NVIDIA_PATH, NVIDIA_LIBS_PATH)
|
||||
}
|
||||
|
||||
if (installed) {
|
||||
status.succeed(`PyTorch: ${PYTORCH_VERSION}`)
|
||||
} else {
|
||||
status.succeed(`PyTorch: ${PYTORCH_VERSION}`)
|
||||
}
|
||||
} catch (error) {
|
||||
if (status.isSpinning) {
|
||||
status.fail('Failed to set up PyTorch')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export default setupPyTorch
|
||||
@@ -0,0 +1,114 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { homedir } from 'node:os'
|
||||
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { NetworkHelper } from '@/helpers/network-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const MOVE_FALLBACK_ERROR_CODES = new Set(['EXDEV', 'EPERM', 'EBUSY', 'EACCES'])
|
||||
const QMD_MODELS_DIR_PATH = path.join(homedir(), '.cache', 'qmd', 'models')
|
||||
|
||||
const QMD_MODELS = [
|
||||
{
|
||||
/**
|
||||
* We do not use it yet, but better to get it now,
|
||||
* so it'd be ready when we enable embeddings
|
||||
*/
|
||||
url: 'https://huggingface.co/ggml-org/embeddinggemma-300M-GGUF/resolve/main/embeddinggemma-300M-Q8_0.gguf?download=true',
|
||||
filename: 'hf_ggml-org_embeddinggemma-300M-Q8_0.gguf'
|
||||
},
|
||||
{
|
||||
url: 'https://huggingface.co/ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/resolve/main/qwen3-reranker-0.6b-q8_0.gguf?download=true',
|
||||
filename: 'hf_ggml-org_qwen3-reranker-0.6b-q8_0.gguf'
|
||||
},
|
||||
{
|
||||
url: 'https://huggingface.co/tobil/qmd-query-expansion-1.7B/resolve/main/qmd-query-expansion-1.7B-Q4_K_M.gguf?download=true',
|
||||
filename: 'hf_tobil_qmd-query-expansion-1.7B-q4_k_m.gguf'
|
||||
}
|
||||
]
|
||||
|
||||
function getModelFilenameFromURL(modelURL) {
|
||||
const parsedURL = new URL(modelURL)
|
||||
|
||||
return path.basename(parsedURL.pathname)
|
||||
}
|
||||
|
||||
function isMoveFallbackError(error) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
MOVE_FALLBACK_ERROR_CODES.has(error.code)
|
||||
)
|
||||
}
|
||||
|
||||
async function movePath(sourcePath, destinationPath) {
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, destinationPath)
|
||||
} catch (error) {
|
||||
if (!isMoveFallbackError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
await fs.promises.copyFile(sourcePath, destinationPath)
|
||||
await fs.promises.rm(sourcePath, { force: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadModel(model) {
|
||||
const destinationPath = path.join(QMD_MODELS_DIR_PATH, model.filename)
|
||||
const legacyFilename = getModelFilenameFromURL(model.url)
|
||||
const legacyPath = path.join(QMD_MODELS_DIR_PATH, legacyFilename)
|
||||
|
||||
if (fs.existsSync(destinationPath)) {
|
||||
return 'existing'
|
||||
}
|
||||
|
||||
if (legacyFilename !== model.filename && fs.existsSync(legacyPath)) {
|
||||
await movePath(legacyPath, destinationPath)
|
||||
|
||||
return 'renamed'
|
||||
}
|
||||
|
||||
const resolvedURL = await NetworkHelper.setHuggingFaceURL(model.url)
|
||||
|
||||
await FileHelper.downloadFile(resolvedURL, destinationPath)
|
||||
|
||||
return 'downloaded'
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
const status = createSetupStatus('Checking QMD models...').start()
|
||||
|
||||
try {
|
||||
await fs.promises.mkdir(QMD_MODELS_DIR_PATH, {
|
||||
recursive: true
|
||||
})
|
||||
|
||||
status.pause()
|
||||
let downloadedModelCount = 0
|
||||
|
||||
for (const model of QMD_MODELS) {
|
||||
const modelState = await downloadModel(model)
|
||||
|
||||
if (modelState === 'downloaded') {
|
||||
downloadedModelCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
status.text = 'Finalizing QMD models...'
|
||||
status.start()
|
||||
|
||||
status.succeed(
|
||||
downloadedModelCount > 0
|
||||
? `QMD models: ready - ${downloadedModelCount} downloaded`
|
||||
: 'QMD models: ready'
|
||||
)
|
||||
} catch (e) {
|
||||
if (status.isSpinning) {
|
||||
status.fail('Failed to set up QMD models')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import {
|
||||
getLLMProviderAccountConfig
|
||||
} from '@/core/llm-manager/llm-provider-account-configs'
|
||||
|
||||
import { SetupUI, setupConsola } from './setup-ui'
|
||||
|
||||
const REMOTE_LLM_PROVIDERS = [
|
||||
{
|
||||
...getRequiredProviderAccountConfig('openrouter'),
|
||||
models: [
|
||||
{ label: 'openai/gpt-5.6-sol (Recommended)', value: 'openai/gpt-5.6-sol' },
|
||||
{ label: 'openai/gpt-5.6-terra', value: 'openai/gpt-5.6-terra' },
|
||||
{ label: 'openai/gpt-5.6-luna', value: 'openai/gpt-5.6-luna' },
|
||||
{ label: 'openai/gpt-5.5', value: 'openai/gpt-5.5' },
|
||||
{ label: 'openai/gpt-5.4', value: 'openai/gpt-5.4' },
|
||||
{ label: 'openai/gpt-5.4-mini', value: 'openai/gpt-5.4-mini' },
|
||||
{
|
||||
label: 'anthropic/claude-fable-5',
|
||||
value: 'anthropic/claude-fable-5'
|
||||
},
|
||||
{
|
||||
label: 'anthropic/claude-opus-4.8',
|
||||
value: 'anthropic/claude-opus-4.8'
|
||||
},
|
||||
{
|
||||
label: 'anthropic/claude-opus-4.7',
|
||||
value: 'anthropic/claude-opus-4.7'
|
||||
},
|
||||
{
|
||||
label: 'anthropic/claude-opus-4.6',
|
||||
value: 'anthropic/claude-opus-4.6'
|
||||
},
|
||||
{
|
||||
label: 'anthropic/claude-sonnet-4.6',
|
||||
value: 'anthropic/claude-sonnet-4.6'
|
||||
},
|
||||
{ label: 'xiaomi/mimo-v2.5-pro', value: 'xiaomi/mimo-v2.5-pro' },
|
||||
{ label: 'z-ai/glm-5.2', value: 'z-ai/glm-5.2' },
|
||||
{ label: 'z-ai/glm-5.1', value: 'z-ai/glm-5.1' },
|
||||
{ label: 'z-ai/glm-5-turbo', value: 'z-ai/glm-5-turbo' },
|
||||
{ label: 'moonshotai/kimi-k2.6', value: 'moonshotai/kimi-k2.6' },
|
||||
{ label: 'minimax/minimax-m3', value: 'minimax/minimax-m3' }
|
||||
]
|
||||
},
|
||||
{
|
||||
...getRequiredProviderAccountConfig('openai'),
|
||||
models: [
|
||||
{ label: 'GPT-5.6 Sol (Recommended)', value: 'gpt-5.6-sol' },
|
||||
{ label: 'GPT-5.6 Terra', value: 'gpt-5.6-terra' },
|
||||
{ label: 'GPT-5.6 Luna', value: 'gpt-5.6-luna' },
|
||||
{ label: 'GPT-5.5', value: 'gpt-5.5' },
|
||||
{ label: 'GPT-5.4', value: 'gpt-5.4' },
|
||||
{ label: 'GPT-5.4 mini', value: 'gpt-5.4-mini' },
|
||||
{ label: 'GPT-5.4 nano', value: 'gpt-5.4-nano' }
|
||||
]
|
||||
},
|
||||
{
|
||||
...getRequiredProviderAccountConfig('anthropic'),
|
||||
models: [
|
||||
{ label: 'Claude Opus 4.8 (Recommended)', value: 'claude-opus-4-8' },
|
||||
{ label: 'Claude Fable 5', value: 'claude-fable-5' },
|
||||
{ label: 'Claude Opus 4.7', value: 'claude-opus-4-7' },
|
||||
{ label: 'Claude Opus 4.6', value: 'claude-opus-4-6' },
|
||||
{ label: 'Claude Sonnet 4.6', value: 'claude-sonnet-4-6' },
|
||||
{ label: 'Claude Haiku 4.5', value: 'claude-haiku-4-5' }
|
||||
]
|
||||
},
|
||||
{
|
||||
...getRequiredProviderAccountConfig('zai'),
|
||||
models: [
|
||||
{ label: 'GLM-5.2 (Recommended)', value: 'glm-5.2' },
|
||||
{ label: 'GLM-5.1', value: 'glm-5.1' },
|
||||
{ label: 'GLM-5-Turbo', value: 'glm-5-turbo' },
|
||||
{ label: 'GLM-5', value: 'glm-5' }
|
||||
]
|
||||
},
|
||||
{
|
||||
...getRequiredProviderAccountConfig('minimax'),
|
||||
models: [
|
||||
{ label: 'MiniMax-M3 (Recommended)', value: 'MiniMax-M3' },
|
||||
{ label: 'MiniMax-M2.7', value: 'MiniMax-M2.7' }
|
||||
]
|
||||
},
|
||||
{
|
||||
...getRequiredProviderAccountConfig('moonshotai'),
|
||||
models: [
|
||||
{ label: 'Kimi K2.6', value: 'kimi-k2.6' },
|
||||
{ label: 'Kimi K2.5', value: 'kimi-k2.5' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
function getRequiredProviderAccountConfig(providerValue) {
|
||||
const providerAccountConfig = getLLMProviderAccountConfig(providerValue)
|
||||
|
||||
if (!providerAccountConfig || !providerAccountConfig.apiKeyURL) {
|
||||
throw new Error(
|
||||
`Missing provider account configuration for "${providerValue}".`
|
||||
)
|
||||
}
|
||||
|
||||
return providerAccountConfig
|
||||
}
|
||||
|
||||
function getProviderOptions() {
|
||||
return REMOTE_LLM_PROVIDERS.map((provider) => ({
|
||||
label: provider.label,
|
||||
value: provider.value
|
||||
}))
|
||||
}
|
||||
|
||||
function getProviderConfig(providerValue) {
|
||||
return REMOTE_LLM_PROVIDERS.find(
|
||||
(provider) => provider.value === providerValue
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a remote LLM provider, model, and API key when local AI is skipped.
|
||||
*/
|
||||
export default async function setupRemoteLLM() {
|
||||
SetupUI.info(
|
||||
'No problem. I can use an online AI service instead.'
|
||||
)
|
||||
SetupUI.info(
|
||||
'I just need 3 quick details so I can connect it for you.'
|
||||
)
|
||||
|
||||
const providerValue = await setupConsola.prompt(
|
||||
'Which online AI service should I use?',
|
||||
{
|
||||
type: 'select',
|
||||
initial: REMOTE_LLM_PROVIDERS[0].value,
|
||||
options: getProviderOptions(),
|
||||
cancel: 'default'
|
||||
}
|
||||
)
|
||||
const provider = getProviderConfig(providerValue)
|
||||
|
||||
if (!provider) {
|
||||
throw new Error(`Unsupported remote LLM provider "${providerValue}".`)
|
||||
}
|
||||
|
||||
const modelValue = await setupConsola.prompt(
|
||||
`Which model should I use with ${provider.label}?`,
|
||||
{
|
||||
type: 'select',
|
||||
initial: provider.models[0].value,
|
||||
options: provider.models,
|
||||
cancel: 'default'
|
||||
}
|
||||
)
|
||||
|
||||
SetupUI.info(
|
||||
`Create your API key here: ${SetupUI.underlined(provider.apiKeyURL)}`
|
||||
)
|
||||
const apiKey = await setupConsola.prompt(
|
||||
`Paste your ${provider.label} API key. I will save it in your local .env file.`,
|
||||
{
|
||||
type: 'text',
|
||||
placeholder: 'Paste API key here',
|
||||
validate(value) {
|
||||
if (!value || value.trim() === '') {
|
||||
return 'Please paste your API key.'
|
||||
}
|
||||
},
|
||||
cancel: 'default'
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
remoteLLMProvider: provider.value,
|
||||
remoteLLMModel: modelValue,
|
||||
remoteLLMAPIKeyEnv: provider.apiKeyEnv,
|
||||
remoteLLMAPIKey: apiKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const MOVE_FALLBACK_ERROR_CODES = new Set(['EXDEV', 'EPERM', 'EBUSY', 'EACCES'])
|
||||
|
||||
function readManifest(manifestPath) {
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function removePath(targetPath) {
|
||||
await fs.promises.rm(targetPath, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function isMoveFallbackError(error) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
MOVE_FALLBACK_ERROR_CODES.has(error.code)
|
||||
)
|
||||
}
|
||||
|
||||
async function movePath(sourcePath, destinationPath) {
|
||||
try {
|
||||
await fs.promises.rename(sourcePath, destinationPath)
|
||||
} catch (error) {
|
||||
if (!isMoveFallbackError(error)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
await fs.promises.cp(sourcePath, destinationPath, {
|
||||
recursive: true,
|
||||
force: true
|
||||
})
|
||||
await removePath(sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
async function moveDirectoryContents(sourcePath, destinationPath) {
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true })
|
||||
|
||||
const entries = await fs.promises.readdir(sourcePath, {
|
||||
withFileTypes: true
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
entries.map((entry) =>
|
||||
movePath(
|
||||
path.join(sourcePath, entry.name),
|
||||
path.join(destinationPath, entry.name)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
async function cleanInstallDirectory(basePath) {
|
||||
await fs.promises.mkdir(basePath, { recursive: true })
|
||||
|
||||
const entries = await fs.promises.readdir(basePath, {
|
||||
withFileTypes: true
|
||||
})
|
||||
|
||||
await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.name !== 'versions.json')
|
||||
.map((entry) => removePath(path.join(basePath, entry.name)))
|
||||
)
|
||||
}
|
||||
|
||||
async function getFlattenedExtractionRoot(extractionPath) {
|
||||
const entries = await fs.promises.readdir(extractionPath, {
|
||||
withFileTypes: true
|
||||
})
|
||||
|
||||
if (entries.length === 1 && entries[0]?.isDirectory()) {
|
||||
return path.join(extractionPath, entries[0].name)
|
||||
}
|
||||
|
||||
return extractionPath
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a portable runtime into Leon's managed `bin/` directory from either
|
||||
* an archive asset or a direct executable download.
|
||||
*/
|
||||
export async function setupRuntimeBinary({
|
||||
name,
|
||||
version,
|
||||
basePath,
|
||||
installPath,
|
||||
manifestPath,
|
||||
binaryPath,
|
||||
downloadURL,
|
||||
archiveFileName = null
|
||||
}) {
|
||||
const status = createSetupStatus(`Downloading and setting up ${name}...`).start()
|
||||
const manifest = readManifest(manifestPath)
|
||||
|
||||
if (manifest?.version === version && fs.existsSync(binaryPath)) {
|
||||
status.succeed(`${name}: ${version}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await cleanInstallDirectory(basePath)
|
||||
|
||||
try {
|
||||
status.pause()
|
||||
|
||||
if (archiveFileName) {
|
||||
const archivePath = path.join(basePath, archiveFileName)
|
||||
const extractionPath = await fs.promises.mkdtemp(
|
||||
path.join(basePath, `${name.toLowerCase()}-extract-`)
|
||||
)
|
||||
|
||||
try {
|
||||
await FileHelper.downloadFile(downloadURL, archivePath, {
|
||||
cliProgress: true,
|
||||
parallelStreams: 3,
|
||||
skipExisting: false
|
||||
})
|
||||
status.text = `Installing ${name}...`
|
||||
status.start()
|
||||
|
||||
await FileHelper.extractArchive(archivePath, extractionPath)
|
||||
|
||||
const flattenedRootPath = await getFlattenedExtractionRoot(extractionPath)
|
||||
|
||||
await moveDirectoryContents(flattenedRootPath, installPath)
|
||||
} finally {
|
||||
await Promise.all([
|
||||
fs.promises.rm(archivePath, { force: true }),
|
||||
fs.promises.rm(extractionPath, { recursive: true, force: true })
|
||||
])
|
||||
}
|
||||
} else {
|
||||
await fs.promises.mkdir(path.dirname(binaryPath), { recursive: true })
|
||||
|
||||
await FileHelper.downloadFile(downloadURL, binaryPath, {
|
||||
cliProgress: true,
|
||||
parallelStreams: 3,
|
||||
skipExisting: false
|
||||
})
|
||||
status.text = `Finalizing ${name}...`
|
||||
status.start()
|
||||
}
|
||||
|
||||
if (!SystemHelper.isWindows()) {
|
||||
await fs.promises.chmod(binaryPath, 0o755)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(binaryPath)) {
|
||||
throw new Error(`Cannot find ${name} binary at "${binaryPath}"`)
|
||||
}
|
||||
|
||||
await FileHelper.createManifestFile(manifestPath, name.toLowerCase(), version, {
|
||||
os: SystemHelper.getInformation().type,
|
||||
architecture: SystemHelper.getInformation().cpuArchitecture
|
||||
})
|
||||
|
||||
status.succeed(`${name}: ${version}`)
|
||||
} catch (error) {
|
||||
if (status.isSpinning) {
|
||||
status.fail(`Failed to set up ${name}`)
|
||||
}
|
||||
|
||||
throw new Error(`Failed to set up ${name}: ${error}`)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROFILE_NATIVE_SKILLS_PATH } from '@/constants'
|
||||
|
||||
import { mergeMissingSettings } from '../settings-merge'
|
||||
|
||||
/**
|
||||
* Set up skills settings
|
||||
*/
|
||||
export default async function (skillFriendlyName, currentSkill) {
|
||||
const skillName = path.basename(currentSkill.path)
|
||||
const skillSrcPath = path.join(currentSkill.path, 'src')
|
||||
const settingsPath = path.join(
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
skillName,
|
||||
'settings.json'
|
||||
)
|
||||
const settingsSamplePath = path.join(skillSrcPath, 'settings.sample.json')
|
||||
|
||||
// If there is a bridge set from the skill settings
|
||||
if (currentSkill.bridge) {
|
||||
// Check if the settings and settings.sample file exist
|
||||
if (fs.existsSync(settingsPath) && fs.existsSync(settingsSamplePath)) {
|
||||
const settings = JSON.parse(
|
||||
await fs.promises.readFile(settingsPath, 'utf8')
|
||||
)
|
||||
const settingsSample = JSON.parse(
|
||||
await fs.promises.readFile(settingsSamplePath, 'utf8')
|
||||
)
|
||||
const mergedSettings = mergeMissingSettings(settingsSample, settings)
|
||||
|
||||
if (JSON.stringify(settings) !== JSON.stringify(mergedSettings)) {
|
||||
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true })
|
||||
await fs.promises.writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(mergedSettings, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
} else if (!fs.existsSync(settingsSamplePath)) {
|
||||
// Stop the setup if the settings.sample.json of the current skill does not exist
|
||||
throw new Error(
|
||||
`The "${skillFriendlyName}" skill settings file does not exist. Try to pull the project (git pull)`
|
||||
)
|
||||
} else {
|
||||
// Duplicate settings.sample.json of the current skill to settings.json
|
||||
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true })
|
||||
await fs.promises.copyFile(settingsSamplePath, settingsPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import { SkillDomainHelper } from '@/helpers/skill-domain-helper'
|
||||
|
||||
import { createSetupStatus } from '../setup-status'
|
||||
|
||||
import setupSkillsSettings from './setup-skills-settings'
|
||||
import syncSkillDependencies from './sync-skill-dependencies'
|
||||
|
||||
/**
|
||||
* Browse skills and set them up
|
||||
*/
|
||||
export default async function () {
|
||||
const status = createSetupStatus('Setting up skills...').start()
|
||||
|
||||
try {
|
||||
const skillNames = SkillDomainHelper.listAllSkillFoldersSync()
|
||||
|
||||
for (const skillName of skillNames) {
|
||||
const currentSkill = await SkillDomainHelper.getNewSkillConfig(
|
||||
skillName,
|
||||
{ includeDisabled: true }
|
||||
)
|
||||
const currentSkillPath = SkillDomainHelper.getNewSkillConfigPath(
|
||||
skillName,
|
||||
{ includeDisabled: true }
|
||||
)
|
||||
|
||||
if (!currentSkill || !currentSkillPath) {
|
||||
continue
|
||||
}
|
||||
|
||||
const skillContext = {
|
||||
path: currentSkillPath ? path.dirname(currentSkillPath) : '',
|
||||
bridge: currentSkill.bridge
|
||||
}
|
||||
|
||||
await setupSkillsSettings(skillName, skillContext)
|
||||
await syncSkillDependencies(skillName, skillContext)
|
||||
}
|
||||
|
||||
status.succeed('Skills: ready')
|
||||
} catch (e) {
|
||||
status.fail('Failed to set up skills')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
syncNodejsSourceDependencies,
|
||||
syncPythonSourceDependencies
|
||||
} from '../sync-source-dependencies'
|
||||
|
||||
/**
|
||||
* Sync skill dependencies only when a skill is installed, updated, or its
|
||||
* dependency manifest changes.
|
||||
*/
|
||||
export default async function syncSkillDependencies(
|
||||
_skillFriendlyName,
|
||||
currentSkill
|
||||
) {
|
||||
const skillSRCPath = path.join(currentSkill.path, 'src')
|
||||
|
||||
if (currentSkill.bridge === 'nodejs') {
|
||||
await syncNodejsSourceDependencies(skillSRCPath)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (currentSkill.bridge === 'python') {
|
||||
await syncPythonSourceDependencies(skillSRCPath)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import ora from 'ora'
|
||||
|
||||
import { createSetupJokeScheduler } from './setup-jokes'
|
||||
|
||||
/**
|
||||
* Create a setup spinner that plays well with prompts and download output.
|
||||
*/
|
||||
export function createSetupStatus(text) {
|
||||
const spinner = ora({
|
||||
spinner: 'dots2',
|
||||
color: 'cyan',
|
||||
text,
|
||||
stream: process.stdout,
|
||||
discardStdin: false
|
||||
})
|
||||
const jokeScheduler = createSetupJokeScheduler()
|
||||
const status = {
|
||||
start() {
|
||||
jokeScheduler.start()
|
||||
spinner.start()
|
||||
|
||||
return status
|
||||
},
|
||||
pause() {
|
||||
spinner.stop()
|
||||
|
||||
return status
|
||||
},
|
||||
stop() {
|
||||
jokeScheduler.finish()
|
||||
spinner.stop()
|
||||
|
||||
return status
|
||||
},
|
||||
succeed(successText) {
|
||||
jokeScheduler.finish()
|
||||
spinner.succeed(successText)
|
||||
|
||||
return status
|
||||
},
|
||||
fail(failureText) {
|
||||
jokeScheduler.finish()
|
||||
spinner.fail(failureText)
|
||||
|
||||
return status
|
||||
},
|
||||
warn(warningText) {
|
||||
jokeScheduler.finish()
|
||||
spinner.warn(warningText)
|
||||
|
||||
return status
|
||||
},
|
||||
get isSpinning() {
|
||||
return spinner.isSpinning
|
||||
},
|
||||
get text() {
|
||||
return spinner.text
|
||||
},
|
||||
set text(nextText) {
|
||||
spinner.text = nextText
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
import { PYTHON_TCP_SERVER_SRC_PATH } from '@/constants'
|
||||
|
||||
import {
|
||||
getProjectVenvPythonPath,
|
||||
setupPythonProjectEnv
|
||||
} from './setup-python-project-env'
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
const NLTK_DATA_DIR_NAME = 'nltk_data'
|
||||
const NLTK_VENV_DIR_NAME = '.venv'
|
||||
const PYTHON_TCP_SERVER_VENV_BIN_PATH = getProjectVenvPythonPath(
|
||||
PYTHON_TCP_SERVER_SRC_PATH
|
||||
)
|
||||
const NLTK_DATA_PATH = path.join(
|
||||
PYTHON_TCP_SERVER_SRC_PATH,
|
||||
NLTK_VENV_DIR_NAME,
|
||||
NLTK_DATA_DIR_NAME
|
||||
)
|
||||
const NLTK_DATASETS = [
|
||||
{
|
||||
id: 'cmudict',
|
||||
resourcePath: 'corpora/cmudict'
|
||||
},
|
||||
{
|
||||
id: 'averaged_perceptron_tagger_eng',
|
||||
resourcePath: 'taggers/averaged_perceptron_tagger_eng'
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* Check whether a required NLTK dataset directory exists in the TCP server venv.
|
||||
*/
|
||||
async function isNLTKDatasetInstalled(resourcePath) {
|
||||
const datasetPath = path.join(NLTK_DATA_PATH, resourcePath)
|
||||
|
||||
try {
|
||||
return (await fs.promises.stat(datasetPath)).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NLTK data are used by g2p-en during TTS text normalization.
|
||||
*
|
||||
* @see https://www.nltk.org/data.html
|
||||
*/
|
||||
async function downloadNLTKData() {
|
||||
const status = createSetupStatus(
|
||||
'Setting up Python TCP server NLTK data...'
|
||||
).start()
|
||||
const missingDatasets = []
|
||||
|
||||
await fs.promises.mkdir(NLTK_DATA_PATH, { recursive: true })
|
||||
|
||||
for (const dataset of NLTK_DATASETS) {
|
||||
if (!(await isNLTKDatasetInstalled(dataset.resourcePath))) {
|
||||
missingDatasets.push(dataset)
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDatasets.length === 0) {
|
||||
status.succeed('Python TCP server NLTK data: up-to-date')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
status.text = `Downloading Python TCP server NLTK data: ${missingDatasets
|
||||
.map(({ id }) => id)
|
||||
.join(', ')}`
|
||||
|
||||
await execa(
|
||||
PYTHON_TCP_SERVER_VENV_BIN_PATH,
|
||||
[
|
||||
'-m',
|
||||
'nltk.downloader',
|
||||
'-d',
|
||||
NLTK_DATA_PATH,
|
||||
...missingDatasets.map(({ id }) => id)
|
||||
],
|
||||
{ stdio: 'ignore' }
|
||||
)
|
||||
|
||||
for (const dataset of missingDatasets) {
|
||||
if (!(await isNLTKDatasetInstalled(dataset.resourcePath))) {
|
||||
throw new Error(`NLTK dataset "${dataset.id}" is still missing`)
|
||||
}
|
||||
}
|
||||
|
||||
status.succeed('Python TCP server NLTK data: ready')
|
||||
} catch (error) {
|
||||
status.fail(`Failed to download Python TCP server NLTK data: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync the Python TCP server runtime environment from its `pyproject.toml`.
|
||||
*/
|
||||
export default async function setupTCPServerEnv() {
|
||||
await setupPythonProjectEnv({
|
||||
name: 'Python TCP server',
|
||||
projectPath: PYTHON_TCP_SERVER_SRC_PATH,
|
||||
stampFileName: '.last-tcp-server-deps-sync'
|
||||
})
|
||||
await downloadNLTKData()
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH,
|
||||
// PYTHON_TCP_SERVER_TTS_BERT_FRENCH_DIR_PATH,
|
||||
// PYTHON_TCP_SERVER_TTS_BERT_FRENCH_MODEL_HF_PREFIX_DOWNLOAD_URL,
|
||||
PYTHON_TCP_SERVER_TTS_MODEL_PATH,
|
||||
PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH,
|
||||
PYTHON_TCP_SERVER_TTS_MODEL_HF_DOWNLOAD_URL,
|
||||
PYTHON_TCP_SERVER_ASR_MODEL_HF_PREFIX_DOWNLOAD_URL,
|
||||
PYTHON_TCP_SERVER_TTS_BERT_BASE_MODEL_HF_PREFIX_DOWNLOAD_URL
|
||||
} from '@/constants'
|
||||
import {
|
||||
ASR_MODEL_FILES,
|
||||
TTS_BERT_BASE_MODEL_FILES
|
||||
} from '@/core/voice/voice-resource-state'
|
||||
import { FileHelper } from '@/helpers/file-helper'
|
||||
import { NetworkHelper } from '@/helpers/network-helper'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
|
||||
/*const TTS_BERT_FRENCH_MODEL_FILES = [
|
||||
'pytorch_model.bin', // Not needed? Compare with HF auto download in ~/.cache/huggingface/hub...
|
||||
'config.json',
|
||||
'vocab.txt',
|
||||
'tokenizer_config.json'
|
||||
]*/
|
||||
|
||||
async function installTTSModel() {
|
||||
const destPath = PYTHON_TCP_SERVER_TTS_MODEL_PATH
|
||||
const pythonTCPServerTTSModelDownloadURL = await NetworkHelper.setHuggingFaceURL(
|
||||
PYTHON_TCP_SERVER_TTS_MODEL_HF_DOWNLOAD_URL
|
||||
)
|
||||
|
||||
await FileHelper.downloadFile(pythonTCPServerTTSModelDownloadURL, destPath)
|
||||
}
|
||||
async function installASRModel() {
|
||||
for (const modelFile of ASR_MODEL_FILES) {
|
||||
const pythonTCPServerASRModelDownloadURL =
|
||||
await NetworkHelper.setHuggingFaceURL(
|
||||
PYTHON_TCP_SERVER_ASR_MODEL_HF_PREFIX_DOWNLOAD_URL
|
||||
)
|
||||
const modelInstallationFileURL = `${pythonTCPServerASRModelDownloadURL}/${modelFile}?download=true`
|
||||
const destPath = path.join(PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH, modelFile)
|
||||
|
||||
await FileHelper.downloadFile(modelInstallationFileURL, destPath)
|
||||
}
|
||||
}
|
||||
/*async function installTTSBERTFrenchModel() {
|
||||
try {
|
||||
LogHelper.info('Installing TTS BERT French model...')
|
||||
|
||||
for (const modelFile of TTS_BERT_FRENCH_MODEL_FILES) {
|
||||
const pythonTCPServerTTSBERTFrenchModelPrefixDownloadURL = await NetworkHelper.setHuggingFaceURL(
|
||||
PYTHON_TCP_SERVER_TTS_BERT_FRENCH_MODEL_HF_PREFIX_DOWNLOAD_URL
|
||||
)
|
||||
const modelInstallationFileURL = `${pythonTCPServerTTSBERTFrenchModelPrefixDownloadURL}/${modelFile}?download=true`
|
||||
const destPath = path.join(PYTHON_TCP_SERVER_TTS_BERT_FRENCH_DIR_PATH, modelFile)
|
||||
|
||||
LogHelper.info(`Downloading ${modelFile}...`)
|
||||
|
||||
await FileHelper.downloadFile(modelInstallationFileURL, destPath)
|
||||
|
||||
LogHelper.success(`${modelFile} downloaded at ${destPath}`)
|
||||
}
|
||||
|
||||
LogHelper.success('TTS BERT French model installed')
|
||||
} catch (e) {
|
||||
LogHelper.error(`Failed to install TTS BERT French model: ${e}`)
|
||||
process.exit(1)
|
||||
}
|
||||
}*/
|
||||
async function installTTSBERTBaseModel() {
|
||||
for (const modelFile of TTS_BERT_BASE_MODEL_FILES) {
|
||||
const pythonTCPServerTTSBERTBaseModelPrefixDownloadURL =
|
||||
await NetworkHelper.setHuggingFaceURL(
|
||||
PYTHON_TCP_SERVER_TTS_BERT_BASE_MODEL_HF_PREFIX_DOWNLOAD_URL
|
||||
)
|
||||
const modelInstallationFileURL = `${pythonTCPServerTTSBERTBaseModelPrefixDownloadURL}/${modelFile}?download=true`
|
||||
const destPath = path.join(PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH, modelFile)
|
||||
|
||||
await FileHelper.downloadFile(modelInstallationFileURL, destPath)
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureModel({
|
||||
checkText,
|
||||
installText,
|
||||
successText,
|
||||
isInstalled,
|
||||
install
|
||||
}) {
|
||||
const status = createSetupStatus(checkText).start()
|
||||
|
||||
if (isInstalled()) {
|
||||
status.succeed(successText)
|
||||
return
|
||||
}
|
||||
|
||||
status.pause()
|
||||
|
||||
await install()
|
||||
|
||||
status.text = installText
|
||||
status.start()
|
||||
|
||||
status.succeed(successText)
|
||||
}
|
||||
|
||||
export default async () => {
|
||||
await ensureModel({
|
||||
checkText: 'Checking voice language model files...',
|
||||
installText: 'Installing voice language model files...',
|
||||
successText: 'Voice language model files: ready',
|
||||
isInstalled: () =>
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
PYTHON_TCP_SERVER_TTS_BERT_BASE_DIR_PATH,
|
||||
TTS_BERT_BASE_MODEL_FILES[TTS_BERT_BASE_MODEL_FILES.length - 1]
|
||||
)
|
||||
),
|
||||
install: installTTSBERTBaseModel
|
||||
})
|
||||
|
||||
// TODO: later when multiple languages are supported
|
||||
/*LogHelper.info(
|
||||
'Checking whether TTS BERT French language model files are downloaded...'
|
||||
)
|
||||
const areTTSBERTFrenchFilesDownloaded = fs.existsSync(
|
||||
path.join(
|
||||
PYTHON_TCP_SERVER_TTS_BERT_FRENCH_DIR_PATH,
|
||||
TTS_BERT_FRENCH_MODEL_FILES[TTS_BERT_FRENCH_MODEL_FILES.length - 1]
|
||||
)
|
||||
)
|
||||
if (!areTTSBERTFrenchFilesDownloaded) {
|
||||
LogHelper.info('TTS BERT French language model files not downloaded')
|
||||
await installTTSBERTFrenchModel()
|
||||
} else {
|
||||
LogHelper.success(
|
||||
'TTS BERT French language model files are already downloaded'
|
||||
)
|
||||
}*/
|
||||
|
||||
await ensureModel({
|
||||
checkText: 'Checking TTS model...',
|
||||
installText: 'Installing TTS model...',
|
||||
successText: 'TTS model: ready',
|
||||
isInstalled: () => fs.existsSync(PYTHON_TCP_SERVER_TTS_MODEL_PATH),
|
||||
install: installTTSModel
|
||||
})
|
||||
|
||||
await ensureModel({
|
||||
checkText: 'Checking ASR model...',
|
||||
installText: 'Installing ASR model...',
|
||||
successText: 'ASR model: ready',
|
||||
isInstalled: () =>
|
||||
fs.existsSync(
|
||||
path.join(
|
||||
PYTHON_TCP_SERVER_ASR_MODEL_DIR_PATH,
|
||||
ASR_MODEL_FILES[ASR_MODEL_FILES.length - 1]
|
||||
)
|
||||
),
|
||||
install: installASRModel
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROFILE_TOOLS_PATH, TOOLS_PATH } from '@/constants'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
import {
|
||||
syncNodejsSourceDependencies,
|
||||
syncPythonSourceDependencies
|
||||
} from './sync-source-dependencies'
|
||||
|
||||
const NODEJS_SOURCE_PATH = path.join('src', 'nodejs')
|
||||
const PYTHON_SOURCE_PATH = path.join('src', 'python')
|
||||
|
||||
const getToolSourcePaths = async (toolsPath) => {
|
||||
if (!fs.existsSync(toolsPath)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const toolkitEntries = await fs.promises.readdir(toolsPath, {
|
||||
withFileTypes: true
|
||||
})
|
||||
const sourcePaths = []
|
||||
|
||||
for (const toolkitEntry of toolkitEntries) {
|
||||
if (!toolkitEntry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolkitPath = path.join(toolsPath, toolkitEntry.name)
|
||||
const toolEntries = await fs.promises.readdir(toolkitPath, {
|
||||
withFileTypes: true
|
||||
})
|
||||
|
||||
for (const toolEntry of toolEntries) {
|
||||
if (!toolEntry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolPath = path.join(toolkitPath, toolEntry.name)
|
||||
sourcePaths.push({
|
||||
bridge: 'nodejs',
|
||||
path: path.join(toolPath, NODEJS_SOURCE_PATH)
|
||||
})
|
||||
sourcePaths.push({
|
||||
bridge: 'python',
|
||||
path: path.join(toolPath, PYTHON_SOURCE_PATH)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sourcePaths
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync tool dependencies next to each tool source folder.
|
||||
*/
|
||||
export default async function setupToolsDependencies() {
|
||||
const status = createSetupStatus('Setting up tool dependencies...').start()
|
||||
|
||||
try {
|
||||
const sourcePaths = [
|
||||
...(await getToolSourcePaths(TOOLS_PATH)),
|
||||
...(await getToolSourcePaths(PROFILE_TOOLS_PATH))
|
||||
]
|
||||
|
||||
for (const sourcePath of sourcePaths) {
|
||||
if (sourcePath.bridge === 'nodejs') {
|
||||
await syncNodejsSourceDependencies(sourcePath.path)
|
||||
} else {
|
||||
await syncPythonSourceDependencies(sourcePath.path)
|
||||
}
|
||||
}
|
||||
|
||||
status.succeed('Tool dependencies: ready')
|
||||
} catch (e) {
|
||||
status.fail('Failed to set up tool dependencies')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { PROFILE_TOOLS_PATH, TOOLS_PATH } from '@/constants'
|
||||
|
||||
import { createSetupStatus } from './setup-status'
|
||||
import { mergeMissingSettings } from './settings-merge'
|
||||
|
||||
const TOOL_SETTINGS_SAMPLE_FILENAME = 'settings.sample.json'
|
||||
const TOOL_SETTINGS_FILENAME = 'settings.json'
|
||||
|
||||
function readJSONFile(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
||||
}
|
||||
|
||||
async function syncToolSettings(toolkitId, toolId, toolPath) {
|
||||
const settingsSamplePath = path.join(toolPath, TOOL_SETTINGS_SAMPLE_FILENAME)
|
||||
|
||||
if (!fs.existsSync(settingsSamplePath)) {
|
||||
throw new Error(
|
||||
`The "${toolkitId}.${toolId}" tool settings sample does not exist.`
|
||||
)
|
||||
}
|
||||
|
||||
const settingsPath = path.join(
|
||||
PROFILE_TOOLS_PATH,
|
||||
toolkitId,
|
||||
toolId,
|
||||
TOOL_SETTINGS_FILENAME
|
||||
)
|
||||
const settingsSample = readJSONFile(settingsSamplePath)
|
||||
const currentSettings = fs.existsSync(settingsPath)
|
||||
? readJSONFile(settingsPath)
|
||||
: {}
|
||||
const mergedSettings = mergeMissingSettings(settingsSample, currentSettings)
|
||||
|
||||
if (
|
||||
fs.existsSync(settingsPath) &&
|
||||
JSON.stringify(currentSettings) === JSON.stringify(mergedSettings)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(path.dirname(settingsPath), { recursive: true })
|
||||
await fs.promises.writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(mergedSettings, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update profile settings for built-in tools.
|
||||
*/
|
||||
export default async function setupToolsSettings() {
|
||||
const status = createSetupStatus('Setting up tool settings...').start()
|
||||
|
||||
try {
|
||||
for (const toolkitEntry of fs.readdirSync(TOOLS_PATH, {
|
||||
withFileTypes: true
|
||||
})) {
|
||||
if (!toolkitEntry.isDirectory()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolkitId = toolkitEntry.name
|
||||
const toolkitPath = path.join(TOOLS_PATH, toolkitId)
|
||||
const toolkitConfigPath = path.join(toolkitPath, 'toolkit.json')
|
||||
|
||||
if (!fs.existsSync(toolkitConfigPath)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const toolkitConfig = readJSONFile(toolkitConfigPath)
|
||||
|
||||
for (const toolId of toolkitConfig.tools || []) {
|
||||
const toolPath = path.join(toolkitPath, toolId)
|
||||
|
||||
if (!fs.existsSync(path.join(toolPath, 'tool.json'))) {
|
||||
continue
|
||||
}
|
||||
|
||||
await syncToolSettings(toolkitId, toolId, toolPath)
|
||||
}
|
||||
}
|
||||
|
||||
status.succeed('Tool settings: ready')
|
||||
} catch (e) {
|
||||
status.fail('Failed to set up tool settings')
|
||||
throw e
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createConsola } from 'consola'
|
||||
|
||||
export const setupConsola = createConsola({
|
||||
fancy: true,
|
||||
formatOptions: {
|
||||
date: false
|
||||
}
|
||||
})
|
||||
|
||||
function dim(text) {
|
||||
return `\x1b[2m${text}\x1b[0m`
|
||||
}
|
||||
|
||||
function green(text) {
|
||||
return `\x1b[32m${text}\x1b[0m`
|
||||
}
|
||||
|
||||
function underline(text) {
|
||||
return `\x1b[4m${text}\x1b[24m`
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup-only presentation helpers built on top of consola.
|
||||
*/
|
||||
export class SetupUI {
|
||||
static section(title) {
|
||||
setupConsola.box(title)
|
||||
}
|
||||
|
||||
static aside(text) {
|
||||
setupConsola.log(dim(`› ${text}`))
|
||||
}
|
||||
|
||||
static info(text) {
|
||||
setupConsola.info(text)
|
||||
}
|
||||
|
||||
static success(text) {
|
||||
setupConsola.success(text)
|
||||
}
|
||||
|
||||
static successHighlight(text) {
|
||||
console.log(green(`✔ ${text}`))
|
||||
}
|
||||
|
||||
static warning(text) {
|
||||
setupConsola.warn(text)
|
||||
}
|
||||
|
||||
static questionIntro(count) {
|
||||
const quickQuestionLabel =
|
||||
count === 1 ? '1 quick question' : `${count} quick questions`
|
||||
|
||||
this.info(
|
||||
`I just have ${quickQuestionLabel} so I can set things up the way you want.`
|
||||
)
|
||||
}
|
||||
|
||||
static questionSummary(preferences) {
|
||||
this.success(`Local AI: ${preferences.setupLocalAI ? 'Yes' : 'No'}`)
|
||||
this.success(`Voice: ${preferences.setupVoice ? 'Yes' : 'No'}`)
|
||||
}
|
||||
|
||||
static recap(items) {
|
||||
setupConsola.box(items.join('\n'))
|
||||
}
|
||||
|
||||
static bullet(text) {
|
||||
console.log(`● ${text}`)
|
||||
}
|
||||
|
||||
static underlined(text) {
|
||||
return underline(text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import path from 'node:path'
|
||||
|
||||
import {
|
||||
UV_INSTALL_PATH,
|
||||
UV_MANIFEST_PATH,
|
||||
UV_VERSION
|
||||
} from '@/constants'
|
||||
import { CPUArchitectures } from '@/types'
|
||||
import { SystemHelper } from '@/helpers/system-helper'
|
||||
|
||||
import { setupRuntimeBinary } from './setup-runtime-binary'
|
||||
|
||||
const { cpuArchitecture: CPU_ARCH } = SystemHelper.getInformation()
|
||||
|
||||
function getBinaryPath() {
|
||||
return SystemHelper.isWindows()
|
||||
? path.join(UV_INSTALL_PATH, 'uv.exe')
|
||||
: path.join(UV_INSTALL_PATH, 'uv')
|
||||
}
|
||||
|
||||
function getAssetFileName() {
|
||||
if (SystemHelper.isLinux()) {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return 'uv-x86_64-unknown-linux-gnu.tar.gz'
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return 'uv-aarch64-unknown-linux-gnu.tar.gz'
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isMacOS()) {
|
||||
if (CPU_ARCH === CPUArchitectures.X64) {
|
||||
return 'uv-x86_64-apple-darwin.tar.gz'
|
||||
}
|
||||
|
||||
if (CPU_ARCH === CPUArchitectures.ARM64) {
|
||||
return 'uv-aarch64-apple-darwin.tar.gz'
|
||||
}
|
||||
}
|
||||
|
||||
if (SystemHelper.isWindows()) {
|
||||
return CPU_ARCH === CPUArchitectures.ARM64
|
||||
? 'uv-aarch64-pc-windows-msvc.zip'
|
||||
: 'uv-x86_64-pc-windows-msvc.zip'
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported platform for uv: ${SystemHelper.getInformation().type} ${CPU_ARCH}`
|
||||
)
|
||||
}
|
||||
|
||||
export default async function setupUV() {
|
||||
const assetFileName = getAssetFileName()
|
||||
|
||||
await setupRuntimeBinary({
|
||||
name: 'uv',
|
||||
version: UV_VERSION,
|
||||
basePath: UV_INSTALL_PATH,
|
||||
installPath: UV_INSTALL_PATH,
|
||||
manifestPath: UV_MANIFEST_PATH,
|
||||
binaryPath: getBinaryPath(),
|
||||
downloadURL: `https://releases.astral.sh/github/uv/releases/download/${UV_VERSION}/${assetFileName}`,
|
||||
archiveFileName: assetFileName
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import setupNVIDIALibs from './setup-nvidia-libs.js'
|
||||
import setupPyTorch from './setup-pytorch.js'
|
||||
import setupTCPServerModels from './setup-tcp-server-models'
|
||||
|
||||
/**
|
||||
* Install or update all resources needed by local voice mode.
|
||||
*/
|
||||
export default async function setupVoiceResources() {
|
||||
await setupNVIDIALibs()
|
||||
await setupPyTorch()
|
||||
await setupTCPServerModels()
|
||||
}
|
||||
|
||||
const isMainModule = process.argv[1]?.endsWith('setup-voice-resources.js')
|
||||
|
||||
if (isMainModule) {
|
||||
setupVoiceResources().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import fs from 'node:fs'
|
||||
|
||||
import {
|
||||
CACHE_PATH,
|
||||
LEON_HOME_PATH,
|
||||
LEON_PROFILES_PATH,
|
||||
LEON_PROFILE_PATH,
|
||||
LEON_TOOLKITS_PATH,
|
||||
MODELS_PATH,
|
||||
PROFILE_CONTEXT_PATH,
|
||||
PROFILE_AGENT_SKILLS_PATH,
|
||||
PROFILE_LOGS_PATH,
|
||||
PROFILE_MEMORY_PATH,
|
||||
PROFILE_NATIVE_SKILLS_PATH,
|
||||
PROFILE_SKILLS_PATH,
|
||||
PROFILE_TOOLS_PATH,
|
||||
TMP_PATH,
|
||||
IS_GITHUB_ACTIONS
|
||||
} from '@/constants'
|
||||
import { LogHelper } from '@/helpers/log-helper'
|
||||
import { NetworkHelper } from '@/helpers/network-helper'
|
||||
|
||||
import buildApp from '../app/build-app'
|
||||
import buildServer from '../build-server'
|
||||
import train from '../train/train'
|
||||
import generateClientInterfaceToken from '../generate/generate-client-interface-token'
|
||||
import generateJSONSchemas from '../generate/generate-json-schemas'
|
||||
|
||||
import setupDotenv, { updateDotEnvVariable } from './setup-dotenv'
|
||||
import setupConfig from './setup-config'
|
||||
import { CONFIG_MANAGER } from '@/config'
|
||||
import setupCore from './setup-core'
|
||||
import setupNode from './setup-node'
|
||||
import setupPNPM from './setup-pnpm'
|
||||
import setupNativeNodeModules from './setup-native-node-modules'
|
||||
import setupPython from './setup-python'
|
||||
import setupUV from './setup-uv'
|
||||
import setupNodejsBridgeEnv from './setup-nodejs-bridge-env'
|
||||
import setupPythonBridgeEnv from './setup-python-bridge-env'
|
||||
import setupToolsDependencies from './setup-tools-dependencies'
|
||||
import setupToolsSettings from './setup-tools-settings'
|
||||
import setupSkills from './setup-skills/setup-skills'
|
||||
import setupTCPServerEnv from './setup-tcp-server-env'
|
||||
import setupCMake from './setup-cmake'
|
||||
import setupNinja from './setup-ninja'
|
||||
import setupLlamaCPP from './setup-llama-cpp'
|
||||
import setupLocalLLM from './setup-local-llm'
|
||||
import setupQMDLLM from './setup-qmd-llm'
|
||||
import setupVoiceResources from './setup-voice-resources.js'
|
||||
import inspectLocalAICapability from './local-ai-capability'
|
||||
import {
|
||||
inspectLocalAISetupState,
|
||||
inspectVoiceSetupState
|
||||
} from './inspect-setup-state'
|
||||
import postSetup from './post-setup'
|
||||
import { printSetupBanner } from './setup-banner'
|
||||
import { tellSetupCompletionJoke } from './setup-jokes'
|
||||
import setupPreferences from './setup-preferences'
|
||||
import { createSetupStatus } from './setup-status'
|
||||
import { SetupUI } from './setup-ui'
|
||||
import createInstanceID from './create-instance-id'
|
||||
import setFfprobePermissions from './set-ffprobe-permissions'
|
||||
import setupGitHooks from './setup-git-hooks'
|
||||
|
||||
const LOCAL_LLM_TARGET_VALUE = 'llamacpp'
|
||||
|
||||
/**
|
||||
* Create Leon home directories that setup and runtime expect to exist.
|
||||
*/
|
||||
async function ensureLeonHomeStructure() {
|
||||
const status = createSetupStatus('Preparing Leon home...').start()
|
||||
|
||||
await Promise.all([
|
||||
fs.promises.mkdir(LEON_HOME_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(LEON_PROFILES_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(LEON_PROFILE_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(CACHE_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(LEON_TOOLKITS_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(MODELS_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(TMP_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(PROFILE_CONTEXT_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(PROFILE_MEMORY_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(PROFILE_LOGS_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(PROFILE_SKILLS_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(PROFILE_NATIVE_SKILLS_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(PROFILE_AGENT_SKILLS_PATH, { recursive: true }),
|
||||
fs.promises.mkdir(PROFILE_TOOLS_PATH, { recursive: true })
|
||||
])
|
||||
|
||||
status.succeed('Leon home: ready')
|
||||
}
|
||||
|
||||
function isExplicitLocalLLMTarget(value) {
|
||||
const normalizedValue = (value || '').trim()
|
||||
|
||||
return (
|
||||
normalizedValue.startsWith('llamacpp/') ||
|
||||
normalizedValue.startsWith('sglang/') ||
|
||||
normalizedValue.startsWith('/')
|
||||
)
|
||||
}
|
||||
|
||||
function getOptionalLLMTarget(value) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
async function resolveExistingLLMChoice() {
|
||||
const llmConfig = CONFIG_MANAGER.getConfig().llm
|
||||
const leonLLM = getOptionalLLMTarget(llmConfig.default)
|
||||
const leonWorkflowLLM = getOptionalLLMTarget(llmConfig.workflow)
|
||||
const leonAgentLLM = getOptionalLLMTarget(llmConfig.agent)
|
||||
const overrideTargets = [leonWorkflowLLM, leonAgentLLM].filter(Boolean)
|
||||
|
||||
if (overrideTargets.length > 0) {
|
||||
return {
|
||||
hasResolvedChoice: true,
|
||||
setupLocalAI: overrideTargets.some((target) =>
|
||||
isExplicitLocalLLMTarget(target)
|
||||
),
|
||||
targetType: overrideTargets.some((target) =>
|
||||
isExplicitLocalLLMTarget(target)
|
||||
)
|
||||
? 'explicitLocal'
|
||||
: 'remote',
|
||||
label: overrideTargets.join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
if (leonLLM === '') {
|
||||
return {
|
||||
hasResolvedChoice: false,
|
||||
setupLocalAI: false,
|
||||
targetType: 'disabled',
|
||||
label: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasResolvedChoice: true,
|
||||
setupLocalAI: isExplicitLocalLLMTarget(leonLLM),
|
||||
targetType: isExplicitLocalLLMTarget(leonLLM)
|
||||
? 'explicitLocal'
|
||||
: 'remote',
|
||||
label: leonLLM
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLLMSetupChoice(preferences) {
|
||||
CONFIG_MANAGER.reload()
|
||||
const llmConfig = CONFIG_MANAGER.getConfig().llm
|
||||
const leonLLM = getOptionalLLMTarget(llmConfig.default)
|
||||
const leonWorkflowLLM = getOptionalLLMTarget(llmConfig.workflow)
|
||||
const leonAgentLLM = getOptionalLLMTarget(llmConfig.agent)
|
||||
const hasExplicitModeOverride =
|
||||
leonWorkflowLLM !== '' || leonAgentLLM !== ''
|
||||
const hasExplicitGlobalTarget =
|
||||
leonLLM !== ''
|
||||
|
||||
if (
|
||||
preferences.remoteLLMProvider &&
|
||||
preferences.remoteLLMModel &&
|
||||
preferences.remoteLLMAPIKeyEnv &&
|
||||
preferences.remoteLLMAPIKey
|
||||
) {
|
||||
await updateDotEnvVariable(
|
||||
preferences.remoteLLMAPIKeyEnv,
|
||||
preferences.remoteLLMAPIKey.trim()
|
||||
)
|
||||
|
||||
if (!hasExplicitModeOverride) {
|
||||
await CONFIG_MANAGER.setValue(
|
||||
['llm', 'default'],
|
||||
`${preferences.remoteLLMProvider}/${preferences.remoteLLMModel}`
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (hasExplicitModeOverride || hasExplicitGlobalTarget) {
|
||||
return
|
||||
}
|
||||
|
||||
if (preferences.setupLocalAI) {
|
||||
await CONFIG_MANAGER.setValue(['llm', 'default'], LOCAL_LLM_TARGET_VALUE)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await CONFIG_MANAGER.setValue(['llm', 'default'], null)
|
||||
}
|
||||
// Do not load ".env" file because it is not created yet
|
||||
|
||||
/**
|
||||
* Main entry to set up Leon
|
||||
*/
|
||||
;(async () => {
|
||||
// Track setup execution state for user-facing error reporting and interruption handling.
|
||||
let currentStep = 'bootstrap'
|
||||
let shutdownSignal = null
|
||||
let preferences = {
|
||||
setupLocalAI: false,
|
||||
setupVoice: false
|
||||
}
|
||||
let localAICapability = null
|
||||
let localAISetupState = {
|
||||
isInstalled: false,
|
||||
label: ''
|
||||
}
|
||||
let voiceSetupState = {
|
||||
isInstalled: false
|
||||
}
|
||||
const getExitCodeFromSignal = (signal) => (signal === 'SIGINT' ? 130 : 143)
|
||||
|
||||
// Clean up process signal listeners when setup exits normally or with an error.
|
||||
const cleanupSignalHandlers = () => {
|
||||
process.off('SIGINT', handleShutdownSignal)
|
||||
process.off('SIGTERM', handleShutdownSignal)
|
||||
}
|
||||
|
||||
// Abort active downloads and exit cleanly when setup is interrupted.
|
||||
const handleShutdownSignal = (signal) => {
|
||||
if (shutdownSignal) {
|
||||
process.exit(getExitCodeFromSignal(signal))
|
||||
}
|
||||
|
||||
shutdownSignal = signal
|
||||
|
||||
const abortedDownloadCount = NetworkHelper.abortActiveDownloads(
|
||||
`Setup interrupted by ${signal}`
|
||||
)
|
||||
|
||||
if (abortedDownloadCount === 0) {
|
||||
process.exit(getExitCodeFromSignal(signal))
|
||||
}
|
||||
}
|
||||
|
||||
process.on('SIGINT', handleShutdownSignal)
|
||||
process.on('SIGTERM', handleShutdownSignal)
|
||||
|
||||
try {
|
||||
// Print the setup banner outside CI so the local install feels branded.
|
||||
if (!IS_GITHUB_ACTIONS) {
|
||||
printSetupBanner()
|
||||
}
|
||||
|
||||
// Ask setup questions first so the rest of the install can run unattended.
|
||||
if (!IS_GITHUB_ACTIONS) {
|
||||
SetupUI.section('Quick Setup')
|
||||
|
||||
currentStep = 'inspectLocalAICapability'
|
||||
const capabilityStatus = createSetupStatus(
|
||||
'Checking what this computer can handle...'
|
||||
).start()
|
||||
localAICapability = await inspectLocalAICapability()
|
||||
|
||||
if (localAICapability.canInstallLocalAI) {
|
||||
capabilityStatus.succeed(
|
||||
'Local AI supported: better privacy and less to configure online'
|
||||
)
|
||||
} else {
|
||||
capabilityStatus.stop()
|
||||
SetupUI.info('Local AI is not supported on this computer')
|
||||
}
|
||||
|
||||
currentStep = 'resolveExistingLLMChoice'
|
||||
const existingLLMChoice = await resolveExistingLLMChoice()
|
||||
currentStep = 'inspectLocalAISetupState'
|
||||
localAISetupState = inspectLocalAISetupState()
|
||||
currentStep = 'inspectVoiceSetupState'
|
||||
voiceSetupState = inspectVoiceSetupState()
|
||||
|
||||
currentStep = 'setupPreferences'
|
||||
preferences = await setupPreferences(
|
||||
localAICapability,
|
||||
existingLLMChoice,
|
||||
localAISetupState,
|
||||
voiceSetupState
|
||||
)
|
||||
}
|
||||
|
||||
// Prepare the local runtime, bridges, skills, and shared memory models.
|
||||
SetupUI.section('Base Setup')
|
||||
|
||||
currentStep = 'ensureLeonHomeStructure'
|
||||
await ensureLeonHomeStructure()
|
||||
currentStep = 'setupDotenv'
|
||||
await setupDotenv()
|
||||
currentStep = 'generateJSONSchemas'
|
||||
await generateJSONSchemas()
|
||||
currentStep = 'setupConfig'
|
||||
await setupConfig()
|
||||
currentStep = 'syncLLMSetupChoice'
|
||||
await syncLLMSetupChoice(preferences)
|
||||
currentStep = 'setupCore'
|
||||
await setupCore()
|
||||
if (!IS_GITHUB_ACTIONS) {
|
||||
currentStep = 'setupNode'
|
||||
await setupNode()
|
||||
currentStep = 'setupPNPM'
|
||||
await setupPNPM()
|
||||
currentStep = 'setupNativeNodeModules'
|
||||
await setupNativeNodeModules()
|
||||
currentStep = 'setupPython'
|
||||
await setupPython()
|
||||
currentStep = 'setupUV'
|
||||
await setupUV()
|
||||
} else {
|
||||
SetupUI.info(
|
||||
'Skipping portable Node.js, pnpm, Python and uv setup because it is running in CI'
|
||||
)
|
||||
}
|
||||
currentStep = 'setupNodejsBridgeEnv'
|
||||
await setupNodejsBridgeEnv()
|
||||
currentStep = 'setupPythonBridgeEnv'
|
||||
await setupPythonBridgeEnv()
|
||||
currentStep = 'setupTCPServerEnv'
|
||||
await setupTCPServerEnv()
|
||||
currentStep = 'setupToolsDependencies'
|
||||
await setupToolsDependencies()
|
||||
currentStep = 'setupToolsSettings'
|
||||
await setupToolsSettings()
|
||||
currentStep = 'setupSkills'
|
||||
await setupSkills()
|
||||
if (!IS_GITHUB_ACTIONS) {
|
||||
currentStep = 'setupQMDLLM'
|
||||
await setupQMDLLM()
|
||||
} else {
|
||||
SetupUI.info('Skipping QMD model setup because it is running in CI')
|
||||
}
|
||||
|
||||
if (!IS_GITHUB_ACTIONS) {
|
||||
// Install local AI components based on the earlier capability check and answers.
|
||||
SetupUI.section('Local AI')
|
||||
|
||||
if (preferences.setupLocalAI) {
|
||||
currentStep = 'setupCMake'
|
||||
await setupCMake()
|
||||
currentStep = 'setupNinja'
|
||||
await setupNinja()
|
||||
currentStep = 'setupLlamaCPP'
|
||||
await setupLlamaCPP()
|
||||
currentStep = 'setupLocalLLM'
|
||||
await setupLocalLLM(localAICapability)
|
||||
} else if (localAICapability?.canInstallLocalAI) {
|
||||
SetupUI.info('I will skip local AI for now. You can add it later.')
|
||||
} else {
|
||||
SetupUI.info('I will skip local AI because this computer does not support it.')
|
||||
}
|
||||
|
||||
if (preferences.setupVoice) {
|
||||
currentStep = 'setupVoiceResources'
|
||||
await setupVoiceResources()
|
||||
} else {
|
||||
SetupUI.info('I will skip voice setup for now. You can add it later.')
|
||||
}
|
||||
} else {
|
||||
// Skip hardware-specific setup in CI where local AI and voice stacks are not needed.
|
||||
SetupUI.info(
|
||||
'Skipping CMake, Ninja, llama.cpp, local LLM, QMD models, NVIDIA, and PyTorch setups because it is running in CI'
|
||||
)
|
||||
}
|
||||
|
||||
if (!preferences.setupVoice) {
|
||||
SetupUI.info('I will skip voice model downloads for now.')
|
||||
}
|
||||
|
||||
// Finalize generated assets and instance-specific setup metadata.
|
||||
SetupUI.section('Finishing Up')
|
||||
|
||||
currentStep = 'generateClientInterfaceToken'
|
||||
await generateClientInterfaceToken()
|
||||
currentStep = 'train'
|
||||
await train()
|
||||
currentStep = 'setFfprobePermissions'
|
||||
await setFfprobePermissions()
|
||||
currentStep = 'createInstanceID'
|
||||
await createInstanceID()
|
||||
currentStep = 'setupGitHooks'
|
||||
await setupGitHooks()
|
||||
currentStep = 'buildApp'
|
||||
{
|
||||
const status = createSetupStatus('Building app...').start()
|
||||
|
||||
try {
|
||||
await buildApp({ quiet: true })
|
||||
status.succeed('App: ready')
|
||||
} catch (error) {
|
||||
status.fail('Failed to build app')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
currentStep = 'buildServer'
|
||||
{
|
||||
const status = createSetupStatus('Building server...').start()
|
||||
|
||||
try {
|
||||
await buildServer({ quiet: true })
|
||||
status.succeed('Server: ready')
|
||||
} catch (error) {
|
||||
status.fail('Failed to build server')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
if (!IS_GITHUB_ACTIONS) {
|
||||
printSetupBanner()
|
||||
}
|
||||
|
||||
SetupUI.recap([
|
||||
'Setup complete',
|
||||
`Local AI: ${preferences.setupLocalAI ? 'enabled' : 'skipped'}`,
|
||||
`Voice: ${preferences.setupVoice ? 'enabled' : 'skipped'}`
|
||||
])
|
||||
tellSetupCompletionJoke()
|
||||
console.log('')
|
||||
SetupUI.successHighlight('Hooray! I\'m installed and ready to go!')
|
||||
console.log('')
|
||||
SetupUI.bullet(
|
||||
`Follow my creator to get regular updates about me: ${SetupUI.underlined(
|
||||
'https://x.com/grenlouis'
|
||||
)}`
|
||||
)
|
||||
console.log('')
|
||||
currentStep = 'postSetup'
|
||||
await postSetup()
|
||||
} catch (e) {
|
||||
// Exit with the original signal code when setup was intentionally interrupted.
|
||||
if (shutdownSignal) {
|
||||
cleanupSignalHandlers()
|
||||
process.exit(getExitCodeFromSignal(shutdownSignal))
|
||||
}
|
||||
|
||||
// Surface the failing phase clearly when setup stops on an unexpected error.
|
||||
LogHelper.error(
|
||||
`Setup failed during ${currentStep}: ${
|
||||
e instanceof Error ? e.stack || e.message : String(e)
|
||||
}`
|
||||
)
|
||||
cleanupSignalHandlers()
|
||||
process.exit(1)
|
||||
}
|
||||
})()
|
||||
@@ -0,0 +1,128 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
import {
|
||||
PNPM_RUNTIME_BIN_PATH,
|
||||
PYTHON_RUNTIME_BIN_PATH,
|
||||
UV_RUNTIME_BIN_PATH
|
||||
} from '@/constants'
|
||||
import { RuntimeHelper } from '@/helpers/runtime-helper'
|
||||
|
||||
import {
|
||||
getProjectVenvPythonPath,
|
||||
getPyprojectDependencies
|
||||
} from './setup-python-project-env'
|
||||
|
||||
const PACKAGE_JSON_FILE_NAME = 'package.json'
|
||||
const PYPROJECT_FILE_NAME = 'pyproject.toml'
|
||||
const SYNC_STAMP_FILE_NAME = '.last-source-deps-sync'
|
||||
const NODE_MODULES_DIR_NAME = 'node_modules'
|
||||
const VENV_DIR_NAME = '.venv'
|
||||
|
||||
const isFileEmpty = async (filePath) => {
|
||||
const content = await fs.promises.readFile(filePath, 'utf8')
|
||||
|
||||
return content.trim() === ''
|
||||
}
|
||||
|
||||
const getSyncStampPath = (sourcePath) => {
|
||||
return path.join(sourcePath, SYNC_STAMP_FILE_NAME)
|
||||
}
|
||||
|
||||
const isSyncCurrent = async (manifestPath, stampPath, dependencyPath) => {
|
||||
if (
|
||||
!fs.existsSync(stampPath) ||
|
||||
!fs.existsSync(manifestPath) ||
|
||||
!fs.existsSync(dependencyPath)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const [manifestStat, stampStat] = await Promise.all([
|
||||
fs.promises.stat(manifestPath),
|
||||
fs.promises.stat(stampPath)
|
||||
])
|
||||
|
||||
return manifestStat.mtimeMs <= stampStat.mtimeMs
|
||||
}
|
||||
|
||||
const markSourceDependenciesAsSynced = async (sourcePath) => {
|
||||
await fs.promises.writeFile(getSyncStampPath(sourcePath), `${Date.now()}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Node.js dependencies next to the source that declares them.
|
||||
*/
|
||||
export const syncNodejsSourceDependencies = async (sourcePath) => {
|
||||
const packageJSONPath = path.join(sourcePath, PACKAGE_JSON_FILE_NAME)
|
||||
const nodeModulesPath = path.join(sourcePath, NODE_MODULES_DIR_NAME)
|
||||
const stampPath = getSyncStampPath(sourcePath)
|
||||
|
||||
if (!fs.existsSync(packageJSONPath) || (await isFileEmpty(packageJSONPath))) {
|
||||
return
|
||||
}
|
||||
|
||||
if (await isSyncCurrent(packageJSONPath, stampPath, nodeModulesPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
await fs.promises.rm(nodeModulesPath, { recursive: true, force: true })
|
||||
|
||||
await execa(PNPM_RUNTIME_BIN_PATH, [
|
||||
'install',
|
||||
'--ignore-workspace',
|
||||
'--lockfile=false'
|
||||
], {
|
||||
cwd: sourcePath,
|
||||
env: RuntimeHelper.getManagedNodeEnvironment()
|
||||
})
|
||||
|
||||
await markSourceDependenciesAsSynced(sourcePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync Python dependencies into a .venv next to the source that declares them.
|
||||
*/
|
||||
export const syncPythonSourceDependencies = async (sourcePath) => {
|
||||
const manifestPath = path.join(sourcePath, PYPROJECT_FILE_NAME)
|
||||
const venvPath = path.join(sourcePath, VENV_DIR_NAME)
|
||||
const stampPath = getSyncStampPath(sourcePath)
|
||||
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
await isSyncCurrent(
|
||||
manifestPath,
|
||||
stampPath,
|
||||
getProjectVenvPythonPath(sourcePath)
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const dependencies = await getPyprojectDependencies(sourcePath)
|
||||
|
||||
await fs.promises.rm(venvPath, { recursive: true, force: true })
|
||||
await execa(UV_RUNTIME_BIN_PATH, [
|
||||
'venv',
|
||||
'--python',
|
||||
PYTHON_RUNTIME_BIN_PATH,
|
||||
venvPath
|
||||
], { cwd: sourcePath })
|
||||
|
||||
if (dependencies.length > 0) {
|
||||
await execa(UV_RUNTIME_BIN_PATH, [
|
||||
'pip',
|
||||
'install',
|
||||
'--python',
|
||||
getProjectVenvPythonPath(sourcePath),
|
||||
...dependencies
|
||||
], { cwd: sourcePath })
|
||||
}
|
||||
|
||||
await markSourceDependenciesAsSynced(sourcePath)
|
||||
}
|
||||
Reference in New Issue
Block a user