From 654fc722f960992766e7b3c82ea191599e44214d Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 14 Oct 2026 14:25:46 +0900 Subject: [PATCH 1/3] feat: add reviewable workspace AI agent --- src/renderer/src/App.tsx | 160 +++++++++++++++- .../features/workspace/WorkspaceQaPanel.tsx | 135 ++++++++++++- .../src/features/workspace/useAiAgent.ts | 178 ++++++++++++++++++ .../src/features/workspace/useQaChat.ts | 8 +- src/renderer/src/i18n/en.json | 13 +- 5 files changed, 478 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/features/workspace/useAiAgent.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index d94d22e..45e128b 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -57,6 +57,8 @@ import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceCont import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminalContextMenu' 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 { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime' import { useSourceControl } from './features/workspace/useSourceControl' import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus' @@ -607,6 +609,22 @@ function LocalPcWorkspaceMockup({ ) const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState(null) const [selectedModule, setSelectedModule] = useState(null) + const { + enabled: agentEnabled, + statusLoading: agentStatusLoading, + pending: agentPending, + messages: agentMessages, + requestPlan: requestAgentPlan, + applyPlan: applyAgentPlan, + stop: stopAgent, + clear: clearAgentConversation + } = useAiAgent({ + accessToken, + courseId: courseKey, + weekNumber, + apiBaseUrl: LMS_API_BASE_URL, + getFreshAccessToken + }) const [moduleTabs, setModuleTabs] = useState([]) const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false) const [teamGitSyncing, setTeamGitSyncing] = useState(false) @@ -3425,7 +3443,7 @@ function LocalPcWorkspaceMockup({ { ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length }, ]) } - const createCustomModule = async (input: CustomModuleInput): Promise => { + const createCustomModuleRecord = async (input: CustomModuleInput): Promise[number]> => { const customModule = await registerCustomModule(input) const workspaceModule = { name: customModule.name, @@ -3442,6 +3460,125 @@ function LocalPcWorkspaceMockup({ } addCustomModuleToList(customModule) onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info') + return customModule + } + 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 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 + + 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 + } + 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 }) + } + + return [{ name: selectedModule.name, folderName: selectedModule.folderName, files }] + } + const submitAgentRequest = async (content: string): Promise => { + try { + const modules = await collectAgentWorkspace() + await requestAgentPlan(content, modules) + } catch (error) { + console.error('[agent] workspace collection failed', error) + onTitlebarNotice(error instanceof Error ? error.message : '모듈 파일을 읽지 못했습니다.', 'error') + } + } + const applyWorkspaceAgentPlan = async (plan: AgentPlan): Promise => { + let createdModule: ModuleRow | null = null + if (plan.createModule) { + if (availableModules.some((module) => module.name === plan.createModule?.name)) { + throw new Error(`이미 존재하는 모듈입니다: ${plan.createModule.name}`) + } + const customModule = await createCustomModuleRecord({ + name: plan.createModule.name, + category: plan.createModule.category, + dockerImage: plan.createModule.dockerImage ?? '', + packageName: plan.createModule.packageName, + launchFile: plan.createModule.launchFile + }) + createdModule = { + id: customModule.id, + state: 'stopped', + sortOrder: customModule.sortOrder ?? 10_000, + category: customModule.category, + name: customModule.name, + folderName: customModule.folderName, + sourceType: customModule.sourceType, + dockerImage: customModule.dockerImage, + packageName: customModule.packageName, + launchFile: customModule.launchFile, + runtimeTarget: customModule.runtimeTarget, + parameters: [], + cpu: null, + memory: null, + runtime: '-' + } + } + + const resolveModule = (moduleName: string): ModuleRow | null => { + if (createdModule?.name === moduleName) return createdModule + return availableModules.find((module) => module.name === moduleName) ?? (selectedModule?.name === moduleName ? selectedModule : null) + } + let firstChangedFile: { module: ModuleRow; path: string } | null = null + for (const change of plan.changes) { + const module = resolveModule(change.moduleName) + if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`) + const rendererPath = `modules/${module.name}/${change.path}` + if (remoteWorkspace) { + await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), module.name, rendererPath, 'file', module.folderName) + await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), module.name, rendererPath, change.content, module.folderName) + } else { + await window.localWorkspace.moduleFiles.create(module.name, rendererPath, 'file', clonePathPreview, module.folderName) + await window.localWorkspace.moduleFiles.write(module.name, rendererPath, change.content, clonePathPreview, module.folderName) + } + setEditorFiles((files) => ({ ...files, [rendererPath]: change.content })) + setSavedEditorFiles((files) => ({ ...files, [rendererPath]: change.content })) + setDirtyEditorFiles((files) => { + const next = { ...files } + delete next[rendererPath] + return next + }) + if (!firstChangedFile) firstChangedFile = { module, path: rendererPath } + } + + if (firstChangedFile) { + setSelectedModule(firstChangedFile.module) + setModuleTabs((tabs) => tabs.some((tab) => tab.name === firstChangedFile!.module.name) ? tabs : [...tabs, firstChangedFile!.module]) + setActiveIdeActivity('workspace') + await loadModuleFiles(firstChangedFile.module, true) + openEditorFile(firstChangedFile.path) + } else if (createdModule) { + setSelectedModule(createdModule) + setModuleTabs((tabs) => tabs.some((tab) => tab.name === createdModule!.name) ? tabs : [...tabs, createdModule!]) + setActiveIdeActivity('workspace') + await loadModuleFiles(createdModule, true) + } + onTitlebarNotice(`${plan.changes.length}개 파일에 AI 변경안을 적용했습니다.`, 'info') } const importGitModule = async (input: GitModuleImportInput): Promise => { if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.') @@ -4154,22 +4291,31 @@ function LocalPcWorkspaceMockup({ qaMessagesLoading={qaMessagesLoading} visibleAiMessages={visibleAiMessages} visibleTeacherMessages={visibleTeacherMessages} + agentMessages={agentMessages} + agentEnabled={agentEnabled} + agentStatusLoading={agentStatusLoading} + agentPending={agentPending} aiPending={aiPending} teacherThreadStatus={teacherThreadStatus} qaCollapseSignal={qaCollapseSignal} - inputBusy={aiPending || Boolean(aiTypingMessageId)} - inputDisabled={!validChatWeek} + inputBusy={qaMode === 'agent' ? agentPending : aiPending || Boolean(aiTypingMessageId)} + inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))} workspaceChatScrollRef={workspaceChatScrollRef} onMinimize={() => setRightPanelMinimized(true)} onModeChange={(mode) => { setQaMode(mode) if (mode === 'teacher') setTeacherChatFlash(false) }} - onStop={stopAiResponse} - onSubmit={(content) => void sendQaMessage(content, { contextWeek: curriculumWeek.week })} - onClearConversation={() => { - void clearQaConversation().catch((error) => console.error('[chat] clear failed', error)) + onStop={qaMode === 'agent' ? stopAgent : stopAiResponse} + onSubmit={(content) => { + if (qaMode === 'agent') void submitAgentRequest(content) + else void sendQaMessage(content, { contextWeek: curriculumWeek.week }) }} + onClearConversation={() => { + if (qaMode === 'agent') clearAgentConversation() + else void clearQaConversation().catch((error) => console.error('[chat] clear failed', error)) + }} + onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, () => applyWorkspaceAgentPlan(plan))} /> {workspaceRestoreLoading && ( diff --git a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx index 722a33d..417a636 100644 --- a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx @@ -1,10 +1,11 @@ import { t, useLocaleRender } from '../../i18n' -import { Bot, MessageCircle, Minus, User } from 'lucide-react' +import { Bot, Check, FileCode2, Loader2, MessageCircle, Minus, User, WandSparkles } 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' -type QaMode = 'ai' | 'teacher' +type QaMode = 'ai' | 'agent' | 'teacher' type WorkspaceQaMessage = { id: string @@ -31,6 +32,10 @@ type WorkspaceQaPanelProps = { qaMessagesLoading: boolean visibleAiMessages: WorkspaceQaMessage[] visibleTeacherMessages: WorkspaceQaMessage[] + agentMessages: AgentConversationMessage[] + agentEnabled: boolean + agentStatusLoading: boolean + agentPending: boolean aiPending: boolean teacherThreadStatus: string | null qaCollapseSignal: number @@ -42,6 +47,7 @@ type WorkspaceQaPanelProps = { onStop: () => void onSubmit: (content: string) => void onClearConversation: () => void + onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void } export default function WorkspaceQaPanel({ @@ -60,6 +66,10 @@ export default function WorkspaceQaPanel({ qaMessagesLoading, visibleAiMessages, visibleTeacherMessages, + agentMessages, + agentEnabled, + agentStatusLoading, + agentPending, aiPending, teacherThreadStatus, qaCollapseSignal, @@ -70,11 +80,12 @@ export default function WorkspaceQaPanel({ onModeChange, onStop, onSubmit, - onClearConversation + onClearConversation, + onApplyAgentPlan }: WorkspaceQaPanelProps): React.JSX.Element { useLocaleRender() const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) - const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages + const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages const lastMessage = activeMessages[activeMessages.length - 1] useEffect(() => { @@ -126,6 +137,8 @@ export default function WorkspaceQaPanel({ observer?.disconnect() } }, [ + agentMessages.length, + agentPending, aiPending, lastMessage?.content, lastMessage?.id, @@ -160,9 +173,10 @@ export default function WorkspaceQaPanel({ -
+
{[ { id: 'ai' as const, label: 'AI', Icon: Bot }, + { id: 'agent' as const, label: t("에이전트"), Icon: WandSparkles }, { id: 'teacher' as const, label: t("강사"), Icon: User } ].map(({ id, label, Icon }) => { const disabled = id === 'teacher' && teacherQaDisabled @@ -216,6 +230,15 @@ export default function WorkspaceQaPanel({ pending={aiPending} collapseSignal={qaCollapseSignal} /> + ) : qaMode === 'agent' ? ( + ) : ( void +}): React.JSX.Element { + useLocaleRender() + if (statusLoading) { + return ( +
+ + {t("에이전트 설정을 확인하는 중입니다.")} +
+ ) + } + + if (!enabled) { + return ( +
+ {t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")} +
+ ) + } + + if (!messages.length && !pending) { + return ( +
+ {t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}
{t("변경 내용은 확인 후 직접 적용합니다.")} +
+ ) + } + + return ( +
+ {messages.map((message) => message.role === 'user' ? ( +
+
{message.content}
+
+ ) : ( +
+ +
+ {message.plan ? ( +
+
+

{message.plan.summary}

+

{message.plan.explanation}

+ {message.provider && message.model ?

{message.provider} · {message.model}

: null} +
+ {message.plan.createModule ? ( +
+

{t("새 모듈")}: {message.plan.createModule.name}

+

{message.plan.createModule.packageName} · {message.plan.createModule.launchFile}

+
+ ) : null} +
+ {message.plan.changes.map((change) => ( +
+ +
+

{change.moduleName}/{change.path}

+

{change.reason}

+
+
+ ))} +
+ {message.applyError ?

{message.applyError}

: null} + +
+ ) :

{message.content}

} +
+
+ ))} + {pending ? ( +
+ +
+ {t("코드 변경안을 만드는 중입니다.")} +
+
+ ) : null} +
+ ) +} + function AiMessages({ dark, messages, diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts new file mode 100644 index 0000000..81719d8 --- /dev/null +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -0,0 +1,178 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +export type AgentFileChange = { + moduleName: string + path: string + content: string + reason: string +} + +export type AgentModulePlan = { + name: string + category: string + folderName: string + dockerImage: string | null + packageName: string + launchFile: string +} + +export type AgentPlan = { + summary: string + explanation: string + changes: AgentFileChange[] + createModule: AgentModulePlan | null +} + +export type AgentWorkspaceModule = { + name: string + folderName?: string + files: Array<{ path: string; content: string }> +} + +export type AgentConversationMessage = { + id: string + role: 'user' | 'assistant' + content: string + provider?: string + model?: string + plan?: AgentPlan + applyState?: 'ready' | 'applying' | 'applied' | 'error' + applyError?: string +} + +type UseAiAgentOptions = { + accessToken: string + courseId: string + weekNumber: number + apiBaseUrl: string + getFreshAccessToken: () => Promise +} + +type AgentStatus = { + enabled: boolean + provider: string | null + model: string | null +} + +export function useAiAgent({ accessToken, courseId, weekNumber, apiBaseUrl, getFreshAccessToken }: UseAiAgentOptions) { + const [status, setStatus] = useState({ enabled: false, provider: null, model: null }) + const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId)) + const [pending, setPending] = useState(false) + const [messages, setMessages] = useState([]) + const abortControllerRef = useRef(null) + + const requestApi = useCallback(async (path: string, options?: RequestInit): Promise => { + const token = await getFreshAccessToken() + const response = await fetch(`${apiBaseUrl}${path}`, { + ...options, + headers: { + ...(options?.body ? { 'Content-Type': 'application/json' } : {}), + Authorization: `Bearer ${token}`, + ...options?.headers + } + }) + if (!response.ok) { + const payload = await response.json().catch(() => null) as { message?: string | string[] } | null + const message = Array.isArray(payload?.message) ? payload.message.join('\n') : payload?.message + throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.') + } + return response.json() as Promise + }, [apiBaseUrl, getFreshAccessToken]) + + useEffect(() => { + abortControllerRef.current?.abort() + setMessages([]) + if (!accessToken || !courseId) { + setStatus({ enabled: false, provider: null, model: null }) + setStatusLoading(false) + return + } + + let cancelled = false + setStatusLoading(true) + requestApi(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`) + .then((nextStatus) => { + if (!cancelled) setStatus(nextStatus) + }) + .catch(() => { + if (!cancelled) setStatus({ enabled: false, provider: null, model: null }) + }) + .finally(() => { + if (!cancelled) setStatusLoading(false) + }) + return () => { + cancelled = true + } + }, [accessToken, courseId, requestApi, weekNumber]) + + const requestPlan = async (rawRequest: string, modules: AgentWorkspaceModule[]): Promise => { + const request = rawRequest.trim() + if (!request || pending || !status.enabled) return + const userMessage: AgentConversationMessage = { + id: `agent-user-${Date.now()}`, + role: 'user', + content: request + } + setMessages((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', { + method: 'POST', + signal: abortController.signal, + body: JSON.stringify({ courseId, week: weekNumber, request, modules }) + }) + setMessages((current) => [...current, { + id: `agent-plan-${Date.now()}`, + role: 'assistant', + content: response.plan.summary, + provider: response.provider, + model: response.model, + plan: response.plan, + applyState: 'ready' + }]) + } catch (error) { + if (abortController.signal.aborted) return + setMessages((current) => [...current, { + id: `agent-error-${Date.now()}`, + role: 'assistant', + content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.' + }]) + } finally { + if (abortControllerRef.current === abortController) abortControllerRef.current = null + setPending(false) + } + } + + const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise): Promise => { + 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)) + try { + await apply(message.plan) + setMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applied', applyError: undefined } : item)) + } catch (error) { + const applyError = error instanceof Error ? error.message : '변경안을 적용하지 못했습니다.' + setMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item)) + } + } + + const stop = (): void => { + abortControllerRef.current?.abort() + abortControllerRef.current = null + setPending(false) + } + + return { + ...status, + statusLoading, + pending, + messages, + requestPlan, + applyPlan, + stop, + clear: () => setMessages([]) + } +} diff --git a/src/renderer/src/features/workspace/useQaChat.ts b/src/renderer/src/features/workspace/useQaChat.ts index 834705e..7d3799d 100644 --- a/src/renderer/src/features/workspace/useQaChat.ts +++ b/src/renderer/src/features/workspace/useQaChat.ts @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { io } from 'socket.io-client' import type { Socket } from 'socket.io-client' -export type QaMode = 'ai' | 'teacher' +export type QaMode = 'ai' | 'agent' | 'teacher' export type QaChatMessage = { id: string @@ -221,7 +221,7 @@ export function useQaChat({ useEffect(() => { const target = workspaceChatScrollRef.current if (!target) return - const currentLoading = qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading + const currentLoading = qaMode === 'ai' ? aiMessagesLoading : qaMode === 'teacher' ? teacherMessagesLoading : false if (currentLoading) return const scrollToBottom = (): void => { @@ -286,6 +286,7 @@ export function useQaChat({ const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise => { const content = rawContent.trim() if (!content || aiPending || aiTypingMessageId || !chatAvailable) return + if (qaMode === 'agent') return setQaCollapseSignal((value) => value + 1) if (qaMode !== 'ai') { @@ -427,6 +428,7 @@ export function useQaChat({ const clearQaConversation = async (): Promise => { if (!chatAvailable) return + if (qaMode === 'agent') return if (qaMode === 'ai') { aiRevealCancelRef.current = true aiAbortControllerRef.current?.abort() @@ -493,7 +495,7 @@ export function useQaChat({ aiTypingMessageId, teacherThreadStatus, qaCollapseSignal, - qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading, + qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : qaMode === 'teacher' ? teacherMessagesLoading : false, visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit), displayedAiMessages: selectedAiConversation ? selectedAiConversation.messages diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index cd8ab20..66fd226 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -662,5 +662,16 @@ "활성": "Active", "활성 연결": "Active connections", "활성 연결만 보기": "Show active connections only", - "활성 연결이 없습니다.": "No active connections." + "활성 연결이 없습니다.": "No active connections.", + "에이전트": "Agent", + "에이전트 설정을 확인하는 중입니다.": "Checking agent settings.", + "강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.": "The agent becomes available after the instructor configures it for this course.", + "현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.": "Ask the agent to change the current module or create a new one.", + "변경 내용은 확인 후 직접 적용합니다.": "Review the proposed changes before applying them.", + "새 모듈": "New module", + "적용 중": "Applying", + "적용 완료": "Applied", + "다시 적용": "Retry apply", + "변경 적용": "Apply changes", + "코드 변경안을 만드는 중입니다.": "Preparing code changes." } From 53518d1114fe6ae473f28abd77385e9bee027b8c Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 14 Oct 2026 15:15:28 +0900 Subject: [PATCH 2/3] version up! --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index dbfb490..f62e59a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.2.8", + "version": "1.2.9", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { From 1c0d861051456d83c41e543e004350eb8740bb9e Mon Sep 17 00:00:00 2001 From: chyoo Date: Thu, 15 Oct 2026 14:39:01 +0900 Subject: [PATCH 3/3] feat(ai): improve agent workflows and persistence for v1.2.14 --- package-lock.json | 4 +- package.json | 2 +- scripts/test-agent-session-sync.cjs | 139 +++++++ src/main/index.ts | 207 +++++++++++ src/preload/index.ts | 6 + src/renderer/src/App.tsx | 350 ++++++++++++++++-- src/renderer/src/components/ChatWidgets.tsx | 2 + src/renderer/src/env.d.ts | 12 + .../features/workspace/ModuleListToolbar.tsx | 8 +- .../workspace/WorkspaceEditorPane.tsx | 35 +- .../workspace/WorkspaceEditorPathBar.tsx | 9 +- .../workspace/WorkspaceEditorTabs.tsx | 37 +- .../features/workspace/WorkspaceQaPanel.tsx | 234 ++++++++++-- .../workspace/WorkspaceRosConnectionsView.tsx | 27 +- .../features/workspace/WorkspaceTopBar.tsx | 16 +- .../features/workspace/agentSessionSync.ts | 129 +++++++ .../src/features/workspace/useAiAgent.ts | 267 +++++++++++-- .../workspace/useWorkspaceModuleControl.ts | 27 +- src/renderer/src/i18n/en.json | 18 + 19 files changed, 1384 insertions(+), 145 deletions(-) create mode 100644 scripts/test-agent-session-sync.cjs create mode 100644 src/renderer/src/features/workspace/agentSessionSync.ts 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({