feat(ai): improve agent workflows and persistence for v1.2.14
This commit is contained in:
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lms",
|
||||
"version": "1.2.1",
|
||||
"version": "1.2.14",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.9",
|
||||
"version": "1.2.14",
|
||||
"description": "Autonomy Studio LMS Electron app",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
|
||||
@@ -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([])), [])
|
||||
})
|
||||
@@ -5811,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> {
|
||||
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
|
||||
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
|
||||
@@ -6475,6 +6680,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
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: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: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))
|
||||
@@ -6539,6 +6745,7 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
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: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: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))
|
||||
|
||||
@@ -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),
|
||||
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: {
|
||||
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'),
|
||||
@@ -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),
|
||||
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: {
|
||||
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),
|
||||
|
||||
+300
-28
@@ -58,7 +58,7 @@ import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminal
|
||||
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
|
||||
import { useQaChat } from './features/workspace/useQaChat'
|
||||
import { useAiAgent } from './features/workspace/useAiAgent'
|
||||
import type { AgentPlan, AgentWorkspaceModule } from './features/workspace/useAiAgent'
|
||||
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
|
||||
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
|
||||
import { useSourceControl } from './features/workspace/useSourceControl'
|
||||
import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus'
|
||||
@@ -400,6 +400,18 @@ type ModuleWorkspaceSnapshot = {
|
||||
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 VisualizationResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||
@@ -614,16 +626,20 @@ function LocalPcWorkspaceMockup({
|
||||
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
|
||||
getFreshAccessToken,
|
||||
onResponse: () => onTitlebarNotice(t("AI 에이전트 답변이 도착했습니다."), 'info', true)
|
||||
})
|
||||
const [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
|
||||
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
|
||||
@@ -687,6 +703,8 @@ function LocalPcWorkspaceMockup({
|
||||
finishTabDrop,
|
||||
resetTabDrag
|
||||
} = 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 [imagePreviewSources, setImagePreviewSources] = useState<Partial<Record<string, string>>>(initialImagePreviewSources)
|
||||
const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null)
|
||||
@@ -730,6 +748,8 @@ function LocalPcWorkspaceMockup({
|
||||
setActiveExampleEditorFile(null)
|
||||
setOpenExampleEditorTabs([])
|
||||
setSelectedExampleTreePath(null)
|
||||
setAgentDiffPreviews([])
|
||||
setActiveAgentDiffPreviewId(null)
|
||||
}, [curriculumWeek.week])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2337,7 +2357,7 @@ function LocalPcWorkspaceMockup({
|
||||
visualizationHostInstanceId,
|
||||
])
|
||||
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
|
||||
? dark
|
||||
? 'border-[#2563eb]/45 bg-[#2563eb]/24 text-[#9dbdff]'
|
||||
@@ -2346,8 +2366,15 @@ function LocalPcWorkspaceMockup({
|
||||
? '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]'
|
||||
].join(' ')
|
||||
const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext'
|
||||
const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false
|
||||
const visibleAgentDiffPreviews = exampleActive || !selectedModule
|
||||
? []
|
||||
: 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 remoteEditorFile = useRemoteEditorFile({
|
||||
enabled: remoteWorkspace,
|
||||
@@ -2379,6 +2406,7 @@ function LocalPcWorkspaceMockup({
|
||||
return () => { disposed = true }
|
||||
}, [activeFileIsImage, clonePathPreview, currentActiveEditorFile, imagePreviewSources, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule])
|
||||
const openEditorFile = (file: EditorFilePath): void => {
|
||||
setActiveAgentDiffPreviewId(null)
|
||||
if (file.startsWith('examples/example-code/')) {
|
||||
setSelectedExampleTreePath(file)
|
||||
setActiveExampleEditorFile(file)
|
||||
@@ -2390,6 +2418,7 @@ function LocalPcWorkspaceMockup({
|
||||
setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file]))
|
||||
}
|
||||
const openEditorFileAtLine = (file: EditorFilePath): void => {
|
||||
setActiveAgentDiffPreviewId(null)
|
||||
setSelectedTreePath(file)
|
||||
setActiveEditorFile(file)
|
||||
setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file]))
|
||||
@@ -3465,49 +3494,259 @@ function LocalPcWorkspaceMockup({
|
||||
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
|
||||
await createCustomModuleRecord(input)
|
||||
}
|
||||
const collectAgentWorkspace = async (): Promise<AgentWorkspaceModule[]> => {
|
||||
if (!selectedModule) return []
|
||||
const modulePrefix = `modules/${selectedModule.name}`
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), selectedModule.name, selectedModule.folderName)
|
||||
: await window.localWorkspace.moduleFiles.read(selectedModule.name, clonePathPreview, selectedModule.folderName)
|
||||
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
|
||||
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) => left === preferredPath ? -1 : right === preferredPath ? 1 : left.localeCompare(right))
|
||||
.slice(0, 24)
|
||||
.sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right))
|
||||
const files: AgentWorkspaceModule['files'] = []
|
||||
let totalCharacters = 0
|
||||
|
||||
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(), selectedModule.name, rendererPath, selectedModule.folderName)).content
|
||||
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 })
|
||||
}
|
||||
|
||||
return [{ name: selectedModule.name, folderName: selectedModule.folderName, files }]
|
||||
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)
|
||||
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) {
|
||||
@@ -3578,6 +3817,9 @@ function LocalPcWorkspaceMockup({
|
||||
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> => {
|
||||
@@ -4051,19 +4293,36 @@ function LocalPcWorkspaceMockup({
|
||||
gridColumn: 6,
|
||||
gridRow: 2,
|
||||
gridTemplateRows: exampleActive
|
||||
? currentOpenEditorTabs.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)' : 'minmax(0, 1fr)'
|
||||
: visibleEditorTabs.length ? `44px 28px minmax(0, 1fr) ${terminalPanelHeight}px` : `minmax(0, 1fr) ${terminalPanelHeight}px`
|
||||
}}
|
||||
>
|
||||
<WorkspaceEditorTabs
|
||||
dark={dark}
|
||||
files={currentOpenEditorTabs}
|
||||
activeFile={currentActiveEditorFile}
|
||||
files={visibleEditorTabs}
|
||||
activeFile={activeEditorTab}
|
||||
draggingFile={draggingEditorTab}
|
||||
dropIndex={tabDropIndex}
|
||||
isDirtyFile={isDirtyEditorFile}
|
||||
onOpenFile={openEditorFile}
|
||||
onCloseFile={closeEditorTab}
|
||||
isPreviewFile={(file) => file.startsWith('agent-diff:')}
|
||||
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) => {
|
||||
setTabContextMenu({ file, x, y })
|
||||
}}
|
||||
@@ -4116,8 +4375,9 @@ function LocalPcWorkspaceMockup({
|
||||
|
||||
<WorkspaceEditorPathBar
|
||||
dark={dark}
|
||||
currentActiveEditorFile={currentActiveEditorFile}
|
||||
currentActiveEditorFile={activeEditorDisplayPath}
|
||||
exampleActive={exampleActive}
|
||||
readOnlyLabel={activeAgentDiffPreview ? t("AI 변경안") : undefined}
|
||||
isDirtyFile={isDirtyEditorFile}
|
||||
onSave={saveCurrentEditorFile}
|
||||
/>
|
||||
@@ -4129,7 +4389,8 @@ function LocalPcWorkspaceMockup({
|
||||
dark={dark}
|
||||
textDim={textDim}
|
||||
editorPaneRef={editorPaneRef}
|
||||
currentActiveEditorFile={currentActiveEditorFile}
|
||||
currentActiveEditorFile={activeEditorDisplayPath}
|
||||
diffPreview={activeAgentDiffPreview}
|
||||
activeFileIsImage={activeFileIsImage}
|
||||
activeImagePreviewSource={activeImagePreviewSource}
|
||||
editorLanguage={editorLanguage}
|
||||
@@ -4295,6 +4556,7 @@ function LocalPcWorkspaceMockup({
|
||||
agentEnabled={agentEnabled}
|
||||
agentStatusLoading={agentStatusLoading}
|
||||
agentPending={agentPending}
|
||||
agentPersistenceError={agentPersistenceError}
|
||||
aiPending={aiPending}
|
||||
teacherThreadStatus={teacherThreadStatus}
|
||||
qaCollapseSignal={qaCollapseSignal}
|
||||
@@ -4312,10 +4574,20 @@ function LocalPcWorkspaceMockup({
|
||||
else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
|
||||
}}
|
||||
onClearConversation={() => {
|
||||
if (qaMode === 'agent') clearAgentConversation()
|
||||
if (qaMode === 'agent') {
|
||||
clearAgentConversation()
|
||||
setAgentDiffPreviews([])
|
||||
setActiveAgentDiffPreviewId(null)
|
||||
}
|
||||
else void clearQaConversation().catch((error) => console.error('[chat] clear failed', error))
|
||||
}}
|
||||
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, () => applyWorkspaceAgentPlan(plan))}
|
||||
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 && (
|
||||
|
||||
@@ -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(' ')}>
|
||||
<textarea
|
||||
rows={2}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
|
||||
Vendored
+12
@@ -91,6 +91,14 @@ interface Window {
|
||||
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 }>
|
||||
}
|
||||
agent: {
|
||||
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => Promise<{
|
||||
ok: boolean
|
||||
output: string
|
||||
exitCode: number | null
|
||||
message?: string
|
||||
}>
|
||||
}
|
||||
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<{
|
||||
cacheRoot: string
|
||||
@@ -267,6 +275,7 @@ interface Window {
|
||||
openGuide: () => Promise<void>
|
||||
}
|
||||
moduleFiles: Window['moduleFiles']
|
||||
agent: Window['agent']
|
||||
modules: Window['modules']
|
||||
rosGraph: {
|
||||
snapshot: () => Promise<{
|
||||
@@ -310,6 +319,9 @@ interface Window {
|
||||
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']>
|
||||
}
|
||||
agent: {
|
||||
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ReturnType<Window['agent']['runCommand']>
|
||||
}
|
||||
maps: {
|
||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
||||
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
|
||||
|
||||
@@ -112,15 +112,17 @@ export default function ModuleListToolbar({
|
||||
onClick()
|
||||
}}
|
||||
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',
|
||||
dark
|
||||
? '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
|
||||
].join(' ')}
|
||||
>
|
||||
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />}
|
||||
{t(label)}
|
||||
{busy
|
||||
? <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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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'
|
||||
|
||||
type Props = {
|
||||
@@ -10,6 +10,11 @@ type Props = {
|
||||
textDim: string
|
||||
editorPaneRef: React.RefObject<HTMLDivElement | null>
|
||||
currentActiveEditorFile: string | null
|
||||
diffPreview?: {
|
||||
id: string
|
||||
originalContent: string
|
||||
modifiedContent: string
|
||||
} | null
|
||||
activeFileIsImage: boolean
|
||||
activeImagePreviewSource?: string
|
||||
editorLanguage: string
|
||||
@@ -32,6 +37,7 @@ function WorkspaceEditorPane({
|
||||
textDim,
|
||||
editorPaneRef,
|
||||
currentActiveEditorFile,
|
||||
diffPreview,
|
||||
activeFileIsImage,
|
||||
activeImagePreviewSource,
|
||||
editorLanguage,
|
||||
@@ -48,7 +54,32 @@ function WorkspaceEditorPane({
|
||||
useLocaleRender()
|
||||
return (
|
||||
<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="px-6 py-5 text-center">
|
||||
<FileText size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} />
|
||||
|
||||
@@ -6,6 +6,7 @@ type Props = {
|
||||
dark: boolean
|
||||
currentActiveEditorFile: string | null
|
||||
exampleActive: boolean
|
||||
readOnlyLabel?: string
|
||||
isDirtyFile: (file: string) => boolean
|
||||
onSave: () => void
|
||||
}
|
||||
@@ -14,6 +15,7 @@ function WorkspaceEditorPathBar({
|
||||
dark,
|
||||
currentActiveEditorFile,
|
||||
exampleActive,
|
||||
readOnlyLabel,
|
||||
isDirtyFile,
|
||||
onSave
|
||||
}: Props): React.JSX.Element | null {
|
||||
@@ -31,8 +33,13 @@ function WorkspaceEditorPathBar({
|
||||
</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>
|
||||
{!exampleActive && (
|
||||
{!exampleActive && !readOnlyLabel && (
|
||||
<Tooltip label={t("저장")} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { FileText, X } from 'lucide-react'
|
||||
import { FileDiff, FileText, X } from 'lucide-react'
|
||||
|
||||
type EditorFilePath = string
|
||||
|
||||
@@ -10,6 +10,8 @@ type Props = {
|
||||
draggingFile: EditorFilePath | null
|
||||
dropIndex: number | null
|
||||
isDirtyFile: (file: EditorFilePath) => boolean
|
||||
isPreviewFile?: (file: EditorFilePath) => boolean
|
||||
getFileLabel?: (file: EditorFilePath) => string
|
||||
onOpenFile: (file: EditorFilePath) => void
|
||||
onCloseFile: (file: EditorFilePath) => void
|
||||
onContextMenu: (file: EditorFilePath, x: number, y: number) => void
|
||||
@@ -26,6 +28,8 @@ function WorkspaceEditorTabs({
|
||||
draggingFile,
|
||||
dropIndex,
|
||||
isDirtyFile,
|
||||
isPreviewFile = () => false,
|
||||
getFileLabel = (file) => file.split('/').pop() ?? file,
|
||||
onOpenFile,
|
||||
onCloseFile,
|
||||
onContextMenu,
|
||||
@@ -40,11 +44,14 @@ function WorkspaceEditorTabs({
|
||||
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="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
|
||||
key={file}
|
||||
type="button"
|
||||
draggable
|
||||
draggable={!previewFile}
|
||||
onClick={() => onOpenFile(file)}
|
||||
onAuxClick={(event) => {
|
||||
if (event.button !== 1) return
|
||||
@@ -53,12 +60,21 @@ function WorkspaceEditorTabs({
|
||||
}}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
if (previewFile) return
|
||||
onContextMenu(file, event.clientX, event.clientY)
|
||||
}}
|
||||
onDragStart={(event) => onDragStart(file, index, event)}
|
||||
onDragOver={(event) => onDragOver(index, event)}
|
||||
onDrop={onDrop}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragStart={(event) => {
|
||||
if (!previewFile) onDragStart(file, index, event)
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
if (!previewFile) onDragOver(index, event)
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
if (!previewFile) onDrop(event)
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (!previewFile) onDragEnd()
|
||||
}}
|
||||
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',
|
||||
draggingFile === file ? 'opacity-60' : '',
|
||||
@@ -77,8 +93,8 @@ function WorkspaceEditorTabs({
|
||||
{draggingFile && dropIndex === index + 1 && index === files.length - 1 && (
|
||||
<span className="absolute -right-[3px] bottom-0 top-0 w-[2px] rounded-full bg-[#d1d5db]" />
|
||||
)}
|
||||
<FileText size={13} />
|
||||
<span className="truncate">{file.split('/').pop()}</span>
|
||||
<FileIcon size={13} className={previewFile ? 'text-[#a78bfa]' : undefined} />
|
||||
<span className="truncate">{getFileLabel(file)}</span>
|
||||
{isDirtyFile(file) && (
|
||||
<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} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { Bot, Check, FileCode2, Loader2, MessageCircle, Minus, User, WandSparkles } 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 { useEffect, useState } from 'react'
|
||||
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||
import type { AgentConversationMessage, AgentPlan } from './useAiAgent'
|
||||
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
|
||||
|
||||
type QaMode = 'ai' | 'agent' | 'teacher'
|
||||
|
||||
@@ -36,6 +36,7 @@ type WorkspaceQaPanelProps = {
|
||||
agentEnabled: boolean
|
||||
agentStatusLoading: boolean
|
||||
agentPending: boolean
|
||||
agentPersistenceError?: string | null
|
||||
aiPending: boolean
|
||||
teacherThreadStatus: string | null
|
||||
qaCollapseSignal: number
|
||||
@@ -48,6 +49,40 @@ type WorkspaceQaPanelProps = {
|
||||
onSubmit: (content: string) => 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({
|
||||
@@ -70,6 +105,7 @@ export default function WorkspaceQaPanel({
|
||||
agentEnabled,
|
||||
agentStatusLoading,
|
||||
agentPending,
|
||||
agentPersistenceError,
|
||||
aiPending,
|
||||
teacherThreadStatus,
|
||||
qaCollapseSignal,
|
||||
@@ -81,7 +117,10 @@ export default function WorkspaceQaPanel({
|
||||
onStop,
|
||||
onSubmit,
|
||||
onClearConversation,
|
||||
onApplyAgentPlan
|
||||
onApplyAgentPlan,
|
||||
onOpenAgentChange,
|
||||
onRunAgentCommand,
|
||||
onRejectAgentCommand
|
||||
}: WorkspaceQaPanelProps): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
@@ -166,8 +205,10 @@ export default function WorkspaceQaPanel({
|
||||
>
|
||||
<div className={['border-b p-3', borderSoft, dark ? '' : 'bg-white'].join(' ')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={['flex items-center gap-2 text-[13px] font-bold', textStrong].join(' ')}>
|
||||
<MessageCircle size={14} className={textDim} />{t("질의응답")}</h3>
|
||||
<h3 className={['flex items-center gap-2 text-[13px] font-bold leading-none', textStrong].join(' ')}>
|
||||
<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(' ')}>
|
||||
<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>
|
||||
@@ -191,7 +232,7 @@ export default function WorkspaceQaPanel({
|
||||
if (!disabled) onModeChange(id)
|
||||
}}
|
||||
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
|
||||
? dark
|
||||
@@ -208,14 +249,17 @@ export default function WorkspaceQaPanel({
|
||||
: 'bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
|
||||
].join(' ')}
|
||||
>
|
||||
<Icon size={14} className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')} />
|
||||
<span className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{t(label)}</span>
|
||||
<Icon size={14} className={['relative z-10 block shrink-0', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')} />
|
||||
<span className={['relative z-10 translate-y-px leading-none', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{t(label)}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<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 ? (
|
||||
<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>
|
||||
@@ -238,6 +282,9 @@ export default function WorkspaceQaPanel({
|
||||
pending={agentPending}
|
||||
messages={agentMessages}
|
||||
onApply={onApplyAgentPlan}
|
||||
onOpenChange={onOpenAgentChange}
|
||||
onRunCommand={onRunAgentCommand}
|
||||
onRejectCommand={onRejectAgentCommand}
|
||||
/>
|
||||
) : (
|
||||
<TeacherMessages
|
||||
@@ -282,7 +329,10 @@ function AgentMessages({
|
||||
statusLoading,
|
||||
pending,
|
||||
messages,
|
||||
onApply
|
||||
onApply,
|
||||
onOpenChange,
|
||||
onRunCommand,
|
||||
onRejectCommand
|
||||
}: {
|
||||
dark: boolean
|
||||
enabled: boolean
|
||||
@@ -290,6 +340,9 @@ function AgentMessages({
|
||||
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) {
|
||||
@@ -321,56 +374,167 @@ function AgentMessages({
|
||||
<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-[230px] rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
|
||||
<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={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg border px-3 py-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
<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>
|
||||
<p className={['font-black', dark ? 'text-white/90' : 'text-[#111827]'].join(' ')}>{message.plan.summary}</p>
|
||||
<p className="mt-1 whitespace-pre-wrap">{message.plan.explanation}</p>
|
||||
<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) => (
|
||||
<div key={`${change.moduleName}:${change.path}`} className="flex min-w-0 gap-2">
|
||||
<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>
|
||||
</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'}
|
||||
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 className={['inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-[12px] font-medium', dark ? 'border-white/10 bg-[#303030] text-white/55' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#475569]'].join(' ')}>
|
||||
<span>{t("코드 변경안을 만드는 중입니다.")}</span><Loader2 size={13} className="animate-spin" />
|
||||
<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}
|
||||
|
||||
@@ -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 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(' ')}>
|
||||
<Activity size={13} />{t("실행 모듈")}{runningModules}
|
||||
<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} 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 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(' ')}>
|
||||
<Waypoints size={13} />{t("토픽")}{connections.length}
|
||||
<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} 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>
|
||||
<button
|
||||
type="button"
|
||||
@@ -284,30 +286,35 @@ export default function WorkspaceRosConnectionsView({
|
||||
title={t("활성 연결만 보기")}
|
||||
onClick={() => setActiveOnly((value) => !value)}
|
||||
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
|
||||
? 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]'
|
||||
].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>
|
||||
{rosGraph.message && (
|
||||
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
|
||||
{rosGraph.message}
|
||||
</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(' ')}>
|
||||
ROS Domain {rosDomainId}
|
||||
<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(' ')}>
|
||||
<span className="translate-y-px">ROS Domain</span><span className="translate-y-px">{rosDomainId}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void refreshRosGraph(true)}
|
||||
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
|
||||
|
||||
@@ -80,7 +80,9 @@ export default function WorkspaceTopBar({
|
||||
</div>
|
||||
<div className={['flex items-center gap-2 text-[12px] font-bold', textMid].join(' ')}>
|
||||
<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
|
||||
type="button"
|
||||
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" />
|
||||
</>
|
||||
)}
|
||||
<MessageCircle size={14} className="relative z-10" />
|
||||
<span className="relative z-10">{t("질의응답")}</span>
|
||||
<MessageCircle size={14} className="relative z-10 block shrink-0" />
|
||||
<span className="relative z-10 translate-y-px leading-none">{t("질의응답")}</span>
|
||||
</button>
|
||||
<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 && (
|
||||
<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 && (
|
||||
<>
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createAgentSessionSync } from './agentSessionSync'
|
||||
|
||||
export type AgentFileChange = {
|
||||
moduleName: string
|
||||
@@ -16,9 +17,27 @@ export type AgentModulePlan = {
|
||||
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
|
||||
}
|
||||
@@ -26,9 +45,18 @@ export type AgentPlan = {
|
||||
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'
|
||||
@@ -38,14 +66,22 @@ export type AgentConversationMessage = {
|
||||
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 = {
|
||||
@@ -54,20 +90,35 @@ type AgentStatus = {
|
||||
model: string | null
|
||||
}
|
||||
|
||||
export function useAiAgent({ accessToken, courseId, weekNumber, apiBaseUrl, getFreshAccessToken }: UseAiAgentOptions) {
|
||||
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 token = await getFreshAccessToken()
|
||||
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: `Bearer ${token}`,
|
||||
Authorization: authorization,
|
||||
...options?.headers
|
||||
}
|
||||
})
|
||||
@@ -77,11 +128,39 @@ export function useAiAgent({ accessToken, courseId, weekNumber, apiBaseUrl, getF
|
||||
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
}, [apiBaseUrl, getFreshAccessToken])
|
||||
}, [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(() => {
|
||||
abortControllerRef.current?.abort()
|
||||
setMessages([])
|
||||
if (!accessToken || !courseId) {
|
||||
setStatus({ enabled: false, provider: null, model: null })
|
||||
setStatusLoading(false)
|
||||
@@ -105,57 +184,162 @@ export function useAiAgent({ accessToken, courseId, weekNumber, apiBaseUrl, getF
|
||||
}
|
||||
}, [accessToken, courseId, requestApi, weekNumber])
|
||||
|
||||
const requestPlan = async (rawRequest: string, modules: AgentWorkspaceModule[]): Promise<void> => {
|
||||
// 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 || !status.enabled) return
|
||||
if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return
|
||||
const userMessage: AgentConversationMessage = {
|
||||
id: `agent-user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: request
|
||||
}
|
||||
setMessages((current) => [...current, userMessage])
|
||||
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
|
||||
setPending(true)
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await requestApi<{ provider: string; model: string; plan: AgentPlan }>('/chat/agent/plan', {
|
||||
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 })
|
||||
body: JSON.stringify({ courseId, week: weekNumber, request, modules, runtimeLogs })
|
||||
})
|
||||
setMessages((current) => [...current, {
|
||||
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: response.plan.summary,
|
||||
content: plan.summary,
|
||||
provider: response.provider,
|
||||
model: response.model,
|
||||
plan: response.plan,
|
||||
applyState: 'ready'
|
||||
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
|
||||
setMessages((current) => [...current, {
|
||||
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
|
||||
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
|
||||
setMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
|
||||
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)
|
||||
setMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applied', applyError: undefined } : item))
|
||||
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 : '변경안을 적용하지 못했습니다.'
|
||||
setMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item))
|
||||
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,14 +349,49 @@ export function useAiAgent({ accessToken, courseId, weekNumber, apiBaseUrl, getF
|
||||
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: statusLoading || historyLoading,
|
||||
pending,
|
||||
messages,
|
||||
persistenceError: persistenceError ?? historyError,
|
||||
requestPlan,
|
||||
applyPlan,
|
||||
updateCommandExecution,
|
||||
stop,
|
||||
clear: () => setMessages([])
|
||||
clear
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ type UseWorkspaceModuleControlResult = {
|
||||
startAllModules: () => Promise<void>
|
||||
stopAllModules: () => Promise<void>
|
||||
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>
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
||||
if (!workspaceActiveRef.current) return
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<boolean> => {
|
||||
if (!workspaceActiveRef.current) return false
|
||||
// The parking area only opts modules out of bulk actions; those modules
|
||||
// must remain individually controllable while the rest are stopping.
|
||||
const canControlExcludedModuleDuringBulkStop = (
|
||||
@@ -484,21 +484,21 @@ export function useWorkspaceModuleControl({
|
||||
if (
|
||||
(!canControlExcludedModuleDuringBulkStop && installingModuleName)
|
||||
|| queuedModuleActionsRef.current.has(module.name)
|
||||
) return
|
||||
) return false
|
||||
|
||||
const actionId = ++moduleActionIdRef.current
|
||||
const controlGeneration = controlGenerationRef.current
|
||||
queuedModuleActionsRef.current.set(module.name, actionId)
|
||||
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
||||
setModuleInstallError(null)
|
||||
const execute = async (): Promise<void> => {
|
||||
const execute = async (): Promise<boolean> => {
|
||||
const isCurrentControl = (): boolean => (
|
||||
workspaceActiveRef.current
|
||||
&& controlGenerationRef.current === controlGeneration
|
||||
&& queuedModuleActionsRef.current.get(module.name) === actionId
|
||||
)
|
||||
try {
|
||||
if (!isCurrentControl()) return
|
||||
if (!isCurrentControl()) return false
|
||||
const savedMapOverrides = action === 'start' || action === 'restart'
|
||||
? await prepareSavedMap?.()
|
||||
: undefined
|
||||
@@ -518,7 +518,7 @@ export function useWorkspaceModuleControl({
|
||||
? await remoteModuleStart(module, savedMapOverrides)
|
||||
: 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 (!isCurrentControl()) return
|
||||
if (!isCurrentControl()) return false
|
||||
|
||||
if (action === 'start' || action === 'restart') {
|
||||
markModuleState(module, 'running')
|
||||
@@ -537,9 +537,9 @@ export function useWorkspaceModuleControl({
|
||||
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
|
||||
}
|
||||
}
|
||||
if (!isCurrentControl()) return
|
||||
if (!isCurrentControl()) return false
|
||||
await loadAvailableModules({ silent: true })
|
||||
if (!isCurrentControl()) return
|
||||
if (!isCurrentControl()) return false
|
||||
const actionLabel = action === 'stop'
|
||||
? '종료되었습니다'
|
||||
: action === 'build'
|
||||
@@ -548,9 +548,10 @@ export function useWorkspaceModuleControl({
|
||||
? '재시작되었습니다'
|
||||
: '실행되었습니다'
|
||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[modules] ${action} failed`, error)
|
||||
if (!isCurrentControl()) return
|
||||
if (!isCurrentControl()) return false
|
||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
||||
const actionLabel = action === 'stop'
|
||||
? '종료 실패'
|
||||
@@ -561,6 +562,7 @@ export function useWorkspaceModuleControl({
|
||||
: '실행 실패'
|
||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
|
||||
await loadAvailableModules({ silent: true })
|
||||
return false
|
||||
} finally {
|
||||
if (queuedModuleActionsRef.current.get(module.name) === actionId) {
|
||||
queuedModuleActionsRef.current.delete(module.name)
|
||||
@@ -570,13 +572,12 @@ export function useWorkspaceModuleControl({
|
||||
}
|
||||
|
||||
if (action === 'stop') {
|
||||
await execute()
|
||||
return
|
||||
return execute()
|
||||
}
|
||||
|
||||
const queuedWork = moduleActionQueueRef.current.then(execute, execute)
|
||||
moduleActionQueueRef.current = queuedWork.then(() => undefined, () => undefined)
|
||||
await queuedWork
|
||||
return queuedWork
|
||||
}, [
|
||||
activeRuntimeImages,
|
||||
availableModules,
|
||||
|
||||
@@ -673,5 +673,23 @@
|
||||
"적용 완료": "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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user