Compare commits

...

3 Commits

15 changed files with 756 additions and 95 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.14", "version": "1.2.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.14", "version": "1.2.15",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.14", "version": "1.2.15",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+135
View File
@@ -0,0 +1,135 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const output = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, DOMException })
const { resolveAgentContext } = output
const plain = value => JSON.parse(JSON.stringify(value))
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
const response = (requestedModules = [], requestedLogs = []) => ({
provider: 'QWEN', model: 'test', usage: { totalTokens: 10 },
plan: { summary: 'result', explanation: 'analysis', requestedModules, requestedLogs, actionPlan: [], commands: [], changes: [], createModule: null }
})
const options = overrides => ({
initialContext: { modules: [moduleInfo('MKMini'), moduleInfo('Control')], runtimeLogs: [] },
signal: new AbortController().signal,
...overrides
})
test('continues MKMini -> Control -> final analysis and retains earlier source files', async () => {
const inputs = []
const loads = []
const result = await resolveAgentContext(options({
requestPlan: async context => {
inputs.push(plain(context))
return [response(['MKMini']), response(['Control']), response()][inputs.length - 1]
},
loadRequestedContext: async request => {
loads.push(plain(request))
return { modules: ['MKMini', 'Control'].map(name => moduleInfo(name, request.modules.includes(name) ? [{ path: `${name}.py`, content: `${name} source` }] : [])), runtimeLogs: [] }
}
}))
assert.equal(inputs.length, 3)
assert.deepEqual(loads, [{ modules: ['MKMini'], logs: [] }, { modules: ['Control'], logs: [] }])
assert.equal(inputs[2].modules[0].files[0].content, 'MKMini source')
assert.equal(inputs[2].modules[1].files[0].content, 'Control source')
assert.deepEqual(plain(result.plan.requestedModules), [])
assert.equal(result.usage.totalTokens, 30)
})
test('keeps logs while loading a later source module', async () => {
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
calls += 1
if (calls === 3) assert.equal(context.runtimeLogs[0].content, 'error log')
return [response([], ['Control']), response(['Control']), response()][calls - 1]
},
loadRequestedContext: async request => ({
modules: [moduleInfo('Control', request.modules.length ? [{ path: 'node.py', content: 'code' }] : [])],
runtimeLogs: request.logs.length ? [{ moduleName: 'Control', state: 'running', content: 'error log' }] : []
})
}))
assert.equal(calls, 3)
})
test('rejects repeated requests instead of returning a stalled plan', async () => {
let loads = 0
await assert.rejects(resolveAgentContext(options({
requestPlan: async () => response(['Control']),
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
})), /반복 요청/)
assert.equal(loads, 1)
})
test('requests only new resources when the model also repeats an earlier module', async () => {
let count = 0
const loads = []
await resolveAgentContext(options({
requestPlan: async () => [response(['MKMini']), response(['MKMini', 'Control']), response()][count++],
loadRequestedContext: async request => { loads.push(plain(request)); return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
}))
assert.deepEqual(loads[1], { modules: ['Control'], logs: [] })
})
test('does not start another API call after cancellation during context loading', async () => {
const controller = new AbortController()
let calls = 0
await assert.rejects(resolveAgentContext(options({
signal: controller.signal,
requestPlan: async () => { calls += 1; return response(['Control']) },
loadRequestedContext: async () => { controller.abort(); return { modules: [], runtimeLogs: [] } }
})), { name: 'AbortError' })
assert.equal(calls, 1)
})
test('bounds the number of distinct automatic context requests', async () => {
let calls = 0
await assert.rejects(resolveAgentContext(options({
requestPlan: async () => response([`Module${calls++}`]),
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] })
})), /5회/)
assert.equal(calls, 6)
})
test('passes file-read failures to the model for a concrete final explanation', async () => {
let count = 0
await resolveAgentContext(options({
requestPlan: async context => {
if (count++ === 0) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').fileContext.status, 'error')
return response()
},
loadRequestedContext: async () => ({ modules: [{ ...moduleInfo('Control'), fileContext: { status: 'error', message: 'permission denied' } }], runtimeLogs: [] })
}))
})
test('does not silently truncate accumulated source context beyond API limits', async () => {
await assert.rejects(resolveAgentContext(options({
requestPlan: async () => response(['Control']),
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }])], runtimeLogs: [] })
})), /조회 한도/)
})
test('does not display a context-only response when no loader is available', async () => {
await assert.rejects(resolveAgentContext(options({ requestPlan: async () => response(['Control']) })), /읽을 수 없는/)
})
test('keeps a just-started module running when a stale collector supplies source files', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [{ ...moduleInfo('Control'), state: 'running' }], runtimeLogs: [] },
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.equal(context.modules[0].state, 'running')
assert.equal(context.modules[0].files[0].content, 'code')
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
}))
})
+38
View File
@@ -0,0 +1,38 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression
function visit(node) {
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('options.attachments')) {
payloadExpression = node.getText(source)
}
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
function serialize(attachments) {
return JSON.parse(vm.runInNewContext(payloadExpression, {
courseId: 'course-1', weekNumber: 3, request: '설정 위치 알려줘', options: { attachments },
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
}))
}
for (const attachments of [undefined, []]) {
test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => {
const body = serialize(attachments)
assert.equal(Object.hasOwn(body, 'attachments'), false)
assert.equal(body.request, '설정 위치 알려줘')
assert.equal(body.modules[0].name, 'Control')
assert.equal(body.week, 3)
})
}
test('preserves actual attachment contents rather than silently dropping them', () => {
const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
assert.deepEqual(serialize(attachments).attachments, attachments)
})
+35
View File
@@ -0,0 +1,35 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const output = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, TextDecoder })
const { readChatAttachments } = output
const file = (name, content) => new File([content], name)
test('reads source/config/log files and preserves Korean UTF-8 text', async () => {
const files = await readChatAttachments([file('control.py', '# 조향 설정'), file('config.yaml', 'robot: mkmini'), file('runtime.log', 'started')], [])
assert.equal(files[0].content, '# 조향 설정')
assert.equal(files[2].name, 'runtime.log')
})
test('rejects unsupported image/PDF files', async () => {
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /이미지와 PDF/)
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /이미지와 PDF/)
})
test('rejects more than five attachments including existing ones', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a')], Array(5).fill({ name: 'b.txt', content: '' })), /최대 5개/)
})
test('rejects binary and non-UTF8 content even under a text extension', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a\0b')], []), /바이너리/)
await assert.rejects(readChatAttachments([file('a.txt', new Uint8Array([255, 254]))], []), /UTF-8/)
})
test('rejects oversized files instead of silently truncating', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(100_001))], []), /100KB/)
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_001))], []), /5만 자/)
})
test('rejects aggregate attachment context above 100000 characters', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_000)), file('b.txt', 'a'.repeat(50_000))], []), /10만 자/)
})
+147
View File
@@ -0,0 +1,147 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(file), 'utf8')
const code = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
const i18n = { t: key => key, useLocaleRender() {} }
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
const QaInputBox = () => null
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function setup(overrides = {}) {
const state = []
const effects = []
let index = 0
let cleared = 0
const modeChanges = []
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
'../../i18n': i18n,
'../../components/AppStatusModals': { ConfirmStatusModal },
'../../components/ChatWidgets': { QaInputBox },
'react-dom': { createPortal: child => child },
react: {
...React,
useEffect(effect) { effects.push(effect) },
useState(initial) {
const slot = index++
if (!(slot in state)) state[slot] = initial
return [state[slot], value => { state[slot] = value }]
}
}
}, { document: { body: {} } }).default
const props = {
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
onModeChange(mode) { modeChanges.push(mode) },
onClearConversation() { cleared++ }, ...overrides
}
return {
render() { index = 0; effects.length = 0; return panel(props) },
cleared: () => cleared,
runEffects() { effects.forEach(effect => effect()) },
modeChanges
}
}
for (const qaMode of ['ai', 'agent', 'teacher']) {
test(`${qaMode} history is deleted only after shared modal confirmation`, () => {
const harness = setup({ qaMode, agentMessages: [{ id: 'a', content: 'answer' }], visibleTeacherMessages: [{ id: 't', content: 'answer' }] })
const input = tree => elements(tree, node => node.type === QaInputBox)[0]
const modal = tree => elements(tree, node => node.type === ConfirmStatusModal)[0]
assert.equal(modal(harness.render()), undefined)
input(harness.render()).props.onClearConversation()
assert.equal(harness.cleared(), 0)
modal(harness.render()).props.onCancel()
assert.equal(modal(harness.render()), undefined)
assert.equal(harness.cleared(), 0)
input(harness.render()).props.onClearConversation()
modal(harness.render()).props.onConfirm()
assert.equal(harness.cleared(), 1)
assert.equal(modal(harness.render()), undefined)
})
}
test('context menu deletion also requests confirmation', () => {
const harness = setup()
const root = harness.render()
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ target: { closest: () => null }, preventDefault() {}, clientX: 10, clientY: 10 })
const clear = elements(harness.render(), node => node.type === 'button' && node.props.children === '대화내역 삭제')[0]
clear.props.onClick()
assert.equal(harness.cleared(), 0)
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
})
for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
const harness = setup(props)
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
assert.equal(input.props.clearDisabled, true)
input.props.onClearConversation()
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 0)
assert.equal(harness.cleared(), 0)
})
}
for (const dark of [false, true]) {
test(`shared confirmation renders cancel left and delete right (${dark ? 'dark' : 'light'})`, () => {
const harness = setup({ dark })
elements(harness.render(), node => node.type === QaInputBox)[0].props.onClearConversation()
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
assert.equal(modal.props.dark, dark)
assert.match(modal.props.overlayClassName, /fixed.*z-\[180\]/)
const html = renderToStaticMarkup(modal)
assert.match(html, /취소<\/button><button[^>]*>삭제<\/button>/)
assert.match(html, /bg-\[#d14b4b\]/)
})
}
test('input no longer opens a native confirmation dialog', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
assert.doesNotMatch(source, /window\.confirm/)
assert.match(source, /onClick=\{onClearConversation\}/)
})
for (const dark of [false, true]) {
for (const agentEnabled of [false, true]) {
test(`agent availability controls tab count and layout (${dark ? 'dark' : 'light'}, enabled=${agentEnabled})`, () => {
const harness = setup({ dark, agentEnabled })
const grids = elements(harness.render(), node => node.props.className?.includes('mt-3 grid'))
assert.equal(grids.length, 1)
const tabs = elements(grids[0], node => node.type === 'button')
assert.deepEqual(tabs.map(tab => tab.props.children[1].props.children), agentEnabled ? ['AI', '에이전트', '강사'] : ['AI', '강사'])
assert.ok(grids[0].props.className.includes(agentEnabled ? 'grid-cols-3' : 'grid-cols-2'))
})
}
}
test('returns to AI when the selected agent is disabled after status loading', () => {
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: false, minimized: true })
harness.render()
harness.runEffects()
assert.deepEqual(harness.modeChanges, ['ai'])
})
test('does not reset the selected agent while status is still loading', () => {
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: true, minimized: true })
harness.render()
harness.runEffects()
assert.deepEqual(harness.modeChanges, [])
})
+67
View File
@@ -0,0 +1,67 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/AppStatusModals.tsx'), 'utf8')
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText
function loadModals() {
const exports = {}
vm.runInNewContext(compiled, {
exports,
require: name => name === '../i18n' ? { t: key => key, useLocaleRender() {} } : require(name)
})
return exports
}
function buttons(element) {
if (!React.isValidElement(element)) return []
if (element.type === 'button') return [element]
return React.Children.toArray(element.props.children).flatMap(buttons)
}
for (const dark of [false, true]) {
test(`extension action stays on the right in both dialogs (${dark ? 'dark' : 'light'})`, () => {
const { StatusNoticeModal, ControlExtensionConfirmModal } = loadModals()
let confirmed = 0
let extended = 0
const notice = StatusNoticeModal({
overlayClassName: 'overlay', dark, tone: 'clock', title: '제어 시간 안내', message: '제어 시간이 곧 종료됩니다.',
actionLabel: '연장', onAction: () => { extended++ }, onConfirm: () => { confirmed++ }
})
const noticeButtons = buttons(notice)
assert.deepEqual(noticeButtons.map(button => button.props.children), ['확인', '연장'])
assert.match(noticeButtons[1].props.className, /bg-\[#6f4df6\]/)
assert.doesNotMatch(noticeButtons[0].props.className, /bg-\[#6f4df6\]/)
noticeButtons[0].props.onClick()
noticeButtons[1].props.onClick()
assert.equal(confirmed, 1)
assert.equal(extended, 1)
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark, platformName: 'MK-mini', onCancel() {}, onConfirm() {} })
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장'])
assert.match(buttons(confirmation)[1].props.className, /bg-\[#6f4df6\]/)
assert.match(renderToStaticMarkup(confirmation), /취소<\/button><button[^>]*>연장<\/button>/)
})
}
test('notices without an extension action retain their single primary confirmation button', () => {
const { StatusNoticeModal } = loadModals()
const notice = StatusNoticeModal({ overlayClassName: 'overlay', dark: true, tone: 'warning', title: '안내', message: '메시지', onConfirm() {} })
assert.equal(buttons(notice).length, 1)
assert.equal(buttons(notice)[0].props.children, '확인')
assert.match(buttons(notice)[0].props.className, /bg-\[#6f4df6\]/)
})
test('the extension confirmation keeps the right button disabled while submitting', () => {
const { ControlExtensionConfirmModal } = loadModals()
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark: true, platformName: 'MK-mini', submitting: true, onCancel() {}, onConfirm() {} })
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장 중'])
assert.ok(buttons(confirmation).every(button => button.props.disabled))
})
+76 -49
View File
@@ -59,6 +59,7 @@ import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
import { useQaChat } from './features/workspace/useQaChat' import { useQaChat } from './features/workspace/useQaChat'
import { useAiAgent } from './features/workspace/useAiAgent' import { useAiAgent } from './features/workspace/useAiAgent'
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } 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 { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
import { useSourceControl } from './features/workspace/useSourceControl' import { useSourceControl } from './features/workspace/useSourceControl'
import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus' import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus'
@@ -3495,14 +3496,20 @@ function LocalPcWorkspaceMockup({
await createCustomModuleRecord(input) await createCustomModuleRecord(input)
} }
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => { const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({ const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({
name: module.name, name: module.name,
folderName: module.folderName, folderName: module.folderName,
category: module.category, category: module.category,
state: module.state, 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) => ( const targetModules = availableModules.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase()) module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
)) ))
@@ -3518,48 +3525,60 @@ function LocalPcWorkspaceMockup({
for (const module of targetModules) { for (const module of targetModules) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break if (totalFiles >= 24 || totalCharacters >= 140_000) break
const modulePrefix = `modules/${module.name}` try {
const result = remoteWorkspace const modulePrefix = `modules/${module.name}`
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName) const result = remoteWorkspace
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName) ? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}` : await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content])) const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null
const sourcePriority = (path: string): number => { const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
if (path === preferredPath) return -100 const sourcePriority = (path: string): number => {
if (/(^|\/)src(\/|$)/.test(path)) return -50 if (path === preferredPath) return -100
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40 if (/(^|\/)src(\/|$)/.test(path)) return -50
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30 if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
return 0 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
} }
if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue .filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path))
totalCharacters += content.length .sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right))
totalFiles += 1 const files: AgentWorkspaceModule['files'] = []
files.push({ path: rendererPath.slice(modulePrefix.length + 1), content })
}
const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name) for (const rendererPath of paths) {
const workspaceModule = { if (totalFiles >= 24 || totalCharacters >= 140_000) break
name: module.name, let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath)
folderName: module.folderName, if (content === undefined && remoteWorkspace) {
category: module.category, content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content
state: module.state, }
files 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 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 { try {
const modules = await collectAgentWorkspace() const modules = await collectAgentWorkspace()
await requestAgentPlan(content, modules, [], { await requestAgentPlan(content, modules, [], {
loadRequestedContext: async (request) => ({ attachments,
modules: await collectAgentWorkspace(request.modules), loadRequestedContext: loadAgentRequestedContext
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
})
}) })
} catch (error) { } catch (error) {
console.error('[agent] workspace collection failed', error) console.error('[agent] workspace collection failed', error)
@@ -3730,7 +3754,10 @@ function LocalPcWorkspaceMockup({
`명령 출력:\n${result.output.slice(-2400) || '(출력 없음)'}`, `명령 출력:\n${result.output.slice(-2400) || '(출력 없음)'}`,
'이미 실행한 명령을 그대로 반복하지 말고, 결과상 추가 작업이 필요할 때만 다음 승인 명령 또는 파일 변경안을 제안하세요.' '이미 실행한 명령을 그대로 반복하지 말고, 결과상 추가 작업이 필요할 때만 다음 승인 명령 또는 파일 변경안을 제안하세요.'
].filter(Boolean).join('\n') ].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) { } catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.') const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
.replace(/^Error invoking remote method '[^']+': Error:\s*/, '') .replace(/^Error invoking remote method '[^']+': Error:\s*/, '')
@@ -4569,8 +4596,8 @@ function LocalPcWorkspaceMockup({
if (mode === 'teacher') setTeacherChatFlash(false) if (mode === 'teacher') setTeacherChatFlash(false)
}} }}
onStop={qaMode === 'agent' ? stopAgent : stopAiResponse} onStop={qaMode === 'agent' ? stopAgent : stopAiResponse}
onSubmit={(content) => { onSubmit={(content, attachments) => {
if (qaMode === 'agent') void submitAgentRequest(content) if (qaMode === 'agent') void submitAgentRequest(content, attachments)
else void sendQaMessage(content, { contextWeek: curriculumWeek.week }) else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
}} }}
onClearConversation={() => { onClearConversation={() => {
@@ -75,6 +75,7 @@ export function StatusNoticeModal({
onConfirm: () => void onConfirm: () => void
}): React.JSX.Element { }): React.JSX.Element {
useLocaleRender() useLocaleRender()
const hasAction = Boolean(actionLabel && onAction)
return ( return (
<div className={overlayClassName}> <div className={overlayClassName}>
<section className={modalClass(dark)}> <section className={modalClass(dark)}>
@@ -91,12 +92,12 @@ export function StatusNoticeModal({
</p> </p>
)} )}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onConfirm} className={hasAction ? cancelButtonClass(dark) : confirmButtonClass('primary')}>{t("확인")}</button>
{actionLabel && onAction && ( {actionLabel && onAction && (
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}> <button type="button" onClick={onAction} className={confirmButtonClass('primary')}>
{t(actionLabel)} {t(actionLabel)}
</button> </button>
)} )}
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>{t("확인")}</button>
</div> </div>
</div> </div>
</section> </section>
@@ -276,7 +277,7 @@ export function ControlExtensionConfirmModal({
) )
} }
function ConfirmStatusModal({ export function ConfirmStatusModal({
overlayClassName, overlayClassName,
dark, dark,
icon, icon,
+71 -7
View File
@@ -1,7 +1,8 @@
import { t, useLocaleRender } from '../i18n' 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 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 ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
@@ -239,6 +240,9 @@ export const QaInputBox = memo(function QaInputBox({
busy, busy,
disabled, disabled,
accent = 'blue', accent = 'blue',
attachmentsEnabled = false,
onClearConversation,
clearDisabled = false,
onStop, onStop,
onSubmit onSubmit
}: { }: {
@@ -246,11 +250,29 @@ export const QaInputBox = memo(function QaInputBox({
busy: boolean busy: boolean
disabled: boolean disabled: boolean
accent?: 'blue' | 'cyan' accent?: 'blue' | 'cyan'
attachmentsEnabled?: boolean
onClearConversation?: () => void
clearDisabled?: boolean
onStop: () => void onStop: () => void
onSubmit: (content: string) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
}): React.JSX.Element { }): React.JSX.Element {
const [draft, setDraft] = useState('') 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 submit = (): void => {
const content = draft.trim() const content = draft.trim()
@@ -258,9 +280,11 @@ export const QaInputBox = memo(function QaInputBox({
onStop() onStop()
return return
} }
if (!content || disabled) return if (!content || disabled || readingAttachments) return
setDraft('') setDraft('')
onSubmit(content) onSubmit(content, attachmentsEnabled ? attachments : undefined)
setAttachments([])
setAttachmentError(null)
} }
return ( return (
@@ -273,7 +297,7 @@ export const QaInputBox = memo(function QaInputBox({
value={draft} value={draft}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault() event.preventDefault()
submit() submit()
} }
@@ -286,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({
/> />
<button <button
type="button" type="button"
aria-label={busy ? t('중지') : t('전송')}
disabled={!canSubmit} disabled={!canSubmit}
onClick={busy ? onStop : submit} onClick={busy ? onStop : submit}
className={[ className={[
@@ -298,6 +323,45 @@ export const QaInputBox = memo(function QaInputBox({
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />} {busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
</button> </button>
</div> </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> </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 { 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 type React from 'react'
import { useEffect, useState } 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 { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent' import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments'
type QaMode = 'ai' | 'agent' | 'teacher' type QaMode = 'ai' | 'agent' | 'teacher'
@@ -46,7 +49,7 @@ type WorkspaceQaPanelProps = {
onMinimize: () => void onMinimize: () => void
onModeChange: (mode: QaMode) => void onModeChange: (mode: QaMode) => void
onStop: () => void onStop: () => void
onSubmit: (content: string) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
onClearConversation: () => void onClearConversation: () => void
onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void
onOpenAgentChange: (messageId: string, change: AgentFileChange) => void onOpenAgentChange: (messageId: string, change: AgentFileChange) => void
@@ -124,12 +127,25 @@ export default function WorkspaceQaPanel({
}: WorkspaceQaPanelProps): React.JSX.Element { }: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender() useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1] const lastMessage = activeMessages[activeMessages.length - 1]
const clearDisabled = inputBusy || qaMessagesLoading || (agentStatusLoading && qaMode === 'agent') || activeMessages.length === 0
useEffect(() => { useEffect(() => {
if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai') setClearConfirmationOpen(false)
}, [onModeChange, qaMode, teacherQaDisabled]) }, [qaMode, workspaceActive, minimized])
const requestClearConversation = (): void => {
setContextMenu(null)
if (!clearDisabled) setClearConfirmationOpen(true)
}
useEffect(() => {
if ((teacherQaDisabled && qaMode === 'teacher') || (!agentStatusLoading && !agentEnabled && qaMode === 'agent')) {
onModeChange('ai')
}
}, [agentEnabled, agentStatusLoading, onModeChange, qaMode, teacherQaDisabled])
useEffect(() => { useEffect(() => {
if (!contextMenu) return if (!contextMenu) return
@@ -214,12 +230,12 @@ export default function WorkspaceQaPanel({
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span> <span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span>
</button> </button>
</div> </div>
<div className={['mt-3 grid grid-cols-3 overflow-hidden rounded-md border', borderSoft].join(' ')}> <div className={['mt-3 grid overflow-hidden rounded-md border', agentEnabled ? 'grid-cols-3' : 'grid-cols-2', borderSoft].join(' ')}>
{[ {[
{ id: 'ai' as const, label: 'AI', Icon: Bot }, { id: 'ai' as const, label: 'AI', Icon: Bot },
{ id: 'agent' as const, label: t("에이전트"), Icon: WandSparkles }, { id: 'agent' as const, label: t("에이전트"), Icon: WandSparkles },
{ id: 'teacher' as const, label: t("강사"), Icon: User } { id: 'teacher' as const, label: t("강사"), Icon: User }
].map(({ id, label, Icon }) => { ].filter(({ id }) => id !== 'agent' || agentEnabled).map(({ id, label, Icon }) => {
const disabled = id === 'teacher' && teacherQaDisabled const disabled = id === 'teacher' && teacherQaDisabled
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai' const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
@@ -304,11 +320,9 @@ export default function WorkspaceQaPanel({
> >
<button <button
type="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(' ')} disabled={clearDisabled}
onClick={() => { 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(' ')}
setContextMenu(null) onClick={requestClearConversation}
onClearConversation()
}}
>{t("대화내역 삭제")}</button> >{t("대화내역 삭제")}</button>
</div> </div>
)} )}
@@ -318,7 +332,27 @@ export default function WorkspaceQaPanel({
disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')} disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
onStop={onStop} onStop={onStop}
onSubmit={onSubmit} 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> </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 { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync' import { createAgentSessionSync } from './agentSessionSync'
import { resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
import type { ChatAttachment } from '../../components/chatAttachments'
export type AgentFileChange = { export type AgentFileChange = {
moduleName: string moduleName: string
@@ -47,6 +49,10 @@ export type AgentWorkspaceModule = {
folderName?: string folderName?: string
category?: string category?: string
state?: string state?: string
fileContext?: {
status: 'loaded' | 'empty' | 'error' | 'uninstalled' | 'limited'
message?: string
}
files: Array<{ path: string; content: string }> files: Array<{ path: string; content: string }>
} }
@@ -125,6 +131,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (!response.ok) { if (!response.ok) {
const payload = await response.json().catch(() => null) as { message?: string | string[] } | null const payload = await response.json().catch(() => null) as { message?: string | string[] } | null
const message = Array.isArray(payload?.message) ? payload.message.join('\n') : payload?.message 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 에이전트 요청을 처리하지 못했습니다.') throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
} }
return response.json() as Promise<T> return response.json() as Promise<T>
@@ -243,10 +252,8 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
runtimeLogs: AgentRuntimeLog[] = [], runtimeLogs: AgentRuntimeLog[] = [],
options: { options: {
showUserMessage?: boolean showUserMessage?: boolean
loadRequestedContext?: (request: { modules: string[]; logs: string[] }) => Promise<{ attachments?: ChatAttachment[]
modules: AgentWorkspaceModule[] loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
runtimeLogs: AgentRuntimeLog[]
}>
} = {} } = {}
): Promise<void> => { ): Promise<void> => {
const request = rawRequest.trim() const request = rawRequest.trim()
@@ -254,7 +261,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const userMessage: AgentConversationMessage = { const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`, id: `agent-user-${Date.now()}`,
role: 'user', 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]) if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true) setPending(true)
@@ -262,27 +269,16 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
abortControllerRef.current = abortController abortControllerRef.current = abortController
try { try {
let response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', { const response = await resolveAgentContext({
method: 'POST', initialContext: { modules, runtimeLogs },
signal: abortController.signal, signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request, modules, runtimeLogs }) loadRequestedContext: options.loadRequestedContext,
}) requestPlan: (context) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
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', {
method: 'POST', method: 'POST',
signal: abortController.signal, signal: abortController.signal,
body: JSON.stringify({ body: JSON.stringify({ courseId, week: weekNumber, request, ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context })
courseId,
week: weekNumber,
request,
modules: requestedContext.modules,
runtimeLogs: requestedContext.runtimeLogs
})
}) })
} })
if (abortController.signal.aborted) return if (abortController.signal.aborted) return
const plan: AgentPlan = { const plan: AgentPlan = {
...response.plan, ...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", "로컬 모듈 Git 가져오기": "Import local module from Git",
"로컬 사용자 모듈": "Local custom module", "로컬 사용자 모듈": "Local custom module",
"현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.", "현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.",