diff --git a/scripts/test-agent-context.cjs b/scripts/test-agent-context.cjs new file mode 100644 index 0000000..7a2519e --- /dev/null +++ b/scripts/test-agent-context.cjs @@ -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: [] }) + })) +}) diff --git a/scripts/test-agent-request-payload.cjs b/scripts/test-agent-request-payload.cjs new file mode 100644 index 0000000..649a38c --- /dev/null +++ b/scripts/test-agent-request-payload.cjs @@ -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) +}) diff --git a/scripts/test-chat-attachments.cjs b/scripts/test-chat-attachments.cjs new file mode 100644 index 0000000..a480577 --- /dev/null +++ b/scripts/test-chat-attachments.cjs @@ -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만 자/) +}) diff --git a/scripts/test-chat-clear-confirmation.cjs b/scripts/test-chat-clear-confirmation.cjs new file mode 100644 index 0000000..3975982 --- /dev/null +++ b/scripts/test-chat-clear-confirmation.cjs @@ -0,0 +1,115 @@ +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 = [] + let index = 0 + let cleared = 0 + 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() {}, + 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', visibleAiMessages: [{ id: '1', content: 'hello' }], + visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null }, + onClearConversation() { cleared++ }, ...overrides + } + return { + render() { index = 0; return panel(props) }, + cleared: () => cleared + } +} + +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>/) + 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\}/) +}) diff --git a/scripts/test-control-extension-modals.cjs b/scripts/test-control-extension-modals.cjs new file mode 100644 index 0000000..b155b4d --- /dev/null +++ b/scripts/test-control-extension-modals.cjs @@ -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>/) + }) +} + +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)) +}) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index fc097a1..3f06656 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -59,6 +59,7 @@ import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel' import { useQaChat } from './features/workspace/useQaChat' import { useAiAgent } from './features/workspace/useAiAgent' import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent' +import type { ChatAttachment } from './components/chatAttachments' import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime' import { useSourceControl } from './features/workspace/useSourceControl' import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus' @@ -3495,14 +3496,20 @@ function LocalPcWorkspaceMockup({ await createCustomModuleRecord(input) } const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise => { + const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase())) const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({ name: module.name, folderName: module.folderName, category: module.category, state: module.state, - files: [] + files: [], + ...(requestedNames.has(module.name.toLowerCase()) ? { + fileContext: { + status: module.state === 'uninstalled' ? 'uninstalled' as const : 'limited' as const, + message: module.state === 'uninstalled' ? '설치되지 않아 소스를 읽을 수 없습니다.' : '파일 수·크기 제한으로 읽지 못했습니다.' + } + } : {}) })) - const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase())) const targetModules = availableModules.filter((module) => ( module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase()) )) @@ -3518,48 +3525,60 @@ function LocalPcWorkspaceMockup({ for (const module of targetModules) { if (totalFiles >= 24 || totalCharacters >= 140_000) break - const modulePrefix = `modules/${module.name}` - const result = remoteWorkspace - ? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName) - : await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName) - const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}` - const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null - const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content])) - const sourcePriority = (path: string): number => { - if (path === preferredPath) return -100 - if (/(^|\/)src(\/|$)/.test(path)) return -50 - if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40 - if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30 - return 0 - } - const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath))) - .filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path)) - .sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right)) - const files: AgentWorkspaceModule['files'] = [] - - for (const rendererPath of paths) { - if (totalFiles >= 24 || totalCharacters >= 140_000) break - let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath) - if (content === undefined && remoteWorkspace) { - content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content + try { + const modulePrefix = `modules/${module.name}` + const result = remoteWorkspace + ? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName) + : await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName) + const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}` + const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null + const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content])) + const sourcePriority = (path: string): number => { + if (path === preferredPath) return -100 + if (/(^|\/)src(\/|$)/.test(path)) return -50 + if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40 + if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30 + return 0 } - if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue - if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue - totalCharacters += content.length - totalFiles += 1 - files.push({ path: rendererPath.slice(modulePrefix.length + 1), content }) - } + const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath))) + .filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path)) + .sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right)) + const files: AgentWorkspaceModule['files'] = [] - const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name) - const workspaceModule = { - name: module.name, - folderName: module.folderName, - category: module.category, - state: module.state, - files + for (const rendererPath of paths) { + if (totalFiles >= 24 || totalCharacters >= 140_000) break + let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath) + if (content === undefined && remoteWorkspace) { + content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content + } + if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue + if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue + totalCharacters += content.length + totalFiles += 1 + files.push({ path: rendererPath.slice(modulePrefix.length + 1), content }) + } + + const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name) + const workspaceModule = { + name: module.name, + folderName: module.folderName, + category: module.category, + state: module.state, + files, + fileContext: { + status: files.length ? 'loaded' as const : 'empty' as const, + message: files.length ? '텍스트 소스를 조회했습니다. 파일 수·크기·형식 제한으로 일부 파일이 제외될 수 있습니다.' : '읽을 수 있는 텍스트 파일이 없습니다. 파일 수·크기·형식 제한 또는 빈 소스일 수 있습니다.' + } + } + if (moduleIndex < 0) workspaceModules.push(workspaceModule) + else workspaceModules[moduleIndex] = workspaceModule + } catch (error) { + const workspaceModule = workspaceModules.find((item) => item.name === module.name) + if (workspaceModule) workspaceModule.fileContext = { + status: 'error', + message: (error instanceof Error ? error.message : '모듈 소스를 읽지 못했습니다.').slice(0, 500) + } } - if (moduleIndex < 0) workspaceModules.push(workspaceModule) - else workspaceModules[moduleIndex] = workspaceModule } return workspaceModules @@ -3590,14 +3609,19 @@ function LocalPcWorkspaceMockup({ } })) } - const submitAgentRequest = async (content: string): Promise => { + 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 => { try { const modules = await collectAgentWorkspace() await requestAgentPlan(content, modules, [], { - loadRequestedContext: async (request) => ({ - modules: await collectAgentWorkspace(request.modules), - runtimeLogs: await collectAgentRuntimeLogs(request.logs) - }) + attachments, + loadRequestedContext: loadAgentRequestedContext }) } catch (error) { console.error('[agent] workspace collection failed', error) @@ -3730,7 +3754,10 @@ function LocalPcWorkspaceMockup({ `명령 출력:\n${result.output.slice(-2400) || '(출력 없음)'}`, '이미 실행한 명령을 그대로 반복하지 말고, 결과상 추가 작업이 필요할 때만 다음 승인 명령 또는 파일 변경안을 제안하세요.' ].filter(Boolean).join('\n') - await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], { showUserMessage: false }) + await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], { + showUserMessage: false, + loadRequestedContext: loadAgentRequestedContext + }) } catch (error) { const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.') .replace(/^Error invoking remote method '[^']+': Error:\s*/, '') @@ -4569,8 +4596,8 @@ function LocalPcWorkspaceMockup({ if (mode === 'teacher') setTeacherChatFlash(false) }} onStop={qaMode === 'agent' ? stopAgent : stopAiResponse} - onSubmit={(content) => { - if (qaMode === 'agent') void submitAgentRequest(content) + onSubmit={(content, attachments) => { + if (qaMode === 'agent') void submitAgentRequest(content, attachments) else void sendQaMessage(content, { contextWeek: curriculumWeek.week }) }} onClearConversation={() => { diff --git a/src/renderer/src/components/AppStatusModals.tsx b/src/renderer/src/components/AppStatusModals.tsx index a19d1f4..3e6fd38 100644 --- a/src/renderer/src/components/AppStatusModals.tsx +++ b/src/renderer/src/components/AppStatusModals.tsx @@ -75,6 +75,7 @@ export function StatusNoticeModal({ onConfirm: () => void }): React.JSX.Element { useLocaleRender() + const hasAction = Boolean(actionLabel && onAction) return (
@@ -91,12 +92,12 @@ export function StatusNoticeModal({

)}
+ {actionLabel && onAction && ( - )} -
@@ -276,7 +277,7 @@ export function ControlExtensionConfirmModal({ ) } -function ConfirmStatusModal({ +export function ConfirmStatusModal({ overlayClassName, dark, icon, diff --git a/src/renderer/src/components/ChatWidgets.tsx b/src/renderer/src/components/ChatWidgets.tsx index 8bb02c2..f4a98dd 100644 --- a/src/renderer/src/components/ChatWidgets.tsx +++ b/src/renderer/src/components/ChatWidgets.tsx @@ -1,7 +1,8 @@ import { t, useLocaleRender } from '../i18n' -import { ChevronDown, Send, Square } from 'lucide-react' +import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react' import type React from 'react' -import { memo, useEffect, useState } from 'react' +import { memo, useEffect, useRef, useState } from 'react' +import { CHAT_ATTACHMENT_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments' import ReactMarkdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -239,6 +240,9 @@ export const QaInputBox = memo(function QaInputBox({ busy, disabled, accent = 'blue', + attachmentsEnabled = false, + onClearConversation, + clearDisabled = false, onStop, onSubmit }: { @@ -246,11 +250,29 @@ export const QaInputBox = memo(function QaInputBox({ busy: boolean disabled: boolean accent?: 'blue' | 'cyan' + attachmentsEnabled?: boolean + onClearConversation?: () => void + clearDisabled?: boolean onStop: () => void - onSubmit: (content: string) => void + onSubmit: (content: string, attachments?: ChatAttachment[]) => void }): React.JSX.Element { const [draft, setDraft] = useState('') - const canSubmit = busy || (Boolean(draft.trim()) && !disabled) + const [attachments, setAttachments] = useState([]) + const [attachmentError, setAttachmentError] = useState(null) + const [readingAttachments, setReadingAttachments] = useState(false) + const fileInputRef = useRef(null) + const attachmentReadRef = useRef(0) + const canSubmit = busy || (Boolean(draft.trim()) && !disabled && !readingAttachments) + + useEffect(() => { + attachmentReadRef.current += 1 + if (!attachmentsEnabled) { + setAttachments([]) + setAttachmentError(null) + setReadingAttachments(false) + } + return () => { attachmentReadRef.current += 1 } + }, [attachmentsEnabled]) const submit = (): void => { const content = draft.trim() @@ -258,9 +280,11 @@ export const QaInputBox = memo(function QaInputBox({ onStop() return } - if (!content || disabled) return + if (!content || disabled || readingAttachments) return setDraft('') - onSubmit(content) + onSubmit(content, attachmentsEnabled ? attachments : undefined) + setAttachments([]) + setAttachmentError(null) } return ( @@ -273,7 +297,7 @@ export const QaInputBox = memo(function QaInputBox({ value={draft} onChange={(event) => setDraft(event.target.value)} onKeyDown={(event) => { - if (event.key === 'Enter' && !event.shiftKey) { + if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { event.preventDefault() submit() } @@ -286,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({ /> + {attachmentsEnabled && attachments.length > 0 && ( +
+ {attachments.map((file, index) => ( + + + {file.name} + + + ))} +
+ )} + {attachmentError &&

{t(attachmentError)}

} + {(attachmentsEnabled || onClearConversation) && ( +
+ {attachmentsEnabled && ( + <> + { + 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) + }) + }} /> + + + )} + {onClearConversation && ( + + )} +
+ )} ) }) diff --git a/src/renderer/src/components/chatAttachments.ts b/src/renderer/src/components/chatAttachments.ts new file mode 100644 index 0000000..ac09728 --- /dev/null +++ b/src/renderer/src/components/chatAttachments.ts @@ -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 { + 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 +} diff --git a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx index 151f260..3cb85b3 100644 --- a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx @@ -1,9 +1,12 @@ import { t, useLocaleRender } from '../../i18n' -import { Bot, Check, CheckCircle2, Circle, Copy, FileCode2, ListChecks, Loader2, MessageCircle, Minus, Play, TerminalSquare, User, WandSparkles, X } from 'lucide-react' +import { Bot, Check, CheckCircle2, Circle, Copy, FileCode2, ListChecks, Loader2, MessageCircle, Minus, Play, TerminalSquare, Trash2, User, WandSparkles, X } from 'lucide-react' import type React from 'react' import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { ConfirmStatusModal } from '../../components/AppStatusModals' import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets' import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent' +import type { ChatAttachment } from '../../components/chatAttachments' type QaMode = 'ai' | 'agent' | 'teacher' @@ -46,7 +49,7 @@ type WorkspaceQaPanelProps = { onMinimize: () => void onModeChange: (mode: QaMode) => void onStop: () => void - onSubmit: (content: string) => void + onSubmit: (content: string, attachments?: ChatAttachment[]) => void onClearConversation: () => void onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void onOpenAgentChange: (messageId: string, change: AgentFileChange) => void @@ -124,8 +127,19 @@ export default function WorkspaceQaPanel({ }: WorkspaceQaPanelProps): React.JSX.Element { useLocaleRender() const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) + const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false) const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages const lastMessage = activeMessages[activeMessages.length - 1] + const clearDisabled = inputBusy || qaMessagesLoading || (agentStatusLoading && qaMode === 'agent') || activeMessages.length === 0 + + useEffect(() => { + setClearConfirmationOpen(false) + }, [qaMode, workspaceActive, minimized]) + + const requestClearConversation = (): void => { + setContextMenu(null) + if (!clearDisabled) setClearConfirmationOpen(true) + } useEffect(() => { if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai') @@ -304,11 +318,9 @@ export default function WorkspaceQaPanel({ > )} @@ -318,7 +330,27 @@ export default function WorkspaceQaPanel({ disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')} onStop={onStop} onSubmit={onSubmit} + attachmentsEnabled={qaMode === 'agent'} + onClearConversation={requestClearConversation} + clearDisabled={clearDisabled} /> + {clearConfirmationOpen && createPortal( + } + title={t('대화내역 삭제')} + message={t('대화내역을 삭제할까요?')} + detail={t('현재 탭의 대화내역을 삭제합니다.')} + confirmLabel={t('삭제')} + onCancel={() => setClearConfirmationOpen(false)} + onConfirm={() => { + setClearConfirmationOpen(false) + if (!clearDisabled) onClearConversation() + }} + />, + document.body + )} ) } diff --git a/src/renderer/src/features/workspace/agentContext.ts b/src/renderer/src/features/workspace/agentContext.ts new file mode 100644 index 0000000..9a1195e --- /dev/null +++ b/src/renderer/src/features/workspace/agentContext.ts @@ -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 + loadRequestedContext?: (request: AgentContextRequest) => Promise + signal: AbortSignal +}): Promise { + let context = initialContext + const requestedModules = new Set() + const requestedLogs = new Set() + const usage: NonNullable = {} + 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 분석을 완료하지 못했습니다.') +} diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts index a757c96..3118299 100644 --- a/src/renderer/src/features/workspace/useAiAgent.ts +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { createAgentSessionSync } from './agentSessionSync' +import { resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext' +import type { ChatAttachment } from '../../components/chatAttachments' export type AgentFileChange = { moduleName: string @@ -47,6 +49,10 @@ export type AgentWorkspaceModule = { folderName?: string category?: string state?: string + fileContext?: { + status: 'loaded' | 'empty' | 'error' | 'uninstalled' | 'limited' + message?: string + } files: Array<{ path: string; content: string }> } @@ -125,6 +131,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap if (!response.ok) { const payload = await response.json().catch(() => null) as { message?: string | string[] } | null const message = Array.isArray(payload?.message) ? payload.message.join('\n') : payload?.message + if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) { + throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.') + } throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.') } return response.json() as Promise @@ -243,10 +252,8 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap runtimeLogs: AgentRuntimeLog[] = [], options: { showUserMessage?: boolean - loadRequestedContext?: (request: { modules: string[]; logs: string[] }) => Promise<{ - modules: AgentWorkspaceModule[] - runtimeLogs: AgentRuntimeLog[] - }> + attachments?: ChatAttachment[] + loadRequestedContext?: (request: AgentContextRequest) => Promise } = {} ): Promise => { const request = rawRequest.trim() @@ -254,7 +261,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap const userMessage: AgentConversationMessage = { id: `agent-user-${Date.now()}`, role: 'user', - content: request + content: [request, ...(options.attachments?.length ? [`\n첨부 파일: ${options.attachments.map((file) => file.name).join(', ')}`] : [])].join('\n') } if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage]) setPending(true) @@ -262,27 +269,16 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap abortControllerRef.current = abortController try { - let response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', { - method: 'POST', + const response = await resolveAgentContext({ + initialContext: { modules, runtimeLogs }, signal: abortController.signal, - body: JSON.stringify({ courseId, week: weekNumber, request, modules, runtimeLogs }) - }) - const requestedModules = response.plan.requestedModules ?? [] - const requestedLogs = response.plan.requestedLogs ?? [] - if ((requestedModules.length || requestedLogs.length) && options.loadRequestedContext) { - const requestedContext = await options.loadRequestedContext({ modules: requestedModules, logs: requestedLogs }) - response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', { + loadRequestedContext: options.loadRequestedContext, + requestPlan: (context) => requestApi('/chat/agent/plan', { method: 'POST', signal: abortController.signal, - body: JSON.stringify({ - courseId, - week: weekNumber, - request, - modules: requestedContext.modules, - runtimeLogs: requestedContext.runtimeLogs - }) + body: JSON.stringify({ courseId, week: weekNumber, request, ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context }) }) - } + }) if (abortController.signal.aborted) return const plan: AgentPlan = { ...response.plan, diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 95164f0..3346db9 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -1,4 +1,16 @@ { + "전송": "Send", + "파일 첨부": "Attach files", + "첨부 제거": "Remove attachment", + "대화내역을 삭제할까요?": "Clear this conversation?", + "현재 탭의 대화내역을 삭제합니다.": "The conversation in the current tab will be deleted.", + "파일은 최대 5개까지 첨부할 수 있습니다.": "You can attach up to 5 files.", + "코드·설정·로그 등 텍스트 파일만 첨부할 수 있습니다. 이미지와 PDF는 지원하지 않습니다.": "Only text files such as source code, configuration, and logs are supported. Images and PDFs are not supported.", + "파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.": "File names must be at most 255 characters and files at most 100KB.", + "UTF-8 텍스트 파일만 첨부할 수 있습니다.": "Only UTF-8 text files are supported.", + "바이너리 파일은 첨부할 수 없습니다.": "Binary files cannot be attached.", + "파일 하나의 내용은 5만 자 이하여야 합니다.": "Each attachment must contain at most 50,000 characters.", + "첨부 파일의 전체 내용은 10만 자 이하여야 합니다.": "All attachments together must contain at most 100,000 characters.", "로컬 모듈 Git 가져오기": "Import local module from Git", "로컬 사용자 모듈": "Local custom module", "현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.",