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 WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminalContextMenu'
|
||||||
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
|
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
|
||||||
import { useQaChat } from './features/workspace/useQaChat'
|
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 { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
|
||||||
import { useSourceControl } from './features/workspace/useSourceControl'
|
import { useSourceControl } from './features/workspace/useSourceControl'
|
||||||
import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus'
|
import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus'
|
||||||
@@ -607,6 +609,22 @@ function LocalPcWorkspaceMockup({
|
|||||||
)
|
)
|
||||||
const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState<StudioSourceSettings | null>(null)
|
const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState<StudioSourceSettings | null>(null)
|
||||||
const [selectedModule, setSelectedModule] = useState<ModuleRow | 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 [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
|
||||||
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
|
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
|
||||||
const [teamGitSyncing, setTeamGitSyncing] = useState(false)
|
const [teamGitSyncing, setTeamGitSyncing] = useState(false)
|
||||||
@@ -3425,7 +3443,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
{ ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length },
|
{ ...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 customModule = await registerCustomModule(input)
|
||||||
const workspaceModule = {
|
const workspaceModule = {
|
||||||
name: customModule.name,
|
name: customModule.name,
|
||||||
@@ -3442,6 +3460,125 @@ function LocalPcWorkspaceMockup({
|
|||||||
}
|
}
|
||||||
addCustomModuleToList(customModule)
|
addCustomModuleToList(customModule)
|
||||||
onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
|
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> => {
|
const importGitModule = async (input: GitModuleImportInput): Promise<void> => {
|
||||||
if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.')
|
if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.')
|
||||||
@@ -4154,22 +4291,31 @@ function LocalPcWorkspaceMockup({
|
|||||||
qaMessagesLoading={qaMessagesLoading}
|
qaMessagesLoading={qaMessagesLoading}
|
||||||
visibleAiMessages={visibleAiMessages}
|
visibleAiMessages={visibleAiMessages}
|
||||||
visibleTeacherMessages={visibleTeacherMessages}
|
visibleTeacherMessages={visibleTeacherMessages}
|
||||||
|
agentMessages={agentMessages}
|
||||||
|
agentEnabled={agentEnabled}
|
||||||
|
agentStatusLoading={agentStatusLoading}
|
||||||
|
agentPending={agentPending}
|
||||||
aiPending={aiPending}
|
aiPending={aiPending}
|
||||||
teacherThreadStatus={teacherThreadStatus}
|
teacherThreadStatus={teacherThreadStatus}
|
||||||
qaCollapseSignal={qaCollapseSignal}
|
qaCollapseSignal={qaCollapseSignal}
|
||||||
inputBusy={aiPending || Boolean(aiTypingMessageId)}
|
inputBusy={qaMode === 'agent' ? agentPending : aiPending || Boolean(aiTypingMessageId)}
|
||||||
inputDisabled={!validChatWeek}
|
inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))}
|
||||||
workspaceChatScrollRef={workspaceChatScrollRef}
|
workspaceChatScrollRef={workspaceChatScrollRef}
|
||||||
onMinimize={() => setRightPanelMinimized(true)}
|
onMinimize={() => setRightPanelMinimized(true)}
|
||||||
onModeChange={(mode) => {
|
onModeChange={(mode) => {
|
||||||
setQaMode(mode)
|
setQaMode(mode)
|
||||||
if (mode === 'teacher') setTeacherChatFlash(false)
|
if (mode === 'teacher') setTeacherChatFlash(false)
|
||||||
}}
|
}}
|
||||||
onStop={stopAiResponse}
|
onStop={qaMode === 'agent' ? stopAgent : stopAiResponse}
|
||||||
onSubmit={(content) => void sendQaMessage(content, { contextWeek: curriculumWeek.week })}
|
onSubmit={(content) => {
|
||||||
onClearConversation={() => {
|
if (qaMode === 'agent') void submitAgentRequest(content)
|
||||||
void clearQaConversation().catch((error) => console.error('[chat] clear failed', error))
|
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 && (
|
{workspaceRestoreLoading && (
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { t, useLocaleRender } from '../../i18n'
|
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 type React from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
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 = {
|
type WorkspaceQaMessage = {
|
||||||
id: string
|
id: string
|
||||||
@@ -31,6 +32,10 @@ type WorkspaceQaPanelProps = {
|
|||||||
qaMessagesLoading: boolean
|
qaMessagesLoading: boolean
|
||||||
visibleAiMessages: WorkspaceQaMessage[]
|
visibleAiMessages: WorkspaceQaMessage[]
|
||||||
visibleTeacherMessages: WorkspaceQaMessage[]
|
visibleTeacherMessages: WorkspaceQaMessage[]
|
||||||
|
agentMessages: AgentConversationMessage[]
|
||||||
|
agentEnabled: boolean
|
||||||
|
agentStatusLoading: boolean
|
||||||
|
agentPending: boolean
|
||||||
aiPending: boolean
|
aiPending: boolean
|
||||||
teacherThreadStatus: string | null
|
teacherThreadStatus: string | null
|
||||||
qaCollapseSignal: number
|
qaCollapseSignal: number
|
||||||
@@ -42,6 +47,7 @@ type WorkspaceQaPanelProps = {
|
|||||||
onStop: () => void
|
onStop: () => void
|
||||||
onSubmit: (content: string) => void
|
onSubmit: (content: string) => void
|
||||||
onClearConversation: () => void
|
onClearConversation: () => void
|
||||||
|
onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkspaceQaPanel({
|
export default function WorkspaceQaPanel({
|
||||||
@@ -60,6 +66,10 @@ export default function WorkspaceQaPanel({
|
|||||||
qaMessagesLoading,
|
qaMessagesLoading,
|
||||||
visibleAiMessages,
|
visibleAiMessages,
|
||||||
visibleTeacherMessages,
|
visibleTeacherMessages,
|
||||||
|
agentMessages,
|
||||||
|
agentEnabled,
|
||||||
|
agentStatusLoading,
|
||||||
|
agentPending,
|
||||||
aiPending,
|
aiPending,
|
||||||
teacherThreadStatus,
|
teacherThreadStatus,
|
||||||
qaCollapseSignal,
|
qaCollapseSignal,
|
||||||
@@ -70,11 +80,12 @@ export default function WorkspaceQaPanel({
|
|||||||
onModeChange,
|
onModeChange,
|
||||||
onStop,
|
onStop,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onClearConversation
|
onClearConversation,
|
||||||
|
onApplyAgentPlan
|
||||||
}: WorkspaceQaPanelProps): React.JSX.Element {
|
}: WorkspaceQaPanelProps): React.JSX.Element {
|
||||||
useLocaleRender()
|
useLocaleRender()
|
||||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
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]
|
const lastMessage = activeMessages[activeMessages.length - 1]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,6 +137,8 @@ export default function WorkspaceQaPanel({
|
|||||||
observer?.disconnect()
|
observer?.disconnect()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
agentMessages.length,
|
||||||
|
agentPending,
|
||||||
aiPending,
|
aiPending,
|
||||||
lastMessage?.content,
|
lastMessage?.content,
|
||||||
lastMessage?.id,
|
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>
|
<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>
|
</button>
|
||||||
</div>
|
</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: 'ai' as const, label: 'AI', Icon: Bot },
|
||||||
|
{ id: 'agent' as const, label: t("에이전트"), Icon: WandSparkles },
|
||||||
{ id: 'teacher' as const, label: t("강사"), Icon: User }
|
{ id: 'teacher' as const, label: t("강사"), Icon: User }
|
||||||
].map(({ id, label, Icon }) => {
|
].map(({ id, label, Icon }) => {
|
||||||
const disabled = id === 'teacher' && teacherQaDisabled
|
const disabled = id === 'teacher' && teacherQaDisabled
|
||||||
@@ -216,6 +230,15 @@ export default function WorkspaceQaPanel({
|
|||||||
pending={aiPending}
|
pending={aiPending}
|
||||||
collapseSignal={qaCollapseSignal}
|
collapseSignal={qaCollapseSignal}
|
||||||
/>
|
/>
|
||||||
|
) : qaMode === 'agent' ? (
|
||||||
|
<AgentMessages
|
||||||
|
dark={dark}
|
||||||
|
enabled={agentEnabled}
|
||||||
|
statusLoading={agentStatusLoading}
|
||||||
|
pending={agentPending}
|
||||||
|
messages={agentMessages}
|
||||||
|
onApply={onApplyAgentPlan}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<TeacherMessages
|
<TeacherMessages
|
||||||
dark={dark}
|
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({
|
function AiMessages({
|
||||||
dark,
|
dark,
|
||||||
messages,
|
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 { io } from 'socket.io-client'
|
||||||
import type { Socket } 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 = {
|
export type QaChatMessage = {
|
||||||
id: string
|
id: string
|
||||||
@@ -221,7 +221,7 @@ export function useQaChat({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = workspaceChatScrollRef.current
|
const target = workspaceChatScrollRef.current
|
||||||
if (!target) return
|
if (!target) return
|
||||||
const currentLoading = qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading
|
const currentLoading = qaMode === 'ai' ? aiMessagesLoading : qaMode === 'teacher' ? teacherMessagesLoading : false
|
||||||
if (currentLoading) return
|
if (currentLoading) return
|
||||||
|
|
||||||
const scrollToBottom = (): void => {
|
const scrollToBottom = (): void => {
|
||||||
@@ -286,6 +286,7 @@ export function useQaChat({
|
|||||||
const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => {
|
const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => {
|
||||||
const content = rawContent.trim()
|
const content = rawContent.trim()
|
||||||
if (!content || aiPending || aiTypingMessageId || !chatAvailable) return
|
if (!content || aiPending || aiTypingMessageId || !chatAvailable) return
|
||||||
|
if (qaMode === 'agent') return
|
||||||
setQaCollapseSignal((value) => value + 1)
|
setQaCollapseSignal((value) => value + 1)
|
||||||
|
|
||||||
if (qaMode !== 'ai') {
|
if (qaMode !== 'ai') {
|
||||||
@@ -427,6 +428,7 @@ export function useQaChat({
|
|||||||
|
|
||||||
const clearQaConversation = async (): Promise<void> => {
|
const clearQaConversation = async (): Promise<void> => {
|
||||||
if (!chatAvailable) return
|
if (!chatAvailable) return
|
||||||
|
if (qaMode === 'agent') return
|
||||||
if (qaMode === 'ai') {
|
if (qaMode === 'ai') {
|
||||||
aiRevealCancelRef.current = true
|
aiRevealCancelRef.current = true
|
||||||
aiAbortControllerRef.current?.abort()
|
aiAbortControllerRef.current?.abort()
|
||||||
@@ -493,7 +495,7 @@ export function useQaChat({
|
|||||||
aiTypingMessageId,
|
aiTypingMessageId,
|
||||||
teacherThreadStatus,
|
teacherThreadStatus,
|
||||||
qaCollapseSignal,
|
qaCollapseSignal,
|
||||||
qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading,
|
qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : qaMode === 'teacher' ? teacherMessagesLoading : false,
|
||||||
visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
|
visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
|
||||||
displayedAiMessages: selectedAiConversation
|
displayedAiMessages: selectedAiConversation
|
||||||
? selectedAiConversation.messages
|
? selectedAiConversation.messages
|
||||||
|
|||||||
@@ -662,5 +662,16 @@
|
|||||||
"활성": "Active",
|
"활성": "Active",
|
||||||
"활성 연결": "Active connections",
|
"활성 연결": "Active connections",
|
||||||
"활성 연결만 보기": "Show active connections only",
|
"활성 연결만 보기": "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