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