feat: improve agent model selection and remote source analysis; bump to 1.2.23
This commit is contained in:
@@ -630,6 +630,8 @@ function LocalPcWorkspaceMockup({
|
||||
enabled: agentEnabled,
|
||||
provider: agentProvider,
|
||||
imagesEnabled: agentImagesEnabled,
|
||||
models: agentModels,
|
||||
selectProvider: selectAgentProvider,
|
||||
statusLoading: agentStatusLoading,
|
||||
pending: agentPending,
|
||||
activity: agentActivity,
|
||||
@@ -3526,9 +3528,9 @@ function LocalPcWorkspaceMockup({
|
||||
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
|
||||
await createCustomModuleRecord(input)
|
||||
}
|
||||
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => {
|
||||
const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise<AgentWorkspaceModule[]> => {
|
||||
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
|
||||
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({
|
||||
const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
category: module.category,
|
||||
@@ -3542,7 +3544,7 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
} : {})
|
||||
}))
|
||||
const targetModules = availableModules.filter((module) => (
|
||||
const targetModules = moduleSnapshot.filter((module) => (
|
||||
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
|
||||
))
|
||||
if (!targetModules.length) return workspaceModules
|
||||
@@ -3677,12 +3679,13 @@ function LocalPcWorkspaceMockup({
|
||||
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentApplying) return
|
||||
try {
|
||||
const modules = await collectAgentWorkspace()
|
||||
const modules = await collectAgentWorkspace([], await readAgentModules())
|
||||
await requestAgentPlan(content, modules, [], {
|
||||
attachments,
|
||||
loadRequestedContext: loadAgentRequestedContext,
|
||||
executeTool: executeWorkspaceAgentTool,
|
||||
environment: remoteWorkspace ? 'remote' : 'local'
|
||||
environment: remoteWorkspace ? 'remote' : 'local',
|
||||
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[agent] workspace collection failed', error)
|
||||
@@ -3824,7 +3827,7 @@ function LocalPcWorkspaceMockup({
|
||||
const currentModules = await readAgentModules()
|
||||
assertActive()
|
||||
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
|
||||
showUserMessage: false, environment, executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
|
||||
showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
|
||||
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -3906,7 +3909,8 @@ function LocalPcWorkspaceMockup({
|
||||
showUserMessage: false,
|
||||
loadRequestedContext: loadAgentRequestedContext,
|
||||
executeTool: executeWorkspaceAgentTool,
|
||||
environment: remoteWorkspace ? 'remote' : 'local'
|
||||
environment: remoteWorkspace ? 'remote' : 'local',
|
||||
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
|
||||
@@ -4752,6 +4756,8 @@ function LocalPcWorkspaceMockup({
|
||||
agentEnabled={agentEnabled}
|
||||
agentProvider={agentProvider}
|
||||
agentImagesEnabled={Boolean(agentImagesEnabled)}
|
||||
agentModels={agentModels}
|
||||
onAgentProviderChange={selectAgentProvider}
|
||||
agentStatusLoading={agentStatusLoading}
|
||||
agentPending={agentPending}
|
||||
agentActivity={agentActivity}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Bot, ChevronDown } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
|
||||
|
||||
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
|
||||
models: AgentModelOption[]
|
||||
provider: string | null
|
||||
disabled: boolean
|
||||
dark: boolean
|
||||
onChange: (provider: string) => void
|
||||
}): React.JSX.Element {
|
||||
const selected = models.find(model => model.provider === provider)
|
||||
return (
|
||||
<div className="relative ml-auto min-w-0 max-w-[280px] flex-1">
|
||||
<Bot size={13} className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2" aria-hidden="true" />
|
||||
<select
|
||||
aria-label={t('에이전트 모델 선택')}
|
||||
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
|
||||
value={provider ?? ''}
|
||||
disabled={disabled || models.length === 0}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
style={{ colorScheme: dark ? 'dark' : 'light' }}
|
||||
className={['h-7 w-full min-w-0 cursor-pointer appearance-none truncate rounded border py-0 pl-7 pr-7 text-[11px] outline-none transition focus-visible:border-[#8b5cf6] disabled:cursor-default disabled:opacity-45',
|
||||
dark ? 'border-white/10 bg-[#282828] text-white/75 hover:bg-[#303030]' : 'border-[#d8dee9] bg-[#f8f9fc] text-[#475569] hover:bg-[#eef0f5]'].join(' ')}
|
||||
>
|
||||
{!models.length && <option value="">{t('사용 가능한 모델 없음')}</option>}
|
||||
{models.map(model => <option key={model.provider} value={model.provider}>{agentModelLabel(model)}</option>)}
|
||||
</select>
|
||||
<ChevronDown size={12} className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2" aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -298,6 +298,7 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
imagesEnabled = false,
|
||||
onClearConversation,
|
||||
clearDisabled = false,
|
||||
footerAction,
|
||||
onStop,
|
||||
onSubmit
|
||||
}: {
|
||||
@@ -309,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
imagesEnabled?: boolean
|
||||
onClearConversation?: () => void
|
||||
clearDisabled?: boolean
|
||||
footerAction?: React.ReactNode
|
||||
onStop: () => void
|
||||
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
|
||||
}): React.JSX.Element {
|
||||
@@ -456,8 +458,8 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
)}
|
||||
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
|
||||
{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 || onClearConversation || footerAction) && (
|
||||
<div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
|
||||
{attachmentsEnabled && (
|
||||
<>
|
||||
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
|
||||
@@ -471,6 +473,7 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
{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>
|
||||
)}
|
||||
{footerAction}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ import { ChatImageAttachments } from '../../components/ChatImageAttachments'
|
||||
import { AgentProgressBubble } from '../../components/AgentProgressBubble'
|
||||
import { AgentWorkDuration } from '../../components/AgentWorkDuration'
|
||||
import { AgentCodeReferences } from '../../components/AgentCodeReferences'
|
||||
import { AgentModelSelector } from '../../components/AgentModelSelector'
|
||||
import type { AgentModelOption } from './agentModels'
|
||||
import type { AgentProgressStep } from './agentContext'
|
||||
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
|
||||
@@ -47,6 +49,8 @@ type WorkspaceQaPanelProps = {
|
||||
agentEnabled: boolean
|
||||
agentProvider?: string | null
|
||||
agentImagesEnabled?: boolean
|
||||
agentModels?: AgentModelOption[]
|
||||
onAgentProviderChange?: (provider: string) => void
|
||||
agentStatusLoading: boolean
|
||||
agentPending: boolean
|
||||
agentActivity?: string
|
||||
@@ -124,6 +128,8 @@ export default function WorkspaceQaPanel({
|
||||
agentEnabled,
|
||||
agentProvider,
|
||||
agentImagesEnabled = false,
|
||||
agentModels = [],
|
||||
onAgentProviderChange,
|
||||
agentStatusLoading,
|
||||
agentPending,
|
||||
agentActivity,
|
||||
@@ -348,6 +354,7 @@ export default function WorkspaceQaPanel({
|
||||
imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
|
||||
onClearConversation={requestClearConversation}
|
||||
clearDisabled={clearDisabled}
|
||||
footerAction={qaMode === 'agent' && onAgentProviderChange ? <AgentModelSelector models={agentModels} provider={agentProvider ?? null} dark={dark} disabled={inputBusy || agentStatusLoading || inputDisabled} onChange={onAgentProviderChange} /> : undefined}
|
||||
/>
|
||||
{clearConfirmationOpen && createPortal(
|
||||
<ConfirmStatusModal
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
|
||||
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||
import { compactAgentEvidence, describeAgentEvidence, missingAgentRead, readFileEvidence } from './agentEvidence'
|
||||
|
||||
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] }
|
||||
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[]; contextNotes?: string[] }
|
||||
export type AgentContextRequest = { modules: string[]; logs: string[] }
|
||||
export type AgentProgressStep = {
|
||||
id: string
|
||||
@@ -49,11 +50,11 @@ export function recoverInterruptedAgentMessages(messages: AgentConversationMessa
|
||||
|
||||
export function agentPlanRequestText(request: string, finalize = false): string {
|
||||
if (!finalize) return request
|
||||
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
|
||||
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 코드 위치와 역할 설명은 전체 파일이 아닌 확인된 줄 범위만으로 답할 수 있습니다. 확인된 내용은 codeReferences와 함께 답하고 미확인 항목만 별도로 구분하세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
|
||||
return instruction + request.slice(0, 4000 - instruction.length)
|
||||
}
|
||||
|
||||
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext {
|
||||
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = [], preferredReads: AgentToolRequest[] = []): AgentContext {
|
||||
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
|
||||
const selected = new Map<string, AgentWorkspaceModule['files']>()
|
||||
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
|
||||
@@ -111,12 +112,28 @@ function fitAgentContext(context: AgentContext, preferredModules: string[] = [],
|
||||
return { ...log, content, ...(notes ? { message: notes } : {}) }
|
||||
})
|
||||
let toolSize = 0
|
||||
const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => {
|
||||
if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false
|
||||
const evidence = compactAgentEvidence(context.toolResults ?? [])
|
||||
const priority = (result: AgentToolResult): number => {
|
||||
const file = readFileEvidence(result)
|
||||
if (file && preferredReads.some(tool => tool.name === 'files.read' && tool.moduleName === result.moduleName && tool.args?.path === file.path)) return 6
|
||||
return result.name === 'action.result' ? 5 : !result.name.startsWith('files.') ? 4
|
||||
: file || result.status === 'error' ? 3 : 1
|
||||
}
|
||||
const candidatesByPriority = evidence.map((result, index) => ({ result, index }))
|
||||
.sort((a, b) => priority(b.result) - priority(a.result) || b.index - a.index)
|
||||
const retained = new Set<AgentToolResult>()
|
||||
for (const { result } of candidatesByPriority) {
|
||||
if (retained.size >= 12 || result.data.length > 60_000 || toolSize + result.data.length > 180_000) continue
|
||||
retained.add(result)
|
||||
toolSize += result.data.length
|
||||
return true
|
||||
}).slice(0, 12).reverse()
|
||||
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) }
|
||||
}
|
||||
const toolResults = evidence.filter(result => retained.has(result))
|
||||
const omittedTools = evidence.filter(result => !retained.has(result))
|
||||
const contextNotes = omittedTools.length
|
||||
? [`용량·개수 한도로 조회 근거 ${omittedTools.length}개를 제외했습니다. 제외된 내용을 확인했다고 주장하지 마세요.`,
|
||||
...omittedTools.slice(-10).map(result => describeAgentEvidence(result).slice(0, 500))]
|
||||
: []
|
||||
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults, contextNotes } : {}) }
|
||||
}
|
||||
|
||||
export async function resolveAgentContext({
|
||||
@@ -142,6 +159,7 @@ export async function resolveAgentContext({
|
||||
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
|
||||
const queriedTools = new Set<string>()
|
||||
let toolCount = 0
|
||||
let confirmedAnswer: AgentPlanResponse | undefined
|
||||
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
|
||||
const usage: NonNullable<AgentConversationMessage['usage']> = {}
|
||||
const checkAborted = (): void => {
|
||||
@@ -162,9 +180,17 @@ export async function resolveAgentContext({
|
||||
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
|
||||
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
|
||||
)).join('\n').slice(0, 8000)
|
||||
const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000)
|
||||
const toolStatus = (context.toolResults ?? []).map(result => `- ${describeAgentEvidence(result)}`).join('\n').slice(0, 8000)
|
||||
const candidate = response.plan.codeReferences?.length ? response : confirmedAnswer
|
||||
const references = candidate?.plan.codeReferences?.filter(reference => {
|
||||
const latest = (cachedContext.toolResults ?? []).filter(result => result.moduleName === reference.moduleName)
|
||||
.map(readFileEvidence).filter(file => file?.path === reference.path).at(-1)
|
||||
return !latest || latest.exists && latest.hash === reference.expectedHash
|
||||
})
|
||||
const confirmed = references?.length && candidate ? { ...candidate, plan: { ...candidate.plan, codeReferences: references } } : undefined
|
||||
const confirmedReferences = Boolean(confirmed?.plan.codeReferences?.length)
|
||||
const explanation = stillRequesting
|
||||
? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`
|
||||
? `${confirmedReferences ? `${confirmed!.plan.explanation}\n\n추가 분석은 완료하지 못했지만 확인된 코드 위치는 유지했습니다.` : 'AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.'}\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`.slice(0, 12_000)
|
||||
: response.plan.explanation
|
||||
return {
|
||||
...response,
|
||||
@@ -172,7 +198,8 @@ export async function resolveAgentContext({
|
||||
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
|
||||
plan: {
|
||||
...response.plan,
|
||||
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
|
||||
summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
|
||||
...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}),
|
||||
explanation,
|
||||
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
|
||||
}
|
||||
@@ -186,19 +213,31 @@ export async function resolveAgentContext({
|
||||
const response = await requestPlan(context)
|
||||
checkAborted()
|
||||
addUsage(response)
|
||||
if (response.plan.codeReferences?.length) confirmedAnswer = response
|
||||
const modules = [...new Set(response.plan.requestedModules ?? [])]
|
||||
const logs = [...new Set(response.plan.requestedLogs ?? [])]
|
||||
const tools = response.plan.toolRequests ?? []
|
||||
onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length })
|
||||
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
|
||||
if (round === maxRounds) return finalize()
|
||||
if (round === maxRounds) {
|
||||
context = fitAgentContext(cachedContext, [], [], tools)
|
||||
return finalize()
|
||||
}
|
||||
if (tools.length) {
|
||||
if (!executeTool || tools.length > 6) return finalize()
|
||||
const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool)))
|
||||
if (!fresh.length || toolCount + fresh.length > 24) return finalize()
|
||||
const fresh = tools.filter(tool => {
|
||||
const missing = missingAgentRead(tool, cachedContext.toolResults ?? [])
|
||||
return missing && !queriedTools.has(fingerprint(missing))
|
||||
})
|
||||
if (!fresh.length || toolCount + fresh.length > 24) {
|
||||
context = fitAgentContext(cachedContext, [], [], tools)
|
||||
return finalize()
|
||||
}
|
||||
const results = [...(cachedContext.toolResults ?? [])]
|
||||
for (const [index, tool] of fresh.entries()) {
|
||||
for (const [index, proposedTool] of fresh.entries()) {
|
||||
checkAborted()
|
||||
const tool = missingAgentRead(proposedTool, results)
|
||||
if (!tool) continue
|
||||
const key = fingerprint(tool)
|
||||
if (queriedTools.has(key)) continue
|
||||
queriedTools.add(key)
|
||||
@@ -209,8 +248,8 @@ export async function resolveAgentContext({
|
||||
toolCount++
|
||||
results.push(result)
|
||||
}
|
||||
cachedContext = { ...cachedContext, toolResults: results }
|
||||
context = fitAgentContext(cachedContext)
|
||||
cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) }
|
||||
context = fitAgentContext(cachedContext, [], [], tools)
|
||||
onProgress?.('조회 결과 분석 중')
|
||||
// A model may also use legacy source requests; process those on the next pass.
|
||||
continue
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||
|
||||
type FileEvidence = {
|
||||
path: string; exists: boolean; content: string; hash: string | null
|
||||
complete: boolean; startLine: number; endLine: number; totalLines: number
|
||||
contentTruncated?: boolean
|
||||
}
|
||||
|
||||
export function readFileEvidence(result: AgentToolResult): FileEvidence | undefined {
|
||||
if (result.name !== 'files.read' || result.status !== 'ok') return
|
||||
try {
|
||||
const data = JSON.parse(result.data)
|
||||
if (typeof data.path !== 'string' || typeof data.content !== 'string' || typeof data.complete !== 'boolean'
|
||||
|| data.contentTruncated !== undefined && typeof data.contentTruncated !== 'boolean') return
|
||||
if (data.exists === false && data.complete === true && data.hash === null && data.content === '') {
|
||||
return { ...data, startLine: 1, endLine: 0, totalLines: 0 }
|
||||
}
|
||||
if (data.exists !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) return
|
||||
const startLine = data.startLine ?? 1
|
||||
const last = startLine + data.content.split('\n').length - 1
|
||||
// Older clients cannot distinguish a ranged read from a cut final line.
|
||||
const endLine = data.endLine ?? last - Number(result.truncated)
|
||||
if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(endLine)
|
||||
|| endLine < startLine - 1 || endLine > last || !Number.isSafeInteger(data.totalLines) || data.totalLines < 1 || endLine > data.totalLines
|
||||
|| data.contentTruncated === true && endLine >= last) return
|
||||
return { ...data, startLine, endLine }
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
const fileKey = (result: AgentToolResult, file: FileEvidence): string => JSON.stringify([result.target, result.moduleName, file.path])
|
||||
|
||||
// Only combine observations of the same file revision on the same host.
|
||||
export function compactAgentEvidence(results: AgentToolResult[]): AgentToolResult[] {
|
||||
const compacted: AgentToolResult[] = []
|
||||
for (const result of results) {
|
||||
const file = readFileEvidence(result)
|
||||
if (!file) { compacted.push(result); continue }
|
||||
const key = fileKey(result, file)
|
||||
let current = result
|
||||
let currentFile = file
|
||||
for (let index = compacted.length - 1; index >= 0; index--) {
|
||||
const previous = compacted[index]
|
||||
const prior = readFileEvidence(previous)
|
||||
if (!prior || fileKey(previous, prior) !== key) continue
|
||||
if (prior.hash !== file.hash || prior.exists !== file.exists) {
|
||||
compacted.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
if (currentFile.complete) { compacted.splice(index, 1); continue }
|
||||
if (prior.complete) { current = previous; currentFile = prior; compacted.splice(index, 1); continue }
|
||||
if (prior.contentTruncated !== false || currentFile.contentTruncated !== false
|
||||
|| prior.totalLines !== currentFile.totalLines
|
||||
|| prior.endLine + 1 < currentFile.startLine || currentFile.endLine + 1 < prior.startLine) continue
|
||||
const lines = new Map<number, string>()
|
||||
let conflict = false
|
||||
for (const part of [prior, currentFile]) {
|
||||
for (const [offset, line] of part.content.split('\n').entries()) {
|
||||
const number = part.startLine + offset
|
||||
if (number > part.endLine) break
|
||||
if (lines.has(number) && lines.get(number) !== line) conflict = true
|
||||
lines.set(number, line)
|
||||
}
|
||||
}
|
||||
if (conflict) continue
|
||||
const startLine = Math.min(prior.startLine, currentFile.startLine)
|
||||
const endLine = Math.max(prior.endLine, currentFile.endLine)
|
||||
const content = Array.from({ length: endLine - startLine + 1 }, (_, offset) => lines.get(startLine + offset)!).join('\n')
|
||||
// Merged ranges remain read-only evidence: no unverified full-file hash promotion.
|
||||
const merged = { ...currentFile, startLine, endLine, content, complete: false, contentTruncated: false }
|
||||
const data = JSON.stringify(merged)
|
||||
if (content.length > 40_000 || data.length > 60_000) continue
|
||||
currentFile = merged
|
||||
current = { ...result, data, truncated: true }
|
||||
compacted.splice(index, 1)
|
||||
}
|
||||
compacted.push(current)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
export function missingAgentRead(tool: AgentToolRequest, results: AgentToolResult[]): AgentToolRequest | undefined {
|
||||
if (tool.name !== 'files.read') return tool
|
||||
const observations = results.filter(result => result.moduleName === tool.moduleName)
|
||||
.flatMap(result => { const file = readFileEvidence(result); return file && file.path === tool.args?.path ? [{ file, target: result.target }] : [] })
|
||||
const latest = observations.at(-1)
|
||||
const files = observations.filter(item => item.target === latest?.target && item.file.hash === latest?.file.hash).map(item => item.file)
|
||||
if (files.some(file => file.complete)) return
|
||||
// An unbounded read may be needed for an approved edit; partial ranges do not replace it.
|
||||
if (tool.args?.startLine === undefined && tool.args?.lineCount === undefined) return tool
|
||||
const start = tool.args?.startLine ?? 1
|
||||
const total = files[0]?.totalLines
|
||||
const end = Math.min(start + (tool.args?.lineCount ?? 200) - 1, total ?? Infinity)
|
||||
let next = start
|
||||
const ranges = files.map(file => [file.startLine, file.endLine]).sort((a, b) => a[0] - b[0])
|
||||
for (const [from, to] of ranges) {
|
||||
if (from > next) return { ...tool, args: { ...tool.args, startLine: next, lineCount: Math.min(end, from - 1) - next + 1 } }
|
||||
if (to >= next) next = to + 1
|
||||
if (next > end) return
|
||||
}
|
||||
if (next > end) return
|
||||
return { ...tool, args: { ...tool.args, startLine: next, lineCount: end - next + 1 } }
|
||||
}
|
||||
|
||||
export function describeAgentEvidence(result: AgentToolResult): string {
|
||||
const file = readFileEvidence(result)
|
||||
return `${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${file ? ` · ${file.path}${file.exists ? ` · ${file.startLine}–${file.endLine}/${file.totalLines}줄` : ' · 파일 없음'}` : ''}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export const AGENT_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const
|
||||
export type AgentProvider = typeof AGENT_PROVIDERS[number]
|
||||
export type AgentModelOption = { provider: AgentProvider; model: string; imagesEnabled: boolean }
|
||||
export type AgentStatus = {
|
||||
enabled: boolean
|
||||
provider: string | null
|
||||
model: string | null
|
||||
imagesEnabled?: boolean
|
||||
models: AgentModelOption[]
|
||||
selectionSupported: boolean
|
||||
}
|
||||
|
||||
export const AGENT_PROVIDER_LABELS: Record<AgentProvider, string> = {
|
||||
A_COPILOT: 'A-Copilot', OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen'
|
||||
}
|
||||
|
||||
export function normalizeAgentStatus(value: Partial<AgentStatus>): AgentStatus {
|
||||
const selectionSupported = Array.isArray(value.models)
|
||||
const candidates = value.enabled ? selectionSupported ? value.models! : [value] : []
|
||||
const models = AGENT_PROVIDERS.flatMap(provider => {
|
||||
const item = candidates.find(item => item.provider === provider && typeof item.model === 'string' && item.model.trim() && item.model.length <= 120)
|
||||
return item ? [{ provider, model: item.model!.trim(), imagesEnabled: provider === 'QWEN' && item.imagesEnabled === true }] : []
|
||||
})
|
||||
return { enabled: models.length > 0, provider: models[0]?.provider ?? null, model: models[0]?.model ?? null,
|
||||
imagesEnabled: models[0]?.imagesEnabled ?? false, models, selectionSupported }
|
||||
}
|
||||
|
||||
export function selectedAgentModel(models: AgentModelOption[], preference: string | null): AgentModelOption | null {
|
||||
return models.find(model => model.provider === preference) ?? models[0] ?? null
|
||||
}
|
||||
|
||||
export function agentModelLabel(model: AgentModelOption): string {
|
||||
return model.provider === 'A_COPILOT' ? 'A-Copilot' : `${AGENT_PROVIDER_LABELS[model.provider]} · ${model.model}`
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createAgentSessionSync } from './agentSessionSync'
|
||||
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
|
||||
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
|
||||
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
|
||||
|
||||
export type AgentFileChange = {
|
||||
expectedHash?: string | null
|
||||
@@ -117,13 +118,6 @@ type UseAiAgentOptions = {
|
||||
onResponse?: () => void
|
||||
}
|
||||
|
||||
type AgentStatus = {
|
||||
enabled: boolean
|
||||
provider: string | null
|
||||
model: string | null
|
||||
imagesEnabled?: boolean
|
||||
}
|
||||
|
||||
type AgentSessionResponse = {
|
||||
sessionId: string | null
|
||||
messages: AgentConversationMessage[]
|
||||
@@ -132,7 +126,11 @@ type AgentSessionResponse = {
|
||||
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
|
||||
|
||||
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
|
||||
const [status, setStatus] = useState<AgentStatus>({ enabled: false, provider: null, model: null })
|
||||
const modelPreferenceKey = `autonomy-studio.lms.agent-model:${JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId])}`
|
||||
const [statusSnapshot, setStatusSnapshot] = useState<{ scope: string; status: AgentStatus }>({ scope: '', status: normalizeAgentStatus({}) })
|
||||
const status = statusSnapshot.scope === modelPreferenceKey ? statusSnapshot.status : normalizeAgentStatus({})
|
||||
const [modelPreference, setModelPreference] = useState<{ scope: string; provider: string | null }>({ scope: '', provider: null })
|
||||
const selectedModel = selectedAgentModel(status.models, modelPreference.scope === modelPreferenceKey ? modelPreference.provider : null)
|
||||
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
|
||||
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
@@ -150,6 +148,18 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
onResponseRef.current = onResponse
|
||||
const authenticated = Boolean(accessToken && courseId && studentEmail)
|
||||
|
||||
const selectProvider = (provider: string): void => {
|
||||
if (pending || applyingRef.current || abortControllerRef.current || !status.models.some(model => model.provider === provider)) return
|
||||
setModelPreference({ scope: modelPreferenceKey, provider })
|
||||
try { window.localStorage.setItem(modelPreferenceKey, provider) } catch { /* Selection still works without persistent storage. */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let provider: string | null = null
|
||||
try { provider = window.localStorage.getItem(modelPreferenceKey) } catch { /* Use the first available model. */ }
|
||||
setModelPreference({ scope: modelPreferenceKey, provider })
|
||||
}, [modelPreferenceKey])
|
||||
|
||||
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
||||
const authorization = new Headers(options?.headers).get('Authorization')
|
||||
?? `Bearer ${await tokenReaderRef.current()}`
|
||||
@@ -167,7 +177,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
|
||||
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
|
||||
}
|
||||
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
|
||||
if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
|
||||
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
@@ -205,27 +215,43 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !courseId) {
|
||||
setStatus({ enabled: false, provider: null, model: null })
|
||||
setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
|
||||
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)
|
||||
})
|
||||
let revision = 0
|
||||
let initialLoad = true
|
||||
const refresh = (): void => {
|
||||
const requestRevision = ++revision
|
||||
if (initialLoad) setStatusLoading(true)
|
||||
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
|
||||
.then(nextStatus => {
|
||||
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && requestRevision === revision) {
|
||||
initialLoad = false
|
||||
setStatusLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
const refreshVisible = (): void => { if (!document.hidden) refresh() }
|
||||
refresh()
|
||||
window.addEventListener('focus', refresh)
|
||||
document.addEventListener('visibilitychange', refreshVisible)
|
||||
const timer = window.setInterval(refreshVisible, 30_000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener('focus', refresh)
|
||||
document.removeEventListener('visibilitychange', refreshVisible)
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [accessToken, courseId, requestApi, weekNumber])
|
||||
}, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber])
|
||||
|
||||
// The workspace stays mounted while other app pages or Q&A tabs are shown.
|
||||
// Request cancellation follows the conversation lifetime, not its visibility.
|
||||
@@ -293,11 +319,14 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
||||
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
||||
environment?: 'local' | 'remote'
|
||||
workspaceTarget?: string
|
||||
toolResults?: AgentToolResult[]
|
||||
} = {}
|
||||
): Promise<void> => {
|
||||
const request = rawRequest.trim()
|
||||
if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return
|
||||
if (!request || pending || historyLoading || statusLoading || abortControllerRef.current || !status.enabled || !selectedModel) return
|
||||
const requestModel = selectedModel
|
||||
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
|
||||
const startedAt = Date.now()
|
||||
const userMessage: AgentConversationMessage = {
|
||||
id: `agent-user-${Date.now()}`,
|
||||
@@ -321,10 +350,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
const imageAttachments: AgentImageReference[] = []
|
||||
for (const file of options.attachments ?? []) {
|
||||
if (file.kind !== 'image') continue
|
||||
if (!status.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
|
||||
if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
|
||||
setActivity(`이미지 업로드 중: ${file.name}`)
|
||||
const body = new FormData()
|
||||
body.append('courseId', courseId)
|
||||
if (selection.provider) body.append('provider', selection.provider)
|
||||
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
|
||||
body.append('file', file.file, file.name)
|
||||
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
|
||||
@@ -345,7 +375,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
||||
method: 'POST',
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context })
|
||||
body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment, workspaceTarget: options.workspaceTarget } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context })
|
||||
})
|
||||
})
|
||||
if (abortController.signal.aborted) return
|
||||
@@ -504,6 +534,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
|
||||
return {
|
||||
...status,
|
||||
provider: selectedModel?.provider ?? null,
|
||||
model: selectedModel?.model ?? null,
|
||||
imagesEnabled: selectedModel?.imagesEnabled ?? false,
|
||||
selectProvider,
|
||||
statusLoading: statusLoading || historyLoading,
|
||||
pending,
|
||||
activity,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"에이전트 모델 선택": "Agent model",
|
||||
"사용 가능한 모델 없음": "No models available",
|
||||
"전송": "Send",
|
||||
"파일 첨부": "Attach files",
|
||||
"첨부 제거": "Remove attachment",
|
||||
|
||||
Reference in New Issue
Block a user