fix: profile import file upload, startup health check, sidebar scroll, node-pty fallback
- Change profile import from server path input to browser file upload (multipart) - Fix startup script to wait for health check before opening browser - Add overflow scroll with hidden scrollbar to sidebar nav - Graceful degradation when node-pty fails to load (WSL compatibility) - Remove rename button from profile cards - Restrict profile name input to English letters, numbers, hyphens - Use raw.githubusercontent.com URLs in README setup script Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -132,13 +132,13 @@ Open **http://localhost:8648**
|
|||||||
Automatically installs Node.js (if missing) and hermes-web-ui on Debian/Ubuntu/macOS:
|
Automatically installs Node.js (if missing) and hermes-web-ui on Debian/Ubuntu/macOS:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/EKKOLearnAI/hermes-web-ui/main/scripts/setup.sh)
|
||||||
```
|
```
|
||||||
|
|
||||||
### WSL
|
### WSL
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bash <(curl -fsSL https://cdn.jsdelivr.net/gh/EKKOLearnAI/hermes-web-ui@main/scripts/setup.sh)
|
bash <(curl -fsSL https://raw.githubusercontent.com/EKKOLearnAI/hermes-web-ui/main/scripts/setup.sh)
|
||||||
hermes-web-ui start
|
hermes-web-ui start
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+44
-7
@@ -135,23 +135,60 @@ function startDaemon(port) {
|
|||||||
child.unref()
|
child.unref()
|
||||||
writePid(child.pid)
|
writePid(child.pid)
|
||||||
|
|
||||||
setTimeout(() => {
|
// Poll health endpoint until server is ready (setTimeout to avoid overlapping requests)
|
||||||
if (isRunning(child.pid)) {
|
const healthUrl = `http://127.0.0.1:${port}/health`
|
||||||
console.log(` ✓ hermes-web-ui started (PID: ${child.pid}, port: ${port})`)
|
const maxWait = 30000
|
||||||
|
const interval = 500
|
||||||
|
let waited = 0
|
||||||
|
|
||||||
|
console.log(` ⏳ Starting hermes-web-ui (PID: ${child.pid}, port: ${port})...`)
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
waited += interval
|
||||||
|
if (!isRunning(child.pid)) {
|
||||||
|
console.log(' ✗ Failed to start hermes-web-ui')
|
||||||
|
console.log(` Check log: ${LOG_FILE}`)
|
||||||
|
removePid()
|
||||||
|
process.exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(healthUrl).then(res => {
|
||||||
|
if (res.ok) {
|
||||||
const url = token
|
const url = token
|
||||||
? `http://localhost:${port}/#/?token=${token}`
|
? `http://localhost:${port}/#/?token=${token}`
|
||||||
: `http://localhost:${port}`
|
: `http://localhost:${port}`
|
||||||
|
console.log(` ✓ hermes-web-ui started`)
|
||||||
console.log(` ${url}`)
|
console.log(` ${url}`)
|
||||||
console.log(` Log: ${LOG_FILE}`)
|
console.log(` Log: ${LOG_FILE}`)
|
||||||
const isWin = process.platform === 'win32'
|
const isWin = process.platform === 'win32'
|
||||||
const cmd = isWin ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}`
|
const cmd = isWin ? `start ${url}` : process.platform === 'darwin' ? `open ${url}` : `xdg-open ${url}`
|
||||||
try { execSync(cmd, { stdio: 'ignore' }) } catch {}
|
try { execSync(cmd, { stdio: 'ignore' }) } catch {}
|
||||||
|
} else if (waited < maxWait) {
|
||||||
|
setTimeout(poll, interval)
|
||||||
} else {
|
} else {
|
||||||
console.log(' ✗ Failed to start hermes-web-ui')
|
console.log(` ⚠ Server process is running but health check failed after ${maxWait / 1000}s`)
|
||||||
removePid()
|
console.log(` Check log: ${LOG_FILE}`)
|
||||||
process.exit(1)
|
const url = token
|
||||||
|
? `http://localhost:${port}/#/?token=${token}`
|
||||||
|
: `http://localhost:${port}`
|
||||||
|
console.log(` ${url}`)
|
||||||
}
|
}
|
||||||
}, 500)
|
}).catch(() => {
|
||||||
|
if (waited < maxWait) {
|
||||||
|
setTimeout(poll, interval)
|
||||||
|
} else {
|
||||||
|
console.log(` ⚠ Server process is running but health check failed after ${maxWait / 1000}s`)
|
||||||
|
console.log(` Check log: ${LOG_FILE}`)
|
||||||
|
const url = token
|
||||||
|
? `http://localhost:${port}/#/?token=${token}`
|
||||||
|
: `http://localhost:${port}`
|
||||||
|
console.log(` ${url}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(poll, interval)
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopDaemon() {
|
function stopDaemon() {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "hermes-web-ui",
|
"name": "hermes-web-ui",
|
||||||
"version": "0.2.6",
|
"version": "0.2.9",
|
||||||
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
"description": "Web dashboard for Hermes Agent — multi-platform AI chat, session management, scheduled jobs, usage analytics & channel configuration (Telegram, Discord, Slack, WhatsApp)",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -100,13 +100,22 @@ export async function exportProfile(name: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function importProfile(archive: string, name?: string): Promise<boolean> {
|
export async function importProfile(file: File): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await request('/api/hermes/profiles/import', {
|
const baseUrl = getBaseUrlValue()
|
||||||
|
const token = getApiKey()
|
||||||
|
const headers: Record<string, string> = {}
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/hermes/profiles/import`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ archive, name }),
|
headers,
|
||||||
|
body: formData,
|
||||||
})
|
})
|
||||||
return true
|
return res.ok
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useProfilesStore } from '@/stores/hermes/profiles'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const props = defineProps<{ profile: HermesProfile }>()
|
const props = defineProps<{ profile: HermesProfile }>()
|
||||||
const emit = defineEmits<{ rename: [name: string] }>()
|
const emit = defineEmits<{}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
@@ -154,9 +154,6 @@ async function handleExport() {
|
|||||||
>
|
>
|
||||||
{{ t('profiles.switchTo') }}
|
{{ t('profiles.switchTo') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton size="tiny" quaternary @click="emit('rename', profile.name)">
|
|
||||||
{{ t('profiles.rename') }}
|
|
||||||
</NButton>
|
|
||||||
<NButton
|
<NButton
|
||||||
size="tiny"
|
size="tiny"
|
||||||
quaternary
|
quaternary
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ function handleClose() {
|
|||||||
<NForm label-placement="top">
|
<NForm label-placement="top">
|
||||||
<NFormItem :label="t('profiles.name')" required>
|
<NFormItem :label="t('profiles.name')" required>
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="name"
|
:value="name"
|
||||||
:placeholder="t('profiles.namePlaceholder')"
|
:placeholder="t('profiles.namePlaceholder')"
|
||||||
|
@input="name = $event.replace(/[^a-zA-Z0-9_-]/g, '')"
|
||||||
@keyup.enter="handleSave"
|
@keyup.enter="handleSave"
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { NModal, NForm, NFormItem, NInput, NButton, useMessage } from 'naive-ui'
|
import { NModal, NUpload, NButton, useMessage } from 'naive-ui'
|
||||||
|
import type { UploadFileInfo } from 'naive-ui'
|
||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
@@ -15,21 +16,39 @@ const message = useMessage()
|
|||||||
|
|
||||||
const showModal = ref(true)
|
const showModal = ref(true)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const archive = ref('')
|
const fileList = ref<UploadFileInfo[]>([])
|
||||||
const name = ref('')
|
|
||||||
|
const ACCEPT_TYPES = [
|
||||||
|
'.tar.gz',
|
||||||
|
'.tgz',
|
||||||
|
'.gz',
|
||||||
|
'.zip',
|
||||||
|
]
|
||||||
|
|
||||||
|
function beforeUpload({ file }: { file: UploadFileInfo }) {
|
||||||
|
const name = file.name?.toLowerCase() || ''
|
||||||
|
const valid = ACCEPT_TYPES.some(ext => name.endsWith(ext))
|
||||||
|
if (!valid) {
|
||||||
|
message.warning(t('profiles.importInvalidFile'))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!archive.value.trim()) {
|
if (!fileList.value.length) {
|
||||||
message.warning(t('profiles.archivePathPlaceholder'))
|
message.warning(t('profiles.importSelectFile'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const ok = await profilesStore.importProfile(
|
const file = fileList.value[0].file
|
||||||
archive.value.trim(),
|
if (!file) {
|
||||||
name.value.trim() || undefined,
|
message.error(t('profiles.importFailed'))
|
||||||
)
|
return
|
||||||
|
}
|
||||||
|
const ok = await profilesStore.importProfile(file)
|
||||||
if (ok) {
|
if (ok) {
|
||||||
message.success(t('profiles.importSuccess'))
|
message.success(t('profiles.importSuccess'))
|
||||||
emit('saved')
|
emit('saved')
|
||||||
@@ -56,26 +75,20 @@ function handleClose() {
|
|||||||
:mask-closable="!loading"
|
:mask-closable="!loading"
|
||||||
@after-leave="emit('close')"
|
@after-leave="emit('close')"
|
||||||
>
|
>
|
||||||
<NForm label-placement="top">
|
<NUpload
|
||||||
<NFormItem :label="t('profiles.archivePath')" required>
|
v-model:file-list="fileList"
|
||||||
<NInput
|
:max="1"
|
||||||
v-model:value="archive"
|
:accept="ACCEPT_TYPES.join(',')"
|
||||||
:placeholder="t('profiles.archivePathPlaceholder')"
|
:disabled="loading"
|
||||||
/>
|
@before-upload="beforeUpload"
|
||||||
</NFormItem>
|
>
|
||||||
|
<NButton>{{ t('profiles.importSelectFile') }}</NButton>
|
||||||
<NFormItem :label="t('profiles.importName')">
|
</NUpload>
|
||||||
<NInput
|
|
||||||
v-model:value="name"
|
|
||||||
:placeholder="t('profiles.importNamePlaceholder')"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
</NForm>
|
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
|
||||||
<NButton type="primary" :loading="loading" @click="handleSave">
|
<NButton type="primary" :loading="loading" :disabled="!fileList.length" @click="handleSave">
|
||||||
{{ t('common.confirm') }}
|
{{ t('common.confirm') }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -428,6 +428,13 @@ async function handleUpdate() {
|
|||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.nav-item {
|
||||||
|
|||||||
@@ -2,12 +2,10 @@
|
|||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted } from 'vue'
|
||||||
import { NSelect } from 'naive-ui'
|
import { NSelect } from 'naive-ui'
|
||||||
import { useProfilesStore } from '@/stores/hermes/profiles'
|
import { useProfilesStore } from '@/stores/hermes/profiles'
|
||||||
import { useAppStore } from '@/stores/hermes/app'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const profilesStore = useProfilesStore()
|
const profilesStore = useProfilesStore()
|
||||||
const appStore = useAppStore()
|
|
||||||
|
|
||||||
const options = computed(() =>
|
const options = computed(() =>
|
||||||
profilesStore.profiles.map(p => ({
|
profilesStore.profiles.map(p => ({
|
||||||
|
|||||||
@@ -232,8 +232,10 @@ export default {
|
|||||||
exportFailed: 'Failed to export profile',
|
exportFailed: 'Failed to export profile',
|
||||||
importSuccess: 'Profile imported',
|
importSuccess: 'Profile imported',
|
||||||
importFailed: 'Failed to import profile',
|
importFailed: 'Failed to import profile',
|
||||||
|
importSelectFile: 'Select archive file',
|
||||||
|
importInvalidFile: 'Please select a valid archive (.tar.gz, .tgz, .gz, .zip)',
|
||||||
name: 'Profile Name',
|
name: 'Profile Name',
|
||||||
namePlaceholder: 'Enter profile name',
|
namePlaceholder: 'English letters, numbers, hyphens only',
|
||||||
newName: 'New Name',
|
newName: 'New Name',
|
||||||
newNamePlaceholder: 'Enter new name',
|
newNamePlaceholder: 'Enter new name',
|
||||||
cloneFromCurrent: 'Clone from current profile',
|
cloneFromCurrent: 'Clone from current profile',
|
||||||
|
|||||||
@@ -232,8 +232,10 @@ export default {
|
|||||||
exportFailed: '导出配置失败',
|
exportFailed: '导出配置失败',
|
||||||
importSuccess: '配置已导入',
|
importSuccess: '配置已导入',
|
||||||
importFailed: '导入配置失败',
|
importFailed: '导入配置失败',
|
||||||
|
importSelectFile: '选择归档文件',
|
||||||
|
importInvalidFile: '请选择有效的归档文件 (.tar.gz, .tgz, .gz, .zip)',
|
||||||
name: '配置名称',
|
name: '配置名称',
|
||||||
namePlaceholder: '输入配置名称',
|
namePlaceholder: '仅限英文、数字、连字符',
|
||||||
newName: '新名称',
|
newName: '新名称',
|
||||||
newNamePlaceholder: '输入新名称',
|
newNamePlaceholder: '输入新名称',
|
||||||
cloneFromCurrent: '从当前配置克隆',
|
cloneFromCurrent: '从当前配置克隆',
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export const useProfilesStore = defineStore('profiles', () => {
|
|||||||
return profilesApi.exportProfile(name)
|
return profilesApi.exportProfile(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function importProfile(archive: string, name?: string) {
|
async function importProfile(file: File) {
|
||||||
const ok = await profilesApi.importProfile(archive, name)
|
const ok = await profilesApi.importProfile(file)
|
||||||
if (ok) await fetchProfiles()
|
if (ok) await fetchProfiles()
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Router from '@koa/router'
|
import Router from '@koa/router'
|
||||||
import { createReadStream, existsSync, unlinkSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'fs'
|
import { createReadStream, existsSync, unlinkSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from 'fs'
|
||||||
|
import { mkdir, writeFile } from 'fs/promises'
|
||||||
import { basename, join } from 'path'
|
import { basename, join } from 'path'
|
||||||
import { tmpdir, homedir } from 'os'
|
import { tmpdir, homedir } from 'os'
|
||||||
import YAML from 'js-yaml'
|
import YAML from 'js-yaml'
|
||||||
@@ -242,20 +243,66 @@ profileRoutes.post('/api/hermes/profiles/:name/export', async (ctx) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// POST /api/profiles/import - Import profile from archive
|
// POST /api/profiles/import - Import profile from uploaded archive
|
||||||
profileRoutes.post('/api/hermes/profiles/import', async (ctx) => {
|
profileRoutes.post('/api/hermes/profiles/import', async (ctx) => {
|
||||||
const { archive, name } = ctx.request.body as { archive?: string; name?: string }
|
const contentType = ctx.get('content-type') || ''
|
||||||
|
if (!contentType.startsWith('multipart/form-data')) {
|
||||||
if (!archive) {
|
|
||||||
ctx.status = 400
|
ctx.status = 400
|
||||||
ctx.body = { error: 'Missing archive path' }
|
ctx.body = { error: 'Expected multipart/form-data' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = '--' + contentType.split('boundary=')[1]
|
||||||
|
if (!boundary || boundary === '--undefined') {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'Missing boundary' }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmpDir = join(tmpdir(), 'hermes-import')
|
||||||
|
await mkdir(tmpDir, { recursive: true })
|
||||||
|
|
||||||
|
// Read raw body and parse multipart
|
||||||
|
const chunks: Buffer[] = []
|
||||||
|
for await (const chunk of ctx.req) chunks.push(chunk)
|
||||||
|
const body = Buffer.concat(chunks).toString('latin1')
|
||||||
|
const parts = body.split(boundary).slice(1, -1)
|
||||||
|
|
||||||
|
let archivePath = ''
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
const headerEnd = part.indexOf('\r\n\r\n')
|
||||||
|
if (headerEnd === -1) continue
|
||||||
|
const header = part.substring(0, headerEnd)
|
||||||
|
const data = part.substring(headerEnd + 4, part.length - 2)
|
||||||
|
|
||||||
|
const filenameMatch = header.match(/filename="([^"]+)"/)
|
||||||
|
if (!filenameMatch) continue
|
||||||
|
|
||||||
|
const filename = filenameMatch[1]
|
||||||
|
const ext = filename.includes('.') ? '.' + filename.split('.').pop() : ''
|
||||||
|
if (!['.gz', '.tar.gz', '.zip', '.tgz'].includes(ext)) continue
|
||||||
|
|
||||||
|
archivePath = join(tmpDir, filename)
|
||||||
|
await writeFile(archivePath, Buffer.from(data, 'binary'))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!archivePath) {
|
||||||
|
ctx.status = 400
|
||||||
|
ctx.body = { error: 'No archive file found (.gz, .zip, .tgz)' }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await hermesCli.importProfile(archive, name)
|
const result = await hermesCli.importProfile(archivePath)
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
try { unlinkSync(archivePath) } catch { }
|
||||||
|
|
||||||
ctx.body = { success: true, message: result.trim() }
|
ctx.body = { success: true, message: result.trim() }
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
try { unlinkSync(archivePath) } catch { }
|
||||||
ctx.status = 500
|
ctx.status = 500
|
||||||
ctx.body = { error: err.message }
|
ctx.body = { error: err.message }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { WebSocketServer } from 'ws'
|
import { WebSocketServer } from 'ws'
|
||||||
import type { Server as HttpServer } from 'http'
|
import type { Server as HttpServer } from 'http'
|
||||||
import { existsSync } from 'fs'
|
import { existsSync } from 'fs'
|
||||||
import * as pty from 'node-pty'
|
|
||||||
import { getToken } from '../../services/auth'
|
import { getToken } from '../../services/auth'
|
||||||
|
|
||||||
|
let pty: any = null
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
pty = require('node-pty')
|
||||||
|
} catch {
|
||||||
|
console.warn('[Terminal] node-pty failed to load, terminal feature disabled')
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Shell detection ────────────────────────────────────────────
|
// ─── Shell detection ────────────────────────────────────────────
|
||||||
|
|
||||||
function findShell(): string {
|
function findShell(): string {
|
||||||
@@ -29,7 +36,7 @@ function shellName(shell: string): string {
|
|||||||
|
|
||||||
interface PtySession {
|
interface PtySession {
|
||||||
id: string
|
id: string
|
||||||
pty: pty.IPty
|
pty: { pid: number; onData: (cb: (data: string) => void) => void; onExit: (cb: (e: { exitCode: number }) => void) => void; write: (data: string) => void; kill: (signal?: string) => void; resize: (cols: number, rows: number) => void }
|
||||||
shell: string
|
shell: string
|
||||||
pid: number
|
pid: number
|
||||||
createdAt: number
|
createdAt: number
|
||||||
@@ -49,7 +56,7 @@ function generateId(): string {
|
|||||||
|
|
||||||
function createSession(shell: string): PtySession {
|
function createSession(shell: string): PtySession {
|
||||||
const id = generateId()
|
const id = generateId()
|
||||||
let ptyProcess: pty.IPty
|
let ptyProcess: PtySession['pty']
|
||||||
try {
|
try {
|
||||||
ptyProcess = pty.spawn(shell, [], {
|
ptyProcess = pty.spawn(shell, [], {
|
||||||
name: 'xterm-color',
|
name: 'xterm-color',
|
||||||
@@ -75,6 +82,11 @@ function createSession(shell: string): PtySession {
|
|||||||
// ─── WebSocket server setup ─────────────────────────────────────
|
// ─── WebSocket server setup ─────────────────────────────────────
|
||||||
|
|
||||||
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
export function setupTerminalWebSocket(httpServer: HttpServer) {
|
||||||
|
if (!pty) {
|
||||||
|
console.warn('[Terminal] node-pty not available, skipping terminal WebSocket setup')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const wss = new WebSocketServer({ noServer: true })
|
const wss = new WebSocketServer({ noServer: true })
|
||||||
const defaultShell = findShell()
|
const defaultShell = findShell()
|
||||||
|
|
||||||
@@ -111,7 +123,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
// ─── PTY output → WebSocket ──────────────────────────────────
|
// ─── PTY output → WebSocket ──────────────────────────────────
|
||||||
|
|
||||||
function attachPtyOutput(session: PtySession) {
|
function attachPtyOutput(session: PtySession) {
|
||||||
session.pty.onData((data) => {
|
session.pty.onData((data: string) => {
|
||||||
if (ws.readyState !== ws.OPEN) return
|
if (ws.readyState !== ws.OPEN) return
|
||||||
if (conn.activeSessionId === session.id) {
|
if (conn.activeSessionId === session.id) {
|
||||||
ws.send(data)
|
ws.send(data)
|
||||||
@@ -130,7 +142,7 @@ export function setupTerminalWebSocket(httpServer: HttpServer) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
session.pty.onExit(({ exitCode }) => {
|
session.pty.onExit(({ exitCode }: { exitCode: number }) => {
|
||||||
conn.outputBuffers.delete(session.id)
|
conn.outputBuffers.delete(session.id)
|
||||||
if (ws.readyState === ws.OPEN) {
|
if (ws.readyState === ws.OPEN) {
|
||||||
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
|
ws.send(JSON.stringify({ type: 'exited', id: session.id, exitCode }))
|
||||||
|
|||||||
Reference in New Issue
Block a user