From 654fc722f960992766e7b3c82ea191599e44214d Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 14 Oct 2026 14:25:46 +0900 Subject: [PATCH] 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." }