feat: add reviewable workspace AI agent

This commit is contained in:
2026-10-14 14:25:46 +09:00
parent cf8d81348d
commit 654fc722f9
5 changed files with 478 additions and 16 deletions
+153 -7
View File
@@ -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<StudioSourceSettings | null>(null)
const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(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<ModuleRow[]>([])
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<void> => {
const createCustomModuleRecord = async (input: CustomModuleInput): Promise<NonNullable<CurriculumWeek['modules']>[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<void> => {
await createCustomModuleRecord(input)
}
const collectAgentWorkspace = async (): Promise<AgentWorkspaceModule[]> => {
if (!selectedModule) return []
const modulePrefix = `modules/${selectedModule.name}`
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), selectedModule.name, selectedModule.folderName)
: await window.localWorkspace.moduleFiles.read(selectedModule.name, clonePathPreview, selectedModule.folderName)
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
const 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<void> => {
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<void> => {
let createdModule: ModuleRow | null = null
if (plan.createModule) {
if (availableModules.some((module) => module.name === plan.createModule?.name)) {
throw new Error(`이미 존재하는 모듈입니다: ${plan.createModule.name}`)
}
const customModule = await createCustomModuleRecord({
name: plan.createModule.name,
category: plan.createModule.category,
dockerImage: plan.createModule.dockerImage ?? '',
packageName: plan.createModule.packageName,
launchFile: plan.createModule.launchFile
})
createdModule = {
id: customModule.id,
state: 'stopped',
sortOrder: customModule.sortOrder ?? 10_000,
category: customModule.category,
name: customModule.name,
folderName: customModule.folderName,
sourceType: customModule.sourceType,
dockerImage: customModule.dockerImage,
packageName: customModule.packageName,
launchFile: customModule.launchFile,
runtimeTarget: customModule.runtimeTarget,
parameters: [],
cpu: null,
memory: null,
runtime: '-'
}
}
const resolveModule = (moduleName: string): ModuleRow | null => {
if (createdModule?.name === moduleName) return createdModule
return availableModules.find((module) => module.name === moduleName) ?? (selectedModule?.name === moduleName ? selectedModule : null)
}
let firstChangedFile: { module: ModuleRow; path: string } | null = null
for (const change of plan.changes) {
const module = resolveModule(change.moduleName)
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
const rendererPath = `modules/${module.name}/${change.path}`
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), module.name, rendererPath, 'file', module.folderName)
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), module.name, rendererPath, change.content, module.folderName)
} else {
await window.localWorkspace.moduleFiles.create(module.name, rendererPath, 'file', clonePathPreview, module.folderName)
await window.localWorkspace.moduleFiles.write(module.name, rendererPath, change.content, clonePathPreview, module.folderName)
}
setEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
setSavedEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
setDirtyEditorFiles((files) => {
const next = { ...files }
delete next[rendererPath]
return next
})
if (!firstChangedFile) firstChangedFile = { module, path: rendererPath }
}
if (firstChangedFile) {
setSelectedModule(firstChangedFile.module)
setModuleTabs((tabs) => tabs.some((tab) => tab.name === firstChangedFile!.module.name) ? tabs : [...tabs, firstChangedFile!.module])
setActiveIdeActivity('workspace')
await loadModuleFiles(firstChangedFile.module, true)
openEditorFile(firstChangedFile.path)
} else if (createdModule) {
setSelectedModule(createdModule)
setModuleTabs((tabs) => tabs.some((tab) => tab.name === createdModule!.name) ? tabs : [...tabs, createdModule!])
setActiveIdeActivity('workspace')
await loadModuleFiles(createdModule, true)
}
onTitlebarNotice(`${plan.changes.length}개 파일에 AI 변경안을 적용했습니다.`, 'info')
}
const importGitModule = async (input: GitModuleImportInput): Promise<void> => {
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 && (
@@ -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({
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span>
</button>
</div>
<div className={['mt-3 grid grid-cols-2 overflow-hidden rounded-md border', borderSoft].join(' ')}>
<div className={['mt-3 grid grid-cols-3 overflow-hidden rounded-md border', borderSoft].join(' ')}>
{[
{ id: 'ai' as const, label: 'AI', Icon: Bot },
{ id: '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' ? (
<AgentMessages
dark={dark}
enabled={agentEnabled}
statusLoading={agentStatusLoading}
pending={agentPending}
messages={agentMessages}
onApply={onApplyAgentPlan}
/>
) : (
<TeacherMessages
dark={dark}
@@ -253,6 +276,108 @@ export default function WorkspaceQaPanel({
)
}
function AgentMessages({
dark,
enabled,
statusLoading,
pending,
messages,
onApply
}: {
dark: boolean
enabled: boolean
statusLoading: boolean
pending: boolean
messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void
}): React.JSX.Element {
useLocaleRender()
if (statusLoading) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center gap-2 text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
<Loader2 size={15} className="animate-spin" />
<span>{t("에이전트 설정을 확인하는 중입니다.")}</span>
</div>
)
}
if (!enabled) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
{t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")}
</div>
)
}
if (!messages.length && !pending) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
{t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}<br />{t("변경 내용은 확인 후 직접 적용합니다.")}
</div>
)
}
return (
<div className="grid gap-3">
{messages.map((message) => message.role === 'user' ? (
<div key={message.id} data-qa-message="true" className="flex justify-end">
<div className="max-w-[230px] rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
</div>
) : (
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg border px-3 py-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
{message.plan ? (
<div className="grid gap-3">
<div>
<p className={['font-black', dark ? 'text-white/90' : 'text-[#111827]'].join(' ')}>{message.plan.summary}</p>
<p className="mt-1 whitespace-pre-wrap">{message.plan.explanation}</p>
{message.provider && message.model ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{message.provider} · {message.model}</p> : null}
</div>
{message.plan.createModule ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
<p className="font-black">{t("새 모듈")}: {message.plan.createModule.name}</p>
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{message.plan.createModule.packageName} · {message.plan.createModule.launchFile}</p>
</div>
) : null}
<div className={['grid gap-1.5 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{message.plan.changes.map((change) => (
<div key={`${change.moduleName}:${change.path}`} className="flex min-w-0 gap-2">
<FileCode2 size={13} className="mt-0.5 shrink-0 text-[#7c3aed]" />
<div className="min-w-0">
<p className="break-all font-bold">{change.moduleName}/{change.path}</p>
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{change.reason}</p>
</div>
</div>
))}
</div>
{message.applyError ? <p className="text-[11px] font-bold text-[#ef4444]">{message.applyError}</p> : null}
<button
type="button"
disabled={message.applyState === 'applying' || message.applyState === 'applied'}
onClick={() => onApply(message.id, message.plan!)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
>
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />}
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button>
</div>
) : <p className="whitespace-pre-wrap">{message.content}</p>}
</div>
</div>
))}
{pending ? (
<div data-qa-message="true" className="flex gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div className={['inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-[12px] font-medium', dark ? 'border-white/10 bg-[#303030] text-white/55' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#475569]'].join(' ')}>
<span>{t("코드 변경안을 만드는 중입니다.")}</span><Loader2 size={13} className="animate-spin" />
</div>
</div>
) : null}
</div>
)
}
function AiMessages({
dark,
messages,
@@ -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<string>
}
type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
}
export function useAiAgent({ accessToken, courseId, weekNumber, apiBaseUrl, getFreshAccessToken }: UseAiAgentOptions) {
const [status, setStatus] = useState<AgentStatus>({ enabled: false, provider: null, model: null })
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
const [pending, setPending] = useState(false)
const [messages, setMessages] = useState<AgentConversationMessage[]>([])
const abortControllerRef = useRef<AbortController | null>(null)
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
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<T>
}, [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<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
.then((nextStatus) => {
if (!cancelled) setStatus(nextStatus)
})
.catch(() => {
if (!cancelled) setStatus({ enabled: false, provider: null, model: null })
})
.finally(() => {
if (!cancelled) setStatusLoading(false)
})
return () => {
cancelled = true
}
}, [accessToken, courseId, requestApi, weekNumber])
const requestPlan = async (rawRequest: string, modules: AgentWorkspaceModule[]): Promise<void> => {
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<void>): Promise<void> => {
const message = messages.find((item) => item.id === messageId)
if (!message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
setMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
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([])
}
}
@@ -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<void> => {
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<void> => {
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
+12 -1
View File
@@ -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."
}