feat: improved TypeScript 7 support; weekly fresh install workflow
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
name: Fresh installation
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 4 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: fresh-install-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
fresh-install:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
steps:
|
||||
# Scheduled workflows are loaded from the default branch, but Leon's
|
||||
# integration work lands on develop before it reaches a release branch.
|
||||
- name: Check out Leon
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'develop' || github.ref }}
|
||||
|
||||
- name: Read runtime versions
|
||||
id: versions
|
||||
shell: bash
|
||||
run: |
|
||||
echo "node=$(jq -r '.node' bin/node/versions.json)" >> "$GITHUB_OUTPUT"
|
||||
echo "pnpm=$(jq -r '.pnpm' bin/pnpm/versions.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ steps.versions.outputs.node }}
|
||||
package-manager-cache: false
|
||||
|
||||
- name: Install fresh pnpm
|
||||
shell: bash
|
||||
run: |
|
||||
corepack enable
|
||||
corepack install --global "pnpm@${{ steps.versions.outputs.pnpm }}"
|
||||
|
||||
- name: Test the interactive installation
|
||||
env:
|
||||
LEON_FRESH_INSTALL_API_KEY: xxx
|
||||
LEON_FRESH_INSTALL_LOG_PATH: /tmp/leon-fresh-install.log
|
||||
run: node scripts/ci/test-fresh-install.js
|
||||
|
||||
- name: Upload installer log on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: fresh-install-log
|
||||
path: /tmp/leon-fresh-install.log
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -10,14 +10,14 @@ export default function renderAuroraComponent(
|
||||
supportedEvents
|
||||
) {
|
||||
if (component) {
|
||||
// `import/namespace` cannot statically validate dynamic component lookups.
|
||||
// eslint-disable-next-line import/namespace
|
||||
// `import-x/namespace` cannot statically validate dynamic component lookups.
|
||||
// eslint-disable-next-line import-x/namespace
|
||||
let reactComponent = auroraComponents[component.component]
|
||||
/**
|
||||
* Find custom component if a former component is not found
|
||||
*/
|
||||
if (!reactComponent) {
|
||||
// eslint-disable-next-line import/namespace
|
||||
// eslint-disable-next-line import-x/namespace
|
||||
reactComponent = customAuroraComponents[component.component]
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
|
||||
@@ -172,11 +172,13 @@ class Leon {
|
||||
onFetch: answerInput.widget.onFetch ?? null,
|
||||
fallbackText,
|
||||
historyMode: answerInput.widgetHistoryMode || 'persisted',
|
||||
componentTree: new WidgetWrapper({
|
||||
...answerInput.widget.wrapperProps,
|
||||
children: [answerInput.widget.render()]
|
||||
}),
|
||||
supportedEvents: SUPPORTED_WIDGET_EVENTS as string[]
|
||||
componentTree: {
|
||||
...new WidgetWrapper({
|
||||
...answerInput.widget.wrapperProps,
|
||||
children: [answerInput.widget.render()]
|
||||
})
|
||||
},
|
||||
supportedEvents: [...SUPPORTED_WIDGET_EVENTS]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist/bin",
|
||||
"rootDir": "../../",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@@/*": ["../../*"],
|
||||
"@/*": ["../../server/src/*"],
|
||||
|
||||
+24
-20
@@ -1,7 +1,13 @@
|
||||
import stylistic from '@stylistic/eslint-plugin'
|
||||
import typescriptEslint from '@typescript-eslint/eslint-plugin'
|
||||
import unicorn from 'eslint-plugin-unicorn'
|
||||
import importPlugin from 'eslint-plugin-import'
|
||||
import {
|
||||
createNodeResolver,
|
||||
importX
|
||||
} from 'eslint-plugin-import-x'
|
||||
import {
|
||||
createTypeScriptImportResolver
|
||||
} from 'eslint-import-resolver-typescript'
|
||||
import globals from 'globals'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
import js from '@eslint/js'
|
||||
@@ -12,8 +18,8 @@ export default [
|
||||
},
|
||||
js.configs.recommended,
|
||||
...typescriptEslint.configs['flat/recommended'],
|
||||
importPlugin.flatConfigs.recommended,
|
||||
importPlugin.flatConfigs.typescript,
|
||||
importX.flatConfigs.recommended,
|
||||
importX.flatConfigs.typescript,
|
||||
{
|
||||
plugins: {
|
||||
'@stylistic': stylistic,
|
||||
@@ -29,12 +35,18 @@ export default [
|
||||
sourceType: 'module'
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: true,
|
||||
node: true
|
||||
}
|
||||
'import-x/resolver-next': [
|
||||
createTypeScriptImportResolver(),
|
||||
createNodeResolver()
|
||||
]
|
||||
},
|
||||
rules: {
|
||||
// Preserve the existing cleanup patterns until they can be reviewed
|
||||
// independently from the ESLint dependency upgrade.
|
||||
'no-useless-assignment': 'off',
|
||||
// Leon often converts infrastructure failures into domain-specific
|
||||
// errors whose public messages intentionally omit the original cause.
|
||||
'preserve-caught-error': 'off',
|
||||
'@typescript-eslint/no-non-null-assertion': ['off'],
|
||||
'no-async-promise-executor': ['off'],
|
||||
'no-underscore-dangle': [
|
||||
@@ -44,12 +56,10 @@ export default [
|
||||
}
|
||||
],
|
||||
'prefer-destructuring': ['off'],
|
||||
'comma-dangle': ['error', 'never'],
|
||||
'@stylistic/comma-dangle': ['error', 'never'],
|
||||
semi: ['error', 'never'],
|
||||
quotes: ['error', 'single'],
|
||||
'@stylistic/semi': ['error', 'never'],
|
||||
'@stylistic/quotes': ['error', 'single'],
|
||||
'object-curly-spacing': ['error', 'always'],
|
||||
'@stylistic/object-curly-spacing': ['error', 'always'],
|
||||
'unicorn/prefer-node-protocol': 'error',
|
||||
'@stylistic/member-delimiter-style': [
|
||||
'error',
|
||||
@@ -66,15 +76,9 @@ export default [
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/consistent-type-definitions': 'error',
|
||||
'import/no-named-as-default': 'off',
|
||||
'import/no-named-as-default-member': 'off',
|
||||
'import/order': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['skills/**/*.ts'],
|
||||
rules: {
|
||||
'import/order': 'off'
|
||||
'import-x/no-named-as-default': 'off',
|
||||
'import-x/no-named-as-default-member': 'off',
|
||||
'import-x/order': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -8,5 +8,5 @@
|
||||
"server/src/tmp",
|
||||
"server/dist"
|
||||
],
|
||||
"exec": "node scripts/run-with-managed-node.js node_modules/typescript/bin/tsc --noEmit -p tsconfig.json && node scripts/run-with-managed-node.js node_modules/tsx/dist/cli.mjs server/src/index.ts"
|
||||
"exec": "pnpm exec tsc --noEmit -p tsconfig.json && node scripts/run-with-managed-node.js node_modules/tsx/dist/cli.mjs server/src/index.ts"
|
||||
}
|
||||
|
||||
+11
-11
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.1.1",
|
||||
"onFail": "error"
|
||||
}
|
||||
},
|
||||
@@ -118,9 +119,7 @@
|
||||
"yaml": "2.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "1.2.3",
|
||||
"@eslint/eslintrc": "3.3.5",
|
||||
"@eslint/js": "9.15.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@tsconfig/node16": "16.1.8",
|
||||
"@tsconfig/strictest": "2.0.8",
|
||||
@@ -131,17 +130,18 @@
|
||||
"@types/node": "25.6.0",
|
||||
"@types/react": "19.2.15",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.1",
|
||||
"@typescript-eslint/parser": "8.58.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.64.0",
|
||||
"@typescript-eslint/parser": "8.64.0",
|
||||
"@typescript/native": "npm:typescript@7.0.2",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"cli-spinner": "0.2.10",
|
||||
"esbuild": "0.27.4",
|
||||
"eslint": "10.2.0",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-unicorn": "49.0.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-unicorn": "72.0.0",
|
||||
"git-changelog": "2.0.0",
|
||||
"globals": "15.12.0",
|
||||
"globals": "17.7.0",
|
||||
"husky": "9.1.7",
|
||||
"json": "11.0.0",
|
||||
"lint-staged": "15.1.0",
|
||||
@@ -152,7 +152,7 @@
|
||||
"semver": "7.5.4",
|
||||
"shx": "0.3.4",
|
||||
"tsx": "4.20.5",
|
||||
"typescript": "5.9.3",
|
||||
"typescript": "npm:@typescript/typescript6@6.0.2",
|
||||
"vite": "8.0.14",
|
||||
"vitest": "4.1.4"
|
||||
}
|
||||
|
||||
Generated
+609
-1424
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { stripVTControlCharacters } from 'node:util'
|
||||
|
||||
const INSTALL_COMMAND =
|
||||
'stty rows 40 cols 120 && pnpm install --frozen-lockfile'
|
||||
const INSTALL_TIMEOUT = 45 * 60 * 1_000
|
||||
const BUILD_TIMEOUT = 10 * 60 * 1_000
|
||||
const START_TIMEOUT = 2 * 60 * 1_000
|
||||
const PROCESS_STOP_TIMEOUT = 5_000
|
||||
const OUTPUT_BUFFER_LIMIT = 32_000
|
||||
const DEFAULT_API_KEY = 'xxx'
|
||||
const DEFAULT_LOG_PATH = '/tmp/leon-fresh-install.log'
|
||||
const SERVER_READY_MESSAGE = 'Server is available at '
|
||||
const SCRIPT_ARGUMENTS = [
|
||||
'--quiet',
|
||||
'--return',
|
||||
'--flush',
|
||||
'--echo',
|
||||
'never',
|
||||
'--command',
|
||||
INSTALL_COMMAND,
|
||||
'/dev/null'
|
||||
]
|
||||
const PROMPTS = [
|
||||
{
|
||||
name: 'local AI',
|
||||
text: 'Do you want me to set up local AI now?',
|
||||
response: 'n\r',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'remote provider',
|
||||
text: 'Which online AI service should I use?',
|
||||
response: '\r',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'remote model',
|
||||
text: 'Which model should I use with',
|
||||
response: '\r',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'API key',
|
||||
text: 'I will save it in your local .env file.',
|
||||
response: null,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'voice',
|
||||
text: 'Do you want to talk to me with your voice now?',
|
||||
response: 'n\r',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'finish',
|
||||
text: 'What do you want to do next?',
|
||||
// Select Finish explicitly so a changed default cannot start Leon here.
|
||||
response: '\x1b[B\r',
|
||||
required: true
|
||||
}
|
||||
]
|
||||
|
||||
let activeChild = null
|
||||
|
||||
function getCleanEnvironment() {
|
||||
const environment = { ...process.env }
|
||||
|
||||
delete environment.GITHUB_ACTIONS
|
||||
delete environment.IS_DOCKER
|
||||
environment.CI = 'true'
|
||||
environment.COLUMNS = '120'
|
||||
environment.LINES = '40'
|
||||
|
||||
return environment
|
||||
}
|
||||
|
||||
function stopProcessGroup(child, signal = 'SIGTERM') {
|
||||
if (!child?.pid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
process.kill(-child.pid, signal)
|
||||
} catch (error) {
|
||||
if (error.code !== 'ESRCH') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stopActiveChildAndExit(signal) {
|
||||
if (activeChild) {
|
||||
stopProcessGroup(activeChild, signal)
|
||||
}
|
||||
|
||||
process.exit(signal === 'SIGINT' ? 130 : 143)
|
||||
}
|
||||
|
||||
process.once('SIGINT', () => stopActiveChildAndExit('SIGINT'))
|
||||
process.once('SIGTERM', () => stopActiveChildAndExit('SIGTERM'))
|
||||
|
||||
function ensureEmptyPNPMStore(environment) {
|
||||
const result = spawnSync('pnpm', ['store', 'path'], {
|
||||
encoding: 'utf8',
|
||||
env: environment
|
||||
})
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Unable to resolve pnpm store path: ${result.stderr}`)
|
||||
}
|
||||
|
||||
const storePath = result.stdout.trim()
|
||||
|
||||
if (fs.existsSync(storePath) && fs.readdirSync(storePath).length > 0) {
|
||||
throw new Error(`pnpm store is not empty: ${storePath}`)
|
||||
}
|
||||
|
||||
console.log(`Fresh pnpm store confirmed: ${storePath}`)
|
||||
}
|
||||
|
||||
function normalizeOutput(output) {
|
||||
return stripVTControlCharacters(output).replaceAll('\r', '')
|
||||
}
|
||||
|
||||
function getPromptResponse(prompt, apiKey) {
|
||||
return prompt.name === 'API key' ? `${apiKey}\r` : prompt.response
|
||||
}
|
||||
|
||||
function runInteractiveInstall(environment, apiKey, logPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(path.dirname(logPath), { recursive: true })
|
||||
const logStream = fs.createWriteStream(logPath)
|
||||
const answeredPrompts = new Set()
|
||||
let recentOutput = ''
|
||||
let hasSettled = false
|
||||
const child = spawn('script', SCRIPT_ARGUMENTS, {
|
||||
detached: true,
|
||||
env: environment,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
activeChild = child
|
||||
|
||||
const finish = (error) => {
|
||||
if (hasSettled) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSettled = true
|
||||
clearTimeout(timeout)
|
||||
logStream.end()
|
||||
activeChild = null
|
||||
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOutput = (chunk, destination) => {
|
||||
destination.write(chunk)
|
||||
logStream.write(chunk)
|
||||
recentOutput = `${recentOutput}${chunk}`.slice(-OUTPUT_BUFFER_LIMIT)
|
||||
const normalizedOutput = normalizeOutput(recentOutput)
|
||||
|
||||
for (const prompt of PROMPTS) {
|
||||
if (
|
||||
!answeredPrompts.has(prompt.name) &&
|
||||
normalizedOutput.includes(prompt.text)
|
||||
) {
|
||||
child.stdin.write(getPromptResponse(prompt, apiKey))
|
||||
answeredPrompts.add(prompt.name)
|
||||
console.log(`\n[clean-install] Answered: ${prompt.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk) => handleOutput(chunk, process.stdout))
|
||||
child.stderr.on('data', (chunk) => handleOutput(chunk, process.stderr))
|
||||
child.once('error', finish)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code !== 0) {
|
||||
finish(
|
||||
new Error(
|
||||
`pnpm install exited with ${signal ? `signal ${signal}` : `code ${code}`}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const missingPrompts = PROMPTS.filter(
|
||||
(prompt) => prompt.required && !answeredPrompts.has(prompt.name)
|
||||
).map((prompt) => prompt.name)
|
||||
|
||||
if (missingPrompts.length > 0) {
|
||||
finish(
|
||||
new Error(
|
||||
`Installer did not present required prompts: ${missingPrompts.join(', ')}`
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
finish()
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
stopProcessGroup(child)
|
||||
finish(new Error(`Installation exceeded ${INSTALL_TIMEOUT} ms`))
|
||||
}, INSTALL_TIMEOUT)
|
||||
})
|
||||
}
|
||||
|
||||
function runCommand(command, args, environment, timeoutMs) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let hasSettled = false
|
||||
const child = spawn(command, args, {
|
||||
detached: true,
|
||||
env: environment,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
|
||||
activeChild = child
|
||||
|
||||
const finish = (error) => {
|
||||
if (hasSettled) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSettled = true
|
||||
clearTimeout(timeout)
|
||||
activeChild = null
|
||||
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
child.once('error', finish)
|
||||
child.once('close', (code, signal) => {
|
||||
if (code === 0) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
finish(
|
||||
new Error(
|
||||
`${command} ${args.join(' ')} exited with ${
|
||||
signal ? `signal ${signal}` : `code ${code}`
|
||||
}`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
stopProcessGroup(child)
|
||||
finish(new Error(`${command} ${args.join(' ')} exceeded ${timeoutMs} ms`))
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
function smokeTestStart(environment) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let hasSettled = false
|
||||
let isReady = false
|
||||
let output = ''
|
||||
let forceStopTimeout = null
|
||||
const child = spawn('pnpm', ['start'], {
|
||||
detached: true,
|
||||
env: {
|
||||
...environment,
|
||||
LEON_OPEN_BROWSER: 'false'
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
|
||||
activeChild = child
|
||||
|
||||
const finish = (error) => {
|
||||
if (hasSettled) {
|
||||
return
|
||||
}
|
||||
|
||||
hasSettled = true
|
||||
clearTimeout(startTimeout)
|
||||
clearTimeout(forceStopTimeout)
|
||||
activeChild = null
|
||||
|
||||
if (error) {
|
||||
reject(error)
|
||||
} else {
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
const handleOutput = (chunk, destination) => {
|
||||
destination.write(chunk)
|
||||
output = `${output}${chunk}`.slice(-OUTPUT_BUFFER_LIMIT)
|
||||
|
||||
if (!isReady && normalizeOutput(output).includes(SERVER_READY_MESSAGE)) {
|
||||
isReady = true
|
||||
stopProcessGroup(child)
|
||||
forceStopTimeout = setTimeout(() => {
|
||||
stopProcessGroup(child, 'SIGKILL')
|
||||
}, PROCESS_STOP_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
child.stdout.on('data', (chunk) => handleOutput(chunk, process.stdout))
|
||||
child.stderr.on('data', (chunk) => handleOutput(chunk, process.stderr))
|
||||
child.once('error', finish)
|
||||
child.once('close', (code, signal) => {
|
||||
if (isReady) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
finish(
|
||||
new Error(
|
||||
`pnpm start exited before Leon was ready with ${
|
||||
signal ? `signal ${signal}` : `code ${code}`
|
||||
}`
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const startTimeout = setTimeout(() => {
|
||||
stopProcessGroup(child)
|
||||
finish(new Error(`pnpm start exceeded ${START_TIMEOUT} ms`))
|
||||
}, START_TIMEOUT)
|
||||
})
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const environment = getCleanEnvironment()
|
||||
const apiKey = process.env.LEON_FRESH_INSTALL_API_KEY || DEFAULT_API_KEY
|
||||
const logPath =
|
||||
process.env.LEON_FRESH_INSTALL_LOG_PATH || DEFAULT_LOG_PATH
|
||||
|
||||
ensureEmptyPNPMStore(environment)
|
||||
await runInteractiveInstall(environment, apiKey, logPath)
|
||||
await runCommand('pnpm', ['build'], environment, BUILD_TIMEOUT)
|
||||
await smokeTestStart(environment)
|
||||
console.log('Fresh installation, build, and start verification passed.')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
if (activeChild) {
|
||||
stopProcessGroup(activeChild)
|
||||
}
|
||||
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -9,6 +9,18 @@ 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_DOWNLOAD_RETRY_OPTIONS = {
|
||||
retries: 5,
|
||||
factor: 1.5,
|
||||
minTimeout: 1_000,
|
||||
maxTimeout: 10_000
|
||||
}
|
||||
const QMD_DOWNLOAD_INFO_RETRY_OPTIONS = {
|
||||
retries: 3,
|
||||
factor: 1.5,
|
||||
minTimeout: 1_000,
|
||||
maxTimeout: 5_000
|
||||
}
|
||||
|
||||
const QMD_MODELS = [
|
||||
{
|
||||
@@ -73,7 +85,12 @@ async function downloadModel(model) {
|
||||
|
||||
const resolvedURL = await NetworkHelper.setHuggingFaceURL(model.url)
|
||||
|
||||
await FileHelper.downloadFile(resolvedURL, destinationPath)
|
||||
// Model files are large enough that transient CDN failures are common.
|
||||
// Keep resumable downloads alive longer than the general file default.
|
||||
await FileHelper.downloadFile(resolvedURL, destinationPath, {
|
||||
retry: QMD_DOWNLOAD_RETRY_OPTIONS,
|
||||
retryFetchDownloadInfo: QMD_DOWNLOAD_INFO_RETRY_OPTIONS
|
||||
})
|
||||
|
||||
return 'downloaded'
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
|
||||
import execa from 'execa'
|
||||
|
||||
@@ -13,6 +14,8 @@ import { createSetupStatus } from './setup-status'
|
||||
|
||||
const NLTK_DATA_DIR_NAME = 'nltk_data'
|
||||
const NLTK_VENV_DIR_NAME = '.venv'
|
||||
const NLTK_DOWNLOAD_MAXIMUM_ATTEMPTS = 3
|
||||
const NLTK_DOWNLOAD_RETRY_DELAY = 2_000
|
||||
const PYTHON_TCP_SERVER_VENV_BIN_PATH = getProjectVenvPythonPath(
|
||||
PYTHON_TCP_SERVER_SRC_PATH
|
||||
)
|
||||
@@ -45,6 +48,41 @@ async function isNLTKDatasetInstalled(resourcePath) {
|
||||
}
|
||||
}
|
||||
|
||||
// Retry in a new Python process because NLTK keeps its downloaded index in
|
||||
// memory, including a truncated response from a transient network failure.
|
||||
async function runNLTKDownloader(datasetIDs, status) {
|
||||
let lastError = null
|
||||
|
||||
for (
|
||||
let attempt = 1;
|
||||
attempt <= NLTK_DOWNLOAD_MAXIMUM_ATTEMPTS;
|
||||
attempt += 1
|
||||
) {
|
||||
try {
|
||||
await execa(PYTHON_TCP_SERVER_VENV_BIN_PATH, [
|
||||
'-m',
|
||||
'nltk.downloader',
|
||||
'-d',
|
||||
NLTK_DATA_PATH,
|
||||
...datasetIDs
|
||||
])
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
|
||||
if (attempt === NLTK_DOWNLOAD_MAXIMUM_ATTEMPTS) {
|
||||
break
|
||||
}
|
||||
|
||||
status.text = `NLTK data download failed, retrying (${attempt}/${NLTK_DOWNLOAD_MAXIMUM_ATTEMPTS})...`
|
||||
await sleep(NLTK_DOWNLOAD_RETRY_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
/**
|
||||
* NLTK data are used by g2p-en during TTS text normalization.
|
||||
*
|
||||
@@ -75,16 +113,9 @@ async function downloadNLTKData() {
|
||||
.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' }
|
||||
await runNLTKDownloader(
|
||||
missingDatasets.map(({ id }) => id),
|
||||
status
|
||||
)
|
||||
|
||||
for (const dataset of missingDatasets) {
|
||||
|
||||
@@ -166,15 +166,22 @@ export class NLUProcessResultUpdater {
|
||||
if (newResult.actionName && newResult.actionName !== '') {
|
||||
const { skillName } = NLU.nluProcessResult
|
||||
const skillConfig = await SkillDomainHelper.getNewSkillConfig(skillName)
|
||||
const newActionConfig =
|
||||
skillConfig?.actions?.[newResult.actionName] || null
|
||||
// Narrow the generated schema to the records merged below instead of
|
||||
// making the compiler expand the complete recursive TypeBox type.
|
||||
const skillActions = skillConfig?.actions as
|
||||
| Record<string, Record<string, unknown> | undefined>
|
||||
| undefined
|
||||
const newActionConfig = skillActions?.[newResult.actionName] || null
|
||||
const newSkillLocaleConfig =
|
||||
(await SkillDomainHelper.getSkillLocaleConfig(
|
||||
BRAIN.lang,
|
||||
skillName
|
||||
)) as SkillLocaleConfigSchema
|
||||
const newActionLocaleConfig =
|
||||
newSkillLocaleConfig['actions'][newResult.actionName]
|
||||
const localeActions = newSkillLocaleConfig['actions'] as Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
>
|
||||
const newActionLocaleConfig = localeActions[newResult.actionName]
|
||||
|
||||
if (!newActionLocaleConfig) {
|
||||
LogHelper.title('NLU')
|
||||
@@ -186,12 +193,12 @@ export class NLUProcessResultUpdater {
|
||||
NLU.nluProcessResult = {
|
||||
...NLU.nluProcessResult,
|
||||
actionName: newResult.actionName,
|
||||
actionConfig: newActionConfig
|
||||
actionConfig: (newActionConfig
|
||||
? {
|
||||
...newActionConfig,
|
||||
...newActionLocaleConfig
|
||||
}
|
||||
: newActionConfig
|
||||
: newActionConfig) as NLUProcessResult['actionConfig']
|
||||
}
|
||||
|
||||
return
|
||||
|
||||
@@ -405,12 +405,23 @@ export default class NLU {
|
||||
/**
|
||||
* Compute required parameters for an action by excluding optional_parameters
|
||||
*/
|
||||
private getRequiredParamsForAction(
|
||||
actionConfig: NLUProcessResult['actionConfig']
|
||||
): string[] {
|
||||
const allParams = Object.keys(actionConfig?.parameters || {})
|
||||
const optionalParams: string[] = (actionConfig?.optional_parameters ||
|
||||
[]) as string[]
|
||||
private getRequiredParamsForAction(actionConfig: unknown): string[] {
|
||||
if (!actionConfig || typeof actionConfig !== 'object') {
|
||||
return []
|
||||
}
|
||||
|
||||
// Only expand the fields needed here; the complete generated schema is
|
||||
// recursive and considerably more expensive for the compiler to infer.
|
||||
const config = actionConfig as Record<string, unknown>
|
||||
const parameters =
|
||||
config['parameters'] && typeof config['parameters'] === 'object'
|
||||
? config['parameters']
|
||||
: {}
|
||||
const optionalParams = Array.isArray(config['optional_parameters'])
|
||||
? (config['optional_parameters'] as string[])
|
||||
: []
|
||||
const allParams = Object.keys(parameters)
|
||||
|
||||
return allParams.filter((p) => !optionalParams.includes(p))
|
||||
}
|
||||
|
||||
@@ -1218,7 +1229,13 @@ export default class NLU {
|
||||
if (hasPendingAction) {
|
||||
this.workflowProgress.showResolvingParameters()
|
||||
const [slotName] = this.conversation.activeState.missingParameters
|
||||
const actionConfig = this._nluProcessResult.actionConfig
|
||||
// Limit inference to the parameter fields consumed by slot filling.
|
||||
const actionConfig = this._nluProcessResult.actionConfig as {
|
||||
parameters?: Record<
|
||||
string,
|
||||
{ description?: string, type?: string } | undefined
|
||||
>
|
||||
} | null
|
||||
const param = actionConfig?.parameters?.[slotName as string]
|
||||
|
||||
if (!slotName || !param) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { DefaultEventsMap } from 'socket.io/dist/typed-events'
|
||||
import { Server as SocketIOServer, Socket } from 'socket.io'
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -125,7 +124,7 @@ interface ConnectedChatClient {
|
||||
protocol: LeonClientInterfaceProtocol
|
||||
profileName: string
|
||||
sessionId: string
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>
|
||||
socket: Socket
|
||||
}
|
||||
|
||||
export default class SocketServer {
|
||||
@@ -136,8 +135,7 @@ export default class SocketServer {
|
||||
{ profileName: string, deviceId: string }
|
||||
>()
|
||||
|
||||
public socket: Socket<DefaultEventsMap, DefaultEventsMap> | undefined =
|
||||
undefined
|
||||
public socket: Socket | undefined = undefined
|
||||
|
||||
constructor() {
|
||||
if (!SocketServer.instance) {
|
||||
@@ -149,7 +147,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private setActiveSocket(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>
|
||||
socket: Socket
|
||||
): void {
|
||||
this.socket = socket
|
||||
}
|
||||
@@ -198,7 +196,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private registerChatClient(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
initData: InitDataEvent,
|
||||
profileName: string,
|
||||
protocol: LeonClientInterfaceProtocol = 'legacy'
|
||||
@@ -226,7 +224,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private registerLeonClient(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
initData: LeonClientInterfaceInitPayload,
|
||||
profileName: string
|
||||
): ConnectedChatClient {
|
||||
@@ -294,7 +292,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private getSocketAuthToken(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
initData?: { token?: string }
|
||||
): string {
|
||||
const handshakeAuthToken = socket.handshake.auth?.['token']
|
||||
@@ -311,7 +309,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private isLeonClientInterfaceAuthorized(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
initData?: { token?: string }
|
||||
): string | null {
|
||||
const initToken = this.getSocketAuthToken(socket, initData)
|
||||
@@ -332,7 +330,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private emitSocketEvent(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
eventName: string,
|
||||
payload?: unknown
|
||||
): void {
|
||||
@@ -625,7 +623,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private monitorLLMInitialization(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
options: {
|
||||
profileName: string
|
||||
usesLlamaCPP: boolean
|
||||
@@ -666,7 +664,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private emitLeonClientReady(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
sessionId: string
|
||||
): void {
|
||||
socket.emit(LEON_CLIENT_INTERFACE_EVENTS.ready, {
|
||||
@@ -676,7 +674,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private emitLeonClientError(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
payload: LeonClientInterfaceErrorPayload
|
||||
): void {
|
||||
socket.emit(LEON_CLIENT_INTERFACE_EVENTS.error, payload)
|
||||
@@ -718,7 +716,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private async handleOwnerMessage(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
utteranceData: UtteranceDataEvent
|
||||
): Promise<void> {
|
||||
const profileName =
|
||||
@@ -730,7 +728,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private async processOwnerMessage(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
utteranceData: UtteranceDataEvent
|
||||
): Promise<void> {
|
||||
this.setActiveSocket(socket)
|
||||
@@ -834,7 +832,7 @@ export default class SocketServer {
|
||||
}
|
||||
|
||||
private async handleWidgetEvent(
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap>,
|
||||
socket: Socket,
|
||||
event: WidgetDataEvent
|
||||
): Promise<void> {
|
||||
const profileName =
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"target": "ES2022",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "ESNext",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@@/*": ["../*"],
|
||||
"@/*": ["../server/src/*"],
|
||||
|
||||
+2
-2
@@ -4,8 +4,7 @@
|
||||
"lib": ["ESNext"],
|
||||
"rootDir": ".",
|
||||
"outDir": "./server/dist",
|
||||
"baseUrl": ".",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "Bundler",
|
||||
"module": "ESNext",
|
||||
"jsx": "react",
|
||||
"paths": {
|
||||
@@ -20,6 +19,7 @@
|
||||
},
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"types": ["node"],
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user