diff --git a/package-lock.json b/package-lock.json index b98a3da..2536222 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f62e59a..7e5f3ab 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/test-agent-session-sync.cjs b/scripts/test-agent-session-sync.cjs new file mode 100644 index 0000000..21cd504 --- /dev/null +++ b/scripts/test-agent-session-sync.cjs @@ -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([])), []) +}) diff --git a/src/main/index.ts b/src/main/index.ts index 2b71973..3907370 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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, + 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 { + 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((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 { + 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 { 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)) diff --git a/src/preload/index.ts b/src/preload/index.ts index b9fd6d0..5a8400a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 45e128b..fc097a1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -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 | ((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([]) const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false) @@ -687,6 +703,8 @@ function LocalPcWorkspaceMockup({ finishTabDrop, resetTabDrag } = useWorkspaceEditorFiles(editorInitialFiles, exampleActive) + const [agentDiffPreviews, setAgentDiffPreviews] = useState([]) + const [activeAgentDiffPreviewId, setActiveAgentDiffPreviewId] = useState(null) const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null) const [imagePreviewSources, setImagePreviewSources] = useState>>(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 => { await createCustomModuleRecord(input) } - const collectAgentWorkspace = async (): Promise => { - 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 => { + 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) } - const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null - const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content])) - 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) - const files: AgentWorkspaceModule['files'] = [] let totalCharacters = 0 + let totalFiles = 0 - for (const rendererPath of paths) { - let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath) - if (content === undefined && remoteWorkspace) { - content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), selectedModule.name, rendererPath, selectedModule.folderName)).content + 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 } - 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 - files.push({ path: rendererPath.slice(modulePrefix.length + 1), content }) + const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath))) + .filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path)) + .sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right)) + const files: AgentWorkspaceModule['files'] = [] + + for (const rendererPath of paths) { + if (totalFiles >= 24 || totalCharacters >= 140_000) break + let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath) + if (content === undefined && remoteWorkspace) { + content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content + } + if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue + if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue + totalCharacters += content.length + totalFiles += 1 + files.push({ path: rendererPath.slice(modulePrefix.length + 1), content }) + } + + const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name) + const workspaceModule = { + name: module.name, + folderName: module.folderName, + category: module.category, + state: module.state, + files + } + if (moduleIndex < 0) workspaceModules.push(workspaceModule) + else workspaceModules[moduleIndex] = workspaceModule } - return [{ name: selectedModule.name, folderName: selectedModule.folderName, files }] + return workspaceModules + } + const collectAgentRuntimeLogs = async (requestedModuleNames: string[]): Promise => { + 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 => { + 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 => { 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 => { + 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 => { + 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 => { 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 => { @@ -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` }} > 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({ @@ -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 && ( diff --git a/src/renderer/src/components/ChatWidgets.tsx b/src/renderer/src/components/ChatWidgets.tsx index 2d3e6a0..8bb02c2 100644 --- a/src/renderer/src/components/ChatWidgets.tsx +++ b/src/renderer/src/components/ChatWidgets.tsx @@ -268,6 +268,8 @@ export const QaInputBox = memo(function QaInputBox({