feat(chat): add agent attachments and shared conversation confirmation
This commit is contained in:
+76
-49
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user