feat: add reviewable workspace AI agent
This commit is contained in:
+153
-7
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user