Compare commits

..

3 Commits

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