feat(chat): add agent attachments and shared conversation confirmation

This commit is contained in:
2026-10-16 11:14:01 +09:00
parent ef260c8fb4
commit 4bdc5af620
13 changed files with 715 additions and 88 deletions
+76 -49
View File
@@ -59,6 +59,7 @@ import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
import { useQaChat } from './features/workspace/useQaChat'
import { useAiAgent } from './features/workspace/useAiAgent'
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
import type { ChatAttachment } from './components/chatAttachments'
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
import { useSourceControl } from './features/workspace/useSourceControl'
import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus'
@@ -3495,14 +3496,20 @@ function LocalPcWorkspaceMockup({
await createCustomModuleRecord(input)
}
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({
name: module.name,
folderName: module.folderName,
category: module.category,
state: module.state,
files: []
files: [],
...(requestedNames.has(module.name.toLowerCase()) ? {
fileContext: {
status: module.state === 'uninstalled' ? 'uninstalled' as const : 'limited' as const,
message: module.state === 'uninstalled' ? '설치되지 않아 소스를 읽을 수 없습니다.' : '파일 수·크기 제한으로 읽지 못했습니다.'
}
} : {})
}))
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const targetModules = availableModules.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
))
@@ -3518,48 +3525,60 @@ function LocalPcWorkspaceMockup({
for (const module of targetModules) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break
const modulePrefix = `modules/${module.name}`
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
const sourcePriority = (path: string): number => {
if (path === preferredPath) return -100
if (/(^|\/)src(\/|$)/.test(path)) return -50
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30
return 0
}
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
.filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path))
.sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right))
const files: AgentWorkspaceModule['files'] = []
for (const rendererPath of paths) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break
let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath)
if (content === undefined && remoteWorkspace) {
content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content
try {
const modulePrefix = `modules/${module.name}`
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
const sourcePriority = (path: string): number => {
if (path === preferredPath) return -100
if (/(^|\/)src(\/|$)/.test(path)) return -50
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30
return 0
}
if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue
if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue
totalCharacters += content.length
totalFiles += 1
files.push({ path: rendererPath.slice(modulePrefix.length + 1), content })
}
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
.filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path))
.sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right))
const files: AgentWorkspaceModule['files'] = []
const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name)
const workspaceModule = {
name: module.name,
folderName: module.folderName,
category: module.category,
state: module.state,
files
for (const rendererPath of paths) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break
let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath)
if (content === undefined && remoteWorkspace) {
content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content
}
if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue
if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue
totalCharacters += content.length
totalFiles += 1
files.push({ path: rendererPath.slice(modulePrefix.length + 1), content })
}
const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name)
const workspaceModule = {
name: module.name,
folderName: module.folderName,
category: module.category,
state: module.state,
files,
fileContext: {
status: files.length ? 'loaded' as const : 'empty' as const,
message: files.length ? '텍스트 소스를 조회했습니다. 파일 수·크기·형식 제한으로 일부 파일이 제외될 수 있습니다.' : '읽을 수 있는 텍스트 파일이 없습니다. 파일 수·크기·형식 제한 또는 빈 소스일 수 있습니다.'
}
}
if (moduleIndex < 0) workspaceModules.push(workspaceModule)
else workspaceModules[moduleIndex] = workspaceModule
} catch (error) {
const workspaceModule = workspaceModules.find((item) => item.name === module.name)
if (workspaceModule) workspaceModule.fileContext = {
status: 'error',
message: (error instanceof Error ? error.message : '모듈 소스를 읽지 못했습니다.').slice(0, 500)
}
}
if (moduleIndex < 0) workspaceModules.push(workspaceModule)
else workspaceModules[moduleIndex] = workspaceModule
}
return workspaceModules
@@ -3590,14 +3609,19 @@ function LocalPcWorkspaceMockup({
}
}))
}
const submitAgentRequest = async (content: string): Promise<void> => {
const loadAgentRequestedContext = async (request: { modules: string[]; logs: string[] }): Promise<{
modules: AgentWorkspaceModule[]
runtimeLogs: AgentRuntimeLog[]
}> => ({
modules: await collectAgentWorkspace(request.modules),
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
})
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
try {
const modules = await collectAgentWorkspace()
await requestAgentPlan(content, modules, [], {
loadRequestedContext: async (request) => ({
modules: await collectAgentWorkspace(request.modules),
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
})
attachments,
loadRequestedContext: loadAgentRequestedContext
})
} catch (error) {
console.error('[agent] workspace collection failed', error)
@@ -3730,7 +3754,10 @@ function LocalPcWorkspaceMockup({
`명령 출력:\n${result.output.slice(-2400) || '(출력 없음)'}`,
'이미 실행한 명령을 그대로 반복하지 말고, 결과상 추가 작업이 필요할 때만 다음 승인 명령 또는 파일 변경안을 제안하세요.'
].filter(Boolean).join('\n')
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], { showUserMessage: false })
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], {
showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext
})
} catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
.replace(/^Error invoking remote method '[^']+': Error:\s*/, '')
@@ -4569,8 +4596,8 @@ function LocalPcWorkspaceMockup({
if (mode === 'teacher') setTeacherChatFlash(false)
}}
onStop={qaMode === 'agent' ? stopAgent : stopAiResponse}
onSubmit={(content) => {
if (qaMode === 'agent') void submitAgentRequest(content)
onSubmit={(content, attachments) => {
if (qaMode === 'agent') void submitAgentRequest(content, attachments)
else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
}}
onClearConversation={() => {
@@ -75,6 +75,7 @@ export function StatusNoticeModal({
onConfirm: () => void
}): React.JSX.Element {
useLocaleRender()
const hasAction = Boolean(actionLabel && onAction)
return (
<div className={overlayClassName}>
<section className={modalClass(dark)}>
@@ -91,12 +92,12 @@ export function StatusNoticeModal({
</p>
)}
<div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onConfirm} className={hasAction ? cancelButtonClass(dark) : confirmButtonClass('primary')}>{t("확인")}</button>
{actionLabel && onAction && (
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}>
<button type="button" onClick={onAction} className={confirmButtonClass('primary')}>
{t(actionLabel)}
</button>
)}
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>{t("확인")}</button>
</div>
</div>
</section>
@@ -276,7 +277,7 @@ export function ControlExtensionConfirmModal({
)
}
function ConfirmStatusModal({
export function ConfirmStatusModal({
overlayClassName,
dark,
icon,
+71 -7
View File
@@ -1,7 +1,8 @@
import { t, useLocaleRender } from '../i18n'
import { ChevronDown, Send, Square } from 'lucide-react'
import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react'
import type React from 'react'
import { memo, useEffect, useState } from 'react'
import { memo, useEffect, useRef, useState } from 'react'
import { CHAT_ATTACHMENT_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -239,6 +240,9 @@ export const QaInputBox = memo(function QaInputBox({
busy,
disabled,
accent = 'blue',
attachmentsEnabled = false,
onClearConversation,
clearDisabled = false,
onStop,
onSubmit
}: {
@@ -246,11 +250,29 @@ export const QaInputBox = memo(function QaInputBox({
busy: boolean
disabled: boolean
accent?: 'blue' | 'cyan'
attachmentsEnabled?: boolean
onClearConversation?: () => void
clearDisabled?: boolean
onStop: () => void
onSubmit: (content: string) => void
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
}): React.JSX.Element {
const [draft, setDraft] = useState('')
const canSubmit = busy || (Boolean(draft.trim()) && !disabled)
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [attachmentError, setAttachmentError] = useState<string | null>(null)
const [readingAttachments, setReadingAttachments] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const attachmentReadRef = useRef(0)
const canSubmit = busy || (Boolean(draft.trim()) && !disabled && !readingAttachments)
useEffect(() => {
attachmentReadRef.current += 1
if (!attachmentsEnabled) {
setAttachments([])
setAttachmentError(null)
setReadingAttachments(false)
}
return () => { attachmentReadRef.current += 1 }
}, [attachmentsEnabled])
const submit = (): void => {
const content = draft.trim()
@@ -258,9 +280,11 @@ export const QaInputBox = memo(function QaInputBox({
onStop()
return
}
if (!content || disabled) return
if (!content || disabled || readingAttachments) return
setDraft('')
onSubmit(content)
onSubmit(content, attachmentsEnabled ? attachments : undefined)
setAttachments([])
setAttachmentError(null)
}
return (
@@ -273,7 +297,7 @@ export const QaInputBox = memo(function QaInputBox({
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
submit()
}
@@ -286,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({
/>
<button
type="button"
aria-label={busy ? t('중지') : t('전송')}
disabled={!canSubmit}
onClick={busy ? onStop : submit}
className={[
@@ -298,6 +323,45 @@ export const QaInputBox = memo(function QaInputBox({
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
</button>
</div>
{attachmentsEnabled && attachments.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-2">
{attachments.map((file, index) => (
<span key={`${file.name}-${index}`} className={['inline-flex min-w-0 max-w-full items-center gap-1 rounded border px-2 py-1 text-[11px]', dark ? 'border-white/10 text-white/70' : 'border-[#d8dee9] text-[#475569]'].join(' ')}>
<Paperclip size={12} className="shrink-0" />
<span className="min-w-0 truncate" title={file.name}>{file.name}</span>
<button type="button" disabled={busy || readingAttachments} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${file.name}`} onClick={() => setAttachments((current) => current.filter((_, item) => item !== index))} className="grid size-5 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10 disabled:cursor-default disabled:opacity-40"><X size={12} /></button>
</span>
))}
</div>
)}
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
{(attachmentsEnabled || onClearConversation) && (
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
{attachmentsEnabled && (
<>
<input ref={fileInputRef} type="file" multiple accept={CHAT_ATTACHMENT_ACCEPT} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? [])
event.currentTarget.value = ''
if (!files.length) return
const readId = ++attachmentReadRef.current
setReadingAttachments(true)
setAttachmentError(null)
void readChatAttachments(files, attachments).then((files) => {
if (attachmentReadRef.current === readId) setAttachments(files)
}).catch((error) => {
if (attachmentReadRef.current === readId) setAttachmentError(error instanceof Error ? error.message : '파일을 읽지 못했습니다.')
}).finally(() => {
if (attachmentReadRef.current === readId) setReadingAttachments(false)
})
}} />
<button type="button" title={t('파일 첨부')} aria-label={t('파일 첨부')} disabled={disabled || busy || readingAttachments} onClick={() => fileInputRef.current?.click()} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 disabled:cursor-default disabled:opacity-35"><Paperclip size={15} className={readingAttachments ? 'animate-pulse' : ''} /></button>
</>
)}
{onClearConversation && (
<button type="button" title={t('대화내역 삭제')} aria-label={t('대화내역 삭제')} disabled={clearDisabled || busy || readingAttachments} onClick={onClearConversation} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 hover:text-[#ef4444] disabled:cursor-default disabled:opacity-35"><Trash2 size={15} /></button>
)}
</div>
)}
</div>
)
})
@@ -0,0 +1,29 @@
export type ChatAttachment = { name: string; content: string }
export const CHAT_ATTACHMENT_ACCEPT = '.txt,.log,.md,.json,.yaml,.yml,.xml,.py,.js,.jsx,.ts,.tsx,.c,.cc,.cpp,.h,.hpp,.sh,.bash,.cfg,.ini,.toml,.csv,.urdf,.xacro,.launch,.rviz,.cmake'
export async function readChatAttachments(files: File[], existing: ChatAttachment[]): Promise<ChatAttachment[]> {
if (files.length + existing.length > 5) throw new Error('파일은 최대 5개까지 첨부할 수 있습니다.')
const result = [...existing]
for (const file of files) {
const name = file.name
const extension = `.${name.split('.').pop()?.toLowerCase()}`
if (!CHAT_ATTACHMENT_ACCEPT.split(',').includes(extension) && !['dockerfile', 'cmakelists.txt'].includes(name.toLowerCase())) {
throw new Error('코드·설정·로그 등 텍스트 파일만 첨부할 수 있습니다. 이미지와 PDF는 지원하지 않습니다.')
}
if (name.length > 255 || file.size > 100_000) throw new Error('파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.')
let content: string
try {
content = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer())
} catch {
throw new Error('UTF-8 텍스트 파일만 첨부할 수 있습니다.')
}
if (content.includes('\0')) throw new Error('바이너리 파일은 첨부할 수 없습니다.')
if (content.length > 50_000) throw new Error('파일 하나의 내용은 5만 자 이하여야 합니다.')
result.push({ name, content })
}
if (result.reduce((sum, file) => sum + file.name.length + file.content.length, 0) > 100_000) {
throw new Error('첨부 파일의 전체 내용은 10만 자 이하여야 합니다.')
}
return result
}
@@ -1,9 +1,12 @@
import { t, useLocaleRender } from '../../i18n'
import { Bot, Check, CheckCircle2, Circle, Copy, FileCode2, ListChecks, Loader2, MessageCircle, Minus, Play, TerminalSquare, User, WandSparkles, X } from 'lucide-react'
import { Bot, Check, CheckCircle2, Circle, Copy, FileCode2, ListChecks, Loader2, MessageCircle, Minus, Play, TerminalSquare, Trash2, User, WandSparkles, X } from 'lucide-react'
import type React from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { ConfirmStatusModal } from '../../components/AppStatusModals'
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments'
type QaMode = 'ai' | 'agent' | 'teacher'
@@ -46,7 +49,7 @@ type WorkspaceQaPanelProps = {
onMinimize: () => void
onModeChange: (mode: QaMode) => void
onStop: () => void
onSubmit: (content: string) => void
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
onClearConversation: () => void
onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void
onOpenAgentChange: (messageId: string, change: AgentFileChange) => void
@@ -124,8 +127,19 @@ export default function WorkspaceQaPanel({
}: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1]
const clearDisabled = inputBusy || qaMessagesLoading || (agentStatusLoading && qaMode === 'agent') || activeMessages.length === 0
useEffect(() => {
setClearConfirmationOpen(false)
}, [qaMode, workspaceActive, minimized])
const requestClearConversation = (): void => {
setContextMenu(null)
if (!clearDisabled) setClearConfirmationOpen(true)
}
useEffect(() => {
if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai')
@@ -304,11 +318,9 @@ export default function WorkspaceQaPanel({
>
<button
type="button"
className={['block w-full px-3 py-2 text-left text-[12px] font-semibold transition', dark ? 'text-white/78 hover:bg-white/10' : 'text-[#334155] hover:bg-[#f1f5f9]'].join(' ')}
onClick={() => {
setContextMenu(null)
onClearConversation()
}}
disabled={clearDisabled}
className={['block w-full px-3 py-2 text-left text-[12px] font-semibold transition disabled:cursor-default disabled:opacity-35', dark ? 'text-white/78 hover:bg-white/10' : 'text-[#334155] hover:bg-[#f1f5f9]'].join(' ')}
onClick={requestClearConversation}
>{t("대화내역 삭제")}</button>
</div>
)}
@@ -318,7 +330,27 @@ export default function WorkspaceQaPanel({
disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
onStop={onStop}
onSubmit={onSubmit}
attachmentsEnabled={qaMode === 'agent'}
onClearConversation={requestClearConversation}
clearDisabled={clearDisabled}
/>
{clearConfirmationOpen && createPortal(
<ConfirmStatusModal
overlayClassName="fixed bottom-0 left-0 right-0 top-[38px] z-[180] grid place-items-center bg-black/36"
dark={dark}
icon={<Trash2 size={15} className="text-[#e87979]" />}
title={t('대화내역 삭제')}
message={t('대화내역을 삭제할까요?')}
detail={t('현재 탭의 대화내역을 삭제합니다.')}
confirmLabel={t('삭제')}
onCancel={() => setClearConfirmationOpen(false)}
onConfirm={() => {
setClearConfirmationOpen(false)
if (!clearDisabled) onClearConversation()
}}
/>,
document.body
)}
</aside>
)
}
@@ -0,0 +1,76 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[] }
export type AgentContextRequest = { modules: string[]; logs: string[] }
export type AgentPlanResponse = {
provider: string
model: string
usage?: AgentConversationMessage['usage']
plan: AgentPlan
}
export async function resolveAgentContext({
initialContext,
requestPlan,
loadRequestedContext,
signal
}: {
initialContext: AgentContext
requestPlan: (context: AgentContext) => Promise<AgentPlanResponse>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
signal: AbortSignal
}): Promise<AgentPlanResponse> {
let context = initialContext
const requestedModules = new Set<string>()
const requestedLogs = new Set<string>()
const usage: NonNullable<AgentConversationMessage['usage']> = {}
const checkAborted = (): void => {
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
}
// Initial request plus at most five context fetches; only return a finished response.
for (let round = 0; round <= 5; round += 1) {
checkAborted()
const response = await requestPlan(context)
checkAborted()
for (const key of ['promptTokens', 'completionTokens', 'totalTokens'] as const) {
if (response.usage?.[key] !== undefined) usage[key] = (usage[key] ?? 0) + response.usage[key]
}
const modules = [...new Set(response.plan.requestedModules ?? [])]
const logs = [...new Set(response.plan.requestedLogs ?? [])]
if (!modules.length && !logs.length) return { ...response, usage }
if (!loadRequestedContext) throw new Error('추가 파일·로그를 읽을 수 없는 요청입니다. 워크스페이스에서 질문을 다시 보내주세요.')
if (round === 5) throw new Error('추가 자료 조회 횟수(5회)를 초과했습니다. 확인할 모듈이나 설정 파일 범위를 좁혀 다시 요청해주세요.')
const nextRequest = {
modules: modules.filter((name) => !requestedModules.has(name)),
logs: logs.filter((name) => !requestedLogs.has(name))
}
if (!nextRequest.modules.length && !nextRequest.logs.length) {
throw new Error(`AI가 이미 조회한 자료를 반복 요청하여 분석을 완료하지 못했습니다 (${[...modules, ...logs].join(', ')}). 해당 모듈의 설치·파일 조회 상태를 확인하고 다시 요청해주세요.`)
}
nextRequest.modules.forEach((name) => requestedModules.add(name))
nextRequest.logs.forEach((name) => requestedLogs.add(name))
const loaded = await loadRequestedContext(nextRequest)
checkAborted()
const loadedModules = new Map(loaded.modules.map((module) => [module.name, module]))
const mergedModules = context.modules.map((module) => {
const updated = loadedModules.get(module.name)
loadedModules.delete(module.name)
// Collectors also return metadata-only entries for other modules. Keep earlier sources.
return updated && nextRequest.modules.includes(module.name)
? { ...module, files: updated.files, fileContext: updated.fileContext }
: module
})
mergedModules.push(...loadedModules.values())
const mergedLogs = new Map(context.runtimeLogs.map((log) => [log.moduleName, log]))
loaded.runtimeLogs.forEach((log) => mergedLogs.set(log.moduleName, log))
const fileCount = mergedModules.reduce((sum, module) => sum + module.files.length, 0)
const characters = mergedModules.reduce((sum, module) => sum + module.files.reduce((count, file) => count + file.path.length + file.content.length, 0), 0)
if (fileCount > 24 || characters > 160_000 || mergedLogs.size > 3) {
throw new Error('추가 자료가 조회 한도(파일 24개·16만 자, 로그 모듈 3개)를 초과했습니다. 대상 모듈이나 파일 범위를 좁혀 다시 요청해주세요.')
}
context = { modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
}
throw new Error('AI 분석을 완료하지 못했습니다.')
}
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync'
import { resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
import type { ChatAttachment } from '../../components/chatAttachments'
export type AgentFileChange = {
moduleName: string
@@ -47,6 +49,10 @@ export type AgentWorkspaceModule = {
folderName?: string
category?: string
state?: string
fileContext?: {
status: 'loaded' | 'empty' | 'error' | 'uninstalled' | 'limited'
message?: string
}
files: Array<{ path: string; content: string }>
}
@@ -125,6 +131,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
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
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
}
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
}
return response.json() as Promise<T>
@@ -243,10 +252,8 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
runtimeLogs: AgentRuntimeLog[] = [],
options: {
showUserMessage?: boolean
loadRequestedContext?: (request: { modules: string[]; logs: string[] }) => Promise<{
modules: AgentWorkspaceModule[]
runtimeLogs: AgentRuntimeLog[]
}>
attachments?: ChatAttachment[]
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
} = {}
): Promise<void> => {
const request = rawRequest.trim()
@@ -254,7 +261,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`,
role: 'user',
content: request
content: [request, ...(options.attachments?.length ? [`\n첨부 파일: ${options.attachments.map((file) => file.name).join(', ')}`] : [])].join('\n')
}
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true)
@@ -262,27 +269,16 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
abortControllerRef.current = abortController
try {
let response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', {
method: 'POST',
const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs },
signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request, modules, runtimeLogs })
})
const requestedModules = response.plan.requestedModules ?? []
const requestedLogs = response.plan.requestedLogs ?? []
if ((requestedModules.length || requestedLogs.length) && options.loadRequestedContext) {
const requestedContext = await options.loadRequestedContext({ modules: requestedModules, logs: requestedLogs })
response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', {
loadRequestedContext: options.loadRequestedContext,
requestPlan: (context) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST',
signal: abortController.signal,
body: JSON.stringify({
courseId,
week: weekNumber,
request,
modules: requestedContext.modules,
runtimeLogs: requestedContext.runtimeLogs
})
body: JSON.stringify({ courseId, week: weekNumber, request, ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context })
})
}
})
if (abortController.signal.aborted) return
const plan: AgentPlan = {
...response.plan,
+12
View File
@@ -1,4 +1,16 @@
{
"전송": "Send",
"파일 첨부": "Attach files",
"첨부 제거": "Remove attachment",
"대화내역을 삭제할까요?": "Clear this conversation?",
"현재 탭의 대화내역을 삭제합니다.": "The conversation in the current tab will be deleted.",
"파일은 최대 5개까지 첨부할 수 있습니다.": "You can attach up to 5 files.",
"코드·설정·로그 등 텍스트 파일만 첨부할 수 있습니다. 이미지와 PDF는 지원하지 않습니다.": "Only text files such as source code, configuration, and logs are supported. Images and PDFs are not supported.",
"파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.": "File names must be at most 255 characters and files at most 100KB.",
"UTF-8 텍스트 파일만 첨부할 수 있습니다.": "Only UTF-8 text files are supported.",
"바이너리 파일은 첨부할 수 없습니다.": "Binary files cannot be attached.",
"파일 하나의 내용은 5만 자 이하여야 합니다.": "Each attachment must contain at most 50,000 characters.",
"첨부 파일의 전체 내용은 10만 자 이하여야 합니다.": "All attachments together must contain at most 100,000 characters.",
"로컬 모듈 Git 가져오기": "Import local module from Git",
"로컬 사용자 모듈": "Local custom module",
"현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.",