feat: add global AI guide to LMS launcher
This commit is contained in:
Generated
+19
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.0.4",
|
"version": "1.0.19",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.0.4",
|
"version": "1.0.19",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3"
|
||||||
@@ -7410,6 +7411,22 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-pty": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^7.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-pty/node_modules/node-addon-api": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.44",
|
"version": "2.0.44",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
|
"node-pty": "^1.1.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"socket.io-client": "^4.8.3"
|
"socket.io-client": "^4.8.3"
|
||||||
|
|||||||
+184
-103
@@ -1,6 +1,8 @@
|
|||||||
import { app, BrowserWindow, Menu, globalShortcut, ipcMain, screen } from 'electron'
|
import { app, BrowserWindow, Menu, globalShortcut, ipcMain, screen } from 'electron'
|
||||||
import type { WebContents } from 'electron'
|
import type { WebContents } from 'electron'
|
||||||
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
|
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
|
||||||
|
import * as pty from 'node-pty'
|
||||||
|
import type { IPty } from 'node-pty'
|
||||||
import { createWriteStream, existsSync } from 'fs'
|
import { createWriteStream, existsSync } from 'fs'
|
||||||
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
|
import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
|
||||||
import { homedir } from 'os'
|
import { homedir } from 'os'
|
||||||
@@ -15,7 +17,7 @@ let splashWindow: BrowserWindow | null = null
|
|||||||
const MIN_SPLASH_VISIBLE_MS = 2000
|
const MIN_SPLASH_VISIBLE_MS = 2000
|
||||||
let splashVisibleStartedAt = 0
|
let splashVisibleStartedAt = 0
|
||||||
let splashCloseTimer: NodeJS.Timeout | null = null
|
let splashCloseTimer: NodeJS.Timeout | null = null
|
||||||
const terminalProcesses = new Map<string, ChildProcessWithoutNullStreams>()
|
const terminalProcesses = new Map<string, IPty>()
|
||||||
const terminalStopTimers = new Map<string, NodeJS.Timeout>()
|
const terminalStopTimers = new Map<string, NodeJS.Timeout>()
|
||||||
const intentionalTerminalStops = new Set<string>()
|
const intentionalTerminalStops = new Set<string>()
|
||||||
const moduleCacheRoot = resolve(homedir(), '.caches')
|
const moduleCacheRoot = resolve(homedir(), '.caches')
|
||||||
@@ -45,7 +47,8 @@ const workspaceGitIgnoreLines = [
|
|||||||
'*.py[cod]',
|
'*.py[cod]',
|
||||||
'.pytest_cache/',
|
'.pytest_cache/',
|
||||||
'.mypy_cache/',
|
'.mypy_cache/',
|
||||||
'.cache/'
|
'.cache/',
|
||||||
|
'.autonomy-studio-git.json'
|
||||||
]
|
]
|
||||||
const workspaceGitPathspec = [
|
const workspaceGitPathspec = [
|
||||||
'--',
|
'--',
|
||||||
@@ -489,7 +492,7 @@ function formatDockerRuntime(status: string): string {
|
|||||||
if (!runtime) return '실행 중'
|
if (!runtime) return '실행 중'
|
||||||
|
|
||||||
const normalized = runtime.toLowerCase()
|
const normalized = runtime.toLowerCase()
|
||||||
if (normalized === 'less than a second') return '<1 sec'
|
if (normalized === 'less than a second') return '1 sec'
|
||||||
|
|
||||||
return runtime
|
return runtime
|
||||||
.replace(/\babout\s+/gi, '')
|
.replace(/\babout\s+/gi, '')
|
||||||
@@ -872,13 +875,15 @@ function parseGitStatus(stdout: string): GitChangedFile[] {
|
|||||||
const rawPath = line.slice(3).trim()
|
const rawPath = line.slice(3).trim()
|
||||||
const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ').at(-1)?.trim() ?? rawPath : rawPath
|
const filePath = rawPath.includes(' -> ') ? rawPath.split(' -> ').at(-1)?.trim() ?? rawPath : rawPath
|
||||||
if (isGeneratedWorkspacePath(filePath)) return null
|
if (isGeneratedWorkspacePath(filePath)) return null
|
||||||
const statusCode = x === '?' && y === '?' ? '?' : x.trim() || y.trim() || 'M'
|
const xStatus = x.trim()
|
||||||
|
const yStatus = y.trim()
|
||||||
|
const statusCode = xStatus === '?' && yStatus === '?' ? '?' : xStatus || yStatus || 'M'
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: filePath,
|
path: filePath,
|
||||||
status: mapGitStatusCode(statusCode),
|
status: mapGitStatusCode(statusCode),
|
||||||
staged: x !== ' ' && x !== '?',
|
staged: xStatus !== '' && xStatus !== '?',
|
||||||
unstaged: y !== ' ' || (x === '?' && y === '?')
|
unstaged: yStatus !== ''
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter((file): file is GitChangedFile => Boolean(file))
|
.filter((file): file is GitChangedFile => Boolean(file))
|
||||||
@@ -2354,6 +2359,43 @@ async function startWorkspaceModule(
|
|||||||
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildWorkspaceModule(
|
||||||
|
moduleName: string,
|
||||||
|
workspaceRoot?: string | null,
|
||||||
|
folderName?: string | null,
|
||||||
|
_gpuEnabled?: boolean | null,
|
||||||
|
rosDomainId?: string | null,
|
||||||
|
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||||
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
|
||||||
|
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||||
|
const targetFolderName = folderName?.trim()
|
||||||
|
if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.')
|
||||||
|
|
||||||
|
const moduleRoot = await findWorkspaceModuleRoot(moduleName, resolvedWorkspaceRoot, targetFolderName)
|
||||||
|
if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||||
|
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||||
|
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||||
|
|
||||||
|
await prepareRuntimeImages(runtimeImages)
|
||||||
|
await runCommand(
|
||||||
|
'docker',
|
||||||
|
[
|
||||||
|
'run', '--rm', '--name', `${dockerInfo.containerKey}-build`,
|
||||||
|
'--volume', `${moduleRoot}:/autoware:rw`,
|
||||||
|
'-u', 'root', '--net=host',
|
||||||
|
'-e', `ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`,
|
||||||
|
'-v', '/etc/localtime:/etc/localtime:ro',
|
||||||
|
'--entrypoint', '/bin/bash', autowareDockerImage,
|
||||||
|
'-lc', 'cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install'
|
||||||
|
],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
900000
|
||||||
|
)
|
||||||
|
return { ok: true, message: '빌드가 완료되었습니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
async function startRemoteWorkspaceModule(
|
async function startRemoteWorkspaceModule(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
moduleName: string,
|
moduleName: string,
|
||||||
@@ -2364,7 +2406,7 @@ async function startRemoteWorkspaceModule(
|
|||||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||||
): Promise<{ ok: boolean; message: string }> {
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
const target = normalizeRemoteTarget(targetInput)
|
const target = normalizeRemoteTarget(targetInput)
|
||||||
const targetFolderName = folderName?.trim()
|
const targetFolderName = folderName?.trim() || moduleName
|
||||||
if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.')
|
if (!targetFolderName) throw new Error('모듈 폴더명이 없습니다.')
|
||||||
|
|
||||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||||
@@ -2459,21 +2501,9 @@ async function startRemoteWorkspaceModule(
|
|||||||
...expandRemotePathVar('ROOT'),
|
...expandRemotePathVar('ROOT'),
|
||||||
`FOLDER=${quoteShellSingle(targetFolderName)}`,
|
`FOLDER=${quoteShellSingle(targetFolderName)}`,
|
||||||
'MODULE_PATH="$ROOT/$FOLDER"',
|
'MODULE_PATH="$ROOT/$FOLDER"',
|
||||||
`BUILD_CONTAINER=${quoteShellSingle(`${dockerInfo.containerKey}-build`)}`,
|
'true'
|
||||||
`DOCKER_IMAGE_NAME=${quoteShellSingle(autowareDockerImage)}`,
|
|
||||||
`ROS_DOMAIN_ID_VALUE=${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')}`,
|
|
||||||
'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true',
|
|
||||||
'docker run --rm \\',
|
|
||||||
' --name "$BUILD_CONTAINER" \\',
|
|
||||||
' --volume="$MODULE_PATH:/autoware:rw" \\',
|
|
||||||
' -u root \\',
|
|
||||||
' --net=host \\',
|
|
||||||
' -e ROS_DOMAIN_ID="$ROS_DOMAIN_ID_VALUE" \\',
|
|
||||||
' -v /etc/localtime:/etc/localtime:ro \\',
|
|
||||||
' --entrypoint /bin/bash \\',
|
|
||||||
' "$DOCKER_IMAGE_NAME" -lc "cd /autoware && rm -rf build install log && source /opt/ros/humble/setup.bash && colcon build --symlink-install"'
|
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
900000
|
30000
|
||||||
)
|
)
|
||||||
|
|
||||||
await runSsh(
|
await runSsh(
|
||||||
@@ -2494,6 +2524,42 @@ async function startRemoteWorkspaceModule(
|
|||||||
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function buildRemoteWorkspaceModule(
|
||||||
|
targetInput: RemoteWorkspaceTarget,
|
||||||
|
moduleName: string,
|
||||||
|
folderName?: string | null,
|
||||||
|
_gpuEnabled?: boolean | null,
|
||||||
|
rosDomainId?: string | null,
|
||||||
|
_runtimeImages?: RuntimeImageArtifact[] | null,
|
||||||
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
|
const target = normalizeRemoteTarget(targetInput)
|
||||||
|
const targetFolderName = folderName?.trim() || moduleName
|
||||||
|
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||||
|
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||||
|
|
||||||
|
await runSsh(target, [
|
||||||
|
'set -e',
|
||||||
|
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||||
|
...expandRemotePathVar('ROOT'),
|
||||||
|
`FOLDER=${quoteShellSingle(targetFolderName)}`,
|
||||||
|
'MODULE_PATH="$ROOT/$FOLDER"',
|
||||||
|
'[ -d "$MODULE_PATH" ] || { echo "설치된 모듈을 찾을 수 없습니다." >&2; exit 1; }',
|
||||||
|
`BUILD_CONTAINER=${quoteShellSingle(`${dockerInfo.containerKey}-build`)}`,
|
||||||
|
`ROS_DOMAIN_ID_VALUE=${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')}`,
|
||||||
|
'docker rm -f "$BUILD_CONTAINER" >/dev/null 2>&1 || true',
|
||||||
|
'docker run --rm \\',
|
||||||
|
' --name "$BUILD_CONTAINER" \\',
|
||||||
|
' --volume="$MODULE_PATH:/autoware:rw" \\',
|
||||||
|
' -u root \\',
|
||||||
|
' --net=host \\',
|
||||||
|
' -e ROS_DOMAIN_ID="$ROS_DOMAIN_ID_VALUE" \\',
|
||||||
|
' -v /etc/localtime:/etc/localtime:ro \\',
|
||||||
|
' --entrypoint /bin/bash \\',
|
||||||
|
` ${quoteShellSingle(autowareDockerImage)} -lc "cd /autoware && source /opt/ros/humble/setup.bash && colcon build --symlink-install"`
|
||||||
|
].join('\n'), 900000)
|
||||||
|
return { ok: true, message: '빌드가 완료되었습니다.' }
|
||||||
|
}
|
||||||
|
|
||||||
async function stopWorkspaceModule(
|
async function stopWorkspaceModule(
|
||||||
moduleName: string,
|
moduleName: string,
|
||||||
workspaceRoot?: string | null,
|
workspaceRoot?: string | null,
|
||||||
@@ -2510,7 +2576,12 @@ async function stopWorkspaceModule(
|
|||||||
|
|
||||||
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>())
|
const statuses = await readDockerStatuses().catch(() => new Map<string, { running: boolean; exists: boolean; status: string }>())
|
||||||
const current = statuses.get(dockerInfo.containerKey)
|
const current = statuses.get(dockerInfo.containerKey)
|
||||||
if (!current?.exists) return { ok: true, message: '중지할 컨테이너가 없습니다.' }
|
const buildContainer = `${dockerInfo.containerKey}-build`
|
||||||
|
const currentBuild = statuses.get(buildContainer)
|
||||||
|
if (currentBuild?.exists) {
|
||||||
|
await runCommand('docker', ['rm', '-f', buildContainer], undefined, undefined, 30000).catch(() => undefined)
|
||||||
|
}
|
||||||
|
if (!current?.exists) return { ok: true, message: currentBuild?.exists ? '빌드를 중지했습니다.' : '중지할 컨테이너가 없습니다.' }
|
||||||
|
|
||||||
await runCommand('docker', ['stop', dockerInfo.containerKey], undefined, undefined, 30000).catch(async (error) => {
|
await runCommand('docker', ['stop', dockerInfo.containerKey], undefined, undefined, 30000).catch(async (error) => {
|
||||||
if (current.running) throw error
|
if (current.running) throw error
|
||||||
@@ -2520,14 +2591,20 @@ async function stopWorkspaceModule(
|
|||||||
|
|
||||||
async function stopRemoteWorkspaceModule(
|
async function stopRemoteWorkspaceModule(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
|
moduleName: string,
|
||||||
folderName?: string | null,
|
folderName?: string | null,
|
||||||
): Promise<{ ok: boolean; message: string }> {
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
const target = normalizeRemoteTarget(targetInput)
|
const target = normalizeRemoteTarget(targetInput)
|
||||||
const containerKey = remoteContainerKey(folderName)
|
const targetFolderName = folderName?.trim() || moduleName?.trim()
|
||||||
|
const containerKey = remoteContainerKey(targetFolderName)
|
||||||
if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||||
|
|
||||||
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
|
const [currentStatus, buildStatus] = await Promise.all([
|
||||||
if (!currentStatus.trim()) return { ok: true, message: '중지할 컨테이너가 없습니다.' }
|
runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000),
|
||||||
|
runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}-build$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000),
|
||||||
|
])
|
||||||
|
if (buildStatus.trim()) await runSsh(target, `docker rm -f ${quoteShellSingle(`${containerKey}-build`)} >/dev/null 2>&1 || true`, 30000)
|
||||||
|
if (!currentStatus.trim()) return { ok: true, message: buildStatus.trim() ? '빌드를 중지했습니다.' : '중지할 컨테이너가 없습니다.' }
|
||||||
if (/^Up\b/i.test(currentStatus.trim())) {
|
if (/^Up\b/i.test(currentStatus.trim())) {
|
||||||
await runSsh(target, `docker stop ${quoteShellSingle(containerKey)}`, 30000)
|
await runSsh(target, `docker stop ${quoteShellSingle(containerKey)}`, 30000)
|
||||||
}
|
}
|
||||||
@@ -2632,7 +2709,7 @@ async function restartRemoteWorkspaceModule(
|
|||||||
rosDomainId?: string | null,
|
rosDomainId?: string | null,
|
||||||
runtimeImages?: RuntimeImageArtifact[] | null,
|
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||||
): Promise<{ ok: boolean; message: string }> {
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
await stopRemoteWorkspaceModule(target, folderName)
|
await stopRemoteWorkspaceModule(target, moduleName, folderName)
|
||||||
return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages)
|
return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2665,10 +2742,12 @@ async function readWorkspaceModuleLogs(
|
|||||||
|
|
||||||
async function readRemoteWorkspaceModuleLogs(
|
async function readRemoteWorkspaceModuleLogs(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
|
moduleName: string,
|
||||||
folderName?: string | null,
|
folderName?: string | null,
|
||||||
): Promise<{ ok: boolean; logs: string; message?: string }> {
|
): Promise<{ ok: boolean; logs: string; message?: string }> {
|
||||||
const target = normalizeRemoteTarget(targetInput)
|
const target = normalizeRemoteTarget(targetInput)
|
||||||
const containerKey = remoteContainerKey(folderName)
|
const targetFolderName = folderName?.trim() || moduleName?.trim()
|
||||||
|
const containerKey = remoteContainerKey(targetFolderName)
|
||||||
if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
if (!containerKey || !isAllowedContainerName(containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||||
|
|
||||||
const existsOutput = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Names}}" 2>/dev/null || true`, 20000)
|
const existsOutput = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${containerKey}$`)} --format "{{.Names}}" 2>/dev/null || true`, 20000)
|
||||||
@@ -2861,7 +2940,7 @@ function stopTerminal(terminalKey: string): void {
|
|||||||
if (!proc) return
|
if (!proc) return
|
||||||
intentionalTerminalStops.add(terminalKey)
|
intentionalTerminalStops.add(terminalKey)
|
||||||
terminalProcesses.delete(terminalKey)
|
terminalProcesses.delete(terminalKey)
|
||||||
if (!proc.killed) proc.kill()
|
proc.kill()
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopTerminalsForWebContents(webContentsId: number): void {
|
function stopTerminalsForWebContents(webContentsId: number): void {
|
||||||
@@ -2885,10 +2964,19 @@ function cancelStopTerminal(terminalKey: string): void {
|
|||||||
terminalStopTimers.delete(terminalKey)
|
terminalStopTimers.delete(terminalKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeHiddenTerminalCommand(sender: WebContents, sessionId: string, proc: ChildProcessWithoutNullStreams, command: string): void {
|
function writeHiddenTerminalCommand(sender: WebContents, sessionId: string, proc: IPty, command: string): void {
|
||||||
sender.send('terminal-suppress-data', sessionId, `${command}\r\n`)
|
sender.send('terminal-suppress-data', sessionId, `${command}\r\n`)
|
||||||
sender.send('terminal-suppress-data', sessionId, `${command}\n`)
|
sender.send('terminal-suppress-data', sessionId, `${command}\n`)
|
||||||
proc.stdin.write(`${command}\n`)
|
proc.write(`${command}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeTerminal(sender: WebContents, sessionId: string, cols?: number, rows?: number): boolean {
|
||||||
|
const proc = terminalProcesses.get(getTerminalKey(sender.id, sessionId))
|
||||||
|
if (!proc) return false
|
||||||
|
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
||||||
|
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
|
||||||
|
proc.resize(safeCols, safeRows)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveModuleTerminalRoot(moduleName?: string, workspaceRoot?: string | null, folderName?: string | null): Promise<string> {
|
async function resolveModuleTerminalRoot(moduleName?: string, workspaceRoot?: string | null, folderName?: string | null): Promise<string> {
|
||||||
@@ -2898,6 +2986,30 @@ async function resolveModuleTerminalRoot(moduleName?: string, workspaceRoot?: st
|
|||||||
return moduleRoot
|
return moduleRoot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function terminalEnvironment(cols: number, rows: number): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries({
|
||||||
|
...process.env,
|
||||||
|
TERM: 'xterm-256color',
|
||||||
|
COLORTERM: 'truecolor',
|
||||||
|
COLUMNS: String(cols),
|
||||||
|
LINES: String(rows)
|
||||||
|
}).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachTerminalProcess(sender: WebContents, sessionId: string, terminalKey: string, proc: IPty): void {
|
||||||
|
terminalProcesses.set(terminalKey, proc)
|
||||||
|
proc.onData((data) => {
|
||||||
|
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, data)
|
||||||
|
})
|
||||||
|
proc.onExit(({ exitCode }) => {
|
||||||
|
terminalProcesses.delete(terminalKey)
|
||||||
|
if (intentionalTerminalStops.delete(terminalKey)) return
|
||||||
|
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${exitCode}]\r\n`)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function registerTerminalHandlers(): void {
|
function registerTerminalHandlers(): void {
|
||||||
ipcMain.handle('terminal-start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => {
|
ipcMain.handle('terminal-start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => {
|
||||||
const webContentsId = event.sender.id
|
const webContentsId = event.sender.id
|
||||||
@@ -2911,34 +3023,21 @@ function registerTerminalHandlers(): void {
|
|||||||
await mkdir(terminalCwd, { recursive: true })
|
await mkdir(terminalCwd, { recursive: true })
|
||||||
|
|
||||||
const existingProc = terminalProcesses.get(terminalKey)
|
const existingProc = terminalProcesses.get(terminalKey)
|
||||||
if (existingProc && !existingProc.killed) {
|
if (existingProc) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
||||||
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
|
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
|
||||||
const proc = spawn('script', ['-q', '/dev/null', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], {
|
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], {
|
||||||
cwd: terminalCwd,
|
cwd: terminalCwd,
|
||||||
env: {
|
name: 'xterm-256color',
|
||||||
...process.env,
|
cols: safeCols,
|
||||||
TERM: 'xterm-256color',
|
rows: safeRows,
|
||||||
COLORTERM: 'truecolor',
|
env: terminalEnvironment(safeCols, safeRows)
|
||||||
COLUMNS: String(safeCols),
|
|
||||||
LINES: String(safeRows)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
terminalProcesses.set(terminalKey, proc)
|
attachTerminalProcess(event.sender, sessionId, terminalKey, proc)
|
||||||
const send = (data: Buffer): void => {
|
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString())
|
|
||||||
}
|
|
||||||
proc.stdout.on('data', send)
|
|
||||||
proc.stderr.on('data', send)
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
terminalProcesses.delete(terminalKey)
|
|
||||||
if (intentionalTerminalStops.delete(terminalKey)) return
|
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`)
|
|
||||||
})
|
|
||||||
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -2954,34 +3053,21 @@ function registerTerminalHandlers(): void {
|
|||||||
await mkdir(terminalCwd, { recursive: true })
|
await mkdir(terminalCwd, { recursive: true })
|
||||||
|
|
||||||
const existingProc = terminalProcesses.get(terminalKey)
|
const existingProc = terminalProcesses.get(terminalKey)
|
||||||
if (existingProc && !existingProc.killed) {
|
if (existingProc) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
||||||
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
|
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
|
||||||
const proc = spawn('script', ['-q', '/dev/null', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], {
|
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], {
|
||||||
cwd: terminalCwd,
|
cwd: terminalCwd,
|
||||||
env: {
|
name: 'xterm-256color',
|
||||||
...process.env,
|
cols: safeCols,
|
||||||
TERM: 'xterm-256color',
|
rows: safeRows,
|
||||||
COLORTERM: 'truecolor',
|
env: terminalEnvironment(safeCols, safeRows)
|
||||||
COLUMNS: String(safeCols),
|
|
||||||
LINES: String(safeRows)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
terminalProcesses.set(terminalKey, proc)
|
attachTerminalProcess(event.sender, sessionId, terminalKey, proc)
|
||||||
const send = (data: Buffer): void => {
|
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString())
|
|
||||||
}
|
|
||||||
proc.stdout.on('data', send)
|
|
||||||
proc.stderr.on('data', send)
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
terminalProcesses.delete(terminalKey)
|
|
||||||
if (intentionalTerminalStops.delete(terminalKey)) return
|
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`)
|
|
||||||
})
|
|
||||||
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
@@ -2999,12 +3085,12 @@ function registerTerminalHandlers(): void {
|
|||||||
if (!resolved) return false
|
if (!resolved) return false
|
||||||
|
|
||||||
const existingProc = terminalProcesses.get(terminalKey)
|
const existingProc = terminalProcesses.get(terminalKey)
|
||||||
if (existingProc && !existingProc.killed) return true
|
if (existingProc) return true
|
||||||
|
|
||||||
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
||||||
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
|
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
|
||||||
const target = normalizeRemoteTarget(resolved.target)
|
const target = normalizeRemoteTarget(resolved.target)
|
||||||
const proc = spawn('ssh', [
|
const proc = pty.spawn('ssh', [
|
||||||
'-tt',
|
'-tt',
|
||||||
'-p',
|
'-p',
|
||||||
String(target.port),
|
String(target.port),
|
||||||
@@ -3019,45 +3105,35 @@ function registerTerminalHandlers(): void {
|
|||||||
`${target.user}@${target.host}`,
|
`${target.user}@${target.host}`,
|
||||||
buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows)
|
buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows)
|
||||||
], {
|
], {
|
||||||
env: {
|
name: 'xterm-256color',
|
||||||
...process.env,
|
cols: safeCols,
|
||||||
TERM: 'xterm-256color',
|
rows: safeRows,
|
||||||
COLORTERM: 'truecolor',
|
env: terminalEnvironment(safeCols, safeRows)
|
||||||
COLUMNS: String(safeCols),
|
|
||||||
LINES: String(safeRows)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
terminalProcesses.set(terminalKey, proc)
|
attachTerminalProcess(event.sender, sessionId, terminalKey, proc)
|
||||||
const send = (data: Buffer): void => {
|
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, data.toString())
|
|
||||||
}
|
|
||||||
proc.stdout.on('data', send)
|
|
||||||
proc.stderr.on('data', send)
|
|
||||||
proc.on('close', (code) => {
|
|
||||||
terminalProcesses.delete(terminalKey)
|
|
||||||
if (intentionalTerminalStops.delete(terminalKey)) return
|
|
||||||
if (!event.sender.isDestroyed()) event.sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${code ?? 'unknown'}]\r\n`)
|
|
||||||
})
|
|
||||||
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('terminal-resize', (event, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows))
|
||||||
|
ipcMain.handle('local-workspace:terminal:resize', (event, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows))
|
||||||
|
ipcMain.handle('remote-workspace:terminal:resize', (event, _target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId = 'default') => resizeTerminal(event.sender, sessionId, cols, rows))
|
||||||
|
|
||||||
ipcMain.on('terminal-write', (event, data: string, sessionId = 'default') => {
|
ipcMain.on('terminal-write', (event, data: string, sessionId = 'default') => {
|
||||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data)
|
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data)
|
||||||
})
|
})
|
||||||
ipcMain.on('local-workspace:terminal:write', (event, data: string, sessionId = 'default') => {
|
ipcMain.on('local-workspace:terminal:write', (event, data: string, sessionId = 'default') => {
|
||||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data)
|
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data)
|
||||||
})
|
})
|
||||||
ipcMain.on('remote-workspace:terminal:write', (event, data: string, sessionId = 'default') => {
|
ipcMain.on('remote-workspace:terminal:write', (event, data: string, sessionId = 'default') => {
|
||||||
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.stdin.write(data)
|
terminalProcesses.get(getTerminalKey(event.sender.id, sessionId))?.write(data)
|
||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('terminal-change-directory', async (event, moduleName: string, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => {
|
ipcMain.handle('terminal-change-directory', async (event, moduleName: string, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => {
|
||||||
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
||||||
cancelStopTerminal(terminalKey)
|
cancelStopTerminal(terminalKey)
|
||||||
const proc = terminalProcesses.get(terminalKey)
|
const proc = terminalProcesses.get(terminalKey)
|
||||||
if (!proc || proc.killed) return false
|
if (!proc) return false
|
||||||
const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => {
|
const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => {
|
||||||
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
||||||
return null
|
return null
|
||||||
@@ -3071,7 +3147,7 @@ function registerTerminalHandlers(): void {
|
|||||||
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
||||||
cancelStopTerminal(terminalKey)
|
cancelStopTerminal(terminalKey)
|
||||||
const proc = terminalProcesses.get(terminalKey)
|
const proc = terminalProcesses.get(terminalKey)
|
||||||
if (!proc || proc.killed) return false
|
if (!proc) return false
|
||||||
const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => {
|
const terminalCwd = await resolveModuleTerminalRoot(moduleName, workspaceRoot, folderName).catch((error) => {
|
||||||
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
||||||
return null
|
return null
|
||||||
@@ -3085,7 +3161,7 @@ function registerTerminalHandlers(): void {
|
|||||||
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
const terminalKey = getTerminalKey(event.sender.id, sessionId)
|
||||||
cancelStopTerminal(terminalKey)
|
cancelStopTerminal(terminalKey)
|
||||||
const proc = terminalProcesses.get(terminalKey)
|
const proc = terminalProcesses.get(terminalKey)
|
||||||
if (!proc || proc.killed) return false
|
if (!proc) return false
|
||||||
const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName).catch((error) => {
|
const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName).catch((error) => {
|
||||||
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
event.sender.send('terminal-data', sessionId, `\r\n[terminal error: ${error instanceof Error ? error.message : 'workspace not found'}]\r\n`)
|
||||||
return null
|
return null
|
||||||
@@ -3096,13 +3172,13 @@ function registerTerminalHandlers(): void {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.on('terminal-stop', (event, sessionId = 'default') => {
|
ipcMain.on('terminal-stop', (event, sessionId = 'default') => {
|
||||||
scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId))
|
stopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||||
})
|
})
|
||||||
ipcMain.on('local-workspace:terminal:stop', (event, sessionId = 'default') => {
|
ipcMain.on('local-workspace:terminal:stop', (event, sessionId = 'default') => {
|
||||||
scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId))
|
stopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||||
})
|
})
|
||||||
ipcMain.on('remote-workspace:terminal:stop', (event, sessionId = 'default') => {
|
ipcMain.on('remote-workspace:terminal:stop', (event, sessionId = 'default') => {
|
||||||
scheduleStopTerminal(getTerminalKey(event.sender.id, sessionId))
|
stopTerminal(getTerminalKey(event.sender.id, sessionId))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3275,6 +3351,7 @@ function registerLocalWorkspaceHandlers(): void {
|
|||||||
ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||||
ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
|
ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||||
ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||||
|
ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages))
|
||||||
ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
|
ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||||
ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
|
ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
|
||||||
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
|
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
|
||||||
@@ -3284,11 +3361,12 @@ function registerLocalWorkspaceHandlers(): void {
|
|||||||
ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules))
|
ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules))
|
||||||
ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||||
ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||||
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, folderName))
|
ipcMain.handle('remote-workspace:modules:build', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildRemoteWorkspaceModule(target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages))
|
||||||
|
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName))
|
||||||
ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules))
|
ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules))
|
||||||
ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => resetRemoteWorkspaceSession(target, modules))
|
ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => resetRemoteWorkspaceSession(target, modules))
|
||||||
ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||||
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, folderName))
|
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
|
||||||
ipcMain.handle('remote-workspace:git:status', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null) => readRemoteWorkspaceGitStatus(target, moduleName, folderName, selectedFilePath))
|
ipcMain.handle('remote-workspace:git:status', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null) => readRemoteWorkspaceGitStatus(target, moduleName, folderName, selectedFilePath))
|
||||||
ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId))
|
ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId))
|
||||||
ipcMain.handle('remote-workspace:git:restore-file', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreRemoteWorkspaceGitFile(target, moduleName, folderName, filePath, status))
|
ipcMain.handle('remote-workspace:git:restore-file', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, filePath?: string | null, status?: GitChangedFile['status'] | null) => restoreRemoteWorkspaceGitFile(target, moduleName, folderName, filePath, status))
|
||||||
@@ -3400,7 +3478,7 @@ function openLearningVideoWindow(owner: BrowserWindow | null, input: LearningVid
|
|||||||
const url = input.url?.trim() ?? ''
|
const url = input.url?.trim() ?? ''
|
||||||
const videoId = getYoutubeVideoIdFromEmbedUrl(url)
|
const videoId = getYoutubeVideoIdFromEmbedUrl(url)
|
||||||
if (!videoId) return { ok: false, message: '지원하지 않는 동영상 URL입니다.' }
|
if (!videoId) return { ok: false, message: '지원하지 않는 동영상 URL입니다.' }
|
||||||
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`
|
const embedUrl = `https://www.youtube.com/embed/${videoId}?autoplay=1&rel=0`
|
||||||
|
|
||||||
const videoWindow = new BrowserWindow({
|
const videoWindow = new BrowserWindow({
|
||||||
width: 1080,
|
width: 1080,
|
||||||
@@ -3422,7 +3500,9 @@ function openLearningVideoWindow(owner: BrowserWindow | null, input: LearningVid
|
|||||||
})
|
})
|
||||||
|
|
||||||
videoWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
videoWindow.webContents.setWindowOpenHandler(() => ({ action: 'deny' }))
|
||||||
videoWindow.loadURL(watchUrl).catch((error) => {
|
videoWindow.loadURL(embedUrl, {
|
||||||
|
httpReferrer: 'https://workspace.autonomystudio.net/'
|
||||||
|
}).catch((error) => {
|
||||||
console.error('[learning-video] load failed', error)
|
console.error('[learning-video] load failed', error)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -3446,6 +3526,7 @@ app.whenReady().then(() => {
|
|||||||
ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||||
ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
|
ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||||
ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages))
|
||||||
|
ipcMain.handle('modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages))
|
||||||
ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
|
ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName))
|
||||||
ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
|
ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
|
||||||
ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers())
|
ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers())
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ contextBridge.exposeInMainWorld('modules', {
|
|||||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName),
|
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName),
|
||||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
||||||
|
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages),
|
||||||
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName),
|
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName),
|
||||||
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot),
|
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot),
|
||||||
stopRunning: () => ipcRenderer.invoke('modules:stop-running'),
|
stopRunning: () => ipcRenderer.invoke('modules:stop-running'),
|
||||||
@@ -127,6 +128,7 @@ contextBridge.exposeInMainWorld('exampleCode', {
|
|||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('terminal', {
|
contextBridge.exposeInMainWorld('terminal', {
|
||||||
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('terminal-start', moduleName, cols, rows, sessionId, workspaceRoot, folderName),
|
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('terminal-start', moduleName, cols, rows, sessionId, workspaceRoot, folderName),
|
||||||
|
resize: (cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('terminal-resize', cols, rows, sessionId),
|
||||||
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('terminal-change-directory', moduleName, sessionId, workspaceRoot, folderName),
|
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('terminal-change-directory', moduleName, sessionId, workspaceRoot, folderName),
|
||||||
write: (data: string, sessionId?: string) => ipcRenderer.send('terminal-write', data, sessionId),
|
write: (data: string, sessionId?: string) => ipcRenderer.send('terminal-write', data, sessionId),
|
||||||
stop: (sessionId?: string) => ipcRenderer.send('terminal-stop', sessionId),
|
stop: (sessionId?: string) => ipcRenderer.send('terminal-stop', sessionId),
|
||||||
@@ -164,6 +166,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName),
|
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName),
|
||||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
||||||
|
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages),
|
||||||
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName),
|
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName),
|
||||||
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot),
|
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot),
|
||||||
stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'),
|
stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'),
|
||||||
@@ -185,6 +188,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName),
|
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName),
|
||||||
|
resize: (cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('local-workspace:terminal:resize', cols, rows, sessionId),
|
||||||
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:change-directory', moduleName, sessionId, workspaceRoot, folderName),
|
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:change-directory', moduleName, sessionId, workspaceRoot, folderName),
|
||||||
write: (data: string, sessionId?: string) => ipcRenderer.send('local-workspace:terminal:write', data, sessionId),
|
write: (data: string, sessionId?: string) => ipcRenderer.send('local-workspace:terminal:write', data, sessionId),
|
||||||
stop: (sessionId?: string) => ipcRenderer.send('local-workspace:terminal:stop', sessionId),
|
stop: (sessionId?: string) => ipcRenderer.send('local-workspace:terminal:stop', sessionId),
|
||||||
@@ -217,6 +221,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
|||||||
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
|
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
|
||||||
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
|
||||||
|
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages),
|
||||||
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName),
|
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName),
|
||||||
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules),
|
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules),
|
||||||
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules),
|
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules),
|
||||||
@@ -232,6 +237,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
|||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName),
|
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName),
|
||||||
|
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('remote-workspace:terminal:resize', target, cols, rows, sessionId),
|
||||||
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:change-directory', target, moduleName, sessionId, folderName),
|
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:change-directory', target, moduleName, sessionId, folderName),
|
||||||
write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId),
|
write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId),
|
||||||
stop: (sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:stop', sessionId),
|
stop: (sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:stop', sessionId),
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB |
@@ -19,6 +19,7 @@ import MaterialsModal from './features/curriculum/MaterialsModal'
|
|||||||
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi'
|
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi'
|
||||||
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
|
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
|
||||||
import { getContextHelpSteps } from './features/help/contextHelp'
|
import { getContextHelpSteps } from './features/help/contextHelp'
|
||||||
|
import AiGuideView from './features/AiGuideView'
|
||||||
import type { PlatformFilter } from './features/platforms/PlatformFilterTabs'
|
import type { PlatformFilter } from './features/platforms/PlatformFilterTabs'
|
||||||
import PlatformSelectModal from './features/platforms/PlatformSelectModal'
|
import PlatformSelectModal from './features/platforms/PlatformSelectModal'
|
||||||
import PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel'
|
import PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel'
|
||||||
@@ -653,7 +654,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
const textMid = dark ? 'text-white/70' : 'text-[#4f596b]'
|
const textMid = dark ? 'text-white/70' : 'text-[#4f596b]'
|
||||||
const textDim = dark ? 'text-white/45' : 'text-[#727b8c]'
|
const textDim = dark ? 'text-white/45' : 'text-[#727b8c]'
|
||||||
const studentSlug = makeStudentSlug(studentEmail)
|
const studentSlug = makeStudentSlug(studentEmail)
|
||||||
const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, weekNumber, platformKey)
|
const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, courseKey, weekNumber, platformKey)
|
||||||
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platformId}-${studentSlug}-week-${weekNumber}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
|
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platformId}-${studentSlug}-week-${weekNumber}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
|
||||||
const {
|
const {
|
||||||
visualizationStacks,
|
visualizationStacks,
|
||||||
@@ -730,7 +731,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
curriculumModuleKey,
|
curriculumModuleKey,
|
||||||
remoteWorkspace,
|
remoteWorkspace,
|
||||||
remoteWorkspaceTargetKey,
|
remoteWorkspaceTargetKey,
|
||||||
pollingActive: activeIdeActivity === 'modules',
|
pollingActive: true,
|
||||||
applyModuleParameterOverrides
|
applyModuleParameterOverrides
|
||||||
})
|
})
|
||||||
const readGitStatus = useCallback((module: ModuleRow, selectedPath: string | null) => (
|
const readGitStatus = useCallback((module: ModuleRow, selectedPath: string | null) => (
|
||||||
@@ -2621,6 +2622,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
onDeleteModule={(module, options) => void deleteModule(module, options)}
|
onDeleteModule={(module, options) => void deleteModule(module, options)}
|
||||||
onUpdateModuleParameters={(module, rosParams) => void updateModuleParameters(module, rosParams)}
|
onUpdateModuleParameters={(module, rosParams) => void updateModuleParameters(module, rosParams)}
|
||||||
onStartModule={(module) => void runModuleControl(module, 'start')}
|
onStartModule={(module) => void runModuleControl(module, 'start')}
|
||||||
|
onBuildModule={(module) => void runModuleControl(module, 'build')}
|
||||||
onStopModule={(module) => void runModuleControl(module, 'stop')}
|
onStopModule={(module) => void runModuleControl(module, 'stop')}
|
||||||
onRestartModule={(module) => void runModuleControl(module, 'restart')}
|
onRestartModule={(module) => void runModuleControl(module, 'restart')}
|
||||||
onInstallAll={() => void installAllModules()}
|
onInstallAll={() => void installAllModules()}
|
||||||
@@ -2709,6 +2711,7 @@ export default function App(): React.JSX.Element {
|
|||||||
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
|
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
|
||||||
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '')
|
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '')
|
||||||
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
|
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
|
||||||
|
const [outerView, setOuterView] = useState<'curriculum' | 'guide'>('curriculum')
|
||||||
const [selectedWeekState, setSelectedWeekState] = useState<CurriculumWeek | null>(null)
|
const [selectedWeekState, setSelectedWeekState] = useState<CurriculumWeek | null>(null)
|
||||||
const [activeLearningStep, setActiveLearningStep] = useState<string | null>(null)
|
const [activeLearningStep, setActiveLearningStep] = useState<string | null>(null)
|
||||||
const [curriculumRefreshSignal, setCurriculumRefreshSignal] = useState(0)
|
const [curriculumRefreshSignal, setCurriculumRefreshSignal] = useState(0)
|
||||||
@@ -2914,6 +2917,32 @@ export default function App(): React.JSX.Element {
|
|||||||
return makeRequest(nextSession.accessToken)
|
return makeRequest(nextSession.accessToken)
|
||||||
}, [applyAuthSession, clearAuthSession, getFreshAccessToken])
|
}, [applyAuthSession, clearAuthSession, getFreshAccessToken])
|
||||||
|
|
||||||
|
const {
|
||||||
|
aiPending: guideAiPending,
|
||||||
|
aiTypingMessageId: guideAiTypingMessageId,
|
||||||
|
qaMessagesLoading: guideMessagesLoading,
|
||||||
|
displayedAiMessages: guideAiMessages,
|
||||||
|
aiConversationHistory: guideAiConversationHistory,
|
||||||
|
activeAiConversation: guideActiveAiConversation,
|
||||||
|
selectedAiConversationId: guideSelectedAiConversationId,
|
||||||
|
viewingAiHistory: guideViewingAiHistory,
|
||||||
|
chatAvailable: guideChatAvailable,
|
||||||
|
stopAiResponse: stopGuideAiResponse,
|
||||||
|
sendQaMessage: sendGuideQaMessage,
|
||||||
|
selectAiConversation: selectGuideAiConversation,
|
||||||
|
startNewAiConversation: startGuideAiConversation,
|
||||||
|
deleteAiConversation: deleteGuideAiConversation
|
||||||
|
} = useQaChat({
|
||||||
|
accessToken: authSession?.accessToken ?? '',
|
||||||
|
weekNumber: 0,
|
||||||
|
globalScope: true,
|
||||||
|
apiBaseUrl: LMS_API_BASE_URL,
|
||||||
|
socketBaseUrl: LMS_SOCKET_ORIGIN,
|
||||||
|
socketPath: LMS_SOCKET_PATH,
|
||||||
|
getFreshAccessToken,
|
||||||
|
onTitlebarNotice: showTitlebarNotice
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
@@ -3689,13 +3718,21 @@ export default function App(): React.JSX.Element {
|
|||||||
await loadCurriculum()
|
await loadCurriculum()
|
||||||
setPlatformModalOpen(true)
|
setPlatformModalOpen(true)
|
||||||
}
|
}
|
||||||
const navigateSidebar = (item: 'curriculum' | 'studio' | 'guide' | 'settings'): void => {
|
const navigateSidebar = (item: 'curriculum' | 'studio' | 'guide'): void => {
|
||||||
if (item === 'curriculum') {
|
if (item === 'curriculum') {
|
||||||
|
setOuterView('curriculum')
|
||||||
setLocalPcLayoutOpen(false)
|
setLocalPcLayoutOpen(false)
|
||||||
setRecentSessionModalOpen(false)
|
setRecentSessionModalOpen(false)
|
||||||
if (!userSidebarCollapsed) setSidebarCollapsed(false)
|
if (!userSidebarCollapsed) setSidebarCollapsed(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (item === 'guide') {
|
||||||
|
setOuterView('guide')
|
||||||
|
setLocalPcLayoutOpen(false)
|
||||||
|
setRecentSessionModalOpen(false)
|
||||||
|
setSidebarCollapsed(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (item === 'studio' && instructorAccount) {
|
if (item === 'studio' && instructorAccount) {
|
||||||
showInstructorStudioGuide()
|
showInstructorStudioGuide()
|
||||||
return
|
return
|
||||||
@@ -4006,13 +4043,14 @@ export default function App(): React.JSX.Element {
|
|||||||
onOpenCourseSelection={() => {
|
onOpenCourseSelection={() => {
|
||||||
setCourseSelectionVisible(true)
|
setCourseSelectionVisible(true)
|
||||||
setLocalPcLayoutOpen(false)
|
setLocalPcLayoutOpen(false)
|
||||||
|
setOuterView('curriculum')
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex min-h-0 flex-1 bg-[#0a0b0e]">
|
<div className="flex min-h-0 flex-1 bg-[#0a0b0e]">
|
||||||
<Sidebar
|
<Sidebar
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
onToggle={toggleSidebar}
|
onToggle={toggleSidebar}
|
||||||
activeItem={localPcLayoutOpen ? 'studio' : 'curriculum'}
|
activeItem={localPcLayoutOpen ? 'studio' : outerView}
|
||||||
studioEnabled={Boolean(selectedCourseId)}
|
studioEnabled={Boolean(selectedCourseId)}
|
||||||
onNavigate={navigateSidebar}
|
onNavigate={navigateSidebar}
|
||||||
sessionStatus={sidebarRunningSessions.length ? 'running' : sessionStatus}
|
sessionStatus={sidebarRunningSessions.length ? 'running' : sessionStatus}
|
||||||
@@ -4034,7 +4072,9 @@ export default function App(): React.JSX.Element {
|
|||||||
<main
|
<main
|
||||||
className={[
|
className={[
|
||||||
'relative min-w-0 flex-1 rounded-t-[28px] [scrollbar-gutter:stable]',
|
'relative min-w-0 flex-1 rounded-t-[28px] [scrollbar-gutter:stable]',
|
||||||
localPcLayoutOpen
|
outerView === 'guide'
|
||||||
|
? 'overflow-hidden bg-[#202020] p-0 text-white'
|
||||||
|
: localPcLayoutOpen
|
||||||
? ideTheme === 'dark'
|
? ideTheme === 'dark'
|
||||||
? 'overflow-hidden bg-[#202020] p-0 text-[#e6e6e6]'
|
? 'overflow-hidden bg-[#202020] p-0 text-[#e6e6e6]'
|
||||||
: 'overflow-hidden bg-[#f4f6fb] p-0 text-[#151827]'
|
: 'overflow-hidden bg-[#f4f6fb] p-0 text-[#151827]'
|
||||||
@@ -4084,8 +4124,33 @@ export default function App(): React.JSX.Element {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className={localPcLayoutOpen ? 'hidden' : 'block'}>
|
<div className={localPcLayoutOpen ? 'hidden' : outerView === 'guide' ? 'h-full min-h-0' : 'block'}>
|
||||||
{courseSelectionVisible ? (
|
{outerView === 'guide' ? (
|
||||||
|
<AiGuideView
|
||||||
|
courseTitle={selectedCourse?.title ?? null}
|
||||||
|
weekNumber={selectedWeekState?.week ?? null}
|
||||||
|
weekTitle={selectedWeekState?.title ?? null}
|
||||||
|
messages={guideAiMessages}
|
||||||
|
activeConversation={guideActiveAiConversation}
|
||||||
|
typingMessageId={guideAiTypingMessageId}
|
||||||
|
history={guideAiConversationHistory}
|
||||||
|
selectedHistoryId={guideSelectedAiConversationId}
|
||||||
|
viewingHistory={guideViewingAiHistory}
|
||||||
|
loading={guideMessagesLoading}
|
||||||
|
busy={guideAiPending}
|
||||||
|
disabled={!guideChatAvailable}
|
||||||
|
onSubmit={(content) => void sendGuideQaMessage(content)}
|
||||||
|
onQuickSubmit={(content) => void sendGuideQaMessage(content, { contextWeek: selectedWeekState?.week })}
|
||||||
|
onStop={stopGuideAiResponse}
|
||||||
|
onClear={() => void startGuideAiConversation()}
|
||||||
|
onSelectHistory={selectGuideAiConversation}
|
||||||
|
onDeleteHistory={(threadId) => void deleteGuideAiConversation(threadId)}
|
||||||
|
onClose={() => {
|
||||||
|
setOuterView('curriculum')
|
||||||
|
if (!userSidebarCollapsed) setSidebarCollapsed(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : courseSelectionVisible ? (
|
||||||
<CourseSelectionScreen
|
<CourseSelectionScreen
|
||||||
courses={studentCourses}
|
courses={studentCourses}
|
||||||
selectedCourseId={selectedCourseId}
|
selectedCourseId={selectedCourseId}
|
||||||
@@ -4166,7 +4231,7 @@ export default function App(): React.JSX.Element {
|
|||||||
<PlatformSettingsPanel
|
<PlatformSettingsPanel
|
||||||
target={platformSettingsTarget}
|
target={platformSettingsTarget}
|
||||||
draft={platformSettingsDraft}
|
draft={platformSettingsDraft}
|
||||||
clonePath={makeWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, makeStudentSlug(authSession?.user.email ?? 'student'), selectedWeek.week)}
|
clonePath={makeWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, makeStudentSlug(authSession?.user.email ?? 'student'), courseKey, selectedWeek.week)}
|
||||||
onDraftChange={setPlatformSettingsDraft}
|
onDraftChange={setPlatformSettingsDraft}
|
||||||
onClose={closePlatformSettings}
|
onClose={closePlatformSettings}
|
||||||
onSave={savePlatformSettings}
|
onSave={savePlatformSettings}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power, Settings } from 'lucide-react'
|
import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ id: 'curriculum', label: '커리큘럼', icon: BookOpen },
|
{ id: 'curriculum', label: '커리큘럼', icon: BookOpen },
|
||||||
{ id: 'studio', label: '개발 스튜디오', icon: LayoutDashboard },
|
{ id: 'studio', label: '개발 스튜디오', icon: LayoutDashboard },
|
||||||
{ id: 'guide', label: 'AI 가이드', icon: Bot },
|
{ id: 'guide', label: 'AI 가이드', icon: Bot }
|
||||||
{ id: 'settings', label: '설정', icon: Settings }
|
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type NavItemId = (typeof navItems)[number]['id']
|
type NavItemId = (typeof navItems)[number]['id']
|
||||||
|
|||||||
Vendored
+4
@@ -100,6 +100,7 @@ interface Window {
|
|||||||
}>
|
}>
|
||||||
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||||
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||||
|
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||||
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
||||||
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
|
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
|
||||||
stopRunning: () => Promise<{ ok: boolean; message: string }>
|
stopRunning: () => Promise<{ ok: boolean; message: string }>
|
||||||
@@ -177,6 +178,7 @@ interface Window {
|
|||||||
}
|
}
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
||||||
|
resize: (cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
|
||||||
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
||||||
write: (data: string, sessionId?: string) => void
|
write: (data: string, sessionId?: string) => void
|
||||||
stop: (sessionId?: string) => void
|
stop: (sessionId?: string) => void
|
||||||
@@ -239,6 +241,7 @@ interface Window {
|
|||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||||
|
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
|
||||||
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
|
||||||
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
||||||
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
|
||||||
@@ -254,6 +257,7 @@ interface Window {
|
|||||||
}
|
}
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise<boolean>
|
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise<boolean>
|
||||||
|
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
|
||||||
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
|
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
|
||||||
write: (data: string, sessionId?: string) => void
|
write: (data: string, sessionId?: string) => void
|
||||||
stop: (sessionId?: string) => void
|
stop: (sessionId?: string) => void
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react'
|
||||||
|
import type React from 'react'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { ExpandableMarkdownMessage, QaInputBox } from '../components/ChatWidgets'
|
||||||
|
import type { QaChatMessage, QaChatThread } from './workspace/useQaChat'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
courseTitle: string | null; weekNumber: number | null; weekTitle: string | null
|
||||||
|
messages: QaChatMessage[]; activeConversation: QaChatThread | null; typingMessageId: string | null
|
||||||
|
history: QaChatThread[]; selectedHistoryId: string | null; viewingHistory: boolean
|
||||||
|
loading: boolean; busy: boolean; disabled: boolean
|
||||||
|
onSubmit: (content: string) => void; onQuickSubmit: (content: string) => void; onStop: () => void; onClear: () => void
|
||||||
|
onSelectHistory: (threadId: string | null) => void; onDeleteHistory: (threadId: string) => void; onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const quickQuestions = [
|
||||||
|
{ Icon: BookOpen, label: '이번 주차 요약', prompt: '현재 주차의 학습 목표와 진행 순서를 간단히 정리해줘.' },
|
||||||
|
{ Icon: Code2, label: '실습 시작 방법', prompt: '현재 주차 실습을 시작하기 전에 확인할 내용을 알려줘.' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const formatDate = (value?: string): string => {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('ko-KR', { month: 'numeric', day: 'numeric' }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTime = (value?: string): string => {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? '' : new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiGuideView(props: Props): React.JSX.Element {
|
||||||
|
const {
|
||||||
|
courseTitle, weekNumber, weekTitle, messages, activeConversation, typingMessageId, history, selectedHistoryId, viewingHistory,
|
||||||
|
loading, busy, disabled, onSubmit, onQuickSubmit, onStop, onClear, onSelectHistory, onDeleteHistory, onClose
|
||||||
|
} = props
|
||||||
|
const [selectedQuestionId, setSelectedQuestionId] = useState<string | null>(null)
|
||||||
|
const [switchingConversation, setSwitchingConversation] = useState(false)
|
||||||
|
const [deleteConfirmThread, setDeleteConfirmThread] = useState<QaChatThread | null>(null)
|
||||||
|
const questionRefs = useRef<Record<string, HTMLElement | null>>({})
|
||||||
|
const conversationScrollRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const contextLabel = weekNumber && weekTitle ? `${weekNumber}주차 · ${weekTitle}` : '선택된 학습 주차 없음'
|
||||||
|
const latestAssistantMessageId = [...messages].reverse().find((message) => message.role === 'assistant')?.id
|
||||||
|
const questionMessages = messages.filter((message) => message.role === 'user')
|
||||||
|
|
||||||
|
const selectConversation = (threadId: string | null): void => {
|
||||||
|
setSwitchingConversation(true)
|
||||||
|
setSelectedQuestionId(null)
|
||||||
|
onSelectHistory(threadId)
|
||||||
|
window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
|
||||||
|
conversationScrollRef.current?.scrollTo({ top: conversationScrollRef.current.scrollHeight, behavior: 'auto' })
|
||||||
|
window.setTimeout(() => setSwitchingConversation(false), 1000)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#202020] text-white">
|
||||||
|
<header className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#252525] px-3">
|
||||||
|
<strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong>
|
||||||
|
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button>
|
||||||
|
</header>
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#202228]">
|
||||||
|
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
|
||||||
|
<div className="rounded-lg border border-[#3b7ffa]/25 bg-[#3b7ffa]/[0.08] px-3 pb-3 pt-2">
|
||||||
|
<span className="text-[10px] font-bold tracking-[0.08em] text-[#9bbdff]">현재 학습</span>
|
||||||
|
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
|
||||||
|
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-[#82abff]" /><span className="min-w-0 flex-1">새 대화</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>
|
||||||
|
<div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">빠른 질문</span><div className="mt-2 grid gap-1">
|
||||||
|
{quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-[#82abff]" /><span className="min-w-0 flex-1">{label}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)}
|
||||||
|
</div></div>
|
||||||
|
<div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">최근 대화</span><div className="mt-2 grid gap-1">
|
||||||
|
{activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 대화'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 대화</span></button> : viewingHistory ? <button type="button" onClick={() => selectConversation(null)} className="flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] font-semibold text-white/48 transition hover:bg-white/[0.06] hover:text-white/75"><MessageSquareText size={13} /><span className="truncate">새 대화로 돌아가기</span></button> : null}
|
||||||
|
{history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 대화'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="대화 삭제" aria-label="대화 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)}
|
||||||
|
</div></div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="relative flex min-w-0 flex-1 flex-col bg-[#181a20]">
|
||||||
|
<div ref={conversationScrollRef} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-28 pt-7">
|
||||||
|
{loading ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 대화를 불러오는 중</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">AI 가이드를 준비하고 있습니다</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
|
||||||
|
{messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-[#3b7ffa]/25 bg-[#3b7ffa]/10 text-[#96b8ff]"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2><p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p></div>}
|
||||||
|
{messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#3b7ffa] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(32,91,204,0.24)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-[#8fb4ff]"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>}</><ExpandableMarkdownMessage content={message.content || '응답을 작성하고 있습니다.'} dark defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">응답이 중단됨</p>}</article>)}
|
||||||
|
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3 text-[12px] font-semibold text-[#8fb4ff]"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
|
||||||
|
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#181a20]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252525] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-[#8fb4ff]" /> 대화 불러오는 중</span></div>}
|
||||||
|
</main>
|
||||||
|
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#202228]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold tracking-[0.08em] text-white/44">질문 목록</span></div><div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
|
||||||
|
</div>
|
||||||
|
{deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#252525] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">대화 삭제</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">이 대화 내역을 삭제할까요?</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 대화'}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 대화와 답변은 복구할 수 없습니다.</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">삭제</button></div></div></section></div>}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Download, FolderOpen, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
|
import { Download, FolderOpen, Hammer, Play, RefreshCw, Square, Trash2 } from 'lucide-react'
|
||||||
import type { LucideIcon } from 'lucide-react'
|
import type { LucideIcon } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ type ModuleListRowProps<Module extends ModuleListRowItem> = {
|
|||||||
onInstallModule: (module: Module) => void
|
onInstallModule: (module: Module) => void
|
||||||
onDeleteModule: (module: Module) => void
|
onDeleteModule: (module: Module) => void
|
||||||
onStartModule: (module: Module) => void
|
onStartModule: (module: Module) => void
|
||||||
|
onBuildModule: (module: Module) => void
|
||||||
onStopModule: (module: Module) => void
|
onStopModule: (module: Module) => void
|
||||||
onRestartModule: (module: Module) => void
|
onRestartModule: (module: Module) => void
|
||||||
}
|
}
|
||||||
@@ -85,6 +86,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
onInstallModule,
|
onInstallModule,
|
||||||
onDeleteModule,
|
onDeleteModule,
|
||||||
onStartModule,
|
onStartModule,
|
||||||
|
onBuildModule,
|
||||||
onStopModule,
|
onStopModule,
|
||||||
onRestartModule
|
onRestartModule
|
||||||
}: ModuleListRowProps<Module>): React.JSX.Element {
|
}: ModuleListRowProps<Module>): React.JSX.Element {
|
||||||
@@ -105,8 +107,16 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
{ label: '재시작', Icon: RefreshCw, tone: 'neutral', action: onRestartModule },
|
{ label: '재시작', Icon: RefreshCw, tone: 'neutral', action: onRestartModule },
|
||||||
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
|
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
|
||||||
]
|
]
|
||||||
|
: module.state === 'stopped' && controlling
|
||||||
|
? [
|
||||||
|
{ label: '빌드', Icon: Hammer, tone: 'neutral', action: onBuildModule },
|
||||||
|
{ label: '중지', Icon: Square, tone: 'warning', action: onStopModule }
|
||||||
|
]
|
||||||
: module.state === 'stopped'
|
: module.state === 'stopped'
|
||||||
? [{ label: '실행', Icon: Play, tone: 'start', action: onStartModule }]
|
? [
|
||||||
|
{ label: '빌드', Icon: Hammer, tone: 'neutral', action: onBuildModule },
|
||||||
|
{ label: '실행', Icon: Play, tone: 'start', action: onStartModule }
|
||||||
|
]
|
||||||
: []
|
: []
|
||||||
const installAction = module.state === 'uninstalled'
|
const installAction = module.state === 'uninstalled'
|
||||||
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
|
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
|
||||||
@@ -203,20 +213,26 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{controlActions.map(({ label, Icon, tone, action }) => (
|
{controlActions.map(({ label, Icon, tone, action }) => (
|
||||||
|
(() => {
|
||||||
|
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
|
||||||
|
const disabled = busy && !canCancelBuild
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${module.name}-${label}`}
|
key={`${module.name}-${label}`}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busy}
|
disabled={disabled}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
action(module)
|
action(module)
|
||||||
}}
|
}}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
className={actionButtonClass(dark, tone, busy)}
|
className={actionButtonClass(dark, tone, disabled)}
|
||||||
>
|
>
|
||||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
{busy && !canCancelBuild ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||||
<TooltipLabel dark={dark}>{label}</TooltipLabel>
|
<TooltipLabel dark={dark}>{label}</TooltipLabel>
|
||||||
</button>
|
</button>
|
||||||
|
)
|
||||||
|
})()
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex justify-center">
|
<span className="flex justify-center">
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type ModuleListViewProps = {
|
|||||||
onDeleteModule: (module: ModuleRow, options: { deleteRepository: boolean }) => void
|
onDeleteModule: (module: ModuleRow, options: { deleteRepository: boolean }) => void
|
||||||
onUpdateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => void
|
onUpdateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => void
|
||||||
onStartModule: (module: ModuleRow) => void
|
onStartModule: (module: ModuleRow) => void
|
||||||
|
onBuildModule: (module: ModuleRow) => void
|
||||||
onStopModule: (module: ModuleRow) => void
|
onStopModule: (module: ModuleRow) => void
|
||||||
onRestartModule: (module: ModuleRow) => void
|
onRestartModule: (module: ModuleRow) => void
|
||||||
onInstallAll: () => void
|
onInstallAll: () => void
|
||||||
@@ -44,6 +45,7 @@ export default function ModuleListView({
|
|||||||
onDeleteModule,
|
onDeleteModule,
|
||||||
onUpdateModuleParameters,
|
onUpdateModuleParameters,
|
||||||
onStartModule,
|
onStartModule,
|
||||||
|
onBuildModule,
|
||||||
onStopModule,
|
onStopModule,
|
||||||
onRestartModule,
|
onRestartModule,
|
||||||
onInstallAll,
|
onInstallAll,
|
||||||
@@ -134,6 +136,7 @@ export default function ModuleListView({
|
|||||||
onInstallModule={onInstallModule}
|
onInstallModule={onInstallModule}
|
||||||
onDeleteModule={setDeleteConfirmModule}
|
onDeleteModule={setDeleteConfirmModule}
|
||||||
onStartModule={onStartModule}
|
onStartModule={onStartModule}
|
||||||
|
onBuildModule={onBuildModule}
|
||||||
onStopModule={onStopModule}
|
onStopModule={onStopModule}
|
||||||
onRestartModule={onRestartModule}
|
onRestartModule={onRestartModule}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ function WorkspaceTerminalPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={terminalHostRef}
|
ref={terminalHostRef}
|
||||||
className={['overflow-hidden px-2 py-1', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}
|
className={['box-border overflow-hidden px-2 py-1', dark ? 'bg-[#171717]' : 'bg-[#f9fafc]'].join(' ')}
|
||||||
style={{ height: Math.max(110, terminalPanelHeight - 41) }}
|
style={{ height: Math.max(110, terminalPanelHeight - 41) }}
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -9,18 +9,22 @@ export const defaultStudioSourceSettings: StudioSourceSettings = {
|
|||||||
rosDomainId: '77'
|
rosDomainId: '77'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, weekNumber: number): string {
|
function normalizeCoursePathSegment(courseKey: string): string {
|
||||||
|
return courseKey.trim().replace(/[^a-zA-Z0-9_-]+/g, '-') || 'default-course'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number): string {
|
||||||
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
||||||
const suffix = `${studentSlug}/week-${weekNumber}`
|
const suffix = `${studentSlug}/${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
|
||||||
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) {
|
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) {
|
||||||
return normalizedRoot
|
return normalizedRoot
|
||||||
}
|
}
|
||||||
return `${normalizedRoot}/${suffix}`
|
return `${normalizedRoot}/${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, weekNumber: number, platformKey: string): string {
|
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
|
||||||
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
|
||||||
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, weekNumber)
|
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
|
||||||
const safePlatformKey = platformKey
|
const safePlatformKey = platformKey
|
||||||
.replace(/^futuredrive[-_]/i, '')
|
.replace(/^futuredrive[-_]/i, '')
|
||||||
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
.replace(/[^a-zA-Z0-9_-]+/g, '-')
|
||||||
|
|||||||
@@ -15,10 +15,20 @@ export type QaChatMessage = {
|
|||||||
status?: 'ACTIVE' | 'CANCELED'
|
status?: 'ACTIVE' | 'CANCELED'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QaChatThread = {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
status: 'OPEN' | 'WAITING_INSTRUCTOR' | 'RESOLVED' | 'ARCHIVED'
|
||||||
|
lastMessageAt?: string
|
||||||
|
messages: QaChatMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
type ChatThreadResponse = {
|
type ChatThreadResponse = {
|
||||||
id: string
|
id: string
|
||||||
type: 'AI' | 'INSTRUCTOR' | 'MIXED'
|
type: 'AI' | 'INSTRUCTOR' | 'MIXED'
|
||||||
status: 'OPEN' | 'WAITING_INSTRUCTOR' | 'RESOLVED' | 'ARCHIVED'
|
status: QaChatThread['status']
|
||||||
|
title: string
|
||||||
|
lastMessageAt?: string
|
||||||
messages: Array<{
|
messages: Array<{
|
||||||
id: string
|
id: string
|
||||||
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM'
|
senderType: 'STUDENT' | 'AI' | 'INSTRUCTOR' | 'SYSTEM'
|
||||||
@@ -43,6 +53,7 @@ type ChatNotificationPayload = {
|
|||||||
type UseQaChatOptions = {
|
type UseQaChatOptions = {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
weekNumber: number
|
weekNumber: number
|
||||||
|
globalScope?: boolean
|
||||||
apiBaseUrl: string
|
apiBaseUrl: string
|
||||||
socketBaseUrl: string
|
socketBaseUrl: string
|
||||||
socketPath: string
|
socketPath: string
|
||||||
@@ -63,6 +74,7 @@ const qaVisibleMessageLimit = 20
|
|||||||
export function useQaChat({
|
export function useQaChat({
|
||||||
accessToken,
|
accessToken,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
|
globalScope = false,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
socketBaseUrl,
|
socketBaseUrl,
|
||||||
socketPath,
|
socketPath,
|
||||||
@@ -71,12 +83,15 @@ export function useQaChat({
|
|||||||
}: UseQaChatOptions) {
|
}: UseQaChatOptions) {
|
||||||
const [qaMode, setQaMode] = useState<QaMode>('ai')
|
const [qaMode, setQaMode] = useState<QaMode>('ai')
|
||||||
const [aiMessages, setAiMessages] = useState<QaChatMessage[]>(initialAiChatMessages)
|
const [aiMessages, setAiMessages] = useState<QaChatMessage[]>(initialAiChatMessages)
|
||||||
|
const [aiConversations, setAiConversations] = useState<QaChatThread[]>([])
|
||||||
|
const [activeAiConversationId, setActiveAiConversationId] = useState<string | null>(null)
|
||||||
|
const [selectedAiConversationId, setSelectedAiConversationId] = useState<string | null>(null)
|
||||||
const [teacherMessages, setTeacherMessages] = useState<QaChatMessage[]>([])
|
const [teacherMessages, setTeacherMessages] = useState<QaChatMessage[]>([])
|
||||||
const [teacherThreadStatus, setTeacherThreadStatus] = useState<ChatThreadResponse['status'] | null>(null)
|
const [teacherThreadStatus, setTeacherThreadStatus] = useState<ChatThreadResponse['status'] | null>(null)
|
||||||
const [chatRefreshSignal, setChatRefreshSignal] = useState(0)
|
const [chatRefreshSignal, setChatRefreshSignal] = useState(0)
|
||||||
const [teacherChatFlash, setTeacherChatFlash] = useState(false)
|
const [teacherChatFlash, setTeacherChatFlash] = useState(false)
|
||||||
const [aiMessagesLoading, setAiMessagesLoading] = useState(Boolean(accessToken && weekNumber >= 1))
|
const [aiMessagesLoading, setAiMessagesLoading] = useState(Boolean(accessToken && (globalScope || weekNumber >= 1)))
|
||||||
const [teacherMessagesLoading, setTeacherMessagesLoading] = useState(Boolean(accessToken && weekNumber >= 1))
|
const [teacherMessagesLoading, setTeacherMessagesLoading] = useState(Boolean(accessToken && (globalScope || weekNumber >= 1)))
|
||||||
const [aiPending, setAiPending] = useState(false)
|
const [aiPending, setAiPending] = useState(false)
|
||||||
const [aiTypingMessageId, setAiTypingMessageId] = useState<string | null>(null)
|
const [aiTypingMessageId, setAiTypingMessageId] = useState<string | null>(null)
|
||||||
const [qaCollapseSignal, setQaCollapseSignal] = useState(0)
|
const [qaCollapseSignal, setQaCollapseSignal] = useState(0)
|
||||||
@@ -85,7 +100,9 @@ export function useQaChat({
|
|||||||
const aiRevealCancelRef = useRef(false)
|
const aiRevealCancelRef = useRef(false)
|
||||||
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
|
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
const validChatWeek = Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
|
const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
|
||||||
|
const chatAvailable = globalScope || validChatWeek !== null
|
||||||
|
const chatWeekQuery = validChatWeek ? `&week=${validChatWeek}` : ''
|
||||||
|
|
||||||
const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
||||||
const requestAccessToken = accessToken ? await getFreshAccessToken() : null
|
const requestAccessToken = accessToken ? await getFreshAccessToken() : null
|
||||||
@@ -141,7 +158,7 @@ export function useQaChat({
|
|||||||
}, [accessToken, onTitlebarNotice, socketBaseUrl, socketPath])
|
}, [accessToken, onTitlebarNotice, socketBaseUrl, socketPath])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accessToken || !validChatWeek) {
|
if (!accessToken || !chatAvailable) {
|
||||||
setAiMessagesLoading(false)
|
setAiMessagesLoading(false)
|
||||||
setTeacherMessagesLoading(false)
|
setTeacherMessagesLoading(false)
|
||||||
return
|
return
|
||||||
@@ -149,11 +166,22 @@ export function useQaChat({
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
setAiMessagesLoading(true)
|
setAiMessagesLoading(true)
|
||||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai&week=${validChatWeek}`)
|
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai${chatWeekQuery}&includeArchived=true`)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const messages = payload.threads[0]?.messages ?? []
|
const conversations = payload.threads.map((thread) => ({
|
||||||
setAiMessages(messages.length ? toQaMessages(messages) : initialAiChatMessages)
|
id: thread.id,
|
||||||
|
title: thread.title,
|
||||||
|
status: thread.status,
|
||||||
|
lastMessageAt: thread.lastMessageAt,
|
||||||
|
messages: toQaMessages(thread.messages)
|
||||||
|
}))
|
||||||
|
const activeConversation = conversations.find((thread) => thread.status !== 'ARCHIVED')
|
||||||
|
setAiConversations(conversations)
|
||||||
|
setActiveAiConversationId(activeConversation?.id ?? null)
|
||||||
|
setSelectedAiConversationId(null)
|
||||||
|
const messages = activeConversation?.messages ?? []
|
||||||
|
setAiMessages(messages.length ? messages : initialAiChatMessages)
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) setAiMessages(initialAiChatMessages)
|
if (!cancelled) setAiMessages(initialAiChatMessages)
|
||||||
@@ -163,7 +191,7 @@ export function useQaChat({
|
|||||||
})
|
})
|
||||||
|
|
||||||
setTeacherMessagesLoading(true)
|
setTeacherMessagesLoading(true)
|
||||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher&week=${validChatWeek}`)
|
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher${chatWeekQuery}`)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const thread = payload.threads[0]
|
const thread = payload.threads[0]
|
||||||
@@ -183,7 +211,7 @@ export function useQaChat({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [accessToken, chatRefreshSignal, validChatWeek])
|
}, [accessToken, chatAvailable, chatRefreshSignal, chatWeekQuery])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = workspaceChatScrollRef.current
|
const target = workspaceChatScrollRef.current
|
||||||
@@ -236,7 +264,7 @@ export function useQaChat({
|
|||||||
setAiPending(false)
|
setAiPending(false)
|
||||||
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
|
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ week: validChatWeek })
|
body: JSON.stringify(validChatWeek ? { week: validChatWeek } : {})
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
setAiMessages((messages) => [
|
setAiMessages((messages) => [
|
||||||
...messages,
|
...messages,
|
||||||
@@ -250,9 +278,9 @@ export function useQaChat({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendQaMessage = async (rawContent: string): Promise<void> => {
|
const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => {
|
||||||
const content = rawContent.trim()
|
const content = rawContent.trim()
|
||||||
if (!content || aiPending || aiTypingMessageId || !validChatWeek) return
|
if (!content || aiPending || aiTypingMessageId || !chatAvailable) return
|
||||||
setQaCollapseSignal((value) => value + 1)
|
setQaCollapseSignal((value) => value + 1)
|
||||||
|
|
||||||
if (qaMode !== 'ai') {
|
if (qaMode !== 'ai') {
|
||||||
@@ -266,7 +294,7 @@ export function useQaChat({
|
|||||||
try {
|
try {
|
||||||
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
|
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mode: 'teacher', content, week: validChatWeek })
|
body: JSON.stringify({ mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) })
|
||||||
})
|
})
|
||||||
setTeacherMessages((messages) => messages.map((message) => (
|
setTeacherMessages((messages) => messages.map((message) => (
|
||||||
message.id === userMessage.id
|
message.id === userMessage.id
|
||||||
@@ -290,7 +318,8 @@ export function useQaChat({
|
|||||||
const userMessage: QaChatMessage = {
|
const userMessage: QaChatMessage = {
|
||||||
id: `user-${Date.now()}`,
|
id: `user-${Date.now()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content
|
content,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
}
|
}
|
||||||
const nextMessages = [...aiMessages, userMessage]
|
const nextMessages = [...aiMessages, userMessage]
|
||||||
|
|
||||||
@@ -301,14 +330,36 @@ export function useQaChat({
|
|||||||
aiAbortControllerRef.current = abortController
|
aiAbortControllerRef.current = abortController
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
|
const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ content, week: validChatWeek }),
|
body: JSON.stringify({
|
||||||
|
content,
|
||||||
|
...(validChatWeek ? { week: validChatWeek } : {}),
|
||||||
|
...(options?.contextWeek ? { contextWeek: options.contextWeek } : {})
|
||||||
|
}),
|
||||||
signal: abortController.signal
|
signal: abortController.signal
|
||||||
})
|
})
|
||||||
aiAbortControllerRef.current = null
|
aiAbortControllerRef.current = null
|
||||||
if (aiRevealCancelRef.current || response.canceled) return
|
if (aiRevealCancelRef.current || response.canceled) return
|
||||||
const fullContent = response.content ?? ''
|
const fullContent = response.content ?? ''
|
||||||
|
setActiveAiConversationId(response.threadId)
|
||||||
|
setAiConversations((threads) => {
|
||||||
|
const assistantMessage: QaChatMessage = {
|
||||||
|
id: response.message.id,
|
||||||
|
role: 'assistant',
|
||||||
|
content: fullContent,
|
||||||
|
createdAt: response.message.createdAt,
|
||||||
|
keepExpanded: true
|
||||||
|
}
|
||||||
|
const nextThread: QaChatThread = {
|
||||||
|
id: response.threadId,
|
||||||
|
title: response.threadTitle ?? threads.find((thread) => thread.id === response.threadId)?.title ?? content.slice(0, 42),
|
||||||
|
status: 'OPEN',
|
||||||
|
lastMessageAt: response.message.createdAt,
|
||||||
|
messages: [...nextMessages, assistantMessage]
|
||||||
|
}
|
||||||
|
return [nextThread, ...threads.filter((thread) => thread.id !== response.threadId)]
|
||||||
|
})
|
||||||
expandedAiMessageIdsRef.current.add(response.message.id)
|
expandedAiMessageIdsRef.current.add(response.message.id)
|
||||||
setAiPending(false)
|
setAiPending(false)
|
||||||
setAiTypingMessageId(response.message.id)
|
setAiTypingMessageId(response.message.id)
|
||||||
@@ -369,7 +420,7 @@ export function useQaChat({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const clearQaConversation = async (): Promise<void> => {
|
const clearQaConversation = async (): Promise<void> => {
|
||||||
if (!validChatWeek) return
|
if (!chatAvailable) return
|
||||||
if (qaMode === 'ai') {
|
if (qaMode === 'ai') {
|
||||||
aiRevealCancelRef.current = true
|
aiRevealCancelRef.current = true
|
||||||
aiAbortControllerRef.current?.abort()
|
aiAbortControllerRef.current?.abort()
|
||||||
@@ -378,11 +429,20 @@ export function useQaChat({
|
|||||||
setAiTypingMessageId(null)
|
setAiTypingMessageId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}&week=${validChatWeek}`, {
|
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}${chatWeekQuery}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
|
|
||||||
if (qaMode === 'ai') {
|
if (qaMode === 'ai') {
|
||||||
|
if (activeAiConversationId) {
|
||||||
|
setAiConversations((threads) => threads.map((thread) => (
|
||||||
|
thread.id === activeAiConversationId
|
||||||
|
? { ...thread, status: 'ARCHIVED', messages: aiMessages }
|
||||||
|
: thread
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
setActiveAiConversationId(null)
|
||||||
|
setSelectedAiConversationId(null)
|
||||||
setAiMessages(initialAiChatMessages)
|
setAiMessages(initialAiChatMessages)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -396,6 +456,28 @@ export function useQaChat({
|
|||||||
if (mode === 'teacher') setTeacherChatFlash(false)
|
if (mode === 'teacher') setTeacherChatFlash(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedAiConversation = selectedAiConversationId
|
||||||
|
? aiConversations.find((thread) => thread.id === selectedAiConversationId) ?? null
|
||||||
|
: null
|
||||||
|
|
||||||
|
const selectAiConversation = (threadId: string | null): void => {
|
||||||
|
setSelectedAiConversationId(threadId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const startNewAiConversation = async (): Promise<void> => {
|
||||||
|
if (activeAiConversationId) {
|
||||||
|
await clearQaConversation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSelectedAiConversationId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteAiConversation = async (threadId: string): Promise<void> => {
|
||||||
|
await requestChatApi<{ id: string }>(`/chat/threads/${threadId}`, { method: 'DELETE' })
|
||||||
|
setAiConversations((threads) => threads.filter((thread) => thread.id !== threadId))
|
||||||
|
if (selectedAiConversationId === threadId) setSelectedAiConversationId(null)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
qaMode,
|
qaMode,
|
||||||
setQaMode: setMode,
|
setQaMode: setMode,
|
||||||
@@ -407,8 +489,21 @@ export function useQaChat({
|
|||||||
qaCollapseSignal,
|
qaCollapseSignal,
|
||||||
qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading,
|
qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading,
|
||||||
visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
|
visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
|
||||||
|
displayedAiMessages: selectedAiConversation
|
||||||
|
? selectedAiConversation.messages
|
||||||
|
: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
|
||||||
|
aiConversationHistory: aiConversations.filter((thread) => thread.status === 'ARCHIVED'),
|
||||||
|
activeAiConversation: activeAiConversationId
|
||||||
|
? aiConversations.find((thread) => thread.id === activeAiConversationId) ?? null
|
||||||
|
: null,
|
||||||
|
selectedAiConversationId,
|
||||||
|
viewingAiHistory: Boolean(selectedAiConversation),
|
||||||
|
selectAiConversation,
|
||||||
|
startNewAiConversation,
|
||||||
|
deleteAiConversation,
|
||||||
visibleTeacherMessages: teacherMessages.slice(-qaVisibleMessageLimit),
|
visibleTeacherMessages: teacherMessages.slice(-qaVisibleMessageLimit),
|
||||||
validChatWeek,
|
validChatWeek,
|
||||||
|
chatAvailable,
|
||||||
workspaceChatScrollRef,
|
workspaceChatScrollRef,
|
||||||
stopAiResponse,
|
stopAiResponse,
|
||||||
sendQaMessage,
|
sendQaMessage,
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ type UseWorkspaceModuleControlResult = {
|
|||||||
installAllModules: () => Promise<void>
|
installAllModules: () => Promise<void>
|
||||||
startAllModules: () => Promise<void>
|
startAllModules: () => Promise<void>
|
||||||
stopAllModules: () => Promise<void>
|
stopAllModules: () => Promise<void>
|
||||||
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart') => Promise<void>
|
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<void>
|
||||||
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
|
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@ export function useWorkspaceModuleControl({
|
|||||||
}
|
}
|
||||||
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
|
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
|
||||||
|
|
||||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart'): Promise<void> => {
|
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
||||||
if (installingModuleName || controllingModuleNames.includes(module.name)) return
|
if (installingModuleName || controllingModuleNames.includes(module.name)) return
|
||||||
|
|
||||||
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
||||||
@@ -239,6 +239,10 @@ export function useWorkspaceModuleControl({
|
|||||||
? remoteWorkspace
|
? remoteWorkspace
|
||||||
? await remoteModuleStop(module)
|
? await remoteModuleStop(module)
|
||||||
: await window.localWorkspace.modules.stop(module.name, clonePathPreview, module.folderName)
|
: await window.localWorkspace.modules.stop(module.name, clonePathPreview, module.folderName)
|
||||||
|
: action === 'build'
|
||||||
|
? remoteWorkspace
|
||||||
|
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||||
|
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||||
: action === 'restart'
|
: action === 'restart'
|
||||||
? remoteWorkspace
|
? remoteWorkspace
|
||||||
? await remoteModuleRestart(module)
|
? await remoteModuleRestart(module)
|
||||||
@@ -250,6 +254,8 @@ export function useWorkspaceModuleControl({
|
|||||||
await loadAvailableModules({ silent: true })
|
await loadAvailableModules({ silent: true })
|
||||||
const actionLabel = action === 'stop'
|
const actionLabel = action === 'stop'
|
||||||
? '종료되었습니다'
|
? '종료되었습니다'
|
||||||
|
: action === 'build'
|
||||||
|
? '빌드되었습니다'
|
||||||
: action === 'restart'
|
: action === 'restart'
|
||||||
? '재시작되었습니다'
|
? '재시작되었습니다'
|
||||||
: '실행되었습니다'
|
: '실행되었습니다'
|
||||||
@@ -259,6 +265,8 @@ export function useWorkspaceModuleControl({
|
|||||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
||||||
const actionLabel = action === 'stop'
|
const actionLabel = action === 'stop'
|
||||||
? '종료 실패'
|
? '종료 실패'
|
||||||
|
: action === 'build'
|
||||||
|
? '빌드 실패'
|
||||||
: action === 'restart'
|
: action === 'restart'
|
||||||
? '재시작 실패'
|
? '재시작 실패'
|
||||||
: '실행 실패'
|
: '실행 실패'
|
||||||
|
|||||||
@@ -74,7 +74,10 @@ export function useWorkspaceTerminal({
|
|||||||
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
|
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
|
||||||
})
|
})
|
||||||
.catch((error) => console.error('[terminal] paste failed', error))
|
.catch((error) => console.error('[terminal] paste failed', error))
|
||||||
.finally(() => setTerminalContextMenu(null))
|
.finally(() => {
|
||||||
|
setTerminalContextMenu(null)
|
||||||
|
window.requestAnimationFrame(() => terminalRef.current?.focus())
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTerminalResize = (event: React.MouseEvent<HTMLDivElement>): void => {
|
const startTerminalResize = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||||
@@ -123,7 +126,15 @@ export function useWorkspaceTerminal({
|
|||||||
term.loadAddon(fitAddon)
|
term.loadAddon(fitAddon)
|
||||||
term.open(terminalHostRef.current)
|
term.open(terminalHostRef.current)
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
|
term.focus()
|
||||||
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
|
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key)) {
|
||||||
|
event.stopPropagation()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
const terminalElement = terminalHostRef.current
|
const terminalElement = terminalHostRef.current
|
||||||
|
const focusTerminal = (): void => term.focus()
|
||||||
const handleTerminalContextMenu = (event: MouseEvent): void => {
|
const handleTerminalContextMenu = (event: MouseEvent): void => {
|
||||||
const selection = term.getSelection().trim()
|
const selection = term.getSelection().trim()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -134,6 +145,7 @@ export function useWorkspaceTerminal({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
terminalElement.addEventListener('contextmenu', handleTerminalContextMenu)
|
terminalElement.addEventListener('contextmenu', handleTerminalContextMenu)
|
||||||
|
terminalElement.addEventListener('mousedown', focusTerminal)
|
||||||
let terminalReceivedVisibleData = Boolean(terminalOutputBufferRef.current[terminalSessionId])
|
let terminalReceivedVisibleData = Boolean(terminalOutputBufferRef.current[terminalSessionId])
|
||||||
if (terminalOutputBufferRef.current[terminalSessionId]) {
|
if (terminalOutputBufferRef.current[terminalSessionId]) {
|
||||||
term.write(terminalOutputBufferRef.current[terminalSessionId])
|
term.write(terminalOutputBufferRef.current[terminalSessionId])
|
||||||
@@ -162,10 +174,29 @@ export function useWorkspaceTerminal({
|
|||||||
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
|
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
|
||||||
|
|
||||||
let fallbackPromptTimer: number | null = null
|
let fallbackPromptTimer: number | null = null
|
||||||
const startTerminal = window.localWorkspace.terminal.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
|
let sizeSyncTimer: number | null = null
|
||||||
|
let startFrame: number | null = null
|
||||||
|
let startNestedFrame: number | null = null
|
||||||
|
let disposed = false
|
||||||
|
const startTerminal = new Promise<boolean>((resolvePromise) => {
|
||||||
|
startFrame = window.requestAnimationFrame(() => {
|
||||||
|
startNestedFrame = window.requestAnimationFrame(() => {
|
||||||
|
fitAddon.fit()
|
||||||
|
if (disposed) return resolvePromise(false)
|
||||||
|
void window.localWorkspace.terminal
|
||||||
|
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
|
||||||
|
.then(resolvePromise)
|
||||||
|
.catch(() => resolvePromise(false))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
void startTerminal.then((started) => {
|
void startTerminal.then((started) => {
|
||||||
fitAddon.fit()
|
fitAddon.fit()
|
||||||
if (!started) return
|
if (!started) return
|
||||||
|
sizeSyncTimer = window.setTimeout(() => {
|
||||||
|
fitAddon.fit()
|
||||||
|
void terminalApi.resize(term.cols, term.rows, terminalSessionId)
|
||||||
|
}, 150)
|
||||||
fallbackPromptTimer = window.setTimeout(() => {
|
fallbackPromptTimer = window.setTimeout(() => {
|
||||||
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
|
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
|
||||||
if (terminalReceivedVisibleData || terminalOutputBufferRef.current[terminalSessionId]) return
|
if (terminalReceivedVisibleData || terminalOutputBufferRef.current[terminalSessionId]) return
|
||||||
@@ -183,12 +214,21 @@ export function useWorkspaceTerminal({
|
|||||||
window.addEventListener('resize', onResize)
|
window.addEventListener('resize', onResize)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
if (startFrame !== null) window.cancelAnimationFrame(startFrame)
|
||||||
|
if (startNestedFrame !== null) window.cancelAnimationFrame(startNestedFrame)
|
||||||
window.removeEventListener('resize', onResize)
|
window.removeEventListener('resize', onResize)
|
||||||
terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu)
|
terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu)
|
||||||
|
terminalElement.removeEventListener('mousedown', focusTerminal)
|
||||||
removeDataListener()
|
removeDataListener()
|
||||||
removeSuppressListener()
|
removeSuppressListener()
|
||||||
inputDisposable.dispose()
|
inputDisposable.dispose()
|
||||||
if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer)
|
if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer)
|
||||||
|
if (sizeSyncTimer !== null) window.clearTimeout(sizeSyncTimer)
|
||||||
|
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
|
||||||
|
delete terminalOutputBufferRef.current[terminalSessionId]
|
||||||
|
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
|
||||||
|
terminalApi.stop(terminalSessionId)
|
||||||
term.dispose()
|
term.dispose()
|
||||||
terminalRef.current = null
|
terminalRef.current = null
|
||||||
terminalFitRef.current = null
|
terminalFitRef.current = null
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user