Compare commits

..

8 Commits

24 changed files with 1967 additions and 133 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.1", "version": "1.2.14",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.1", "version": "1.2.14",
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.7", "version": "1.2.14",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+139
View File
@@ -0,0 +1,139 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const exportsObject = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentSessionSync.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: exportsObject, console: { warn() {} } })
const { createAgentSessionSync } = exportsObject
const message = (id, content = id) => ({ id, role: 'assistant', content })
const plain = value => JSON.parse(JSON.stringify(value))
const deferred = () => {
let resolve
const promise = new Promise(done => { resolve = done })
return { promise, resolve }
}
const storage = () => {
const data = new Map()
return {
getItem: key => data.get(key) ?? null,
setItem: (key, value) => data.set(key, value),
removeItem: key => data.delete(key)
}
}
test('saves replies in order and preserves a newer result arriving during an active save', async () => {
const gate = deferred()
const calls = []
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => {
calls.push(plain(batch))
if (calls.length === 1) await gate.promise
} })
sync.enqueue([message('command', 'running')])
await Promise.resolve()
sync.enqueue([message('command', 'completed'), message('answer')])
assert.equal(calls.length, 1)
gate.resolve()
await sync.flush()
assert.equal(calls.length, 2)
assert.deepEqual(calls[1], [message('command', 'completed'), message('answer')])
})
test('keeps a reply cached before network completion and after failed persistence', async () => {
const disk = storage()
const sync = createAgentSessionSync({ storage: disk, storageKey: 'student-a', save: async () => { throw Error('offline') } })
sync.enqueue([message('answer')])
assert.deepEqual(JSON.parse(disk.getItem('student-a')), [message('answer')])
await sync.flush()
assert.ok(sync.getError())
const batches = []
const reopened = createAgentSessionSync({ storage: disk, storageKey: 'student-a', save: async batch => { batches.push(plain(batch)) } })
assert.deepEqual(plain(reopened.restore([{ id: 'question', role: 'user', content: '?' }])), [
{ id: 'question', role: 'user', content: '?' }, message('answer')
])
await reopened.flush()
assert.deepEqual(batches, [[message('answer')]])
assert.equal(disk.getItem('student-a'), null)
assert.equal(reopened.getError(), null)
})
test('does not overwrite a new reply with an older history response', async () => {
const sync = createAgentSessionSync({ storageKey: 'test', save: async () => {} })
sync.enqueue([message('answer', 'new answer')])
await sync.flush()
assert.deepEqual(plain(sync.restore([message('answer', 'old answer')])), [message('answer', 'new answer')])
})
test('saves only modified messages even when restored history exceeds 100 messages', async () => {
const batches = []
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { batches.push(plain(batch)) } })
const history = Array.from({ length: 150 }, (_, index) => message(`message-${index}`))
sync.restore(history)
sync.enqueue([...history, message('new-answer')])
await sync.flush()
assert.deepEqual(batches, [[message('new-answer')]])
})
test('chunks a large backlog below the server message-count limit', async () => {
const batches = []
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { batches.push(plain(batch)) } })
sync.enqueue(Array.from({ length: 205 }, (_, index) => message(`message-${index}`)))
await sync.flush()
assert.deepEqual(batches.map(batch => batch.length), [100, 100, 5])
assert.equal(new Set(batches.flat().map(item => item.id)).size, 205)
})
test('leaving the view does not cancel an accepted write', async () => {
const gate = deferred()
const disk = storage()
let saved = false
const sync = createAgentSessionSync({ storage: disk, storageKey: 'test', save: async () => { await gate.promise; saved = true } })
const unsubscribe = sync.subscribe(() => {})
sync.enqueue([message('answer')])
unsubscribe()
gate.resolve()
await sync.flush()
assert.equal(saved, true)
assert.equal(disk.getItem('test'), null)
})
test('archives after outstanding writes complete and clears cached replies', async () => {
const gate = deferred()
const order = []
const disk = storage()
const sync = createAgentSessionSync({ storage: disk, storageKey: 'test', save: async () => { await gate.promise; order.push('saved') } })
sync.enqueue([message('answer')])
const clearing = sync.clear(async () => { order.push('archived') })
gate.resolve()
await clearing
assert.deepEqual(order, ['saved', 'archived'])
assert.deepEqual(plain(sync.restore([])), [])
assert.equal(disk.getItem('test'), null)
})
test('pending replies stay isolated by student and course scope', async () => {
const disk = storage()
const first = createAgentSessionSync({ storage: disk, storageKey: 'student-a:course-1', save: async () => { throw Error('offline') } })
first.enqueue([message('private-answer')])
await first.flush()
const second = createAgentSessionSync({ storage: disk, storageKey: 'student-b:course-1', save: async () => {} })
assert.deepEqual(plain(second.restore([])), [])
})
test('late command updates cannot recreate a conversation while it is being cleared', async () => {
const archive = deferred()
const calls = []
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { calls.push(plain(batch)) } })
sync.enqueue([message('answer')])
await sync.flush()
const clearing = sync.clear(() => archive.promise)
sync.enqueue([message('answer', 'late command result')])
archive.resolve()
await clearing
await sync.flush()
assert.equal(calls.length, 1)
assert.deepEqual(plain(sync.restore([])), [])
})
+220 -42
View File
@@ -4231,21 +4231,20 @@ async function installRemoteWorkspaceModule(
`STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`, `STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`,
`PREBUILT_JSON="$(printf %s ${quoteShellSingle(encodeBase64(prebuiltJson))} | base64 -d)"`, `PREBUILT_JSON="$(printf %s ${quoteShellSingle(encodeBase64(prebuiltJson))} | base64 -d)"`,
'mkdir -p "$ROOT"', 'mkdir -p "$ROOT"',
'if [ ! -d "$TARGET/.git" ]; then git clone "$TEMPLATE_REPO" "$TARGET"; fi', // An existing workspace owns its source, commits and build artifacts. Install
'git -C "$TARGET" remote set-url origin "$TEMPLATE_REPO"', // retries (including changed repository credentials) must not overwrite it.
'git -C "$TARGET" fetch origin --prune', 'if [ -e "$TARGET/.git" ]; then',
'CURRENT_STUDENT="$(git -C "$TARGET" remote get-url student 2>/dev/null || true)"', ' printf "OK\\t%s\\n" "$TARGET"',
'if [ "$CURRENT_STUDENT" != "$STUDENT_REPO" ]; then', ' exit 0',
' git -C "$TARGET" checkout -B main origin/main', 'fi',
' git -C "$TARGET" reset --hard origin/main', 'git clone "$TEMPLATE_REPO" "$TARGET"',
' git -C "$TARGET" clean -fdx', 'if [ -n "$STUDENT_REPO" ]; then',
' git -C "$TARGET" remote remove student >/dev/null 2>&1 || true',
' git -C "$TARGET" remote add student "$STUDENT_REPO"', ' git -C "$TARGET" remote add student "$STUDENT_REPO"',
' git -C "$TARGET" fetch student --prune || true', ' git -C "$TARGET" fetch student --prune',
' if git -C "$TARGET" show-ref --verify --quiet refs/remotes/student/main; then', ' if git -C "$TARGET" show-ref --verify --quiet refs/remotes/student/main; then',
' git -C "$TARGET" checkout -B student-work student/main', ' git -C "$TARGET" checkout -b student-work student/main',
' else', ' else',
' git -C "$TARGET" checkout -B student-work origin/main', ' git -C "$TARGET" checkout -b student-work origin/main',
' git -C "$TARGET" push student student-work:main', ' git -C "$TARGET" push student student-work:main',
' fi', ' fi',
'fi', 'fi',
@@ -5732,36 +5731,8 @@ async function resetRemoteWorkspaceSession(
targetInput: RemoteWorkspaceTarget, targetInput: RemoteWorkspaceTarget,
modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>,
): Promise<{ ok: boolean; message: string }> { ): Promise<{ ok: boolean; message: string }> {
try { // Keep the legacy IPC contract without resetting student files or Git history.
const target = normalizeRemoteTarget(targetInput) return stopRemoteWorkspaceModules(targetInput, modules)
await stopRemoteWorkspaceModules(target, modules).catch(() => ({ ok: false, message: '모듈 중지 실패' }))
const commands = [
'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
...expandRemotePathVar('ROOT'),
]
for (const module of modules) {
const folderName = module.folderName?.trim()
const templateRepositoryUrl = module.templateRepositoryUrl?.trim()
if (!folderName || !templateRepositoryUrl) continue
const templateRepositoryWithCredential = withGitCredential(remoteRepositoryUrl(templateRepositoryUrl))
commands.push(
`MODULE="$ROOT/${folderName}"`,
'if [ -d "$MODULE/.git" ]; then',
' git -C "$MODULE" remote remove student >/dev/null 2>&1 || true',
` git -C "$MODULE" remote set-url origin ${quoteShellSingle(templateRepositoryWithCredential)}`,
' git -C "$MODULE" fetch origin --prune',
' git -C "$MODULE" checkout -B main origin/main',
' git -C "$MODULE" reset --hard origin/main',
' git -C "$MODULE" clean -fdx',
'fi',
)
}
await runSsh(target, commands.join('\n'), 180000)
return { ok: true, message: '템플릿 기준으로 초기화했습니다.' }
} catch (error) {
return { ok: false, message: error instanceof Error ? error.message : String(error) }
}
} }
async function restartWorkspaceModule( async function restartWorkspaceModule(
@@ -5840,6 +5811,211 @@ async function readRemoteWorkspaceModuleLogs(
} }
} }
type AgentCommandResult = {
ok: boolean
output: string
exitCode: number | null
message?: string
}
const allowedAgentExecutables = new Set([
'bash', 'cat', 'cmake', 'colcon', 'find', 'git', 'grep', 'head', 'ls', 'make', 'ninja', 'node', 'npm', 'npx',
'pnpm', 'printenv', 'pwd', 'python', 'python3', 'pytest', 'rg', 'ros2', 'sed', 'sh', 'tail', 'which', 'yarn'
])
function validateAgentCommand(input: string): string {
const command = input.trim()
if (!command || command.length > 2000) throw new Error('명령 길이가 허용 범위를 벗어났습니다.')
if (/[\0\r\n`;{}<>]|\$\(|\.\.\//.test(command)) throw new Error('명령 치환, 경로 이탈 또는 리다이렉션은 허용되지 않습니다.')
if (/\b(?:sudo|su|shutdown|reboot|poweroff|halt|mkfs|fdisk|parted|mount|umount|passwd|useradd|userdel|chown)\b/i.test(command)) {
throw new Error('시스템 권한 또는 설정을 변경하는 명령은 허용되지 않습니다.')
}
if (/\brm\b|\b(?:curl|wget)\b[^|]*\|/i.test(command)) throw new Error('삭제 또는 원격 스크립트 실행 명령은 허용되지 않습니다.')
if (/(^|\s)(?:~\/(?:\.ssh|\.aws|\.config)|\/[^\s]*\/\.(?:ssh|aws)\/|\/etc\/|\/boot\/|\/proc\/|\/sys\/|\/dev\/|\.env(?:\s|$))/i.test(command)) {
throw new Error('시스템 또는 자격증명 경로에는 접근할 수 없습니다.')
}
const withoutAnd = command.replace(/&&|\|\|/g, '')
if (/[&]/.test(withoutAnd)) throw new Error('백그라운드 명령은 허용되지 않습니다.')
const segments = command.split(/&&|\|\||\|/).map((segment) => segment.trim()).filter(Boolean)
for (const segment of segments) {
const tokens = segment.split(/\s+/).filter(Boolean)
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift()
const executable = tokens[0]
if (!executable) throw new Error('실행할 명령을 확인할 수 없습니다.')
if (executable === 'source' || executable === '.') {
const sourcePath = tokens[1] ?? ''
if (!/^(?:\/opt\/ros\/[A-Za-z0-9._-]+\/setup\.bash|\.?\/?(?:install|build)\/setup\.bash)$/.test(sourcePath)) {
throw new Error('허용되지 않은 환경 설정 파일입니다.')
}
continue
}
if (executable.startsWith('./')) {
if (!/^\.\/[A-Za-z0-9_./-]+\.sh$/.test(executable)) throw new Error('모듈 내부의 셸 스크립트만 실행할 수 있습니다.')
continue
}
if (!allowedAgentExecutables.has(executable)) throw new Error(`허용되지 않은 실행 파일입니다: ${executable}`)
if (executable === 'bash' || executable === 'sh') {
const scriptPath = tokens[1] ?? ''
if (!/^(?:\.\/)?[A-Za-z0-9_./-]+\.sh$/.test(scriptPath)) {
throw new Error('bash와 sh는 모듈 내부의 셸 스크립트만 실행할 수 있습니다.')
}
}
if ((executable === 'python' || executable === 'python3') && tokens.some((token) => token === '-c')) {
throw new Error('인라인 Python 코드는 실행할 수 없습니다.')
}
if (executable === 'node' && tokens.some((token) => token === '-e' || token === '--eval')) {
throw new Error('인라인 Node.js 코드는 실행할 수 없습니다.')
}
if (executable === 'find' && tokens.some((token) => token === '-exec' || token === '-execdir' || token === '-delete')) {
throw new Error('find의 실행 및 삭제 옵션은 허용되지 않습니다.')
}
if (executable === 'sed' && tokens.some((token) => token === '-i' || token.startsWith('-i'))) {
throw new Error('명령을 통한 파일 직접 수정은 허용되지 않습니다.')
}
if (executable === 'git' && !/^(?:status|diff|log|show|rev-parse|branch)$/.test(tokens[1] ?? '')) {
throw new Error('Agent는 Git 조회 명령만 실행할 수 있습니다.')
}
if (executable === 'ros2' && !/^(?:topic|node|interface|doctor|pkg|run|launch)$/.test(tokens[1] ?? '')) {
throw new Error('허용되지 않은 ROS2 명령입니다.')
}
if (executable === 'ros2' && /\b(?:pub|call|send_goal)\b/.test(segment)) {
throw new Error('ROS2 발행 및 제어 명령은 Agent에서 실행할 수 없습니다.')
}
}
return command
}
function appendAgentCommandOutput(current: string, chunk: string): string {
const next = current + chunk
return next.length > 80_000 ? next.slice(-80_000) : next
}
function isAgentRos2Command(command: string): boolean {
return /(?:^|&&|\|\|?|;)\s*ros2\s+/.test(command)
}
function isAgentRos2RuntimeCommand(command: string): boolean {
return /(?:^|&&|\|\|?|;)\s*ros2\s+(?:run|launch)\b/.test(command)
}
function agentContainerCommandArgs(container: { id: string; cwd: string }, command: string, observeRuntime: boolean): string[] {
const init = [
'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi',
'if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi',
command
].join('; ')
return [
'exec', '-w', container.cwd, container.id,
...(observeRuntime ? ['timeout', '--signal=INT', '--kill-after=5s', '20s'] : []),
'bash', '--noprofile', '--norc', '-lc', init
]
}
async function resolveLocalAgentContainer(moduleRoot: string, folderName?: string | null): Promise<{ id: string; cwd: string }> {
const info = parseModuleDockerInfo(folderName?.trim() || basename(moduleRoot))
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('모듈 컨테이너 정보를 확인할 수 없습니다.')
const inspect = await runCommandOutput('docker', ['inspect', info.containerKey], undefined, undefined, 10_000)
return resolveContainerTerminal(inspect, moduleRoot)
}
async function resolveRemoteAgentContainer(
target: ReturnType<typeof normalizeRemoteTarget>,
moduleRoot: string,
folderName?: string | null
): Promise<{ id: string; cwd: string }> {
const info = parseModuleDockerInfo(folderName?.trim() || basename(moduleRoot))
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('모듈 컨테이너 정보를 확인할 수 없습니다.')
const inspect = await runSshOutput(target, `docker inspect ${quoteShellSingle(info.containerKey)}`, 10_000)
return resolveContainerTerminal(inspect, moduleRoot)
}
async function runLocalAgentCommand(moduleName: string, commandInput: string, workspaceRoot?: string | null, folderName?: string | null): Promise<AgentCommandResult> {
const command = validateAgentCommand(commandInput)
const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName)
if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.')
const observeRuntime = isAgentRos2RuntimeCommand(command)
const container = isAgentRos2Command(command) ? await resolveLocalAgentContainer(moduleRoot, folderName) : null
const executable = container ? 'docker' : '/bin/bash'
const args = container
? agentContainerCommandArgs(container, command, observeRuntime)
: ['--noprofile', '--norc', '-lc', command]
return new Promise<AgentCommandResult>((resolvePromise) => {
let output = ''
let timedOut = false
const child = spawn(executable, args, {
cwd: container ? undefined : moduleRoot,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
})
const timeout = setTimeout(() => {
timedOut = true
if (process.platform !== 'win32' && child.pid) {
try { process.kill(-child.pid, 'SIGTERM') } catch { /* already exited */ }
} else child.kill('SIGTERM')
}, observeRuntime ? 30_000 : 120_000)
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (data: string) => { output = appendAgentCommandOutput(output, data) })
child.stderr.on('data', (data: string) => { output = appendAgentCommandOutput(output, data) })
child.on('error', (error) => {
clearTimeout(timeout)
resolvePromise({ ok: false, output, exitCode: null, message: error.message })
})
child.on('close', (code) => {
clearTimeout(timeout)
const exitCode = typeof code === 'number' ? code : null
const observationCompleted = observeRuntime && exitCode === 124
if (observationCompleted) {
output = appendAgentCommandOutput(output, '\n[20초 동안 실행을 확인한 뒤 프로세스를 종료했습니다.]\n')
}
resolvePromise({
ok: !timedOut && (exitCode === 0 || observationCompleted),
output,
exitCode: observationCompleted ? 0 : exitCode,
message: timedOut ? `명령 실행 시간이 ${observeRuntime ? 30 : 120}초를 초과했습니다.` : undefined
})
})
})
}
async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleName: string, commandInput: string, folderName?: string | null): Promise<AgentCommandResult> {
const command = validateAgentCommand(commandInput)
const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName)
const marker = '__AUTONOMY_AGENT_EXIT_CODE__:'
try {
const observeRuntime = isAgentRos2RuntimeCommand(command)
const container = isAgentRos2Command(command)
? await resolveRemoteAgentContainer(resolved.target, resolved.moduleRoot, folderName)
: null
const invocation = container
? ['docker', ...agentContainerCommandArgs(container, command, observeRuntime)].map(quoteShellSingle).join(' ')
: `bash --noprofile --norc -lc ${quoteShellSingle(command)}`
const rawOutput = await runSshOutput(resolved.target, [
`cd ${quoteShellSingle(resolved.moduleRoot)}`,
`${invocation} 2>&1`,
'CODE=$?',
`printf '\\n${marker}%s\\n' "$CODE"`,
].join('; '), observeRuntime ? 35_000 : 130_000)
const markerIndex = rawOutput.lastIndexOf(marker)
const exitCode = markerIndex >= 0 ? Number.parseInt(rawOutput.slice(markerIndex + marker.length).trim(), 10) : null
const observationCompleted = observeRuntime && exitCode === 124
let output = appendAgentCommandOutput('', markerIndex >= 0 ? rawOutput.slice(0, markerIndex).trimEnd() : rawOutput)
if (observationCompleted) output = appendAgentCommandOutput(output, '\n[20초 동안 실행을 확인한 뒤 프로세스를 종료했습니다.]')
return {
ok: exitCode === 0 || observationCompleted,
output,
exitCode: observationCompleted ? 0 : exitCode,
message: markerIndex < 0 ? '원격 명령의 종료 코드를 확인하지 못했습니다.' : undefined
}
} catch (error) {
return { ok: false, output: '', exitCode: null, message: error instanceof Error ? error.message : String(error) }
}
}
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> { async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName) const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] } if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
@@ -6504,6 +6680,7 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers()) ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
ipcMain.handle('local-workspace:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose)) ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose))
ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle)) ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
@@ -6568,6 +6745,7 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('remote-workspace:ros:reset-session', (_event, target: RemoteWorkspaceTarget) => resetRemoteRosSession(target)) ipcMain.handle('remote-workspace:ros:reset-session', (_event, target: RemoteWorkspaceTarget) => resetRemoteRosSession(target))
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, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) 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, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName)) ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
ipcMain.handle('remote-workspace:agent-command:run', (_event, target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string | null) => runRemoteAgentCommand(target, moduleName, command, folderName))
ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target)) ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target))
ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork))) ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork)))
ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target)) ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target))
@@ -0,0 +1,116 @@
import { strict as assert } from 'node:assert'
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawnSync } from 'node:child_process'
import { test } from 'node:test'
import ts from 'typescript'
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
function loadFunction(name: string, dependencies: Record<string, unknown>) {
const declaration = source.statements.find((item) => ts.isFunctionDeclaration(item) && item.name?.text === name)
assert.ok(declaration)
const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 })
return new Function(...Object.keys(dependencies), `${code}; return ${name};`)(...Object.values(dependencies))
}
function command(executable: string, args: string[], cwd: string) {
const result = spawnSync(executable, args, {
cwd, encoding: 'utf8', timeout: 15000,
env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.com' },
})
assert.equal(result.status, 0, result.stderr || String(result.error))
return result.stdout.trim()
}
function fixture() {
const root = mkdtempSync(join(tmpdir(), 'lms-preserve-'))
const template = join(root, 'template')
mkdirSync(template)
command('git', ['init', '-b', 'main'], template)
writeFileSync(join(template, 'node.py'), 'template source\n')
writeFileSync(join(template, '.gitignore'), 'build/\ninstall/\nlog/\n')
command('git', ['add', '.'], template)
command('git', ['commit', '-m', 'template'], template)
const install = loadFunction('installRemoteWorkspaceModule', {
normalizeRemoteTarget: (target: unknown) => target,
remoteRepositoryUrl: (url: string) => url,
withGitCredential: (url: string) => url,
remotePathJoin: join,
safeArtifactFileName: () => 'unused.tar',
quoteShellSingle: (value: string) => "'" + value.replaceAll("'", "'\\''") + "'",
encodeBase64: (value: string) => Buffer.from(value).toString('base64'),
expandRemotePathVar: () => [],
runSsh: async (_target: unknown, script: string) => command('bash', ['-c', script], root),
parseModuleFolderName: () => ({ name: 'Test', category: 'Control' }),
})
const target = { workspaceRoot: join(root, 'workspace') }
return { root, template, target, install, modulePath: join(target.workspaceRoot, 'module') }
}
test('reinstall preserves saved source, local commits, untracked files and build outputs despite changed repository URLs', async () => {
const f = fixture()
try {
await f.install(f.target, 'Test', 'module', f.template)
writeFileSync(join(f.modulePath, 'node.py'), 'student commit\n')
command('git', ['add', 'node.py'], f.modulePath)
command('git', ['commit', '-m', 'student work'], f.modulePath)
const head = command('git', ['rev-parse', 'HEAD'], f.modulePath)
writeFileSync(join(f.modulePath, 'node.py'), 'saved but not committed\n')
writeFileSync(join(f.modulePath, 'new.py'), 'new student file\n')
for (const dir of ['build', 'install', 'log']) {
mkdirSync(join(f.modulePath, dir))
writeFileSync(join(f.modulePath, dir, 'keep'), dir)
}
const status = command('git', ['status', '--porcelain'], f.modulePath)
await f.install(f.target, 'Test', 'module', '/missing/new-template', '/missing/new-student', {
prebuiltArtifacts: [{ url: 'https://invalid.example/overwrite.tar', required: true }],
})
assert.equal(command('git', ['rev-parse', 'HEAD'], f.modulePath), head)
assert.equal(command('git', ['status', '--porcelain'], f.modulePath), status)
assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'saved but not committed\n')
assert.equal(readFileSync(join(f.modulePath, 'new.py'), 'utf8'), 'new student file\n')
for (const dir of ['build', 'install', 'log']) assert.equal(readFileSync(join(f.modulePath, dir, 'keep'), 'utf8'), dir)
} finally { rmSync(f.root, { recursive: true, force: true }) }
})
test('first install restores the student repository instead of replacing it with template source', async () => {
const f = fixture()
try {
const student = join(f.root, 'student')
command('git', ['clone', f.template, student], f.root)
writeFileSync(join(student, 'node.py'), 'submitted student work\n')
command('git', ['add', '.'], student)
command('git', ['commit', '-m', 'submission'], student)
await f.install(f.target, 'Test', 'module', f.template, student)
assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'submitted student work\n')
assert.equal(command('git', ['branch', '--show-current'], f.modulePath), 'student-work')
} finally { rmSync(f.root, { recursive: true, force: true }) }
})
test('first install initializes an empty student repository', async () => {
const f = fixture()
try {
const student = join(f.root, 'student.git')
command('git', ['init', '--bare', student], f.root)
await f.install(f.target, 'Test', 'module', f.template, student)
assert.equal(command('git', ['--git-dir', student, 'show', 'main:node.py'], f.root), 'template source')
} finally { rmSync(f.root, { recursive: true, force: true }) }
})
test('legacy session reset only stops modules and returns stop failures', async () => {
const target = { workspaceRoot: '/student/workspace' }
const modules = [{ moduleName: 'Test', folderName: 'module', templateRepositoryUrl: '/template' }]
const result = { ok: false, message: 'stop failed' }
let calls = 0
const reset = loadFunction('resetRemoteWorkspaceSession', {
stopRemoteWorkspaceModules: async (actualTarget: unknown, actualModules: unknown) => {
calls++
assert.equal(actualTarget, target)
assert.equal(actualModules, modules)
return result
},
})
assert.equal(await reset(target, modules), result)
assert.equal(calls, 1)
})
+6
View File
@@ -215,6 +215,9 @@ contextBridge.exposeInMainWorld('localWorkspace', {
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:rename', moduleName, fromPath, toPath, workspaceRoot, folderName), rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:rename', moduleName, fromPath, toPath, workspaceRoot, folderName),
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName) delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
}, },
agent: {
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-command:run', moduleName, command, workspaceRoot, folderName)
},
modules: { modules: {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules), list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'), listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'),
@@ -300,6 +303,9 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName), rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName),
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName) delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
}, },
agent: {
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-command:run', target, moduleName, command, folderName)
},
maps: { maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts), readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath), copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
+51 -9
View File
@@ -49,7 +49,7 @@
#cameraPreviewWrap { #cameraPreviewWrap {
position:absolute; position:absolute;
left:16px; left:16px;
top:16px; top:60px;
z-index:10; z-index:10;
display:none; display:none;
width:min(260px, calc(100vw - 32px)); width:min(260px, calc(100vw - 32px));
@@ -58,6 +58,7 @@
box-shadow:0 10px 26px rgba(0,0,0,.16); box-shadow:0 10px 26px rgba(0,0,0,.16);
} }
#cameraPreviewWrap.is-visible { display:block; } #cameraPreviewWrap.is-visible { display:block; }
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; } #cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
#cameraPreview { #cameraPreview {
display:block; display:block;
@@ -96,6 +97,9 @@
display:flex; display:flex;
gap:7px; gap:7px;
align-items:flex-start; align-items:flex-start;
max-width:calc(100% - 32px);
flex-wrap:wrap;
justify-content:flex-end;
} }
#topLeftControls { #topLeftControls {
position:absolute; position:absolute;
@@ -241,6 +245,7 @@
#downloadMap, #downloadMap,
#pathToggle, #pathToggle,
#sensorToggle, #sensorToggle,
#cameraToggle,
#poseToggle, #poseToggle,
#goalToggle, #goalToggle,
#obstacleToggle, #obstacleToggle,
@@ -282,6 +287,7 @@
#downloadMap:hover, #downloadMap:hover,
#pathToggle:hover, #pathToggle:hover,
#sensorToggle:hover, #sensorToggle:hover,
#cameraToggle:hover,
#poseToggle:hover, #poseToggle:hover,
#goalToggle:hover, #goalToggle:hover,
#obstacleToggle:hover, #obstacleToggle:hover,
@@ -307,6 +313,7 @@
border-color:rgba(147,197,253,.55); border-color:rgba(147,197,253,.55);
color:#fff; color:#fff;
} }
#cameraToggle.active,
#sensorToggle.active { #sensorToggle.active {
background:rgba(14,116,144,.62); background:rgba(14,116,144,.62);
border-color:rgba(103,232,249,.55); border-color:rgba(103,232,249,.55);
@@ -385,6 +392,10 @@
</div> </div>
<button id="pathToggle" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시">Path</button> <button id="pathToggle" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시">Path</button>
<button id="sensorToggle" title="센서 표시" data-i18n-title="센서 표시" aria-label="센서 표시" data-i18n-aria-label="센서 표시" aria-pressed="false">Sensor</button> <button id="sensorToggle" title="센서 표시" data-i18n-title="센서 표시" aria-label="센서 표시" data-i18n-aria-label="센서 표시" aria-pressed="false">Sensor</button>
<button id="cameraToggle" type="button" title="카메라 영상 표시" data-i18n-title="카메라 영상 표시" aria-label="카메라 영상 표시" data-i18n-aria-label="카메라 영상 표시" aria-pressed="true" aria-controls="cameraPreviewWrap">
<!-- Lucide Camera icon. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/></svg>
</button>
<button id="poseToggle" title="초기 위치 지정" data-i18n-title="초기 위치 지정" aria-label="초기 위치 지정" data-i18n-aria-label="초기 위치 지정">Pose</button> <button id="poseToggle" title="초기 위치 지정" data-i18n-title="초기 위치 지정" aria-label="초기 위치 지정" data-i18n-aria-label="초기 위치 지정">Pose</button>
<button id="goalToggle" title="목표 위치 지정" data-i18n-title="목표 위치 지정" aria-label="목표 위치 지정" data-i18n-aria-label="목표 위치 지정">Goal</button> <button id="goalToggle" title="목표 위치 지정" data-i18n-title="목표 위치 지정" aria-label="목표 위치 지정" data-i18n-aria-label="목표 위치 지정">Goal</button>
<select id="cameraPresetSelect" title="카메라 시점" data-i18n-title="카메라 시점" aria-label="카메라 시점" data-i18n-aria-label="카메라 시점"> <select id="cameraPresetSelect" title="카메라 시점" data-i18n-title="카메라 시점" aria-label="카메라 시점" data-i18n-aria-label="카메라 시점">
@@ -475,6 +486,11 @@ const miniMapToggle = document.getElementById('miniMapToggle');
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap'); const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
const cameraPreview = document.getElementById('cameraPreview'); const cameraPreview = document.getElementById('cameraPreview');
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle'); const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
const cameraToggleButton = document.getElementById('cameraToggle');
const topRightControls = document.getElementById('topRightControls');
new ResizeObserver(() => {
cameraPreviewWrap.style.top = `${topRightControls.offsetTop + topRightControls.offsetHeight + 12}px`;
}).observe(topRightControls);
const responseWaitOverlay = document.getElementById('responseWaitOverlay'); const responseWaitOverlay = document.getElementById('responseWaitOverlay');
const mapSaveOverlay = document.getElementById('mapSaveOverlay'); const mapSaveOverlay = document.getElementById('mapSaveOverlay');
const downloadMapButton = document.getElementById('downloadMap'); const downloadMapButton = document.getElementById('downloadMap');
@@ -907,6 +923,8 @@ let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0; let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null; let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null; let cameraFrameObjectUrl = null;
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
let cameraFrameAbortController = null;
let pathVisible = false; let pathVisible = false;
// SLAM always renders its live scan, including the forward beam. Navigation // SLAM always renders its live scan, including the forward beam. Navigation
// keeps the sensor opt-in so a saved-map view starts cleanly. // keeps the sensor opt-in so a saved-map view starts cleanly.
@@ -2052,6 +2070,7 @@ async function refreshSensorPointCloud() {
} }
function setCameraPreviewAvailable(available) { function setCameraPreviewAvailable(available) {
available = available && cameraPreviewEnabled;
cameraPreviewWrap?.classList.toggle('is-visible', available); cameraPreviewWrap?.classList.toggle('is-visible', available);
if (available) return; if (available) return;
if (cameraFrameObjectUrl) { if (cameraFrameObjectUrl) {
@@ -2067,7 +2086,7 @@ function hasLiveCameraState() {
} }
async function refreshCameraFrame() { async function refreshCameraFrame() {
if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return; if (!cameraPreviewEnabled || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP. // Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) { if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false); setCameraPreviewAvailable(false);
@@ -2075,15 +2094,18 @@ async function refreshCameraFrame() {
} }
if (Date.now() < cameraFrameRetryAt) return; if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true; cameraFrameRequestInFlight = true;
const controller = new AbortController();
cameraFrameAbortController = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try { try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) }); const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) { if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000; cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false); setCameraPreviewAvailable(false);
return; return;
} }
const frame = await response.blob(); const frame = await response.blob();
if (!hasLiveCameraState() || document.hidden) { if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false); setCameraPreviewAvailable(false);
return; return;
} }
@@ -2094,13 +2116,35 @@ async function refreshCameraFrame() {
if (cameraPreview) cameraPreview.src = nextUrl; if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl); if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) { } catch (_) {
cameraFrameRetryAt = Date.now() + 5000; if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false); setCameraPreviewAvailable(false);
} finally { } finally {
window.clearTimeout(timeout);
cameraFrameAbortController = null;
cameraFrameRequestInFlight = false; cameraFrameRequestInFlight = false;
} }
} }
function setCameraPreviewEnabled(enabled) {
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
cameraFrameRefreshTimer = null;
if (!cameraPreviewEnabled) {
cameraFrameAbortController?.abort();
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
cameraPreviewWrap.classList.remove('is-collapsed');
cameraPreviewToggle.setAttribute('aria-expanded', 'true');
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
}
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState())); cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false)); cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
@@ -3231,16 +3275,14 @@ if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
void refreshSensorPointCloud(); void refreshSensorPointCloud();
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750); sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
} }
if (ENABLE_CAMERA_PREVIEW) { setCameraPreviewEnabled(ENABLE_CAMERA_PREVIEW);
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
}
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer); if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer); if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer); if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer); if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
cameraFrameAbortController?.abort();
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl); if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
}); });
@@ -7,6 +7,7 @@ const english = {
'맵 저장': 'Save map', '맵 저장': 'Save map',
'로봇 경로 표시': 'Show robot path', '로봇 경로 표시': 'Show robot path',
'센서 표시': 'Show sensors', '센서 표시': 'Show sensors',
'카메라 영상 표시': 'Show camera preview',
'초기 위치 지정': 'Set initial pose', '초기 위치 지정': 'Set initial pose',
'목표 위치 지정': 'Set goal', '목표 위치 지정': 'Set goal',
'카메라 시점': 'Camera view', '카메라 시점': 'Camera view',
+438 -20
View File
@@ -57,6 +57,8 @@ import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceCont
import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminalContextMenu' import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminalContextMenu'
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel' import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
import { useQaChat } from './features/workspace/useQaChat' import { useQaChat } from './features/workspace/useQaChat'
import { useAiAgent } from './features/workspace/useAiAgent'
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime' import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
import { useSourceControl } from './features/workspace/useSourceControl' import { useSourceControl } from './features/workspace/useSourceControl'
import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus' import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus'
@@ -398,6 +400,18 @@ type ModuleWorkspaceSnapshot = {
selectedTreePath: string | null selectedTreePath: string | null
} }
type AgentDiffPreview = {
id: string
messageId: string
moduleName: string
path: string
rendererPath: string
originalContent: string
modifiedContent: string
}
const AGENT_START_MODULE_COMMAND = 'lms module start'
type WorkspaceVisualizationStateSetter<Value> = (value: Value | ((current: Value) => Value)) => void type WorkspaceVisualizationStateSetter<Value> = (value: Value | ((current: Value) => Value)) => void
type VisualizationResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' type VisualizationResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
@@ -607,6 +621,26 @@ function LocalPcWorkspaceMockup({
) )
const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState<StudioSourceSettings | null>(null) const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState<StudioSourceSettings | null>(null)
const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(null) const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(null)
const {
enabled: agentEnabled,
statusLoading: agentStatusLoading,
pending: agentPending,
messages: agentMessages,
persistenceError: agentPersistenceError,
requestPlan: requestAgentPlan,
applyPlan: applyAgentPlan,
updateCommandExecution: updateAgentCommandExecution,
stop: stopAgent,
clear: clearAgentConversation
} = useAiAgent({
accessToken,
studentEmail,
courseId: courseKey,
weekNumber,
apiBaseUrl: LMS_API_BASE_URL,
getFreshAccessToken,
onResponse: () => onTitlebarNotice(t("AI 에이전트 답변이 도착했습니다."), 'info', true)
})
const [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([]) const [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false) const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
const [teamGitSyncing, setTeamGitSyncing] = useState(false) const [teamGitSyncing, setTeamGitSyncing] = useState(false)
@@ -669,6 +703,8 @@ function LocalPcWorkspaceMockup({
finishTabDrop, finishTabDrop,
resetTabDrag resetTabDrag
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive) } = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
const [agentDiffPreviews, setAgentDiffPreviews] = useState<AgentDiffPreview[]>([])
const [activeAgentDiffPreviewId, setActiveAgentDiffPreviewId] = useState<string | null>(null)
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null) const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
const [imagePreviewSources, setImagePreviewSources] = useState<Partial<Record<string, string>>>(initialImagePreviewSources) const [imagePreviewSources, setImagePreviewSources] = useState<Partial<Record<string, string>>>(initialImagePreviewSources)
const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null) const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null)
@@ -712,6 +748,8 @@ function LocalPcWorkspaceMockup({
setActiveExampleEditorFile(null) setActiveExampleEditorFile(null)
setOpenExampleEditorTabs([]) setOpenExampleEditorTabs([])
setSelectedExampleTreePath(null) setSelectedExampleTreePath(null)
setAgentDiffPreviews([])
setActiveAgentDiffPreviewId(null)
}, [curriculumWeek.week]) }, [curriculumWeek.week])
useEffect(() => { useEffect(() => {
@@ -2319,7 +2357,7 @@ function LocalPcWorkspaceMockup({
visualizationHostInstanceId, visualizationHostInstanceId,
]) ])
const topToggleButtonClass = (active: boolean): string => [ const topToggleButtonClass = (active: boolean): string => [
'flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 transition', 'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 leading-none transition',
active active
? dark ? dark
? 'border-[#2563eb]/45 bg-[#2563eb]/24 text-[#9dbdff]' ? 'border-[#2563eb]/45 bg-[#2563eb]/24 text-[#9dbdff]'
@@ -2328,8 +2366,15 @@ function LocalPcWorkspaceMockup({
? 'border-white/15 text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white' ? 'border-white/15 text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]' : 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ') ].join(' ')
const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext' const visibleAgentDiffPreviews = exampleActive || !selectedModule
const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false ? []
: agentDiffPreviews.filter((preview) => preview.moduleName === selectedModule.name)
const activeAgentDiffPreview = visibleAgentDiffPreviews.find((preview) => preview.id === activeAgentDiffPreviewId) ?? null
const visibleEditorTabs = [...currentOpenEditorTabs, ...visibleAgentDiffPreviews.map((preview) => preview.id)]
const activeEditorTab = activeAgentDiffPreview?.id ?? currentActiveEditorFile
const activeEditorDisplayPath = activeAgentDiffPreview?.rendererPath ?? currentActiveEditorFile
const editorLanguage = activeEditorDisplayPath ? getEditorLanguage(activeEditorDisplayPath) : 'plaintext'
const activeFileIsImage = !activeAgentDiffPreview && currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false
const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined
const remoteEditorFile = useRemoteEditorFile({ const remoteEditorFile = useRemoteEditorFile({
enabled: remoteWorkspace, enabled: remoteWorkspace,
@@ -2361,6 +2406,7 @@ function LocalPcWorkspaceMockup({
return () => { disposed = true } return () => { disposed = true }
}, [activeFileIsImage, clonePathPreview, currentActiveEditorFile, imagePreviewSources, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule]) }, [activeFileIsImage, clonePathPreview, currentActiveEditorFile, imagePreviewSources, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule])
const openEditorFile = (file: EditorFilePath): void => { const openEditorFile = (file: EditorFilePath): void => {
setActiveAgentDiffPreviewId(null)
if (file.startsWith('examples/example-code/')) { if (file.startsWith('examples/example-code/')) {
setSelectedExampleTreePath(file) setSelectedExampleTreePath(file)
setActiveExampleEditorFile(file) setActiveExampleEditorFile(file)
@@ -2372,6 +2418,7 @@ function LocalPcWorkspaceMockup({
setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file])) setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file]))
} }
const openEditorFileAtLine = (file: EditorFilePath): void => { const openEditorFileAtLine = (file: EditorFilePath): void => {
setActiveAgentDiffPreviewId(null)
setSelectedTreePath(file) setSelectedTreePath(file)
setActiveEditorFile(file) setActiveEditorFile(file)
setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file])) setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file]))
@@ -3425,7 +3472,7 @@ function LocalPcWorkspaceMockup({
{ ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length }, { ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length },
]) ])
} }
const createCustomModule = async (input: CustomModuleInput): Promise<void> => { const createCustomModuleRecord = async (input: CustomModuleInput): Promise<NonNullable<CurriculumWeek['modules']>[number]> => {
const customModule = await registerCustomModule(input) const customModule = await registerCustomModule(input)
const workspaceModule = { const workspaceModule = {
name: customModule.name, name: customModule.name,
@@ -3442,6 +3489,338 @@ function LocalPcWorkspaceMockup({
} }
addCustomModuleToList(customModule) addCustomModuleToList(customModule)
onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info') onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
return customModule
}
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
await createCustomModuleRecord(input)
}
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => {
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({
name: module.name,
folderName: module.folderName,
category: module.category,
state: module.state,
files: []
}))
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const targetModules = availableModules.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
))
if (!targetModules.length) return workspaceModules
const isTextSource = (path: string): boolean => {
const name = path.split('/').pop()?.toLowerCase() ?? ''
return /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|zsh|launch|urdf|xacro|rviz|proto|cmake)$/i.test(name)
|| ['dockerfile', 'cmakelists.txt', 'package.xml', 'setup.py', 'setup.cfg'].includes(name)
}
let totalCharacters = 0
let totalFiles = 0
for (const module of targetModules) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break
const modulePrefix = `modules/${module.name}`
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
const sourcePriority = (path: string): number => {
if (path === preferredPath) return -100
if (/(^|\/)src(\/|$)/.test(path)) return -50
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30
return 0
}
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
.filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path))
.sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right))
const files: AgentWorkspaceModule['files'] = []
for (const rendererPath of paths) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break
let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath)
if (content === undefined && remoteWorkspace) {
content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content
}
if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue
if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue
totalCharacters += content.length
totalFiles += 1
files.push({ path: rendererPath.slice(modulePrefix.length + 1), content })
}
const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name)
const workspaceModule = {
name: module.name,
folderName: module.folderName,
category: module.category,
state: module.state,
files
}
if (moduleIndex < 0) workspaceModules.push(workspaceModule)
else workspaceModules[moduleIndex] = workspaceModule
}
return workspaceModules
}
const collectAgentRuntimeLogs = async (requestedModuleNames: string[]): Promise<AgentRuntimeLog[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const targetModules = availableModules.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
)).slice(0, 3)
return Promise.all(targetModules.map(async (module): Promise<AgentRuntimeLog> => {
try {
const result = remoteWorkspace
? await window.remoteWorkspace.modules.logs(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.modules.logs(module.name, clonePathPreview, module.folderName)
return {
moduleName: module.name,
state: module.state,
content: result.logs.split(/\r?\n/).slice(-200).join('\n').slice(-20_000),
message: result.message
}
} catch (error) {
return {
moduleName: module.name,
state: module.state,
content: '',
message: error instanceof Error ? error.message : '로그를 불러오지 못했습니다.'
}
}
}))
}
const submitAgentRequest = async (content: string): Promise<void> => {
try {
const modules = await collectAgentWorkspace()
await requestAgentPlan(content, modules, [], {
loadRequestedContext: async (request) => ({
modules: await collectAgentWorkspace(request.modules),
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
})
})
} catch (error) {
console.error('[agent] workspace collection failed', error)
onTitlebarNotice(error instanceof Error ? error.message : '모듈 파일을 읽지 못했습니다.', 'error')
}
}
const openAgentChangePreview = async (messageId: string, change: AgentFileChange): Promise<void> => {
const module = availableModules.find((item) => item.name === change.moduleName)
?? (selectedModule?.name === change.moduleName ? selectedModule : null)
if (!module) {
onTitlebarNotice(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`, 'error')
return
}
const rendererPath = `modules/${module.name}/${change.path.replace(/^\/+/, '')}`
let originalContent = editorFiles[rendererPath]
if (originalContent === undefined) {
try {
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const normalizePath = (path: string): string => path.startsWith('modules/')
? path
: `modules/${module.name}/${path.replace(/^\/+/, '')}`
const fileContent = Object.entries(result.files).find(([path]) => normalizePath(path) === rendererPath)?.[1]
const fileExists = [...(result.filePaths ?? []), ...Object.keys(result.files)]
.some((path) => normalizePath(path) === rendererPath)
originalContent = fileContent
if (originalContent === undefined && fileExists && remoteWorkspace) {
originalContent = (await window.remoteWorkspace.moduleFiles.content(
getRemoteWorkspaceTarget(),
module.name,
rendererPath,
module.folderName
)).content
}
} catch (error) {
console.warn('[agent] failed to read original file for diff preview', error)
}
}
if (selectedModule?.name !== module.name) openModuleSource(module)
else setActiveIdeActivity('workspace')
const id = `agent-diff:${messageId}:${module.name}:${change.path}`
const preview: AgentDiffPreview = {
id,
messageId,
moduleName: module.name,
path: change.path,
rendererPath,
originalContent: originalContent ?? '',
modifiedContent: change.content
}
setAgentDiffPreviews((previews) => [...previews.filter((item) => item.id !== id), preview])
setActiveAgentDiffPreviewId(id)
}
const closeAgentDiffPreview = (previewId: string): void => {
setAgentDiffPreviews((previews) => previews.filter((preview) => preview.id !== previewId))
setActiveAgentDiffPreviewId((activeId) => activeId === previewId ? null : activeId)
}
const closeAgentDiffPreviewsForMessage = (messageId: string): void => {
setAgentDiffPreviews((previews) => {
const closingIds = new Set(previews.filter((preview) => preview.messageId === messageId).map((preview) => preview.id))
setActiveAgentDiffPreviewId((activeId) => activeId && closingIds.has(activeId) ? null : activeId)
return previews.filter((preview) => preview.messageId !== messageId)
})
}
const runWorkspaceAgentCommand = async (messageId: string, command: AgentCommand): Promise<void> => {
const message = agentMessages.find((item) => item.id === messageId)
const currentExecution = message?.commandExecutions?.[command.id]
if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
const requiresAppliedChanges = message.plan.changes.length > 0 || message.plan.createModule !== null
if (requiresAppliedChanges && message.applyState !== 'applied') {
onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error')
return
}
const module = availableModules.find((item) => item.name === command.moduleName)
?? (selectedModule?.name === command.moduleName ? selectedModule : null)
if (!module) {
updateAgentCommandExecution(messageId, command.id, { status: 'failed', error: `명령 대상 모듈을 찾을 수 없습니다: ${command.moduleName}` })
return
}
updateAgentCommandExecution(messageId, command.id, { status: 'running' })
try {
const startModuleAction = command.command.trim() === AGENT_START_MODULE_COMMAND
const result = startModuleAction
? module.state === 'uninstalled'
? { ok: false, output: '', exitCode: null, message: '설치되지 않은 모듈은 시작할 수 없습니다.' }
: module.state === 'running'
? { ok: true, output: `${module.name} 모듈이 이미 실행 중입니다.`, exitCode: 0 }
: await runModuleControl(module, 'start').then((ok) => ({
ok,
output: ok ? `${module.name} 모듈을 시작했습니다.` : '',
exitCode: ok ? 0 : null,
message: ok ? undefined : `${module.name} 모듈을 시작하지 못했습니다.`
}))
: remoteWorkspace
? await window.remoteWorkspace.agent.runCommand(getRemoteWorkspaceTarget(), module.name, command.command, module.folderName)
: await window.localWorkspace.agent.runCommand(module.name, command.command, clonePathPreview, module.folderName)
updateAgentCommandExecution(messageId, command.id, {
status: result.ok ? 'completed' : 'failed',
output: result.output,
exitCode: result.exitCode,
error: result.message
})
await loadModuleFiles(module, true)
if (installedGitModules.length) await refreshGitStatuses(null)
const messageIndex = agentMessages.findIndex((item) => item.id === messageId)
const originalRequest = messageIndex > 0
? [...agentMessages.slice(0, messageIndex)].reverse().find((item) => item.role === 'user')?.content
: undefined
const nextWorkspace = (await collectAgentWorkspace()).map((workspaceModule) => (
startModuleAction && result.ok && workspaceModule.name === module.name
? { ...workspaceModule, state: 'running' }
: workspaceModule
))
const followUpRequest = [
'이전 작업의 승인된 명령 실행 결과를 분석하고 다음 단계만 제안하세요.',
originalRequest ? `학생의 원래 요청: ${originalRequest}` : '',
`이전 계획 요약: ${message.plan.summary}`,
`실행 모듈: ${command.moduleName}`,
`실행 명령: ${command.command}`,
`종료 코드: ${result.exitCode ?? '확인 불가'}`,
result.message ? `실행 메시지: ${result.message}` : '',
`명령 출력:\n${result.output.slice(-2400) || '(출력 없음)'}`,
'이미 실행한 명령을 그대로 반복하지 말고, 결과상 추가 작업이 필요할 때만 다음 승인 명령 또는 파일 변경안을 제안하세요.'
].filter(Boolean).join('\n')
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], { showUserMessage: false })
} catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
.replace(/^Error invoking remote method '[^']+': Error:\s*/, '')
const policyRejected = /허용되지|실행할 수 없습니다/.test(errorMessage)
updateAgentCommandExecution(messageId, command.id, {
status: policyRejected ? 'rejected' : 'failed',
exitCode: null,
error: errorMessage
})
}
}
const rejectWorkspaceAgentCommand = (messageId: string, command: AgentCommand): void => {
const execution = agentMessages.find((item) => item.id === messageId)?.commandExecutions?.[command.id]
if (execution && execution.status !== 'pending') return
updateAgentCommandExecution(messageId, command.id, { status: 'rejected' })
}
const applyWorkspaceAgentPlan = async (plan: AgentPlan): Promise<void> => {
let createdModule: ModuleRow | null = null
if (plan.createModule) {
if (availableModules.some((module) => module.name === plan.createModule?.name)) {
throw new Error(`이미 존재하는 모듈입니다: ${plan.createModule.name}`)
}
const customModule = await createCustomModuleRecord({
name: plan.createModule.name,
category: plan.createModule.category,
dockerImage: plan.createModule.dockerImage ?? '',
packageName: plan.createModule.packageName,
launchFile: plan.createModule.launchFile
})
createdModule = {
id: customModule.id,
state: 'stopped',
sortOrder: customModule.sortOrder ?? 10_000,
category: customModule.category,
name: customModule.name,
folderName: customModule.folderName,
sourceType: customModule.sourceType,
dockerImage: customModule.dockerImage,
packageName: customModule.packageName,
launchFile: customModule.launchFile,
runtimeTarget: customModule.runtimeTarget,
parameters: [],
cpu: null,
memory: null,
runtime: '-'
}
}
const resolveModule = (moduleName: string): ModuleRow | null => {
if (createdModule?.name === moduleName) return createdModule
return availableModules.find((module) => module.name === moduleName) ?? (selectedModule?.name === moduleName ? selectedModule : null)
}
let firstChangedFile: { module: ModuleRow; path: string } | null = null
for (const change of plan.changes) {
const module = resolveModule(change.moduleName)
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
const rendererPath = `modules/${module.name}/${change.path}`
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), module.name, rendererPath, 'file', module.folderName)
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), module.name, rendererPath, change.content, module.folderName)
} else {
await window.localWorkspace.moduleFiles.create(module.name, rendererPath, 'file', clonePathPreview, module.folderName)
await window.localWorkspace.moduleFiles.write(module.name, rendererPath, change.content, clonePathPreview, module.folderName)
}
setEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
setSavedEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
setDirtyEditorFiles((files) => {
const next = { ...files }
delete next[rendererPath]
return next
})
if (!firstChangedFile) firstChangedFile = { module, path: rendererPath }
}
if (firstChangedFile) {
setSelectedModule(firstChangedFile.module)
setModuleTabs((tabs) => tabs.some((tab) => tab.name === firstChangedFile!.module.name) ? tabs : [...tabs, firstChangedFile!.module])
setActiveIdeActivity('workspace')
await loadModuleFiles(firstChangedFile.module, true)
openEditorFile(firstChangedFile.path)
} else if (createdModule) {
setSelectedModule(createdModule)
setModuleTabs((tabs) => tabs.some((tab) => tab.name === createdModule!.name) ? tabs : [...tabs, createdModule!])
setActiveIdeActivity('workspace')
await loadModuleFiles(createdModule, true)
}
if (plan.changes.length && installedGitModules.length) {
await refreshGitStatuses(null)
}
onTitlebarNotice(`${plan.changes.length}개 파일에 AI 변경안을 적용했습니다.`, 'info')
} }
const importGitModule = async (input: GitModuleImportInput): Promise<void> => { const importGitModule = async (input: GitModuleImportInput): Promise<void> => {
if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.') if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.')
@@ -3914,19 +4293,36 @@ function LocalPcWorkspaceMockup({
gridColumn: 6, gridColumn: 6,
gridRow: 2, gridRow: 2,
gridTemplateRows: exampleActive gridTemplateRows: exampleActive
? currentOpenEditorTabs.length ? '44px 28px minmax(0, 1fr)' : 'minmax(0, 1fr)' ? visibleEditorTabs.length ? '44px 28px minmax(0, 1fr)' : 'minmax(0, 1fr)'
: currentOpenEditorTabs.length ? `44px 28px minmax(0, 1fr) ${terminalPanelHeight}px` : `minmax(0, 1fr) ${terminalPanelHeight}px` : visibleEditorTabs.length ? `44px 28px minmax(0, 1fr) ${terminalPanelHeight}px` : `minmax(0, 1fr) ${terminalPanelHeight}px`
}} }}
> >
<WorkspaceEditorTabs <WorkspaceEditorTabs
dark={dark} dark={dark}
files={currentOpenEditorTabs} files={visibleEditorTabs}
activeFile={currentActiveEditorFile} activeFile={activeEditorTab}
draggingFile={draggingEditorTab} draggingFile={draggingEditorTab}
dropIndex={tabDropIndex} dropIndex={tabDropIndex}
isDirtyFile={isDirtyEditorFile} isDirtyFile={isDirtyEditorFile}
onOpenFile={openEditorFile} isPreviewFile={(file) => file.startsWith('agent-diff:')}
onCloseFile={closeEditorTab} getFileLabel={(file) => {
const preview = visibleAgentDiffPreviews.find((item) => item.id === file)
return preview ? `${preview.path.split('/').pop()} (AI)` : file.split('/').pop() ?? file
}}
onOpenFile={(file) => {
if (file.startsWith('agent-diff:')) {
setActiveAgentDiffPreviewId(file)
return
}
openEditorFile(file)
}}
onCloseFile={(file) => {
if (file.startsWith('agent-diff:')) {
closeAgentDiffPreview(file)
return
}
closeEditorTab(file)
}}
onContextMenu={(file, x, y) => { onContextMenu={(file, x, y) => {
setTabContextMenu({ file, x, y }) setTabContextMenu({ file, x, y })
}} }}
@@ -3979,8 +4375,9 @@ function LocalPcWorkspaceMockup({
<WorkspaceEditorPathBar <WorkspaceEditorPathBar
dark={dark} dark={dark}
currentActiveEditorFile={currentActiveEditorFile} currentActiveEditorFile={activeEditorDisplayPath}
exampleActive={exampleActive} exampleActive={exampleActive}
readOnlyLabel={activeAgentDiffPreview ? t("AI 변경안") : undefined}
isDirtyFile={isDirtyEditorFile} isDirtyFile={isDirtyEditorFile}
onSave={saveCurrentEditorFile} onSave={saveCurrentEditorFile}
/> />
@@ -3992,7 +4389,8 @@ function LocalPcWorkspaceMockup({
dark={dark} dark={dark}
textDim={textDim} textDim={textDim}
editorPaneRef={editorPaneRef} editorPaneRef={editorPaneRef}
currentActiveEditorFile={currentActiveEditorFile} currentActiveEditorFile={activeEditorDisplayPath}
diffPreview={activeAgentDiffPreview}
activeFileIsImage={activeFileIsImage} activeFileIsImage={activeFileIsImage}
activeImagePreviewSource={activeImagePreviewSource} activeImagePreviewSource={activeImagePreviewSource}
editorLanguage={editorLanguage} editorLanguage={editorLanguage}
@@ -4154,22 +4552,42 @@ function LocalPcWorkspaceMockup({
qaMessagesLoading={qaMessagesLoading} qaMessagesLoading={qaMessagesLoading}
visibleAiMessages={visibleAiMessages} visibleAiMessages={visibleAiMessages}
visibleTeacherMessages={visibleTeacherMessages} visibleTeacherMessages={visibleTeacherMessages}
agentMessages={agentMessages}
agentEnabled={agentEnabled}
agentStatusLoading={agentStatusLoading}
agentPending={agentPending}
agentPersistenceError={agentPersistenceError}
aiPending={aiPending} aiPending={aiPending}
teacherThreadStatus={teacherThreadStatus} teacherThreadStatus={teacherThreadStatus}
qaCollapseSignal={qaCollapseSignal} qaCollapseSignal={qaCollapseSignal}
inputBusy={aiPending || Boolean(aiTypingMessageId)} inputBusy={qaMode === 'agent' ? agentPending : aiPending || Boolean(aiTypingMessageId)}
inputDisabled={!validChatWeek} inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))}
workspaceChatScrollRef={workspaceChatScrollRef} workspaceChatScrollRef={workspaceChatScrollRef}
onMinimize={() => setRightPanelMinimized(true)} onMinimize={() => setRightPanelMinimized(true)}
onModeChange={(mode) => { onModeChange={(mode) => {
setQaMode(mode) setQaMode(mode)
if (mode === 'teacher') setTeacherChatFlash(false) if (mode === 'teacher') setTeacherChatFlash(false)
}} }}
onStop={stopAiResponse} onStop={qaMode === 'agent' ? stopAgent : stopAiResponse}
onSubmit={(content) => void sendQaMessage(content, { contextWeek: curriculumWeek.week })} onSubmit={(content) => {
onClearConversation={() => { if (qaMode === 'agent') void submitAgentRequest(content)
void clearQaConversation().catch((error) => console.error('[chat] clear failed', error)) else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
}} }}
onClearConversation={() => {
if (qaMode === 'agent') {
clearAgentConversation()
setAgentDiffPreviews([])
setActiveAgentDiffPreviewId(null)
}
else void clearQaConversation().catch((error) => console.error('[chat] clear failed', error))
}}
onOpenAgentChange={(messageId, change) => void openAgentChangePreview(messageId, change)}
onRunAgentCommand={(messageId, command) => void runWorkspaceAgentCommand(messageId, command)}
onRejectAgentCommand={rejectWorkspaceAgentCommand}
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => {
await applyWorkspaceAgentPlan(plan)
closeAgentDiffPreviewsForMessage(messageId)
})}
/> />
{workspaceRestoreLoading && ( {workspaceRestoreLoading && (
@@ -4493,9 +4911,9 @@ export default function App(): React.JSX.Element {
const modules = (workspaceWeek.modules ?? []) const modules = (workspaceWeek.modules ?? [])
.slice() .slice()
.sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0)) .sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0))
.map((module) => ({ moduleName: module.name, folderName: module.folderName, templateRepositoryUrl: module.templateRepositoryUrl })) .map((module) => ({ moduleName: module.name, folderName: module.folderName }))
if (!modules.length) return if (!modules.length) return
await window.remoteWorkspace.modules.resetSession(target, modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' })) await window.remoteWorkspace.modules.stopMany(target, modules).catch(() => ({ ok: false, message: '모듈 중지 실패' }))
} }
const getFreshAccessToken = useCallback(async (): Promise<string> => { const getFreshAccessToken = useCallback(async (): Promise<string> => {
const currentSession = readStoredAuthSession() ?? authSessionRef.current const currentSession = readStoredAuthSession() ?? authSessionRef.current
@@ -268,6 +268,8 @@ export const QaInputBox = memo(function QaInputBox({
<div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}> <div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}>
<textarea <textarea
rows={2} rows={2}
spellCheck={false}
autoCorrect="off"
value={draft} value={draft}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
+12
View File
@@ -91,6 +91,14 @@ interface Window {
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
} }
agent: {
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => Promise<{
ok: boolean
output: string
exitCode: number | null
message?: string
}>
}
modules: { modules: {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{ list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{
cacheRoot: string cacheRoot: string
@@ -267,6 +275,7 @@ interface Window {
openGuide: () => Promise<void> openGuide: () => Promise<void>
} }
moduleFiles: Window['moduleFiles'] moduleFiles: Window['moduleFiles']
agent: Window['agent']
modules: Window['modules'] modules: Window['modules']
rosGraph: { rosGraph: {
snapshot: () => Promise<{ snapshot: () => Promise<{
@@ -310,6 +319,9 @@ interface Window {
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']> rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']>
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']> delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
} }
agent: {
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ReturnType<Window['agent']['runCommand']>
}
maps: { maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>> readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }> copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
@@ -112,15 +112,17 @@ export default function ModuleListToolbar({
onClick() onClick()
}} }}
className={[ className={[
'flex h-7 whitespace-nowrap items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition', 'inline-flex h-7 whitespace-nowrap items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold leading-none transition',
disabled ? (dark ? 'cursor-default opacity-45' : 'cursor-default') : 'cursor-pointer', disabled ? (dark ? 'cursor-default opacity-45' : 'cursor-default') : 'cursor-pointer',
dark dark
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white' ? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
: disabled ? 'border-[#d2ccdc] bg-[#e9e6ee] text-[#8b8498]' : lightClass : disabled ? 'border-[#d2ccdc] bg-[#e9e6ee] text-[#8b8498]' : lightClass
].join(' ')} ].join(' ')}
> >
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />} {busy
{t(label)} ? <RefreshCw size={14} className="block shrink-0 animate-spin" />
: <Icon size={14} className="block shrink-0" />}
<span className="translate-y-px leading-none">{t(label)}</span>
</button> </button>
))} ))}
</div> </div>
@@ -1,5 +1,5 @@
import { t, useLocaleRender } from '../../i18n' import { t, useLocaleRender } from '../../i18n'
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react' import Editor, { DiffEditor, type BeforeMount, type OnMount } from '@monaco-editor/react'
import { FileText, Loader2, RotateCw } from 'lucide-react' import { FileText, Loader2, RotateCw } from 'lucide-react'
type Props = { type Props = {
@@ -10,6 +10,11 @@ type Props = {
textDim: string textDim: string
editorPaneRef: React.RefObject<HTMLDivElement | null> editorPaneRef: React.RefObject<HTMLDivElement | null>
currentActiveEditorFile: string | null currentActiveEditorFile: string | null
diffPreview?: {
id: string
originalContent: string
modifiedContent: string
} | null
activeFileIsImage: boolean activeFileIsImage: boolean
activeImagePreviewSource?: string activeImagePreviewSource?: string
editorLanguage: string editorLanguage: string
@@ -32,6 +37,7 @@ function WorkspaceEditorPane({
textDim, textDim,
editorPaneRef, editorPaneRef,
currentActiveEditorFile, currentActiveEditorFile,
diffPreview,
activeFileIsImage, activeFileIsImage,
activeImagePreviewSource, activeImagePreviewSource,
editorLanguage, editorLanguage,
@@ -48,7 +54,32 @@ function WorkspaceEditorPane({
useLocaleRender() useLocaleRender()
return ( return (
<div ref={editorPaneRef} className="min-h-0 min-w-0 overflow-hidden"> <div ref={editorPaneRef} className="min-h-0 min-w-0 overflow-hidden">
{!currentActiveEditorFile ? ( {diffPreview && currentActiveEditorFile ? (
<DiffEditor
key={diffPreview.id}
height="100%"
width="100%"
original={diffPreview.originalContent}
modified={diffPreview.modifiedContent}
originalModelPath={`${currentActiveEditorFile}.agent-original`}
modifiedModelPath={`${currentActiveEditorFile}.agent-proposed`}
language={editorLanguage}
theme={dark ? 'ads-ide-dark' : 'ads-ide-light'}
beforeMount={beforeMount}
options={{
automaticLayout: true,
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
fontSize,
minimap: { enabled: minimapEnabled },
originalEditable: false,
readOnly: true,
renderSideBySide: true,
scrollBeyondLastLine: false,
tabSize,
wordWrap: wordWrap ? 'on' : 'off'
}}
/>
) : !currentActiveEditorFile ? (
<div className={['grid h-full place-items-center', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}> <div className={['grid h-full place-items-center', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className="px-6 py-5 text-center"> <div className="px-6 py-5 text-center">
<FileText size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} /> <FileText size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} />
@@ -6,6 +6,7 @@ type Props = {
dark: boolean dark: boolean
currentActiveEditorFile: string | null currentActiveEditorFile: string | null
exampleActive: boolean exampleActive: boolean
readOnlyLabel?: string
isDirtyFile: (file: string) => boolean isDirtyFile: (file: string) => boolean
onSave: () => void onSave: () => void
} }
@@ -14,6 +15,7 @@ function WorkspaceEditorPathBar({
dark, dark,
currentActiveEditorFile, currentActiveEditorFile,
exampleActive, exampleActive,
readOnlyLabel,
isDirtyFile, isDirtyFile,
onSave onSave
}: Props): React.JSX.Element | null { }: Props): React.JSX.Element | null {
@@ -31,8 +33,13 @@ function WorkspaceEditorPathBar({
</span> </span>
</span> </span>
))} ))}
{readOnlyLabel ? (
<span className={['ml-2 shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold', dark ? 'bg-[#7c3aed]/25 text-[#c4b5fd]' : 'bg-[#ede9fe] text-[#6d28d9]'].join(' ')}>
{readOnlyLabel}
</span>
) : null}
</div> </div>
{!exampleActive && ( {!exampleActive && !readOnlyLabel && (
<Tooltip label={t("저장")} side="bottom"> <Tooltip label={t("저장")} side="bottom">
<button <button
type="button" type="button"
@@ -1,5 +1,5 @@
import { t, useLocaleRender } from '../../i18n' import { t, useLocaleRender } from '../../i18n'
import { FileText, X } from 'lucide-react' import { FileDiff, FileText, X } from 'lucide-react'
type EditorFilePath = string type EditorFilePath = string
@@ -10,6 +10,8 @@ type Props = {
draggingFile: EditorFilePath | null draggingFile: EditorFilePath | null
dropIndex: number | null dropIndex: number | null
isDirtyFile: (file: EditorFilePath) => boolean isDirtyFile: (file: EditorFilePath) => boolean
isPreviewFile?: (file: EditorFilePath) => boolean
getFileLabel?: (file: EditorFilePath) => string
onOpenFile: (file: EditorFilePath) => void onOpenFile: (file: EditorFilePath) => void
onCloseFile: (file: EditorFilePath) => void onCloseFile: (file: EditorFilePath) => void
onContextMenu: (file: EditorFilePath, x: number, y: number) => void onContextMenu: (file: EditorFilePath, x: number, y: number) => void
@@ -26,6 +28,8 @@ function WorkspaceEditorTabs({
draggingFile, draggingFile,
dropIndex, dropIndex,
isDirtyFile, isDirtyFile,
isPreviewFile = () => false,
getFileLabel = (file) => file.split('/').pop() ?? file,
onOpenFile, onOpenFile,
onCloseFile, onCloseFile,
onContextMenu, onContextMenu,
@@ -40,11 +44,14 @@ function WorkspaceEditorTabs({
return ( return (
<div className={['min-w-0 overflow-hidden border-b pl-1 pr-2', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d4c9e4] bg-white'].join(' ')}> <div className={['min-w-0 overflow-hidden border-b pl-1 pr-2', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d4c9e4] bg-white'].join(' ')}>
<div className="thin-x-scrollbar flex h-full min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden [scrollbar-gutter:stable]"> <div className="thin-x-scrollbar flex h-full min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden [scrollbar-gutter:stable]">
{files.map((file, index) => ( {files.map((file, index) => {
const previewFile = isPreviewFile(file)
const FileIcon = previewFile ? FileDiff : FileText
return (
<button <button
key={file} key={file}
type="button" type="button"
draggable draggable={!previewFile}
onClick={() => onOpenFile(file)} onClick={() => onOpenFile(file)}
onAuxClick={(event) => { onAuxClick={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
@@ -53,12 +60,21 @@ function WorkspaceEditorTabs({
}} }}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault() event.preventDefault()
if (previewFile) return
onContextMenu(file, event.clientX, event.clientY) onContextMenu(file, event.clientX, event.clientY)
}} }}
onDragStart={(event) => onDragStart(file, index, event)} onDragStart={(event) => {
onDragOver={(event) => onDragOver(index, event)} if (!previewFile) onDragStart(file, index, event)
onDrop={onDrop} }}
onDragEnd={onDragEnd} onDragOver={(event) => {
if (!previewFile) onDragOver(index, event)
}}
onDrop={(event) => {
if (!previewFile) onDrop(event)
}}
onDragEnd={() => {
if (!previewFile) onDragEnd()
}}
className={[ className={[
'group relative flex h-8 min-w-[118px] max-w-[180px] shrink-0 cursor-pointer items-center gap-1.5 rounded-t-md border-x border-t px-3 text-[12px] font-bold transition', 'group relative flex h-8 min-w-[118px] max-w-[180px] shrink-0 cursor-pointer items-center gap-1.5 rounded-t-md border-x border-t px-3 text-[12px] font-bold transition',
draggingFile === file ? 'opacity-60' : '', draggingFile === file ? 'opacity-60' : '',
@@ -77,8 +93,8 @@ function WorkspaceEditorTabs({
{draggingFile && dropIndex === index + 1 && index === files.length - 1 && ( {draggingFile && dropIndex === index + 1 && index === files.length - 1 && (
<span className="absolute -right-[3px] bottom-0 top-0 w-[2px] rounded-full bg-[#d1d5db]" /> <span className="absolute -right-[3px] bottom-0 top-0 w-[2px] rounded-full bg-[#d1d5db]" />
)} )}
<FileText size={13} /> <FileIcon size={13} className={previewFile ? 'text-[#a78bfa]' : undefined} />
<span className="truncate">{file.split('/').pop()}</span> <span className="truncate">{getFileLabel(file)}</span>
{isDirtyFile(file) && ( {isDirtyFile(file) && (
<span className={['ml-auto h-2 w-2 shrink-0 rounded-full', dark ? 'bg-[#f1f5f9]' : 'bg-[#2563eb]'].join(' ')} /> <span className={['ml-auto h-2 w-2 shrink-0 rounded-full', dark ? 'bg-[#f1f5f9]' : 'bg-[#2563eb]'].join(' ')} />
)} )}
@@ -107,7 +123,8 @@ function WorkspaceEditorTabs({
<X size={12} /> <X size={12} />
</span> </span>
</button> </button>
))} )
})}
</div> </div>
</div> </div>
) )
@@ -1,10 +1,11 @@
import { t, useLocaleRender } from '../../i18n' import { t, useLocaleRender } from '../../i18n'
import { Bot, MessageCircle, Minus, User } from 'lucide-react' import { Bot, Check, CheckCircle2, Circle, Copy, FileCode2, ListChecks, Loader2, MessageCircle, Minus, Play, TerminalSquare, User, WandSparkles, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets' import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
type QaMode = 'ai' | 'teacher' type QaMode = 'ai' | 'agent' | 'teacher'
type WorkspaceQaMessage = { type WorkspaceQaMessage = {
id: string id: string
@@ -31,6 +32,11 @@ type WorkspaceQaPanelProps = {
qaMessagesLoading: boolean qaMessagesLoading: boolean
visibleAiMessages: WorkspaceQaMessage[] visibleAiMessages: WorkspaceQaMessage[]
visibleTeacherMessages: WorkspaceQaMessage[] visibleTeacherMessages: WorkspaceQaMessage[]
agentMessages: AgentConversationMessage[]
agentEnabled: boolean
agentStatusLoading: boolean
agentPending: boolean
agentPersistenceError?: string | null
aiPending: boolean aiPending: boolean
teacherThreadStatus: string | null teacherThreadStatus: string | null
qaCollapseSignal: number qaCollapseSignal: number
@@ -42,6 +48,41 @@ type WorkspaceQaPanelProps = {
onStop: () => void onStop: () => void
onSubmit: (content: string) => void onSubmit: (content: string) => void
onClearConversation: () => void onClearConversation: () => void
onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void
onOpenAgentChange: (messageId: string, change: AgentFileChange) => void
onRunAgentCommand: (messageId: string, command: AgentCommand) => void
onRejectAgentCommand: (messageId: string, command: AgentCommand) => void
}
function MessageCopyButton({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
const [copied, setCopied] = useState(false)
const copy = (): void => {
void navigator.clipboard.writeText(content).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1400)
})
}
return (
<button
type="button"
title={copied ? t("복사됨") : t("복사")}
aria-label={copied ? t("복사됨") : t("복사")}
onClick={copy}
className={[
'absolute bottom-2 right-2 z-10 grid h-6 w-6 place-items-center rounded opacity-0 transition group-hover:opacity-100 focus:opacity-100',
dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#64748b] hover:bg-[#ede9fe] hover:text-[#1e293b]'
].join(' ')}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
)
}
function agentMessageCopyText(message: AgentConversationMessage): string {
if (!message.plan) return message.content
return [message.plan.summary, message.plan.explanation].filter(Boolean).join('\n\n')
} }
export default function WorkspaceQaPanel({ export default function WorkspaceQaPanel({
@@ -60,6 +101,11 @@ export default function WorkspaceQaPanel({
qaMessagesLoading, qaMessagesLoading,
visibleAiMessages, visibleAiMessages,
visibleTeacherMessages, visibleTeacherMessages,
agentMessages,
agentEnabled,
agentStatusLoading,
agentPending,
agentPersistenceError,
aiPending, aiPending,
teacherThreadStatus, teacherThreadStatus,
qaCollapseSignal, qaCollapseSignal,
@@ -70,11 +116,15 @@ export default function WorkspaceQaPanel({
onModeChange, onModeChange,
onStop, onStop,
onSubmit, onSubmit,
onClearConversation onClearConversation,
onApplyAgentPlan,
onOpenAgentChange,
onRunAgentCommand,
onRejectAgentCommand
}: WorkspaceQaPanelProps): React.JSX.Element { }: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender() useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1] const lastMessage = activeMessages[activeMessages.length - 1]
useEffect(() => { useEffect(() => {
@@ -126,6 +176,8 @@ export default function WorkspaceQaPanel({
observer?.disconnect() observer?.disconnect()
} }
}, [ }, [
agentMessages.length,
agentPending,
aiPending, aiPending,
lastMessage?.content, lastMessage?.content,
lastMessage?.id, lastMessage?.id,
@@ -153,16 +205,19 @@ export default function WorkspaceQaPanel({
> >
<div className={['border-b p-3', borderSoft, dark ? '' : 'bg-white'].join(' ')}> <div className={['border-b p-3', borderSoft, dark ? '' : 'bg-white'].join(' ')}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className={['flex items-center gap-2 text-[13px] font-bold', textStrong].join(' ')}> <h3 className={['flex items-center gap-2 text-[13px] font-bold leading-none', textStrong].join(' ')}>
<MessageCircle size={14} className={textDim} />{t("질의응답")}</h3> <MessageCircle size={14} className={['block shrink-0', textDim].join(' ')} />
<span className="translate-y-px leading-none">{t("질의응답")}</span>
</h3>
<button type="button" aria-label={t("질의응답 최소화")} onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}> <button type="button" aria-label={t("질의응답 최소화")} onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}>
<Minus size={15} /> <Minus size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span> <span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span>
</button> </button>
</div> </div>
<div className={['mt-3 grid grid-cols-2 overflow-hidden rounded-md border', borderSoft].join(' ')}> <div className={['mt-3 grid grid-cols-3 overflow-hidden rounded-md border', borderSoft].join(' ')}>
{[ {[
{ id: 'ai' as const, label: 'AI', Icon: Bot }, { id: 'ai' as const, label: 'AI', Icon: Bot },
{ id: 'agent' as const, label: t("에이전트"), Icon: WandSparkles },
{ id: 'teacher' as const, label: t("강사"), Icon: User } { id: 'teacher' as const, label: t("강사"), Icon: User }
].map(({ id, label, Icon }) => { ].map(({ id, label, Icon }) => {
const disabled = id === 'teacher' && teacherQaDisabled const disabled = id === 'teacher' && teacherQaDisabled
@@ -177,7 +232,7 @@ export default function WorkspaceQaPanel({
if (!disabled) onModeChange(id) if (!disabled) onModeChange(id)
}} }}
className={[ className={[
'relative flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold transition', 'relative inline-flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold leading-none transition',
disabled ? 'cursor-not-allowed' : 'cursor-pointer', disabled ? 'cursor-not-allowed' : 'cursor-pointer',
disabled disabled
? dark ? dark
@@ -194,14 +249,17 @@ export default function WorkspaceQaPanel({
: 'bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]' : 'bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')} ].join(' ')}
> >
<Icon size={14} className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')} /> <Icon size={14} className={['relative z-10 block shrink-0', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')} />
<span className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{t(label)}</span> <span className={['relative z-10 translate-y-px leading-none', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{t(label)}</span>
</button> </button>
) )
})} })}
</div> </div>
</div> </div>
<div ref={workspaceChatScrollRef} className="thin-y-scrollbar min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}> <div ref={workspaceChatScrollRef} className="thin-y-scrollbar min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}>
{qaMode === 'agent' && agentPersistenceError ? (
<p role="status" className="mb-3 text-[11px] leading-relaxed text-[#d97706]">{t(agentPersistenceError)}</p>
) : null}
{qaMessagesLoading ? ( {qaMessagesLoading ? (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}> <div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
<div> <div>
@@ -216,6 +274,18 @@ export default function WorkspaceQaPanel({
pending={aiPending} pending={aiPending}
collapseSignal={qaCollapseSignal} collapseSignal={qaCollapseSignal}
/> />
) : qaMode === 'agent' ? (
<AgentMessages
dark={dark}
enabled={agentEnabled}
statusLoading={agentStatusLoading}
pending={agentPending}
messages={agentMessages}
onApply={onApplyAgentPlan}
onOpenChange={onOpenAgentChange}
onRunCommand={onRunAgentCommand}
onRejectCommand={onRejectAgentCommand}
/>
) : ( ) : (
<TeacherMessages <TeacherMessages
dark={dark} dark={dark}
@@ -253,6 +323,225 @@ export default function WorkspaceQaPanel({
) )
} }
function AgentMessages({
dark,
enabled,
statusLoading,
pending,
messages,
onApply,
onOpenChange,
onRunCommand,
onRejectCommand
}: {
dark: boolean
enabled: boolean
statusLoading: boolean
pending: boolean
messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void
onOpenChange: (messageId: string, change: AgentFileChange) => void
onRunCommand: (messageId: string, command: AgentCommand) => void
onRejectCommand: (messageId: string, command: AgentCommand) => void
}): React.JSX.Element {
useLocaleRender()
if (statusLoading) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center gap-2 text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
<Loader2 size={15} className="animate-spin" />
<span>{t("에이전트 설정을 확인하는 중입니다.")}</span>
</div>
)
}
if (!enabled) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
{t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")}
</div>
)
}
if (!messages.length && !pending) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
{t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}<br />{t("변경 내용은 확인 후 직접 적용합니다.")}
</div>
)
}
return (
<div className="grid gap-3">
{messages.map((message) => message.role === 'user' ? (
<div key={message.id} data-qa-message="true" className="flex justify-end">
<div className="max-w-[82%] break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
</div>
) : (
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div className="group relative min-w-0 max-w-[calc(100%-38px)]">
<div className={['overflow-hidden rounded-lg border px-3 pb-9 pt-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
{message.plan ? (
<div className="grid gap-3">
<div>
<div className="font-black">
<MarkdownMessage content={message.plan.summary} dark={dark} />
</div>
<div className="mt-1">
<MarkdownMessage content={message.plan.explanation} dark={dark} />
</div>
{message.provider && message.model ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{message.provider} · {message.model}</p> : null}
</div>
{message.plan.actionPlan.length > 0 ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{(() => {
const executions = message.commandExecutions ?? {}
const completedCommands = message.plan?.commands.filter((command) => executions[command.id]?.status === 'completed').length ?? 0
const completedChanges = message.applyState === 'applied' && (message.plan?.changes.length || message.plan?.createModule) ? 1 : 0
const completedSteps = Math.min(message.plan?.actionPlan.length ?? 0, completedCommands + completedChanges)
return (
<>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="flex items-center gap-1.5 font-black"><ListChecks size={13} className="text-[#8b5cf6]" />{t("작업 계획")}</p>
<span className={['text-[10px] font-bold', dark ? 'text-white/38' : 'text-[#94a3b8]'].join(' ')}>{completedSteps}/{message.plan.actionPlan.length}</span>
</div>
<div className="grid gap-1.5">
{message.plan.actionPlan.map((step, index) => {
const finished = index < completedSteps
const active = index === completedSteps
return (
<div key={`${index}-${step}`} className={['flex min-w-0 items-start gap-2', finished ? (dark ? 'text-white/45' : 'text-[#64748b]') : ''].join(' ')}>
{finished ? <CheckCircle2 size={12} className="mt-0.5 shrink-0 text-[#22c55e]" /> : <Circle size={12} className={['mt-0.5 shrink-0', active ? 'text-[#a78bfa]' : dark ? 'text-white/25' : 'text-[#94a3b8]'].join(' ')} />}
<span className={active ? 'font-bold' : ''}>{step}</span>
</div>
)
})}
</div>
</>
)
})()}
</div>
) : null}
{message.plan.commands.length > 0 ? (
<div className={['order-5 grid gap-3 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{message.plan.commands.map((command) => {
const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const }
const moduleStartAction = command.command.trim() === 'lms module start'
const finished = execution.status === 'completed' || execution.status === 'failed'
const policyRejected = execution.status === 'rejected'
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
const waitingForChanges = (message.plan!.changes.length > 0 || message.plan!.createModule !== null) && message.applyState !== 'applied'
return (
<div key={command.id} className={['min-w-0 border-y py-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="flex min-w-0 items-center gap-1.5 font-black"><TerminalSquare size={13} /><span>{moduleStartAction ? t("모듈 시작") : t("명령어")}</span></p>
<button
type="button"
title={t("복사")}
aria-label={t("복사")}
onClick={() => void navigator.clipboard.writeText(moduleStartAction ? `${command.moduleName} · ${t("모듈 시작")}` : command.command)}
className={['grid h-6 w-6 shrink-0 place-items-center rounded transition', dark ? 'hover:bg-white/10' : 'hover:bg-[#ede9fe]'].join(' ')}
><Copy size={12} /></button>
</div>
<p className={['mb-2 text-[11px]', dark ? 'text-white/48' : 'text-[#64748b]'].join(' ')}>{command.moduleName} · {command.purpose}</p>
<pre className={['thin-x-scrollbar max-h-44 overflow-auto whitespace-pre-wrap break-all px-2 py-2 font-mono text-[11px] leading-relaxed', dark ? 'bg-[#202020] text-[#d8dee9]' : 'bg-[#f1f5f9] text-[#1e293b]'].join(' ')}><code>{moduleStartAction ? `${command.moduleName} · ${t("모듈 시작")}` : command.command}</code></pre>
{execution.output ? (
<pre className={['thin-scrollbar mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-all px-2 py-2 font-mono text-[11px] leading-relaxed', dark ? 'bg-[#171717] text-white/65' : 'bg-[#e8edf5] text-[#334155]'].join(' ')}><code>{execution.output}</code></pre>
) : null}
{execution.error ? <p className="mt-2 text-[11px] font-bold text-[#ef4444]">{execution.error}</p> : null}
<div className="mt-2 flex items-center justify-end gap-2">
{execution.status === 'pending' ? <>
<button type="button" onClick={() => onRejectCommand(message.id, command)} className={['inline-flex h-7 items-center gap-1 rounded px-2 text-[11px] font-bold transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white' : 'text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}><X size={12} />{t("거부")}</button>
<button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("승인")}</button>
</> : execution.status === 'running' ? (
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-[#a78bfa]"><Loader2 size={12} className="animate-spin" />{t("실행 중")}</span>
) : policyRejected ? (
<span className={['text-[11px] font-bold', dark ? 'text-white/40' : 'text-[#64748b]'].join(' ')}>{t("거부됨")}</span>
) : execution.status === 'failed' ? (
<>
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-[#ef4444]"><X size={12} />{t("실행 실패")} · exit {execution.exitCode ?? '-'}</span>
<button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("다시 실행")}</button>
</>
) : (
<span className={['inline-flex items-center gap-1 text-[11px] font-bold', execution.status === 'completed' ? 'text-[#22c55e]' : 'text-[#ef4444]'].join(' ')}>
<Check size={12} />
{t("실행 완료")} · exit {execution.exitCode ?? '-'}
</span>
)}
</div>
{finished && !execution.output && !execution.error ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{t("명령 출력이 없습니다.")}</p> : null}
</div>
)
})}
</div>
) : null}
{message.plan.createModule ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
<p className="font-black">{t("새 모듈")}: {message.plan.createModule.name}</p>
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{message.plan.createModule.packageName} · {message.plan.createModule.launchFile}</p>
</div>
) : null}
{message.plan.changes.length > 0 ? (
<div className={['grid gap-1.5 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{message.plan.changes.map((change) => (
<button
key={`${change.moduleName}:${change.path}`}
type="button"
title={t("변경사항 비교")}
onClick={() => onOpenChange(message.id, change)}
className={[
'flex min-w-0 cursor-pointer gap-2 rounded-md px-1.5 py-1 text-left transition',
dark ? 'hover:bg-white/8' : 'hover:bg-[#ede9fe]'
].join(' ')}
>
<FileCode2 size={13} className="mt-0.5 shrink-0 text-[#7c3aed]" />
<div className="min-w-0">
<p className="break-all font-bold">{change.moduleName}/{change.path}</p>
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{change.reason}</p>
</div>
</button>
))}
</div>
) : null}
{message.applyError ? <p className="text-[11px] font-bold text-[#ef4444]">{message.applyError}</p> : null}
{message.plan.changes.length > 0 || message.plan.createModule ? (
<button
type="button"
disabled={message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
onClick={() => onApply(message.id, message.plan!)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
>
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />}
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button>
) : null}
</div>
) : <p className="whitespace-pre-wrap">{message.content}</p>}
</div>
<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />
</div>
</div>
))}
{pending ? (
<div data-qa-message="true" className="flex gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div
role="status"
aria-label={t("코드 변경안을 만드는 중입니다.")}
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
>
<div className="grid animate-pulse gap-2" aria-hidden="true">
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
</div>
</div>
</div>
) : null}
</div>
)
}
function AiMessages({ function AiMessages({
dark, dark,
messages, messages,
@@ -272,11 +272,13 @@ export default function WorkspaceRosConnectionsView({
> >
<div className={['flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#a6b4c9] bg-white'].join(' ')}> <div className={['flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#a6b4c9] bg-white'].join(' ')}>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-[#31c48d]/25 bg-[#31c48d]/10 text-[#8fe8bf]' : 'border-[#79b99a] bg-[#effcf5] text-[#168254]'].join(' ')}> <span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold leading-none', dark ? 'border-[#31c48d]/25 bg-[#31c48d]/10 text-[#8fe8bf]' : 'border-[#79b99a] bg-[#effcf5] text-[#168254]'].join(' ')}>
<Activity size={13} />{t("실행 모듈")}{runningModules} <Activity size={13} className="block shrink-0" />
<span className="inline-flex translate-y-px items-center gap-1 leading-none"><span>{t("실행 모듈")}</span><span>{runningModules}</span></span>
</span> </span>
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075]'].join(' ')}> <span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold leading-none', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
<Waypoints size={13} />{t("토픽")}{connections.length} <Waypoints size={13} className="block shrink-0" />
<span className="inline-flex translate-y-px items-center gap-1 leading-none"><span>{t("토픽")}</span><span>{connections.length}</span></span>
</span> </span>
<button <button
type="button" type="button"
@@ -284,30 +286,35 @@ export default function WorkspaceRosConnectionsView({
title={t("활성 연결만 보기")} title={t("활성 연결만 보기")}
onClick={() => setActiveOnly((value) => !value)} onClick={() => setActiveOnly((value) => !value)}
className={[ className={[
'inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8b5cf6]', 'inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold leading-none transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8b5cf6]',
activeOnly activeOnly
? dark ? 'border-[#a78bfa] bg-[#6d28d9] text-white hover:bg-[#7c3aed]' : 'border-[#b59bd8] bg-[#e7daf5] text-[#5b21a3] hover:bg-[#dac5ee]' ? dark ? 'border-[#a78bfa] bg-[#6d28d9] text-white hover:bg-[#7c3aed]' : 'border-[#b59bd8] bg-[#e7daf5] text-[#5b21a3] hover:bg-[#dac5ee]'
: dark ? 'border-white/10 bg-white/5 text-white/70 hover:bg-white/10' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075] hover:bg-[#ece6f4]' : dark ? 'border-white/10 bg-white/5 text-white/70 hover:bg-white/10' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075] hover:bg-[#ece6f4]'
].join(' ')} ].join(' ')}
> >
{activeOnly ? <Check size={13} /> : <CircleDot size={13} />}{t("활성 연결")} {activeConnections} {activeOnly
? <Check size={13} className="block shrink-0" />
: <CircleDot size={13} className="block shrink-0" />}
<span className="inline-flex translate-y-px items-center gap-1 leading-none"><span>{t("활성 연결")}</span><span>{activeConnections}</span></span>
</button> </button>
{rosGraph.message && ( {rosGraph.message && (
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}> <span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
{rosGraph.message} {rosGraph.message}
</span> </span>
)} )}
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/62' : 'border-[#aebace] bg-[#f6f8fb] text-[#657187]'].join(' ')}> <span className={['inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-semibold leading-none', dark ? 'border-white/10 bg-white/5 text-white/62' : 'border-[#aebace] bg-[#f6f8fb] text-[#657187]'].join(' ')}>
ROS Domain {rosDomainId} <span className="translate-y-px">ROS Domain</span><span className="translate-y-px">{rosDomainId}</span>
</span> </span>
</div> </div>
<button <button
type="button" type="button"
onClick={() => void refreshRosGraph(true)} onClick={() => void refreshRosGraph(true)}
disabled={rosGraphLoading} disabled={rosGraphLoading}
className={['inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-semibold transition', rosGraphLoading ? 'cursor-wait opacity-60' : 'cursor-pointer', dark ? 'border-white/12 text-white/72 hover:bg-white/10 hover:text-white' : 'border-[#a6b4c9] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')} className={['inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-semibold leading-none transition', rosGraphLoading ? 'cursor-wait opacity-60' : 'cursor-pointer', dark ? 'border-white/12 text-white/72 hover:bg-white/10 hover:text-white' : 'border-[#a6b4c9] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')}
> >
<RefreshCw size={13} className={rosGraphLoading ? 'animate-spin' : ''} />{t("새로고침")}</button> <RefreshCw size={13} className={['block shrink-0', rosGraphLoading ? 'animate-spin' : ''].join(' ')} />
<span className="translate-y-px leading-none">{t("새로고침")}</span>
</button>
</div> </div>
<div <div
@@ -80,7 +80,9 @@ export default function WorkspaceTopBar({
</div> </div>
<div className={['flex items-center gap-2 text-[12px] font-bold', textMid].join(' ')}> <div className={['flex items-center gap-2 text-[12px] font-bold', textMid].join(' ')}>
<button type="button" onClick={onToggleLeftPanel} className={topToggleButtonClass(!leftPanelMinimized)}> <button type="button" onClick={onToggleLeftPanel} className={topToggleButtonClass(!leftPanelMinimized)}>
<BookOpen size={14} />{t("학습가이드")}</button> <BookOpen size={14} className="block shrink-0" />
<span className="translate-y-px leading-none">{t("학습가이드")}</span>
</button>
<button <button
type="button" type="button"
onClick={onToggleRightPanel} onClick={onToggleRightPanel}
@@ -92,14 +94,18 @@ export default function WorkspaceTopBar({
<span className="pointer-events-none absolute inset-0 rounded-md bg-[#60a5fa]/18 shadow-[0_0_18px_rgba(96,165,250,0.38)] animate-pulse" /> <span className="pointer-events-none absolute inset-0 rounded-md bg-[#60a5fa]/18 shadow-[0_0_18px_rgba(96,165,250,0.38)] animate-pulse" />
</> </>
)} )}
<MessageCircle size={14} className="relative z-10" /> <MessageCircle size={14} className="relative z-10 block shrink-0" />
<span className="relative z-10">{t("질의응답")}</span> <span className="relative z-10 translate-y-px leading-none">{t("질의응답")}</span>
</button> </button>
<button type="button" onClick={onTogglePreview} className={topToggleButtonClass(previewOpen)}> <button type="button" onClick={onTogglePreview} className={topToggleButtonClass(previewOpen)}>
<PanelLeft size={14} />{t("시각화")}</button> <PanelLeft size={14} className="block shrink-0" />
<span className="translate-y-px leading-none">{t("시각화")}</span>
</button>
{cameraAvailable && ( {cameraAvailable && (
<button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}> <button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}>
<Camera size={14} />{t("오버뷰")}</button> <Camera size={14} className="block shrink-0" />
<span className="translate-y-px leading-none">{t("오버뷰")}</span>
</button>
)} )}
{showControlCountdown && ( {showControlCountdown && (
<> <>
@@ -0,0 +1,129 @@
import type { AgentConversationMessage } from './useAiAgent'
type PendingMessage = { message: AgentConversationMessage; serialized: string }
type SessionSyncOptions = {
save: (messages: AgentConversationMessage[]) => Promise<unknown>
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
storageKey: string
}
export function createAgentSessionSync({ save, storage, storageKey }: SessionSyncOptions) {
const pending = new Map<string, PendingMessage>()
const localMessages = new Map<string, AgentConversationMessage>()
const signatures = new Map<string, string>()
const listeners = new Set<() => void>()
let running: Promise<void> | null = null
let clearing: Promise<void> | null = null
let error: string | null = null
try {
const cached: unknown = JSON.parse(storage?.getItem(storageKey) ?? '[]')
if (Array.isArray(cached)) {
for (const message of cached) {
if (message && typeof message.id === 'string' && typeof message.content === 'string'
&& (message.role === 'user' || message.role === 'assistant')) {
const serialized = JSON.stringify(message)
pending.set(message.id, { message, serialized })
localMessages.set(message.id, message)
signatures.set(message.id, serialized)
}
}
}
} catch (cause) {
console.warn('[agent] failed to read pending conversation', cause)
}
const notify = (): void => { listeners.forEach((listener) => listener()) }
const persistPending = (): void => {
try {
if (pending.size) storage?.setItem(storageKey, JSON.stringify([...pending.values()].map((entry) => entry.message)))
else storage?.removeItem(storageKey)
} catch (cause) {
console.warn('[agent] failed to cache pending conversation', cause)
}
}
const flush = (): Promise<void> => {
if (clearing) return clearing.catch(() => undefined)
if (running) return running
if (!pending.size) return Promise.resolve()
// Keep writes ordered even when a component leaves the workspace mid-save.
running = Promise.resolve().then(async () => {
try {
while (pending.size) {
const batch: PendingMessage[] = []
let characters = 0
for (const entry of pending.values()) {
if (batch.length >= 100 || (batch.length && characters + entry.serialized.length > 4_500_000)) break
batch.push(entry)
characters += entry.serialized.length
}
await save(batch.map((entry) => entry.message))
for (const entry of batch) {
if (pending.get(entry.message.id) === entry) pending.delete(entry.message.id)
}
persistPending()
}
error = null
} catch (cause) {
console.warn('[agent] failed to persist conversation', cause)
error = '대화를 서버에 저장하지 못했습니다. 자동으로 다시 시도합니다.'
} finally {
running = null
notify()
}
})
return running
}
return {
enqueue(messages: AgentConversationMessage[]): void {
if (clearing) return
let changed = false
for (const message of messages) {
const serialized = JSON.stringify(message)
if (signatures.get(message.id) === serialized) continue
const snapshot = JSON.parse(serialized) as AgentConversationMessage
signatures.set(message.id, serialized)
localMessages.set(message.id, snapshot)
pending.set(message.id, { message: snapshot, serialized })
changed = true
}
if (changed) {
persistPending()
void flush()
}
},
restore(serverMessages: AgentConversationMessage[]): AgentConversationMessage[] {
const merged = new Map(serverMessages.map((message) => [message.id, message]))
for (const message of serverMessages) {
if (!localMessages.has(message.id)) signatures.set(message.id, JSON.stringify(message))
}
for (const message of localMessages.values()) merged.set(message.id, message)
return [...merged.values()]
},
flush,
setSave(nextSave: SessionSyncOptions['save']): void { save = nextSave },
clear(archive: () => Promise<unknown>): Promise<void> {
if (clearing) return clearing
clearing = Promise.resolve().then(async () => {
if (running) await running
await archive()
pending.clear()
localMessages.clear()
signatures.clear()
error = null
persistPending()
}).finally(() => {
clearing = null
notify()
})
return clearing
},
getError: (): string | null => error,
subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
}
}
@@ -0,0 +1,397 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync'
export type AgentFileChange = {
moduleName: string
path: string
content: string
reason: string
}
export type AgentModulePlan = {
name: string
category: string
folderName: string
dockerImage: string | null
packageName: string
launchFile: string
}
export type AgentCommand = {
id: string
moduleName: string
command: string
purpose: string
}
export type AgentCommandExecution = {
status: 'pending' | 'running' | 'completed' | 'failed' | 'rejected'
output?: string
exitCode?: number | null
error?: string
}
export type AgentPlan = {
summary: string
explanation: string
requestedModules: string[]
requestedLogs: string[]
actionPlan: string[]
commands: AgentCommand[]
changes: AgentFileChange[]
createModule: AgentModulePlan | null
}
export type AgentWorkspaceModule = {
name: string
folderName?: string
category?: string
state?: string
files: Array<{ path: string; content: string }>
}
export type AgentRuntimeLog = {
moduleName: string
state: string
content: string
message?: string
}
export type AgentConversationMessage = {
id: string
role: 'user' | 'assistant'
content: string
provider?: string
model?: string
plan?: AgentPlan
applyState?: 'ready' | 'applying' | 'applied' | 'error'
applyError?: string
commandExecutions?: Record<string, AgentCommandExecution>
usage?: {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
}
type UseAiAgentOptions = {
accessToken: string
studentEmail: string
courseId: string
weekNumber: number
apiBaseUrl: string
getFreshAccessToken: () => Promise<string>
onResponse?: () => void
}
type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
}
type AgentSessionResponse = {
sessionId: string | null
messages: AgentConversationMessage[]
}
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
const [status, setStatus] = useState<AgentStatus>({ enabled: false, provider: null, model: null })
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [messages, setMessages] = useState<AgentConversationMessage[]>([])
const abortControllerRef = useRef<AbortController | null>(null)
const tokenReaderRef = useRef(getFreshAccessToken)
tokenReaderRef.current = getFreshAccessToken
const onResponseRef = useRef(onResponse)
onResponseRef.current = onResponse
const authenticated = Boolean(accessToken && courseId && studentEmail)
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
const authorization = new Headers(options?.headers).get('Authorization')
?? `Bearer ${await tokenReaderRef.current()}`
const response = await fetch(`${apiBaseUrl}${path}`, {
...options,
headers: {
...(options?.body ? { 'Content-Type': 'application/json' } : {}),
Authorization: authorization,
...options?.headers
}
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as { message?: string | string[] } | null
const message = Array.isArray(payload?.message) ? payload.message.join('\n') : payload?.message
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
}
return response.json() as Promise<T>
}, [apiBaseUrl])
const session = useMemo(() => {
const scope = JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId, weekNumber])
const save = (batch: AgentConversationMessage[]) => requestApi<AgentSessionResponse>('/chat/agent/session/sync', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({
courseId,
week: Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : undefined,
messages: batch
})
})
let sync = sessionSyncs.get(scope)
if (!sync) {
let storage: Storage | undefined
try { storage = window.localStorage } catch { /* memory queue still works */ }
sync = createAgentSessionSync({ save, storage, storageKey: `autonomy-studio.lms.agent-pending:${scope}` })
sessionSyncs.set(scope, sync)
} else sync.setSave(save)
return sync
}, [accessToken, apiBaseUrl, courseId, requestApi, studentEmail, weekNumber])
const persistenceError = useSyncExternalStore(session.subscribe, session.getError)
const messageStateRef = useRef({ session, messages })
const updateMessages = useCallback((update: (current: AgentConversationMessage[]) => AgentConversationMessage[]): void => {
if (messageStateRef.current.session !== session) return
const next = update(messageStateRef.current.messages)
messageStateRef.current = { session, messages: next }
setMessages(next)
session.enqueue(next)
}, [session])
useEffect(() => {
if (!accessToken || !courseId) {
setStatus({ enabled: false, provider: null, model: null })
setStatusLoading(false)
return
}
let cancelled = false
setStatusLoading(true)
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
.then((nextStatus) => {
if (!cancelled) setStatus(nextStatus)
})
.catch(() => {
if (!cancelled) setStatus({ enabled: false, provider: null, model: null })
})
.finally(() => {
if (!cancelled) setStatusLoading(false)
})
return () => {
cancelled = true
}
}, [accessToken, courseId, requestApi, weekNumber])
// The workspace stays mounted while other app pages or Q&A tabs are shown.
// Request cancellation follows the conversation lifetime, not its visibility.
useEffect(() => {
setPending(false)
return () => {
abortControllerRef.current?.abort()
abortControllerRef.current = null
}
}, [authenticated, session])
useEffect(() => {
const cached = authenticated ? session.restore([]) : []
messageStateRef.current = { session, messages: cached }
setMessages(cached)
setHistoryError(null)
if (!authenticated) {
setHistoryLoading(false)
return
}
let cancelled = false
setHistoryLoading(true)
const query = new URLSearchParams({ courseId })
if (Number.isInteger(weekNumber) && weekNumber >= 1) query.set('week', String(weekNumber))
requestApi<AgentSessionResponse>(`/chat/agent/session?${query.toString()}`)
.then((response) => {
if (!cancelled) {
const restored = session.restore(Array.isArray(response.messages) ? response.messages : [])
messageStateRef.current = { session, messages: restored }
setMessages(restored)
}
})
.catch((error) => {
if (!cancelled) {
console.warn('[agent] failed to load conversation', error)
setHistoryError('대화 내역을 불러오지 못했습니다.')
}
})
.finally(() => {
if (!cancelled) {
setHistoryLoading(false)
}
})
void session.flush()
const retry = (): void => { void session.flush() }
const retryTimer = window.setInterval(retry, 10_000)
window.addEventListener('online', retry)
return () => {
cancelled = true
window.clearInterval(retryTimer)
window.removeEventListener('online', retry)
}
}, [authenticated, courseId, requestApi, session, weekNumber])
const requestPlan = async (
rawRequest: string,
modules: AgentWorkspaceModule[],
runtimeLogs: AgentRuntimeLog[] = [],
options: {
showUserMessage?: boolean
loadRequestedContext?: (request: { modules: string[]; logs: string[] }) => Promise<{
modules: AgentWorkspaceModule[]
runtimeLogs: AgentRuntimeLog[]
}>
} = {}
): Promise<void> => {
const request = rawRequest.trim()
if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return
const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`,
role: 'user',
content: request
}
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true)
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
let response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', {
method: 'POST',
signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request, modules, runtimeLogs })
})
const requestedModules = response.plan.requestedModules ?? []
const requestedLogs = response.plan.requestedLogs ?? []
if ((requestedModules.length || requestedLogs.length) && options.loadRequestedContext) {
const requestedContext = await options.loadRequestedContext({ modules: requestedModules, logs: requestedLogs })
response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', {
method: 'POST',
signal: abortController.signal,
body: JSON.stringify({
courseId,
week: weekNumber,
request,
modules: requestedContext.modules,
runtimeLogs: requestedContext.runtimeLogs
})
})
}
if (abortController.signal.aborted) return
const plan: AgentPlan = {
...response.plan,
requestedModules: response.plan.requestedModules ?? [],
requestedLogs: response.plan.requestedLogs ?? [],
actionPlan: response.plan.actionPlan ?? [],
commands: response.plan.commands ?? []
}
const hasApplicableChanges = plan.changes.length > 0 || plan.createModule !== null
updateMessages((current) => [...current, {
id: `agent-plan-${Date.now()}`,
role: 'assistant',
content: plan.summary,
provider: response.provider,
model: response.model,
usage: response.usage,
plan,
applyState: hasApplicableChanges ? 'ready' : undefined,
commandExecutions: Object.fromEntries(plan.commands.map((command) => [command.id, { status: 'pending' }]))
}])
onResponseRef.current?.()
} catch (error) {
if (abortController.signal.aborted) return
updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`,
role: 'assistant',
content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.'
}])
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null
setPending(false)
}
}
}
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
const message = messages.find((item) => item.id === messageId)
if (!message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
try {
await apply(message.plan)
updateMessages((current) => current.map((item) => item.id === messageId
? {
...item,
applyState: 'applied',
applyError: undefined,
commandExecutions: Object.fromEntries(Object.entries(item.commandExecutions ?? {}).map(([commandId, execution]) => [
commandId,
execution.status === 'completed' || execution.status === 'failed' ? { status: 'pending' as const } : execution
]))
}
: item))
} catch (error) {
const applyError = error instanceof Error ? error.message : '변경안을 적용하지 못했습니다.'
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item))
}
}
const stop = (): void => {
abortControllerRef.current?.abort()
abortControllerRef.current = null
setPending(false)
}
const updateCommandExecution = (messageId: string, commandId: string, execution: AgentCommandExecution): void => {
updateMessages((current) => current.map((message) => message.id === messageId
? {
...message,
commandExecutions: {
...message.commandExecutions,
[commandId]: execution
}
}
: message))
}
const clear = (): void => {
if (!authenticated || historyLoading) return
stop()
setHistoryLoading(true)
const query = new URLSearchParams({ courseId })
if (Number.isInteger(weekNumber) && weekNumber >= 1) query.set('week', String(weekNumber))
void session.clear(() => requestApi<{ archivedCount: number }>(`/chat/agent/session?${query.toString()}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` }
})).then(() => {
if (messageStateRef.current.session !== session) return
messageStateRef.current = { session, messages: [] }
setMessages([])
setHistoryError(null)
}).catch((error) => {
console.warn('[agent] failed to archive conversation', error)
if (messageStateRef.current.session === session) setHistoryError('대화 내역을 삭제하지 못했습니다.')
}).finally(() => {
if (messageStateRef.current.session === session) setHistoryLoading(false)
})
}
return {
...status,
statusLoading: statusLoading || historyLoading,
pending,
messages,
persistenceError: persistenceError ?? historyError,
requestPlan,
applyPlan,
updateCommandExecution,
stop,
clear
}
}
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
import type { Socket } from 'socket.io-client' import type { Socket } from 'socket.io-client'
export type QaMode = 'ai' | 'teacher' export type QaMode = 'ai' | 'agent' | 'teacher'
export type QaChatMessage = { export type QaChatMessage = {
id: string id: string
@@ -221,7 +221,7 @@ export function useQaChat({
useEffect(() => { useEffect(() => {
const target = workspaceChatScrollRef.current const target = workspaceChatScrollRef.current
if (!target) return if (!target) return
const currentLoading = qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading const currentLoading = qaMode === 'ai' ? aiMessagesLoading : qaMode === 'teacher' ? teacherMessagesLoading : false
if (currentLoading) return if (currentLoading) return
const scrollToBottom = (): void => { const scrollToBottom = (): void => {
@@ -286,6 +286,7 @@ export function useQaChat({
const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => { const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => {
const content = rawContent.trim() const content = rawContent.trim()
if (!content || aiPending || aiTypingMessageId || !chatAvailable) return if (!content || aiPending || aiTypingMessageId || !chatAvailable) return
if (qaMode === 'agent') return
setQaCollapseSignal((value) => value + 1) setQaCollapseSignal((value) => value + 1)
if (qaMode !== 'ai') { if (qaMode !== 'ai') {
@@ -427,6 +428,7 @@ export function useQaChat({
const clearQaConversation = async (): Promise<void> => { const clearQaConversation = async (): Promise<void> => {
if (!chatAvailable) return if (!chatAvailable) return
if (qaMode === 'agent') return
if (qaMode === 'ai') { if (qaMode === 'ai') {
aiRevealCancelRef.current = true aiRevealCancelRef.current = true
aiAbortControllerRef.current?.abort() aiAbortControllerRef.current?.abort()
@@ -493,7 +495,7 @@ export function useQaChat({
aiTypingMessageId, aiTypingMessageId,
teacherThreadStatus, teacherThreadStatus,
qaCollapseSignal, qaCollapseSignal,
qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading, qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : qaMode === 'teacher' ? teacherMessagesLoading : false,
visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit), visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
displayedAiMessages: selectedAiConversation displayedAiMessages: selectedAiConversation
? selectedAiConversation.messages ? selectedAiConversation.messages
@@ -115,7 +115,10 @@ export function useRemoteWorkspaceRuntime({
.sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0)) .sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0))
.map(({ moduleName, folderName, templateRepositoryUrl }) => ({ moduleName, folderName, templateRepositoryUrl })) .map(({ moduleName, folderName, templateRepositoryUrl }) => ({ moduleName, folderName, templateRepositoryUrl }))
if (!modules.length) return if (!modules.length) return
await window.remoteWorkspace.modules.resetSession(getRemoteWorkspaceTarget(), modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' })) await window.remoteWorkspace.modules.stopMany(
getRemoteWorkspaceTarget(),
modules.map(({ moduleName, folderName }) => ({ moduleName, folderName })),
).catch(() => ({ ok: false, message: '모듈 중지 실패' }))
return return
} }
await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' })) await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' }))
@@ -70,7 +70,7 @@ type UseWorkspaceModuleControlResult = {
startAllModules: () => Promise<void> startAllModules: () => Promise<void>
stopAllModules: () => Promise<void> stopAllModules: () => Promise<void>
restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean> restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<void> runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<boolean>
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void> updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
} }
@@ -473,8 +473,8 @@ export function useWorkspaceModuleControl({
} }
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId]) }, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => { const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<boolean> => {
if (!workspaceActiveRef.current) return if (!workspaceActiveRef.current) return false
// The parking area only opts modules out of bulk actions; those modules // The parking area only opts modules out of bulk actions; those modules
// must remain individually controllable while the rest are stopping. // must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = ( const canControlExcludedModuleDuringBulkStop = (
@@ -484,21 +484,21 @@ export function useWorkspaceModuleControl({
if ( if (
(!canControlExcludedModuleDuringBulkStop && installingModuleName) (!canControlExcludedModuleDuringBulkStop && installingModuleName)
|| queuedModuleActionsRef.current.has(module.name) || queuedModuleActionsRef.current.has(module.name)
) return ) return false
const actionId = ++moduleActionIdRef.current const actionId = ++moduleActionIdRef.current
const controlGeneration = controlGenerationRef.current const controlGeneration = controlGenerationRef.current
queuedModuleActionsRef.current.set(module.name, actionId) queuedModuleActionsRef.current.set(module.name, actionId)
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name])) setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null) setModuleInstallError(null)
const execute = async (): Promise<void> => { const execute = async (): Promise<boolean> => {
const isCurrentControl = (): boolean => ( const isCurrentControl = (): boolean => (
workspaceActiveRef.current workspaceActiveRef.current
&& controlGenerationRef.current === controlGeneration && controlGenerationRef.current === controlGeneration
&& queuedModuleActionsRef.current.get(module.name) === actionId && queuedModuleActionsRef.current.get(module.name) === actionId
) )
try { try {
if (!isCurrentControl()) return if (!isCurrentControl()) return false
const savedMapOverrides = action === 'start' || action === 'restart' const savedMapOverrides = action === 'start' || action === 'restart'
? await prepareSavedMap?.() ? await prepareSavedMap?.()
: undefined : undefined
@@ -518,7 +518,7 @@ export function useWorkspaceModuleControl({
? await remoteModuleStart(module, savedMapOverrides) ? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
if (!isCurrentControl()) return if (!isCurrentControl()) return false
if (action === 'start' || action === 'restart') { if (action === 'start' || action === 'restart') {
markModuleState(module, 'running') markModuleState(module, 'running')
@@ -537,9 +537,9 @@ export function useWorkspaceModuleControl({
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패') if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
} }
} }
if (!isCurrentControl()) return if (!isCurrentControl()) return false
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
if (!isCurrentControl()) return if (!isCurrentControl()) return false
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
? '종료되었습니다' ? '종료되었습니다'
: action === 'build' : action === 'build'
@@ -548,9 +548,10 @@ export function useWorkspaceModuleControl({
? '재시작되었습니다' ? '재시작되었습니다'
: '실행되었습니다' : '실행되었습니다'
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info') onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
return true
} catch (error) { } catch (error) {
console.error(`[modules] ${action} failed`, error) console.error(`[modules] ${action} failed`, error)
if (!isCurrentControl()) return if (!isCurrentControl()) return false
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패') setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
? '종료 실패' ? '종료 실패'
@@ -561,6 +562,7 @@ export function useWorkspaceModuleControl({
: '실행 실패' : '실행 실패'
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error') onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
return false
} finally { } finally {
if (queuedModuleActionsRef.current.get(module.name) === actionId) { if (queuedModuleActionsRef.current.get(module.name) === actionId) {
queuedModuleActionsRef.current.delete(module.name) queuedModuleActionsRef.current.delete(module.name)
@@ -570,13 +572,12 @@ export function useWorkspaceModuleControl({
} }
if (action === 'stop') { if (action === 'stop') {
await execute() return execute()
return
} }
const queuedWork = moduleActionQueueRef.current.then(execute, execute) const queuedWork = moduleActionQueueRef.current.then(execute, execute)
moduleActionQueueRef.current = queuedWork.then(() => undefined, () => undefined) moduleActionQueueRef.current = queuedWork.then(() => undefined, () => undefined)
await queuedWork return queuedWork
}, [ }, [
activeRuntimeImages, activeRuntimeImages,
availableModules, availableModules,
+30 -1
View File
@@ -662,5 +662,34 @@
"활성": "Active", "활성": "Active",
"활성 연결": "Active connections", "활성 연결": "Active connections",
"활성 연결만 보기": "Show active connections only", "활성 연결만 보기": "Show active connections only",
"활성 연결이 없습니다.": "No active connections." "활성 연결이 없습니다.": "No active connections.",
"에이전트": "Agent",
"에이전트 설정을 확인하는 중입니다.": "Checking agent settings.",
"강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.": "The agent becomes available after the instructor configures it for this course.",
"현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.": "Ask the agent to change the current module or create a new one.",
"변경 내용은 확인 후 직접 적용합니다.": "Review the proposed changes before applying them.",
"새 모듈": "New module",
"적용 중": "Applying",
"적용 완료": "Applied",
"다시 적용": "Retry apply",
"변경 적용": "Apply changes",
"변경사항 비교": "Compare changes",
"AI 변경안": "AI proposal",
"작업 계획": "Action plan",
"명령어": "Command",
"모듈 시작": "Start module",
"AI 에이전트 답변이 도착했습니다.": "A new AI agent response is available.",
"대화를 서버에 저장하지 못했습니다. 자동으로 다시 시도합니다.": "Could not save the conversation to the server. Retrying automatically.",
"대화 내역을 불러오지 못했습니다.": "Could not load the conversation history.",
"대화 내역을 삭제하지 못했습니다.": "Could not delete the conversation history.",
"코드 변경을 먼저 적용하세요.": "Apply the code changes first.",
"변경 적용 후 실행": "Run after applying changes",
"거부": "Reject",
"승인": "Approve",
"거부됨": "Rejected",
"실행 완료": "Completed",
"실행 실패": "Failed",
"다시 실행": "Run again",
"명령 출력이 없습니다.": "The command produced no output.",
"코드 변경안을 만드는 중입니다.": "Preparing code changes."
} }