feat: add structured agent tools and improve workspace chat UX

This commit is contained in:
2026-10-19 11:30:23 +09:00
parent 098af30fa7
commit cc589fd8cf
35 changed files with 2831 additions and 247 deletions
+3 -1
View File
@@ -23,8 +23,10 @@
"react-i18next": "^17.0.15",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-parse": "11.0.0",
"socket.io-client": "^4.8.3",
"three": "^0.181.2"
"three": "^0.181.2",
"unified": "11.0.5"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
+3 -1
View File
@@ -64,8 +64,10 @@
"react-i18next": "^17.0.15",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"remark-parse": "11.0.0",
"socket.io-client": "^4.8.3",
"three": "^0.181.2"
"three": "^0.181.2",
"unified": "11.0.5"
},
"devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1",
+249 -17
View File
@@ -58,13 +58,81 @@ test('keeps logs while loading a later source module', async () => {
assert.equal(calls, 3)
})
test('rejects repeated requests instead of returning a stalled plan', async () => {
test('makes one analysis-only completion when the model repeats a context request', async () => {
let loads = 0
await assert.rejects(resolveAgentContext(options({
requestPlan: async () => response(['Control']),
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
})), /반복 요청/)
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async (context, finalize) => {
calls++
if (!finalize) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').files[0].path, 'controller.py')
return { ...response(), plan: { ...response().plan, explanation: 'controller.py에서 조향 설정을 확인했습니다.' } }
},
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'steering = 1' }])], runtimeLogs: [] } }
}))
assert.equal(loads, 1)
assert.equal(calls, 3)
assert.match(result.plan.explanation, /조향 설정을 확인/)
assert.equal(result.usage.totalTokens, 30)
})
test('finishes honestly without an infinite loop if the model ignores the finalization request', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async () => { calls++; return response(['Control']) },
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
}))
assert.equal(loads, 1)
assert.equal(calls, 3)
assert.deepEqual(plain(result.plan.requestedModules), [])
assert.deepEqual(plain(result.plan.actionPlan), [])
assert.match(result.plan.explanation, /결론을 확정하지 못했습니다/)
assert.match(result.plan.explanation, /Control: 제공된 파일 0개/)
assert.equal(result.plan.createModule, null)
})
test('uses already provided source files without loading the same module again', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'steering = 1' }])], runtimeLogs: [] },
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) },
loadRequestedContext: async () => { loads++; return { modules: [], runtimeLogs: [] } }
}))
assert.equal(loads, 0)
assert.equal(calls, 2)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('recovery cannot turn repeated source requests into executable commands or edits', async () => {
const result = await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'source' }])], runtimeLogs: [] },
requestPlan: async (_, finalize) => finalize ? {
...response(),
plan: {
...response().plan, actionPlan: ['execute'], commands: [{ id: 'run', command: './run.sh' }],
changes: [{ moduleName: 'Control', path: 'control.py', content: 'changed' }], createModule: { name: 'new' }
}
} : response(['Control']),
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}))
for (const field of ['commands', 'changes', 'actionPlan']) assert.deepEqual(plain(result.plan[field]), [])
assert.equal(result.plan.createModule, null)
})
test('does not publish a finalization response received after cancellation', async () => {
const controller = new AbortController()
let calls = 0
await assert.rejects(resolveAgentContext(options({
signal: controller.signal,
requestPlan: async (_, finalize) => {
calls++
if (finalize) controller.abort()
return finalize ? response() : response(['Control'])
}
})), { name: 'AbortError' })
assert.equal(calls, 2)
})
test('requests only new resources when the model also repeats an earlier module', async () => {
@@ -90,11 +158,14 @@ test('does not start another API call after cancellation during context loading'
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)
let loads = 0
const result = await resolveAgentContext(options({
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response([`Module${calls}`]) },
loadRequestedContext: async request => { loads++; return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
}))
assert.equal(calls, 7)
assert.equal(loads, 5)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('passes file-read failures to the model for a concrete final explanation', async () => {
@@ -109,15 +180,30 @@ test('passes file-read failures to the model for a concrete final explanation',
}))
})
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('excludes oversized source files with an explicit notice and continues with complete smaller files', async () => {
let calls = 0
const smallFile = { path: 'config/steering.yaml', content: 'kinematics: ackermann' }
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
const control = context.modules.find(module => module.name === 'Control')
assert.deepEqual(plain(control.files), [smallFile])
assert.equal(control.fileContext.status, 'limited')
assert.match(control.fileContext.message, /전체 2개 중 1개/)
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }, smallFile])], runtimeLogs: [] })
}))
assert.equal(calls, 2)
})
test('does not display a context-only response when no loader is available', async () => {
await assert.rejects(resolveAgentContext(options({ requestPlan: async () => response(['Control']) })), /읽을 수 없는/)
test('requests a final answer rather than leaving a context-only response when no loader is available', async () => {
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) }
}))
assert.equal(calls, 2)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('keeps a just-started module running when a stale collector supplies source files', async () => {
@@ -133,3 +219,149 @@ test('keeps a just-started module running when a stale collector supplies source
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
}))
})
const files = (name, count, size = 10) => Array.from({ length: count }, (_, index) => ({ path: `${name}-${index}.py`, content: String(index).padEnd(size, 'x') }))
const sourceSize = modules => modules.reduce((sum, module) => sum + module.files.reduce((total, file) => total + file.path.length + file.content.length, 0), 0)
test('balances 24 old and 24 new files instead of failing the second module lookup', async () => {
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
calls++
if (calls === 1) return response(['MKMini'])
if (calls === 2) return response(['Control'])
assert.equal(context.modules.reduce((sum, module) => sum + module.files.length, 0), 24)
for (const name of ['MKMini', 'Control']) {
const module = context.modules.find(module => module.name === name)
assert.equal(module.files.length, 12)
assert.equal(module.fileContext.status, 'limited')
assert.match(module.fileContext.message, /전체 24개 중 12개/)
}
return response()
},
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name, files(name, 24))), runtimeLogs: [] })
}))
assert.equal(calls, 3)
})
test('enforces the combined character budget without slicing source file contents', async () => {
let calls = 0
const contents = new Set(['a'.repeat(60_000), 'b'.repeat(60_000)])
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.ok(sourceSize(context.modules) <= 160_000)
for (const module of context.modules) {
assert.equal(module.files.length, 1)
assert.ok(contents.has(module.files[0].content))
assert.equal(module.fileContext.status, 'limited')
}
return response()
},
loadRequestedContext: async () => ({ modules: ['MKMini', 'Control'].map((name, index) => moduleInfo(name, [
{ path: 'one.py', content: [...contents][index] },
{ path: 'two.py', content: [...contents][index] }
])), runtimeLogs: [] }),
initialContext: { modules: [moduleInfo('MKMini', [{ path: 'one.py', content: [...contents][0] }, { path: 'two.py', content: [...contents][0] }]), moduleInfo('Control')], runtimeLogs: [] }
}))
})
test('prioritizes newly requested source when earlier context nearly fills the character budget', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('MKMini', files('old', 2, 70_000)), moduleInfo('Control')], runtimeLogs: [] },
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').files[0].content, 'new'.repeat(20_000))
assert.ok(context.modules.find(module => module.name === 'MKMini').files.length > 0)
assert.ok(sourceSize(context.modules) <= 160_000)
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'new'.repeat(20_000) }])], runtimeLogs: [] })
}))
})
test('budgets initial source context before sending the first API request', async () => {
const initialContext = { modules: [moduleInfo('Control', files('source', 30))], runtimeLogs: [] }
await resolveAgentContext(options({
initialContext,
requestPlan: async context => {
assert.equal(context.modules[0].files.length, 24)
assert.equal(context.modules[0].fileContext.status, 'limited')
return response()
}
}))
assert.equal(initialContext.modules[0].files.length, 30)
assert.equal(initialContext.modules[0].fileContext, undefined)
})
test('rotates older logs out with a notice rather than failing on a fourth module', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${moduleName} log` })) },
requestPlan: async context => {
if (calls++ === 0) return response([], ['D'])
assert.deepEqual(plain(context.runtimeLogs.map(log => log.moduleName)), ['D', 'C', 'B'])
assert.match(context.runtimeLogs[0].message, /이전 로그 제외: A/)
return response()
},
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [{ moduleName: 'D', state: 'running', content: 'latest error' }] })
}))
})
test('counts log messages in the server budget and keeps recent log content', async () => {
await resolveAgentContext(options({
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${'x'.repeat(20_000)}LATEST`, message: 'm'.repeat(500) })) },
requestPlan: async context => {
assert.equal(context.runtimeLogs.length, 3)
assert.ok(context.runtimeLogs.reduce((sum, log) => sum + log.content.length + (log.message?.length ?? 0), 0) <= 60_000)
for (const log of context.runtimeLogs) {
assert.ok(log.content.endsWith('LATEST'))
assert.match(log.message, /최근 구간/)
assert.ok(log.message.length <= 500)
}
return response()
}
}))
})
test('collects candidates from every requested module and prioritizes configuration before large source trees', async () => {
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let collector
function visit(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'collectAgentWorkspace') collector = node.initializer.getText(source)
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(collector)
const api = {}
vm.runInNewContext(ts.transpileModule(`exports.collect = ${collector}`, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, {
exports: api,
availableModules: ['MKMini', 'Control'].map(name => ({ name, state: 'stopped' })),
remoteWorkspace: false,
clonePathPreview: '/workspace',
currentActiveEditorFile: null,
editorFiles: {},
window: { localWorkspace: { moduleFiles: { read: async name => ({ files: Object.fromEntries([
...files(name, 30).map((file, index) => [`src/node${index}.py`, file.content]),
['config/steering.yaml', 'kinematics: ackermann']
]) }) } } }
})
const modules = await api.collect(['MKMini', 'Control'])
assert.deepEqual(plain(modules.map(module => module.files.length)), [24, 24])
for (const module of modules) assert.equal(module.files[0].path, 'config/steering.yaml')
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['MKMini', 'Control'])
for (const module of context.modules) {
assert.equal(module.files.length, 12)
assert.equal(module.files[0].path, 'config/steering.yaml')
}
return response()
},
loadRequestedContext: async request => ({ modules: await api.collect(request.modules), runtimeLogs: [] })
}))
})
+31
View File
@@ -0,0 +1,31 @@
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 api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceEditorPane.tsx'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText, {
exports: api,
require: id => id === '../../i18n' ? { t: value => value, useLocaleRender() {} }
: id === '@monaco-editor/react' ? { __esModule: true, default: 'Editor', DiffEditor: 'DiffEditor' }
: id === 'lucide-react' ? {} : require(id)
})
const props = { dark: true, textDim: '', editorPaneRef: { current: null }, currentActiveEditorFile: 'config/control.yaml', activeFileIsImage: false, editorLanguage: 'yaml', editorValue: 'steering: ackermann', fontSize: 12, tabSize: 4, minimapEnabled: false, exampleActive: false, beforeMount() {}, onMount() {}, onChange() {} }
for (const locked of [false, true]) {
test(`the source editor is read-only only while an agent file batch is applying (locked=${locked})`, () => {
const tree = api.default({ ...props, editingLocked: locked })
assert.equal(tree.props.children.type, 'Editor')
assert.equal(tree.props.children.props.options.readOnly, locked)
})
}
test('agent diff previews remain read-only and set tab width through text models', () => {
const tree = api.default({ ...props, diffPreview: { id: 'diff', originalContent: 'old', modifiedContent: 'new' } })
const diff = tree.props.children
assert.equal(diff.type, 'DiffEditor')
assert.equal(diff.props.options.readOnly, true)
assert.equal('tabSize' in diff.props.options, false)
const updates = []
const editor = { getModel: () => ({ updateOptions: options => updates.push(options.tabSize) }) }
diff.props.onMount({ getOriginalEditor: () => editor, getModifiedEditor: () => editor })
assert.deepEqual(updates, [4, 4])
})
+123
View File
@@ -0,0 +1,123 @@
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 api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentPractice.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: api, DOMException, setTimeout })
const { AGENT_PRACTICE_PROFILES: profiles, inspectAgentPractice, verifyAgentPractice, prepareAgentPractice } = api
const plain = value => JSON.parse(JSON.stringify(value))
const profile = profiles.find(item => item.id === 'mkmini-2d')
const modulesFor = p => p.modules.map((name, sortOrder) => ({ name, sortOrder, category: name.includes('MKMini') || name.includes('TurtleBot') || name === 'NAV2-Yahboom' ? 'Vehicle' : name.includes('Gazebo') ? 'Simulator' : name.includes('Perception') ? 'Sensing' : 'System', state: 'stopped', parameters: [`sim_mode:=${p.environment === 'local'}`, `platform:=${p.platform}`, `robot:=${p.platform}`, `level:=${p.level}`] }))
const probeFor = (p, overrides = {}) => ({ id: 'probe', name: 'ros.inspect', status: 'ok', target: 'test:container', observedAt: new Date().toISOString(), truncated: false, data: JSON.stringify({ topics: p.topics.map(name => ({ name, publishers: 1, samples: 3, validSamples: 3, frameIds: name === p.topics[0] ? ['laser'] : [] })), tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_footprint' }, { parent: 'base_footprint', child: 'base_link' }, { parent: 'base_link', child: 'laser' }], ...overrides }) })
function scenario(overrides = {}) {
const modules = modulesFor(profile)
const starts = []
const controller = new AbortController()
const options = {
profileId: profile.id, environment: 'remote', signal: controller.signal,
approvedStarts: [...profile.modules],
list: async () => modules.map(module => ({ ...module, parameters: [...module.parameters] })),
start: async module => { starts.push(module.name); modules.find(item => item.name === module.name).state = 'running'; return true },
probe: async () => probeFor(profile), progress: () => {}, pause: async () => {}, ...overrides
}
return { modules, starts, controller, options }
}
test('SLAM profiles follow the module guide module sequences', () => {
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let expression
const visit = node => { if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'moduleGuidePages') expression = node.initializer.getText(source); ts.forEachChild(node, visit) }
visit(source)
const guide = {}
vm.runInNewContext(ts.transpileModule(`exports.pages = ${expression}`, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: guide })
for (const p of profiles) assert.ok(guide.pages.some(page => JSON.stringify(page.slamModules) === JSON.stringify(p.modules)))
})
test('preflight rejects wrong environments, missing modules, conflicting parameters and unrelated running modules', () => {
const modules = modulesFor(profile)
assert.equal(inspectAgentPractice(profile.id, modules, 'remote').canPrepare, true)
assert.equal(inspectAgentPractice(profile.id, modules, 'local').canPrepare, false)
assert.equal(inspectAgentPractice(profile.id, modules.slice(1), 'remote').canPrepare, false)
modules[0].parameters[0] = 'sim_mode:=true'
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
modules[0].parameters = []
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
const conflicting = [...modulesFor(profile), { name: 'NAV2-Control', category: 'Control', state: 'running', parameters: [] }]
assert.match(inspectAgentPractice(profile.id, conflicting, 'remote').blockers.join(), /NAV2-Control/)
assert.equal(inspectAgentPractice(profile.id, [...modulesFor(profile), { name: 'NAV2-Camera', category: 'Sensing', state: 'running', parameters: [] }], 'remote').canPrepare, true)
})
test('readiness requires observed sensor, odometry, map data and a connected TF chain', () => {
assert.equal(verifyAgentPractice(profile.id, probeFor(profile)).ready, true)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [] })).ready, false)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { topics: profile.topics.map(name => ({ name, publishers: 1, samples: 0 })) })).ready, false)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_link' }] })).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), status: 'error' }).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), truncated: true }).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), data: 'null' }).ready, false)
})
test('prepares only missing starts, in dependency order, and verifies in the SLAM container', async () => {
const s = scenario()
s.modules[0].state = 'running'
let observed
s.options.probe = async (module, topics) => { observed = { module: module.name, topics }; return probeFor(profile) }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, true)
assert.deepEqual(s.starts, [...profile.modules].slice(1))
assert.equal(observed.module, 'NAV2-SLAM')
assert.ok(observed.topics.includes('/tf_static'))
assert.equal(result.steps[0].status, 'already-running')
})
test('a failed start stops subsequent starts and does not issue rollback stop commands', async () => {
const s = scenario()
s.options.start = async module => { s.starts.push(module.name); if (s.starts.length === 2) return false; s.modules[0].state = 'running'; return true }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, false)
assert.deepEqual(s.starts, [...profile.modules].slice(0, 2))
assert.match(result.failures[0], /자동 종료하지/)
})
test('does not start modules that became stopped after approval', async () => {
const s = scenario({ approvedStarts: ['NAV2-SLAM'] })
await assert.rejects(prepareAgentPractice(s.options), /승인 이후/)
assert.equal(s.starts.length, 0)
})
test('does not start with changed parameters after approval', async () => {
const s = scenario()
s.options.approvedParameters = Object.fromEntries(s.modules.map(module => [module.name, [...module.parameters]]))
s.modules[0].parameters.push('custom_speed:=9')
await assert.rejects(prepareAgentPractice(s.options), /실행 파라미터가 바뀌/)
assert.equal(s.starts.length, 0)
})
test('cancellation during a start prevents any subsequent module start', async () => {
const s = scenario()
s.options.start = async module => { s.starts.push(module.name); s.controller.abort(); return true }
await assert.rejects(prepareAgentPractice(s.options), { name: 'AbortError' })
assert.equal(s.starts.length, 1)
})
test('verification retries are bounded and never report ready for publisher-only data', async () => {
const s = scenario()
let probes = 0
s.options.probe = async () => { probes++; return probeFor(profile, { topics: [] }) }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, false)
assert.equal(probes, 3)
assert.equal(result.failures.length, 4)
assert.ok(result.failures.some(message => message.includes('센서 프레임')))
})
test('the common confirmation and apply-before-build guard are connected in the workspace', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
assert.match(source, /<ConfirmStatusModal[\s\S]*?title="SLAM 실습 준비"/)
assert.match(source, /requiresAppliedChanges && message.applyState !== 'applied'/)
assert.match(source, /agent\.applyFiles\(/)
assert.match(source, /getDirtyModuleFiles\(change.moduleName\)/)
assert.match(source, /background: true, freshSlam: true/)
assert.match(source, /onResult: result =>/)
})
+22 -2
View File
@@ -3,6 +3,10 @@ const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const contextApi = {}
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: contextApi, DOMException })
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression
@@ -15,9 +19,10 @@ function visit(node) {
visit(source)
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
function serialize(attachments) {
function serialize(attachments, finalize = false, request = '설정 위치 알려줘') {
return JSON.parse(vm.runInNewContext(payloadExpression, {
courseId: 'course-1', weekNumber: 3, request: '설정 위치 알려줘', options: { attachments },
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
}))
}
@@ -36,3 +41,18 @@ test('preserves actual attachment contents rather than silently dropping them',
const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
assert.deepEqual(serialize(attachments).attachments, attachments)
})
test('finalization instruction reaches the API without sending unsupported DTO fields', () => {
const body = serialize(undefined, true)
assert.match(body.request, /자료 조회는 종료/)
assert.match(body.request, /설정 위치 알려줘/)
assert.match(body.request, /최종 답변/)
assert.equal(Object.hasOwn(body, 'finalize'), false)
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'request', 'runtimeLogs', 'week'])
})
test('finalization keeps the request within the backend 4000-character limit', () => {
const body = serialize(undefined, true, 'x'.repeat(4000))
assert.equal(body.request.length, 4000)
assert.match(body.request, /원래 학생 질문/)
})
+241
View File
@@ -0,0 +1,241 @@
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 load(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 reveal = load('../src/renderer/src/components/agentResponseReveal.ts')
const { createAgentRevealDocument, agentRevealFrame, AGENT_REVEAL_INTERVAL } = reveal
const paragraph = 'MK-mini는 바퀴가 4개라도 전륜 조향 모델을 사용합니다.'
const content = `${paragraph}\n\n1. 물리적 바퀴 수와 운동학 모델은 다릅니다.\n - 앞바퀴로 조향합니다.\n2. 제어 설정을 확인합니다.\n\n마지막 설명입니다.`
test('reveals paragraphs and complete list items at fixed intervals without splitting Korean sentences', () => {
const document = createAgentRevealDocument(content)
const first = agentRevealFrame(document, 1000, 1000)
assert.equal(first.content, paragraph)
assert.equal(first.complete, false)
const second = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL)
assert.match(second.content, /1\. 물리적/)
assert.match(second.content, /앞바퀴로 조향합니다\./)
assert.doesNotMatch(second.content, /2\. 제어/)
const third = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL * 2)
assert.match(third.content, /2\. 제어 설정을 확인합니다\./)
assert.doesNotMatch(third.content, /마지막 설명/)
})
test('keeps fenced code and GFM tables intact', () => {
const text = '설명입니다.\n\n```python\nprint("시작")\nprint("종료")\n```\n\n| 항목 | 값 |\n| --- | --- |\n| 조향 | 전륜 |'
const document = createAgentRevealDocument(text)
assert.equal(document.boundaries.length, 3)
const code = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL).content
assert.equal((code.match(/```/g) ?? []).length, 2)
assert.match(code, /print\("종료"\)/)
assert.doesNotMatch(code, /\| 항목/)
assert.equal(agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL * 2).content, text)
})
test('reference link definitions are available before their position in the answer is revealed', () => {
const document = createAgentRevealDocument('[설명][guide]\n\n다음 문단\n\n[guide]: https://example.com/guide')
const first = agentRevealFrame(document, 0, 0)
assert.match(first.content, /\[guide\]: https:\/\/example.com\/guide/)
assert.doesNotMatch(first.content, /다음 문단/)
})
test('history, elapsed answers and reduced-motion users see the complete response immediately', () => {
const document = createAgentRevealDocument(content)
for (const frame of [agentRevealFrame(document, undefined, 0), agentRevealFrame(document, 0, 60_000), agentRevealFrame(document, 0, 0, true)]) {
assert.equal(frame.content, content)
assert.equal(frame.complete, true)
assert.equal(frame.fadeFrom, undefined)
}
})
test('long answers are grouped into at most twenty steps and finish within five seconds', () => {
const text = Array.from({ length: 100 }, (_, index) => `문단 ${index}입니다.`).join('\n\n')
const document = createAgentRevealDocument(text)
assert.ok(document.boundaries.length <= 20)
assert.ok(agentRevealFrame(document, 0, 0).duration < 5000)
assert.equal(agentRevealFrame(document, 0, 5000).content, text)
})
test('only the newly appended paragraph gets the fade class', () => {
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}
})
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
const html = renderToStaticMarkup(React.createElement(MarkdownMessage, { content: frame.content, fadeFrom: frame.fadeFrom, dark: true }))
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
assert.match(html, /첫 번째 문단/)
assert.match(html, /두 번째 문단/)
assert.doesNotMatch(html, /세 번째 문단/)
})
function renderResponse(message, now, reducedMotion = false, extra = {}) {
const MarkdownMessage = ({ content }) => React.createElement('p', null, content)
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage: MarkdownMessage }, './agentResponseReveal': reveal,
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: reducedMotion }) } })
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
}
test('approval controls wait for the explanation; restored history does not replay', () => {
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '조향 설명', explanation: content }, provider: 'QWEN', model: 'test' }
const first = renderResponse(message, 1000)
assert.match(first, /조향 설명/)
assert.match(first, /aria-busy="true"/)
assert.doesNotMatch(first, /승인|QWEN/)
for (const html of [renderResponse(message, 10_000), renderResponse({ ...message, revealStartedAt: undefined }, 1000), renderResponse(message, 1000, true)]) {
assert.match(html, /aria-busy="false"/)
assert.match(html, /마지막 설명/)
assert.match(html, /승인/)
assert.match(html, /QWEN/)
}
})
test('reveal timing is not included in conversation persistence API payloads', () => {
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let expression
function visit(node) {
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('messages: batch')) expression = node.getText(source)
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(expression)
const batch = [{ id: '1', role: 'assistant', content: '전체 답변', revealStartedAt: 1000 }]
const payload = JSON.parse(vm.runInNewContext(expression, { batch, courseId: 'course', weekNumber: 1 }))
assert.equal(Object.hasOwn(payload.messages[0], 'revealStartedAt'), false)
assert.equal(payload.messages[0].content, '전체 답변')
})
test('animation honors reduced motion in CSS', () => {
const css = fs.readFileSync(require.resolve('../src/renderer/src/index.css'), 'utf8')
assert.match(css, /@keyframes agent-response-fade/)
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*?\.agent-response-fade\s*\{\s*animation: none/)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
test('agent answers use the shared collapse widget after reveal, without hiding action controls', () => {
const MarkdownMessage = () => null
const ExpandableMarkdownMessage = () => null
let now = 1000
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage }, './agentResponseReveal': reveal,
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: false }) } })
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '요약', explanation: '상세 내용입니다.\n\n'.repeat(60) } }
const render = (extra = {}) => ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })
assert.equal(elements(render(), node => node.type === ExpandableMarkdownMessage).length, 0)
now = 10_000
const fresh = render()
assert.equal(elements(fresh, node => node.type === ExpandableMarkdownMessage)[0].props.defaultCollapsed, false)
assert.equal(elements(fresh, node => node.type === 'button').length, 1)
const history = render({ message: { ...message, revealStartedAt: undefined }, collapseOnSignal: true, collapseSignal: 4 })
const widget = elements(history, node => node.type === ExpandableMarkdownMessage)[0]
assert.equal(widget.props.defaultCollapsed, true)
assert.equal(widget.props.collapseOnSignal, true)
assert.equal(widget.props.collapseSignal, 4)
assert.equal(elements(history, node => node.type === 'button').length, 1)
})
for (const dark of [false, true]) {
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
let expanded
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
} }
})
const content = '파일과 파라미터에 대한 상세 설명입니다.\n'.repeat(50)
const render = () => ExpandableMarkdownMessage({ content, dark, defaultCollapsed: true })
let tree = render()
let button = elements(tree, node => node.type === 'button')[0]
assert.equal(button.props['aria-label'], '자세히 보기')
assert.ok(elements(tree, node => node.type === MarkdownMessage)[0].props.content.length < content.length)
button.props.onClick()
tree = render()
button = elements(tree, node => node.type === 'button')[0]
assert.equal(button.props['aria-label'], '접기')
assert.equal(elements(tree, node => node.type === MarkdownMessage)[0].props.content, content)
button.props.onClick()
assert.equal(elements(render(), node => node.type === 'button')[0].props['aria-label'], '자세히 보기')
})
}
test('short answers do not show an unnecessary collapse button', () => {
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
})
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
assert.equal(elements(tree, node => node.type === 'button').length, 0)
})
for (const dark of [false, true]) {
test(`model label and copy action share the last row after the operation controls (${dark ? 'dark' : 'light'})`, () => {
const html = renderResponse({ content: '전체 답변', provider: 'QWEN', model: 'qwen-model-name'.repeat(8) }, 1000, false, {
dark,
footerAction: React.createElement('button', { title: '복사', className: 'copy-action' }, 'Copy')
})
assert.match(html, /class="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2"><p[^>]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p><button title="복사"/)
assert.ok(html.indexOf('승인') < html.indexOf('QWEN'))
})
}
test('copy footer remains available when the message has no model metadata', () => {
const html = renderResponse({ content: '오류 안내' }, 1000, false, {
footerAction: React.createElement('button', { title: '복사' }, 'Copy')
})
assert.match(html, /min-h-6/)
assert.match(html, /title="복사"/)
})
test('reply copy is an inline fixed-size hover button and still copies the full response', async () => {
const source = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declaration = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'MessageCopyButton')
let copied = false
let clipboardText
const { MessageCopyButton } = compileCopy(declaration.getText(source))
function compileCopy(text) {
const code = ts.transpileModule(`export ${text}`, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
const exports = {}
vm.runInNewContext(code, {
exports, require,
useState: () => [copied, value => { copied = value }], t: key => key,
Check: () => null, Copy: () => null,
navigator: { clipboard: { writeText: async text => { clipboardText = text } } },
window: { setTimeout() {} }
})
return exports
}
const content = '# 요약\n\n전체 답변입니다.'
const button = MessageCopyButton({ content, dark: true })
assert.match(button.props.className, /opacity-0.*group-hover:opacity-100/)
assert.match(button.props.className, /shrink-0 cursor-pointer/)
assert.doesNotMatch(button.props.className, /absolute|bottom-|right-/)
button.props.onClick()
await Promise.resolve()
assert.equal(clipboardText, content)
assert.equal(MessageCopyButton({ content, dark: true }).props.title, '복사됨')
})
+207
View File
@@ -0,0 +1,207 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const { spawnSync } = require('node:child_process')
const ts = require('typescript')
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(require('node:fs').readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, filename)
const { validateAgentTool, validateAgentOperation, safeAgentPath } = require('../src/shared/agentTools.ts')
const { runAgentFileTool, applyAgentFiles, agentFileHash } = require('../src/main/agentFileTools.ts')
const { agentRemoteFilesScript } = require('../src/main/agentRemoteFiles.ts')
const { inspectAgentRuntime, agentRosProbePython } = require('../src/main/agentRuntimeTools.ts')
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
const plain = value => JSON.parse(JSON.stringify(value))
const request = (name, args = {}) => ({ id: 'query-1', name, moduleName: 'NAV2-MKMini', args })
function remote(input) {
const result = spawnSync('bash', ['--noprofile', '--norc', '-s'], { input: agentRemoteFilesScript(input), encoding: 'utf8', maxBuffer: 4_000_000, timeout: 10_000 })
if (result.error || result.status !== 0) throw new Error(result.stderr || result.error?.message)
return JSON.parse(result.stdout)
}
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-agent-tools-'))
t.after(() => fs.rm(root, { recursive: true, force: true }))
await fs.mkdir(path.join(root, 'config'))
await fs.writeFile(path.join(root, 'config', 'sensor.yaml'), 'host_ip: 192.168.2.88\n조향: ackermann\n')
await fs.writeFile(path.join(root, 'CMakeLists.txt'), 'project(vehicle)\n')
return root
}
test('tool requests reject writes, extra shell arguments, unsafe paths and unbounded observations', () => {
for (const name of ['shell', 'files.write', 'network.set', 'ros.publish']) assert.throws(() => validateAgentTool(request(name)))
for (const name of ['files.list', 'files.read', 'files.search']) {
for (const p of ['../secret', '/etc/passwd', '.env', 'config/.env', 'build/secret.py', 'config/id_rsa', 'config/key.pem', 'config\\x.py', 'a//b']) assert.throws(() => validateAgentTool(request(name, { path: p, ...(name === 'files.search' ? { query: 'ip' } : {}) })))
}
assert.throws(() => validateAgentTool(request('network.inspect', { command: 'ip addr replace' })))
assert.throws(() => validateAgentTool(request('ros.inspect', { topics: ['/scan; reboot'] })))
assert.throws(() => validateAgentTool(request('files.read', { path: 'a.py', lineCount: 401 })))
assert.throws(() => validateAgentOperation({ name: 'modules.stop' }))
assert.throws(() => validateAgentOperation({ name: 'practice.prepare', profile: 'guess' }))
assert.equal(validateAgentOperation({ name: 'practice.prepare', profile: 'mkmini-3d' }).name, 'practice.prepare')
assert.equal(safeAgentPath('src/launch/vehicle.launch.py'), 'src/launch/vehicle.launch.py')
})
for (const backend of ['local', 'remote']) {
const read = async (root, tool) => backend === 'local' ? runAgentFileTool(root, validateAgentTool(tool)) : remote({ root, request: validateAgentTool(tool) })
const apply = changes => backend === 'local' ? applyAgentFiles(changes) : Promise.resolve().then(() => remote({ changes }))
test(`${backend}: lists and searches sources without hidden, generated or symlinked data`, async t => {
const root = await fixture(t)
await fs.writeFile(path.join(root, '.env'), 'host_ip: PRIVATE')
await fs.mkdir(path.join(root, 'build'))
await fs.writeFile(path.join(root, 'build', 'generated.yaml'), 'host_ip: PRIVATE')
await fs.symlink('/etc/passwd', path.join(root, 'config', 'outside.txt'))
const listing = await read(root, request('files.list'))
assert.deepEqual([...listing.data.paths].sort(), ['CMakeLists.txt', 'config/sensor.yaml'])
const search = await read(root, request('files.search', { query: 'HOST_IP' }))
assert.equal(search.data.matches.length, 1)
assert.equal(search.data.matches[0].path, 'config/sensor.yaml')
assert.equal(search.data.matches[0].line, 1)
await assert.rejects(read(root, request('files.read', { path: 'config/outside.txt' })))
})
test(`${backend}: full file reads include a reproducible hash, partial and missing reads are explicit`, async t => {
const root = await fixture(t)
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
assert.equal(full.data.complete, true)
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
assert.equal(partial.data.content, '조향: ackermann')
assert.equal(partial.data.complete, false)
assert.equal(partial.truncated, true)
const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
assert.equal(missing.data.exists, false)
assert.equal(missing.data.hash, null)
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
})
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
const root = await fixture(t)
await fs.mkdir(path.join(root, 'code'))
for (let i = 0; i < 5; i++) await fs.writeFile(path.join(root, 'code', `large${i}.py`), 'x'.repeat(900_000))
const result = await read(root, request('files.search', { query: 'host_ip', path: 'config' }))
assert.equal(result.truncated, false)
assert.equal(result.data.matches[0].path, 'config/sensor.yaml')
})
test(`${backend}: validates every original before changing any file and preserves permissions`, async t => {
const root = await fixture(t)
const original = await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')
const first = { root, moduleName: 'NAV2-MKMini', path: 'config/sensor.yaml', content: 'host_ip: 192.168.1.88\n', expectedHash: agentFileHash(original) }
const stale = { root, moduleName: 'NAV2-MKMini', path: 'CMakeLists.txt', content: 'changed', expectedHash: '0'.repeat(64) }
await assert.rejects(apply([first, stale]))
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), original)
await fs.chmod(path.join(root, first.path), 0o664)
await apply([first, { root, moduleName: 'NAV2-MKMini', path: 'config/new.yaml', content: 'created: true', expectedHash: null }])
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), first.content)
assert.equal((await fs.stat(path.join(root, first.path))).mode & 0o777, 0o664)
assert.equal(await fs.readFile(path.join(root, 'config/new.yaml'), 'utf8'), 'created: true')
await assert.rejects(apply([first]))
})
}
test('remote batch streams large content rather than putting it into command arguments', async t => {
const root = await fixture(t)
const content = 'large YAML value '.repeat(11_000)
remote({ changes: [{ root, moduleName: 'NAV2-MKMini', path: 'config/large.yaml', expectedHash: null, content }] })
assert.equal(await fs.readFile(path.join(root, 'config/large.yaml'), 'utf8'), content)
})
test('local batch rolls back earlier replacements if a later replacement fails', async t => {
const root = await fixture(t)
const rename = fs.rename
let calls = 0
fs.rename = async (...args) => { if (++calls === 2) throw new Error('injected rename error'); return rename(...args) }
try {
await assert.rejects(applyAgentFiles([
{ root, moduleName: 'M', path: 'config/new.yaml', expectedHash: null, content: 'new' },
{ root, moduleName: 'M', path: 'CMakeLists.txt', expectedHash: agentFileHash('project(vehicle)\n'), content: 'changed' }
]), /injected/)
await assert.rejects(fs.stat(path.join(root, 'config/new.yaml')), { code: 'ENOENT' })
assert.equal(await fs.readFile(path.join(root, 'CMakeLists.txt'), 'utf8'), 'project(vehicle)\n')
} finally { fs.rename = rename }
})
test('network inspection keeps real host and container observations separate', async () => {
const calls = []
const result = await inspectAgentRuntime(request('network.inspect'), async (executable, args) => { calls.push([executable, args]); return '[{"ifname":"eth0"}]' }, async command => { calls.push(command); throw new Error('container is stopped') })
assert.equal(result.host.interfaces[0].ifname, 'eth0')
assert.match(result.container.error, /stopped/)
assert.equal(calls[0][0], 'ip')
assert.equal(calls[1][0], 'ip')
assert.equal(calls[2], 'ip -j address show')
})
test('ROS probe samples retained maps with matching QoS and distinguishes empty sensor payloads', () => {
const preamble = String.raw`
import sys, types, time
NS = types.SimpleNamespace
tick = [0]
def monotonic():
tick[0] += 1
return tick[0]
time.monotonic = monotonic
class Node:
def get_topic_names_and_types(self):
return [(topic, ['test/Message']) for topic in ['/map', '/scan', '/tf_static']]
def get_publishers_info_by_topic(self, topic):
return [NS(qos_profile=NS(durability='retained' if topic in ['/map', '/tf_static'] else 'volatile'))]
def create_subscription(self, message_type, topic, callback, qos):
if topic in ['/map', '/tf_static'] and qos.durability != 'retained':
raise ValueError('wrong durability')
if topic == '/map':
callback(NS(info=NS(width=10, height=10), data=[]))
elif topic == '/scan':
callback(NS(ranges=[1, 2, 3]))
else:
callback(NS(transforms=[NS(header=NS(frame_id='map'), child_frame_id='odom')]))
return object()
def count_publishers(self, topic):
return 1
def get_node_names(self):
return ['probe']
def destroy_node(self):
pass
sys.modules['rclpy'] = NS(init=lambda **kw: None, create_node=lambda name: Node(), spin_once=lambda *a, **kw: None, shutdown=lambda: None)
sys.modules['rclpy.qos'] = NS(QoSProfile=lambda **kw: NS(**kw), ReliabilityPolicy=NS(BEST_EFFORT='best-effort'), DurabilityPolicy=NS(TRANSIENT_LOCAL='retained', VOLATILE='volatile'))
sys.modules['rosidl_runtime_py'] = types.ModuleType('rosidl_runtime_py')
sys.modules['rosidl_runtime_py.utilities'] = NS(get_message=lambda name: object)
`
const run = spawnSync('python3', ['-c', `${preamble}\nexec(${JSON.stringify(agentRosProbePython)})`, Buffer.from(JSON.stringify(['/map', '/scan', '/tf_static'])).toString('base64')], { encoding: 'utf8', timeout: 5000 })
assert.equal(run.status, 0, run.stderr)
const data = JSON.parse(run.stdout)
assert.equal(data.topics.find(topic => topic.name === '/map').samples, 1)
assert.equal(data.topics.find(topic => topic.name === '/map').validSamples, 0)
assert.equal(data.topics.find(topic => topic.name === '/scan').validSamples, 1)
assert.deepEqual(data.tfEdges, [{ parent: 'map', child: 'odom' }])
})
test('restoring an interrupted action does not leave history permanently running', () => {
const { recoverInterruptedAgentMessages } = require('../src/renderer/src/features/workspace/agentContext.ts')
const messages = [{ id: 'reply', role: 'assistant', content: 'result', applyState: 'applying', commandExecutions: { start: { status: 'running', output: 'started one module' }, build: { status: 'completed' } } }]
const restored = recoverInterruptedAgentMessages(messages)
assert.equal(restored[0].applyState, 'error')
assert.equal(restored[0].commandExecutions.start.status, 'failed')
assert.equal(restored[0].commandExecutions.start.output, 'started one module')
assert.equal(restored[0].commandExecutions.build.status, 'completed')
assert.equal(messages[0].commandExecutions.start.status, 'running')
})
test('tool loop sends actual evidence, deduplicates requests despite new IDs and never runs actions', async () => {
let calls = 0
const executed = []
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
executeTool: async tool => { executed.push(tool); return { id: tool.id, name: tool.name, status: 'ok', target: 'test', observedAt: new Date().toISOString(), data: '{"paths":["config/a.yaml"]}', truncated: false } },
requestPlan: async (context, finalize) => {
calls++
if (calls === 2) assert.equal(context.toolResults.length, 1)
return { provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'explanation', requestedModules: [], requestedLogs: [], actionPlan: [], commands: finalize ? [{ operation: { name: 'modules.start' } }] : [], changes: [], createModule: null, toolRequests: [request('files.list', {})] } }
}
})
assert.equal(executed.length, 1)
assert.equal(calls, 3)
assert.deepEqual(plain(result.plan.commands), [])
assert.deepEqual(plain(result.plan.toolRequests), [])
assert.match(result.plan.explanation, /files.list: ok/)
})
test('backend and desktop share exactly the same tool validation contract', async () => {
assert.equal(await fs.readFile(path.resolve(__dirname, '../../lms-be/src/ai/agent-tools.ts'), 'utf8'), await fs.readFile(path.resolve(__dirname, '../src/shared/agentTools.ts'), 'utf8'))
})
+124
View File
@@ -0,0 +1,124 @@
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(source, overrides = {}, globals = {}) {
const exports = {}
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 widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}
})
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
t: key => key, useLocaleRender() {},
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
ChatEmptyState: widgets.ChatEmptyState,
ProgressiveChatResponse,
MessageCopyButton: () => null,
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
})
for (const dark of [false, true]) {
for (const mode of ['AiMessages', 'AgentMessages']) {
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, statusLoading: false, pending: true, messages: [] }))
assert.match(html, /role="status"/)
assert.equal((html.match(/<span class="h-2 /g) ?? []).length, 3)
for (const width of ['w-full', 'w-[76%]', 'w-[52%]']) assert.ok(html.includes(width))
assert.doesNotMatch(html, /animate-spin/)
assert.match(html, /motion-reduce:animate-none/)
})
}
}
test('AI answer forwards reveal timing and collapse settings to the same presentation component', () => {
const html = renderToStaticMarkup(messages.AiMessages({
dark: true, pending: false, collapseSignal: 2,
messages: [{ id: 'answer', role: 'assistant', content: '전체 답변입니다.', revealStartedAt: 1234, keepExpanded: true }]
}))
assert.match(html, /data-reveal-start="1234"/)
assert.match(html, /data-collapsed="false"/)
assert.match(html, /전체 답변/)
})
for (const dark of [false, true]) {
for (const [mode, expected] of [
['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/],
['AgentMessages', /현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요\.<br\/>변경 내용은 확인 후 직접 적용합니다\./],
['TeacherMessages', /강사에게 문의할<br\/>내용을 입력하세요/]
]) {
test(`empty ${mode} view uses the shared speech bubble (${dark ? 'dark' : 'light'})`, () => {
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, pending: false, messages: [] }))
assert.match(html, expected)
assert.match(html, /rounded-lg border px-8 py-5/)
assert.match(html, /aria-hidden="true"[^>]*rotate-45 border-b border-r/)
assert.doesNotMatch(html, /<button|role="status"/)
})
}
}
function chatHarness(revealInView) {
const states = []
const refs = []
let stateIndex = 0
let refIndex = 0
let waits = 0
const content = '첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n마지막 문단입니다.'
const { useQaChat } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useQaChat.ts'), 'utf8'), {
react: {
useEffect() {},
useState(initial) {
const index = stateIndex++
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
return [states[index], value => { states[index] = typeof value === 'function' ? value(states[index]) : value }]
},
useRef(initial) { const index = refIndex++; return refs[index] ??= { current: initial } }
},
'socket.io-client': {}
}, {
URLSearchParams, AbortController, DOMException,
fetch: async () => ({ ok: true, json: async () => ({ threadId: 'thread', content, message: { id: 'answer', content, createdAt: '2026-10-19T03:00:00Z' } }) }),
window: { setTimeout(fn) { waits++; fn() } }
})
return {
render() {
stateIndex = 0; refIndex = 0
return useQaChat({ accessToken: 'test', courseId: 'course', weekNumber: 1, revealInView, getFreshAccessToken: async () => 'test', onTitlebarNotice() {} })
},
content, waits: () => waits
}
}
test('workspace AI stores the complete answer immediately and leaves animation to the view', async () => {
const harness = chatHarness(true)
await harness.render().sendQaMessage('조향 설정을 알려줘')
const state = harness.render()
const answer = state.visibleAiMessages.find(message => message.id === 'answer')
assert.equal(answer.content, harness.content)
assert.equal(typeof answer.revealStartedAt, 'number')
assert.equal(answer.keepExpanded, true)
assert.equal(state.aiPending, false)
assert.equal(state.aiTypingMessageId, null)
assert.equal(harness.waits(), 0)
})
test('the separate AI guide keeps its existing reveal mode unless explicitly opted in', async () => {
const harness = chatHarness(false)
await harness.render().sendQaMessage('질문')
const state = harness.render()
assert.equal(state.visibleAiMessages.find(message => message.id === 'answer').content, harness.content)
assert.ok(harness.waits() > 0)
assert.equal(state.aiTypingMessageId, null)
})
+37 -3
View File
@@ -25,7 +25,7 @@ function elements(root, predicate) {
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function setup(overrides = {}) {
function setup(overrides = {}, browser = {}) {
const state = []
const effects = []
let index = 0
@@ -34,6 +34,7 @@ function setup(overrides = {}) {
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
'../../i18n': i18n,
'../../components/AppStatusModals': { ConfirmStatusModal },
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
'../../components/ChatWidgets': { QaInputBox },
'react-dom': { createPortal: child => child },
react: {
@@ -45,7 +46,7 @@ function setup(overrides = {}) {
return [state[slot], value => { state[slot] = value }]
}
}
}, { document: { body: {} } }).default
}, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default
const props = {
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
@@ -82,13 +83,46 @@ 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 })
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, 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 [selected, inside] of [[false, true], [true, true], [true, false]]) {
test(`native chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
let request
const node = {}
const harness = setup({}, {
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
chatTextMenu: { show: async options => { request = options; return null } }
})
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
area.props.onContextMenu({
currentTarget: { contains: () => inside }, target: { closest: () => ({}) },
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
})
await Promise.resolve()
assert.equal(request.kind, 'message')
assert.equal(request.canCopy, selected && inside)
assert.equal(request.canClear, true)
assert.equal(harness.cleared(), 0)
})
}
test('native menu deletion still goes through the shared confirmation dialog', async () => {
const harness = setup({}, { chatTextMenu: { show: async () => 'clear' } })
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
await Promise.resolve()
assert.equal(harness.cleared(), 0)
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
assert.ok(modal)
modal.props.onConfirm()
assert.equal(harness.cleared(), 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)
+145
View File
@@ -0,0 +1,145 @@
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')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
function nativeMenu() {
let template
let close
let clipboard = ''
let copies = 0
let pastes = 0
const frame = {}
const sender = { mainFrame: frame, isDestroyed: () => false, copy() { copies++ }, paste() { pastes++ } }
const event = { sender, senderFrame: frame }
const { showChatTextMenu } = compile('../src/main/chatTextMenu.ts', {
electron: {
BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) },
clipboard: { readText: () => clipboard },
Menu: { buildFromTemplate(items) {
template = items
return { popup(options) { close = options.callback } }
} }
}
})
return {
show: options => showChatTextMenu(event, options),
items: () => template,
close: () => close(),
setClipboard: text => { clipboard = text },
copies: () => copies, pastes: () => pastes, event
}
}
const labels = { copy: '복사', paste: '붙여넣기', clear: '대화내역 삭제' }
for (const canCopy of [false, true]) {
test(`message copy is enabled only with a selection (selected=${canCopy})`, async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy, canClear: true, labels })
assert.equal(menu.items()[0].enabled, canCopy)
assert.equal(menu.items()[0].label, '복사')
assert.equal(menu.items().some(item => item.label === '붙여넣기'), false)
menu.items()[0].click()
assert.equal(await result, null)
assert.equal(menu.copies(), canCopy ? 1 : 0)
})
}
for (const clipboard of ['', '복사해 둔 내용']) {
test(`input paste availability is read from the OS clipboard (empty=${!clipboard})`, async () => {
const menu = nativeMenu()
menu.setClipboard(clipboard)
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
assert.equal(menu.items().length, 2)
assert.equal(menu.items()[0].enabled, false)
assert.equal(menu.items()[1].label, '붙여넣기')
assert.equal(menu.items()[1].enabled, Boolean(clipboard))
menu.items()[1].click()
await result
assert.equal(menu.pastes(), clipboard ? 1 : 0)
})
}
test('read-only input never allows paste, even with clipboard text', async () => {
const menu = nativeMenu()
menu.setClipboard('text')
const result = menu.show({ kind: 'input', canCopy: true, canPaste: false, labels })
assert.equal(menu.items()[1].enabled, false)
menu.items()[1].click()
await result
assert.equal(menu.pastes(), 0)
})
test('paste rechecks an emptied clipboard before editing the input', async () => {
const menu = nativeMenu()
menu.setClipboard('text')
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
assert.equal(menu.items()[1].enabled, true)
menu.setClipboard('')
menu.items()[1].click()
await result
assert.equal(menu.pastes(), 0)
})
test('delete selection returns a confirmation request rather than deleting data', async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy: false, canClear: true, labels })
menu.items().find(item => item.label === '대화내역 삭제').click()
assert.equal(await result, 'clear')
})
test('closing a menu without choosing an action does nothing', async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy: true, labels })
menu.close()
assert.equal(await result, null)
assert.equal(menu.copies(), 0)
})
test('untrusted frames and invalid menu targets cannot open a native menu', () => {
const menu = nativeMenu()
assert.equal(menu.show({ kind: 'unknown', canCopy: true, labels }), null)
menu.event.senderFrame = {}
assert.equal(menu.show({ kind: 'input', canCopy: true, labels }), 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))]
}
for (const selected of [false, true]) {
test(`textarea opens the menu with native selection and editability (selected=${selected})`, async () => {
let options
let prevented = false
let stopped = false
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {},
react: { ...React, useState: initial => [initial, () => {}], useEffect() {}, useRef: () => ({ current: null }) }
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
const textarea = elements(tree, node => node.type === 'textarea')[0]
textarea.props.onContextMenu({
currentTarget: { selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
preventDefault() { prevented = true }, stopPropagation() { stopped = true }
})
await Promise.resolve()
assert.equal(options.kind, 'input')
assert.equal(options.canCopy, selected)
assert.equal(options.canPaste, true)
assert.equal(options.labels.paste, '붙여넣기')
assert.equal(prevented, true)
assert.equal(stopped, true)
})
}
+111
View File
@@ -0,0 +1,111 @@
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/features/workspace/WorkspaceGuidePanel.tsx'), 'utf8')
const output = {}
let videos = []
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports: output,
require: name => name === '../../i18n' ? { t: key => key, useLocaleRender() {} }
: name === 'react' ? { ...React, useId: () => 'guide' } : require(name),
URL,
window: { learningVideo: { open: async url => { videos.push(url) } } }
})
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 toggled = []
const props = {
dark: true, workspaceActive: true, minimized: false, resourceMode: 'material',
materialItems: [{ title: 'SLAM', description: '센서 토픽과 TF 상태를 점검하고 지도를 생성합니다.', concepts: ['TF 점검', '지도 저장'], videoUrl: '' }],
activeMaterialIndex: 0, expandedMaterialIndexes: [0], topics: [], topicDetails: [], overview: '',
resources: [], downloadingResourceIds: [], onToggleMaterial: index => toggled.push(index), ...overrides
}
const tree = output.default(props)
const headers = elements(tree, node => node.type === 'button' && node.props['aria-controls'])
return { tree, headers, toggled }
}
for (const dark of [false, true]) {
test(`only the material header toggles the card (${dark ? 'dark' : 'light'})`, () => {
const { tree, headers, toggled } = setup({ dark })
assert.equal(headers.length, 1)
const header = headers[0]
assert.equal(header.props.type, 'button')
assert.equal(header.props['aria-expanded'], true)
assert.equal(header.props.disabled, false)
assert.doesNotMatch(header.props.className, /hover:bg/)
assert.match(header.props.className, /focus-visible:ring-2/)
const card = elements(tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
assert.ok(card)
assert.equal(card.props.onClick, undefined)
assert.equal(card.props.onKeyDown, undefined)
assert.equal(card.props.role, undefined)
assert.equal(card.props.tabIndex, undefined)
header.props.onClick()
assert.deepEqual(toggled, [0])
})
test(`description is selectable outside the interactive header (${dark ? 'dark' : 'light'})`, () => {
const { tree, headers, toggled } = setup({ dark })
const contentId = headers[0].props['aria-controls']
const body = elements(tree, node => node.props.id === contentId)[0]
assert.match(body.props.className, /select-text/)
assert.equal(body.props.onClick, undefined)
assert.equal(elements(headers[0], node => node.props.id === contentId).length, 0)
assert.ok(renderToStaticMarkup(body).includes('센서 토픽과 TF 상태'))
assert.equal(elements(body, node => Boolean(node.props.onClick || node.props.onKeyDown)).length, 0)
assert.deepEqual(toggled, [])
})
test(`header padding and dimensions stay identical when toggled (${dark ? 'dark' : 'light'})`, () => {
const expanded = setup({ dark, expandedMaterialIndexes: [0] })
const collapsed = setup({ dark, expandedMaterialIndexes: [] })
for (const state of [expanded, collapsed]) {
const header = state.headers[0]
const card = elements(state.tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
assert.match(card.props.className, /\bpx-3 pt-3\b/)
assert.doesNotMatch(card.props.className, /px-2\.5|pt-2\.5/)
assert.match(header.props.className, /\bflex h-6 w-full\b/)
}
assert.equal(expanded.headers[0].props.className, collapsed.headers[0].props.className)
})
}
test('future material remains disabled, while previous/current steps keep native keyboard buttons', () => {
const items = ['이전', '현재', '다음'].map(title => ({ title, description: '', videoUrl: '' }))
const { headers } = setup({ materialItems: items, activeMaterialIndex: 1, expandedMaterialIndexes: [1] })
assert.deepEqual(headers.map(header => header.props.disabled), [false, false, true])
assert.equal(headers[2].props.onClick, undefined)
for (const header of headers) {
assert.equal(header.props.type, 'button')
assert.equal(header.props.onKeyDown, undefined)
}
assert.ok(setup({ materialItems: items, allowFutureMaterialSteps: true }).headers.every(header => !header.props.disabled))
})
test('video playback is not nested in the collapse button and does not toggle the card', async () => {
videos = []
const { tree, headers, toggled } = setup({ materialItems: [{ title: 'SLAM', description: '설명', videoUrl: 'https://www.youtube.com/watch?v=test123' }] })
const video = elements(tree, node => node.type === 'button' && node.props.className?.includes('aspect-video'))[0]
assert.ok(video)
assert.equal(elements(headers[0], node => node === video).length, 0)
video.props.onClick({ stopPropagation() {} })
await Promise.resolve()
assert.deepEqual(videos, ['https://www.youtube.com/embed/test123'])
assert.deepEqual(toggled, [])
for (const button of elements(tree, node => node.type === 'button')) {
assert.equal(React.Children.toArray(button.props.children).flatMap(child => elements(child, node => node.type === 'button')).length, 0)
}
})
+133
View File
@@ -0,0 +1,133 @@
import { createHash, randomUUID } from 'node:crypto'
import { chmod, lstat, readdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
import { dirname, join, relative, resolve, sep } from 'node:path'
import { safeAgentPath, type AgentCheckedChange, type AgentToolRequest } from '../shared/agentTools'
const textFile = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$/i
export const agentFileHash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
async function checkedPath(root: string, path: string, allowMissing = false): Promise<string> {
safeAgentPath(path)
const base = await realpath(root)
const absolute = resolve(base, path)
if (!absolute.startsWith(base + sep)) throw new Error('모듈 경로를 벗어날 수 없습니다.')
let current = base
const parts = relative(base, absolute).split(sep)
for (let i = 0; i < parts.length; i++) {
current = join(current, parts[i])
try {
const info = await lstat(current)
if (info.isSymbolicLink() || (i < parts.length - 1 && !info.isDirectory())) throw new Error('심볼릭 링크와 일반 파일이 아닌 경로는 사용할 수 없습니다.')
} catch (error) {
if (allowMissing && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') return absolute
throw error
}
}
return absolute
}
async function readText(root: string, path: string): Promise<string> {
if (!textFile.test(path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 조회·수정할 수 있습니다.')
const absolute = await checkedPath(root, path)
const info = await lstat(absolute)
if (!info.isFile() || info.size > 1_048_576) throw new Error('일반 텍스트 파일(1MB 이하)만 조회할 수 있습니다.')
const text = await readFile(absolute, 'utf8')
if (text.includes('\0') || text.includes('\uFFFD')) throw new Error('UTF-8 텍스트 파일이 아닙니다.')
return text
}
export async function runAgentFileTool(root: string, request: AgentToolRequest): Promise<{ data: unknown; truncated: boolean }> {
const args = request.args ?? {}
if (request.name === 'files.read') {
const path = safeAgentPath(args.path)
try {
const content = await readText(root, path)
const lines = content.split('\n')
const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
const startLine = args.startLine ?? 1
const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// A missing leaf in an existing directory is evidence for a new file.
await checkedPath(root, path, true)
return { data: { path, exists: false, hash: null, content: '', complete: true }, truncated: false }
}
}
const prefix = safeAgentPath(args.path, true)
if (prefix) await checkedPath(root, prefix)
const paths: string[] = []
const matches: Array<{ path: string; line: number; text: string }> = []
let visited = 0
let scanned = 0
let truncated = false
const visit = async (directory: string, depth: number): Promise<void> => {
if (depth > 10 || visited >= 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; return }
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
for (const entry of entries) {
const path = relative(root, join(directory, entry.name)).split(sep).join('/')
if (prefix && path !== prefix && !path.startsWith(prefix + '/') && !prefix.startsWith(path + '/')) continue
if (++visited > 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; break }
try { safeAgentPath(path) } catch { continue }
if (entry.isSymbolicLink()) continue
if (entry.isDirectory()) { await visit(join(directory, entry.name), depth + 1); continue }
if (!entry.isFile() || !textFile.test(entry.name)) continue
if (prefix && path !== prefix && !path.startsWith(prefix + '/')) continue
if (request.name === 'files.list') { paths.push(path); continue }
let content: string
try { content = await readText(root, path) } catch { continue }
scanned += content.length
for (const [index, line] of content.split('\n').entries()) {
if (line.toLowerCase().includes(args.query!.toLowerCase())) {
matches.push({ path, line: index + 1, text: line.slice(0, 500) })
if (matches.length >= 80) { truncated = true; break }
}
}
}
}
await visit(root, 0)
return { data: request.name === 'files.list' ? { paths } : { query: args.query, matches }, truncated }
}
export async function applyAgentFiles(changes: Array<AgentCheckedChange & { root: string }>): Promise<void> {
if (!changes.length || changes.length > 24 || changes.reduce((n, c) => n + c.content.length, 0) > 1_000_000) throw new Error('변경 파일 용량을 확인하세요.')
const prepared: Array<{ path: string; temp: string; original: string | null; mode: number }> = []
const committed: typeof prepared = []
const seen = new Set<string>()
try {
for (const change of changes) {
if (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash)) throw new Error('원본 파일 조회 정보가 없습니다. 다시 분석해주세요.')
if (!textFile.test(change.path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 수정할 수 있습니다.')
const path = await checkedPath(change.root, change.path, true)
if (seen.has(path)) throw new Error('중복된 변경 파일입니다.')
seen.add(path)
let original: string | null = null
let mode = 0o644
try { original = await readText(change.root, change.path); mode = (await lstat(path)).mode } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if ((original === null ? null : agentFileHash(original)) !== change.expectedHash) throw new Error(`분석 이후 파일이 변경되었습니다: ${change.moduleName}/${change.path}. 다시 분석해주세요.`)
prepared.push({ path, original, mode, temp: join(dirname(path), `.lms-agent-${randomUUID()}.tmp`) })
}
for (const [index, file] of prepared.entries()) {
await writeFile(file.temp, changes[index].content, { encoding: 'utf8', mode: file.mode, flag: 'wx' })
await chmod(file.temp, file.mode & 0o777)
}
// Recheck the entire batch immediately before committing any file.
for (const file of prepared) {
let latest: string | null = null
try { latest = await readFile(file.path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
if (latest !== file.original) throw new Error('적용 직전 파일이 변경되었습니다. 다시 분석해주세요.')
}
for (const file of prepared) { await rename(file.temp, file.path); committed.push(file) }
} catch (error) {
const failures: string[] = []
for (const file of committed.reverse()) {
try { if (file.original === null) await unlink(file.path); else await writeFile(file.path, file.original, { mode: file.mode }) } catch { failures.push(file.path) }
}
if (failures.length) throw new Error(`일부 파일 복구에 실패했습니다: ${failures.join(', ')}. ${String(error)}`)
throw error
} finally {
await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined)))
}
}
+181
View File
@@ -0,0 +1,181 @@
import type { AgentCheckedChange, AgentToolRequest } from '../shared/agentTools'
export function agentRemoteFilesScript(input: { root: string; request: AgentToolRequest } | { changes: Array<AgentCheckedChange & { root: string }> }): string {
const encoded = Buffer.from(JSON.stringify(input)).toString('base64')
return `python3 - <<'LMS_AGENT_FILES'
import sys
sys.argv = ['lms-agent', '${encoded}']
${agentRemoteFilesPython}
LMS_AGENT_FILES`
}
export const agentRemoteFilesPython = String.raw`
import base64, hashlib, json, os, re, stat, sys, tempfile
args = json.loads(base64.b64decode(sys.argv[1]))
blocked = {'node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'}
text_pattern = re.compile(r'\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$', re.I)
def safe(value):
if not isinstance(value, str) or not value or len(value) > 500 or value.startswith('/') or any(c in value for c in '\\\x00\r\n'):
raise ValueError('Invalid source path')
if any(not p or p.startswith('.') or p.lower() in blocked or re.search(r'\.(pem|key|p12|pfx)$', p, re.I) for p in value.split('/')):
raise ValueError('Private or excluded source path')
return value
def checked(root, path, missing=False):
safe(path)
root = os.path.realpath(root)
current = root
parts = path.split('/')
for i, part in enumerate(parts):
current = os.path.join(current, part)
try:
info = os.lstat(current)
if stat.S_ISLNK(info.st_mode) or (i < len(parts)-1 and not stat.S_ISDIR(info.st_mode)):
raise ValueError('Symlink or non-directory source path')
except FileNotFoundError:
if not (missing and i == len(parts)-1):
raise
if os.path.commonpath([root, current]) != root:
raise ValueError('Source path escapes module')
return current
def read(root, path):
if not text_pattern.search(os.path.basename(path)):
raise ValueError('Only text source files are supported')
absolute = checked(root, path)
info = os.lstat(absolute)
if not stat.S_ISREG(info.st_mode) or info.st_size > 1048576:
raise ValueError('Only regular source files up to 1 MB are supported')
with open(absolute, 'rb') as fp:
content = fp.read(1048577)
if len(content) > 1048576 or b'\x00' in content:
raise ValueError('Binary or oversized file')
return content.decode('utf-8')
def digest(content):
return hashlib.sha256(content.encode('utf-8')).hexdigest()
if 'changes' in args:
changes = args['changes']
if not changes or len(changes) > 24 or sum(len(c['content']) for c in changes) > 1000000:
raise ValueError('Invalid change batch size')
prepared, committed, seen = [], [], set()
try:
for c in changes:
path = checked(c['root'], c['path'], True)
if path in seen or not text_pattern.search(os.path.basename(path)):
raise ValueError('Duplicate or unsupported change file')
seen.add(path)
try:
original = read(c['root'], c['path'])
mode = stat.S_IMODE(os.stat(path).st_mode)
except FileNotFoundError:
original, mode = None, 0o644
if (None if original is None else digest(original)) != c.get('expectedHash', 'missing'):
raise ValueError('File changed after analysis: ' + c['moduleName'] + '/' + c['path'])
prepared.append({'path': path, 'original': original, 'mode': mode, 'content': c['content'], 'temp': None})
for p in prepared:
fd, p['temp'] = tempfile.mkstemp(prefix='.lms-agent-', dir=os.path.dirname(p['path']))
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as fp:
fp.write(p['content'])
os.chmod(p['temp'], p['mode'])
for p in prepared:
try:
with open(p['path'], 'r', encoding='utf-8', newline='') as fp:
latest = fp.read()
except FileNotFoundError:
latest = None
if latest != p['original']:
raise ValueError('File changed immediately before apply')
for p in prepared:
os.replace(p['temp'], p['path'])
committed.append(p)
except Exception as error:
failed = []
for p in reversed(committed):
try:
if p['original'] is None:
os.unlink(p['path'])
else:
with open(p['path'], 'w', encoding='utf-8', newline='') as fp:
fp.write(p['original'])
os.chmod(p['path'], p['mode'])
except Exception:
failed.append(p['path'])
if failed:
raise ValueError('Rollback failed: ' + ', '.join(failed)) from error
raise
finally:
for p in prepared:
if p['temp'] and os.path.exists(p['temp']):
os.unlink(p['temp'])
print(json.dumps({'ok': True}))
else:
root = os.path.realpath(args['root'])
req = args['request']
options = req.get('args') or {}
name = req['name']
if name == 'files.read':
path = options['path']
try:
content = read(root, path)
lines = content.split('\n')
partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000
start = options.get('startLine', 1)
selected = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)])[:40000] if partial else content
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': not partial, 'startLine': start, 'totalLines': len(lines)}
except FileNotFoundError:
checked(root, path, True)
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
print(json.dumps({'data': data, 'truncated': partial}, ensure_ascii=False))
else:
prefix = options.get('path', '')
if prefix:
checked(root, prefix)
paths, matches = [], []
visited, scanned, truncated = 0, 0, False
def fail(error):
raise error
for directory, dirs, files in os.walk(root, followlinks=False, onerror=fail):
depth = len(os.path.relpath(directory, root).split('/')) if directory != root else 0
kept = []
for d in sorted(dirs):
rel = os.path.relpath(os.path.join(directory, d), root)
if prefix and rel != prefix and not rel.startswith(prefix + '/') and not prefix.startswith(rel + '/'):
continue
visited += 1
try:
safe(rel)
if not os.path.islink(os.path.join(directory, d)):
kept.append(d)
except ValueError:
pass
dirs[:] = kept if depth < 10 else []
for f in sorted(files):
path = os.path.relpath(os.path.join(directory, f), root)
if prefix and path != prefix and not path.startswith(prefix + '/'):
continue
visited += 1
if visited > 5000 or len(paths) >= 250 or len(matches) >= 80 or scanned >= 4000000:
truncated = True
break
try:
safe(path)
if os.path.islink(os.path.join(directory, f)) or not text_pattern.search(f):
continue
if prefix and path != prefix and not path.startswith(prefix + '/'):
continue
if name == 'files.list':
paths.append(path)
continue
content = read(root, path)
except (ValueError, OSError, UnicodeError):
continue
scanned += len(content)
for number, line in enumerate(content.split('\n'), 1):
if options['query'].lower() in line.lower():
matches.append({'path': path, 'line': number, 'text': line[:500]})
if len(matches) >= 80:
truncated = True
break
if truncated:
break
data = {'paths': paths} if name == 'files.list' else {'query': options['query'], 'matches': matches}
print(json.dumps({'data': data, 'truncated': truncated}, ensure_ascii=False))
`
+79
View File
@@ -0,0 +1,79 @@
import type { AgentToolRequest } from '../shared/agentTools'
export const DEFAULT_AGENT_TOPICS = ['/raw/lidar', '/scan', '/lidar_filtered', '/cloud_map', '/odom', '/map', '/tf', '/tf_static']
export function agentRosProbe(topics: string[] = DEFAULT_AGENT_TOPICS): string {
const encoded = Buffer.from(JSON.stringify(topics)).toString('base64')
return `python3 -c ${shellQuote(agentRosProbePython)} ${shellQuote(encoded)}`
}
export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'` }
export const agentRosProbePython = String.raw`
import base64, json, sys, time
import rclpy
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
from rosidl_runtime_py.utilities import get_message
rclpy.init(args=[])
node = rclpy.create_node('lms_agent_readonly_probe_' + str(int(time.time() * 1000)))
topics = json.loads(base64.b64decode(sys.argv[1]))
samples, valid_samples, frames, edges, errors, subscriptions = {}, {}, {}, set(), {}, []
try:
deadline = time.monotonic() + 1.5
while time.monotonic() < deadline:
rclpy.spin_once(node, timeout_sec=0.1)
graph = dict(node.get_topic_names_and_types())
def callback(topic):
def receive(message):
samples[topic] = samples.get(topic, 0) + 1
if hasattr(message, 'header') and message.header.frame_id:
frame_set = frames.setdefault(topic, set())
if len(frame_set) < 8:
frame_set.add(message.header.frame_id)
valid = True
if hasattr(message, 'ranges'):
valid = len(message.ranges) > 0
elif hasattr(message, 'width') and hasattr(message, 'data'):
valid = message.width * message.height > 0 and len(message.data) > 0
elif hasattr(message, 'info') and hasattr(message, 'data'):
valid = message.info.width * message.info.height > 0 and len(message.data) > 0
if valid:
valid_samples[topic] = valid_samples.get(topic, 0) + 1
if topic in ('/tf', '/tf_static'):
for transform in message.transforms:
if len(edges) < 200:
edges.add((transform.header.frame_id, transform.child_frame_id))
return receive
for topic in topics:
if topic not in graph:
continue
try:
retained = any(info.qos_profile.durability == DurabilityPolicy.TRANSIENT_LOCAL for info in node.get_publishers_info_by_topic(topic))
qos = QoSProfile(depth=1, reliability=ReliabilityPolicy.BEST_EFFORT, durability=DurabilityPolicy.TRANSIENT_LOCAL if retained else DurabilityPolicy.VOLATILE)
subscriptions.append(node.create_subscription(get_message(graph[topic][0]), topic, callback(topic), qos))
except Exception as error:
errors[topic] = str(error)[:500]
deadline = time.monotonic() + 3.0
while time.monotonic() < deadline:
rclpy.spin_once(node, timeout_sec=0.1)
graph = dict(node.get_topic_names_and_types())
print(json.dumps({'nodes': node.get_node_names()[:200], 'topics': [{'name': t, 'types': graph.get(t, []), 'publishers': node.count_publishers(t), 'samples': samples.get(t, 0), 'validSamples': valid_samples.get(t, 0), 'frameIds': sorted(frames.get(t, [])), 'error': errors.get(t)} for t in topics], 'tfEdges': [{'parent': p, 'child': c} for p, c in sorted(edges)], 'observationSeconds': 3.0}))
finally:
node.destroy_node()
rclpy.shutdown()
`
export async function inspectAgentRuntime(
request: AgentToolRequest,
host: (executable: string, args: string[]) => Promise<string>,
container: (command: string) => Promise<string>
): Promise<unknown> {
if (request.name === 'network.inspect') {
const interfaces = JSON.parse(await host('ip', ['-j', 'address', 'show']))
const routes = JSON.parse(await host('ip', ['-j', 'route', 'show']))
let containerNetwork: unknown
try { containerNetwork = JSON.parse(await container('ip -j address show')) } catch (error) { containerNetwork = { error: String(error).slice(0, 500) } }
return { host: { interfaces, routes }, container: containerNetwork, note: '호스트는 해당 모듈이 설치된 로컬 또는 SSH 대상 장치입니다. 라이다·공유기 자체 설정은 조회하지 않았습니다.' }
}
if (request.name === 'ros.inspect') return JSON.parse(await container(agentRosProbe(request.args?.topics)))
throw new Error('지원하지 않는 런타임 조회입니다.')
}
+38
View File
@@ -0,0 +1,38 @@
import { BrowserWindow, clipboard, Menu } from 'electron'
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import type { ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu'
export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null {
if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null
const window = BrowserWindow.fromWebContents(event.sender)
if (!window || window.isDestroyed()) return null
const label = (value: unknown, fallback: string): string => typeof value === 'string' && value.trim() ? value.slice(0, 80) : fallback
return new Promise((resolve) => {
const items: MenuItemConstructorOptions[] = [{
label: label(input.labels?.copy, 'Copy'),
enabled: input.canCopy === true,
click: () => {
if (!event.sender.isDestroyed() && input.canCopy === true) event.sender.copy()
resolve(null)
}
}]
if (input.kind === 'input') {
items.push({
label: label(input.labels?.paste, 'Paste'),
enabled: input.canPaste === true && clipboard.readText().length > 0,
click: () => {
if (!event.sender.isDestroyed() && input.canPaste === true && clipboard.readText().length > 0) event.sender.paste()
resolve(null)
}
})
} else if (input.canClear !== undefined) {
items.push({ type: 'separator' }, {
label: label(input.labels?.clear, 'Delete conversation'),
enabled: input.canClear === true,
click: () => resolve(input.canClear === true ? 'clear' : null)
})
}
Menu.buildFromTemplate(items).popup({ window, callback: () => resolve(null) })
})
}
+116 -6
View File
@@ -1,8 +1,13 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { appLog } from './appLogging'
import { checkDockerStatus } from './dockerStatus'
import { showChatTextMenu } from './chatTextMenu'
import { cameraRuntimeArtifacts } from './cameraRuntime'
import { remoteModuleFilesScript } from './remoteModuleFiles'
import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
import { agentRemoteFilesScript } from './agentRemoteFiles'
import { inspectAgentRuntime } from './agentRuntimeTools'
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
import { resolveContainerTerminal } from './containerTerminal'
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
@@ -1794,7 +1799,7 @@ function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMa
})
}
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise<string> {
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined', input?: string): Promise<string> {
const readProgress = createModuleProgressParser()
return new Promise<string>((resolvePromise, rejectPromise) => {
let output = ''
@@ -1803,7 +1808,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
const child = spawn(command, args, {
cwd,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
env: {
...process.env,
...env,
@@ -1826,13 +1831,17 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
}, timeoutMs)
child.stdout.setEncoding('utf8')
child.stdout.on('data', (data: string) => {
if (input !== undefined) {
child.stdin?.on('error', () => { /* Process exit is reported by close/error. */ })
child.stdin?.end(input)
}
child.stdout?.setEncoding('utf8')
child.stdout?.on('data', (data: string) => {
stdout += data.toString()
output += data.toString()
readProgress(data.toString())
})
child.stderr.on('data', (data: Buffer) => {
child.stderr?.on('data', (data: Buffer) => {
output += data.toString()
})
child.on('error', (error) => {
@@ -2219,7 +2228,7 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
))
}
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise<string> {
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number, input?: string): Promise<string> {
const normalized = normalizeRemoteTarget(target)
return runCommandOutput(
'ssh',
@@ -2249,6 +2258,7 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri
remoteShellEnvironment(process.env),
timeoutMs,
'stdout',
input,
)
}
@@ -6016,6 +6026,86 @@ async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleN
}
}
function agentFolder(folderName?: string | null): string | undefined {
if (folderName == null) return undefined
if (!/^[A-Za-z0-9[\](). _-]{1,160}$/.test(folderName) || folderName === '.' || folderName === '..') throw new Error('모듈 폴더명이 올바르지 않습니다.')
return folderName
}
async function runWorkspaceAgentTool(input: AgentToolRequest, workspaceRoot?: string, folderName?: string, remote?: RemoteWorkspaceTarget): Promise<AgentToolResult> {
const request = validateAgentTool(input)
const target = remote ? `ssh:${normalizeRemoteTarget(remote).host}` : 'local-host'
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
try {
const folder = agentFolder(folderName)
const resolved = remote ? await resolveRemoteGitModuleRoot(remote, request.moduleName!, folder) : null
const root = resolved?.moduleRoot ?? await findWorkspaceModuleRoot(request.moduleName!, workspaceRoot, folder)
if (!root) throw new Error('설치된 모듈을 찾을 수 없습니다.')
let payload: unknown
if (request.name.startsWith('files.')) {
const response = remote
? JSON.parse(await runUnqueuedSshOutput(resolved!.target, agentRemoteFilesScript({ root, request }), 30_000))
: await runAgentFileTool(root, request)
payload = response.data
result.truncated = response.truncated
} else if (request.name === 'logs.read') {
const response = remote
? await readRemoteWorkspaceModuleLogs(remote, request.moduleName!, folder)
: await readWorkspaceModuleLogs(request.moduleName!, workspaceRoot, folder)
payload = { ...response, logs: response.logs.slice(-20_000) }
if (!response.ok) result.status = 'error'
result.truncated = response.logs.length > 20_000
} else {
const host = (executable: string, args: string[]): Promise<string> => remote
? runUnqueuedSshOutput(resolved!.target, [executable, ...args].map(quoteShellSingle).join(' '), 12_000)
: runCommandOutput(executable, args, undefined, undefined, 12_000, 'stdout')
const container = async (command: string): Promise<string> => {
const runtime = remote ? await resolveRemoteAgentContainer(resolved!.target, root, folder) : await resolveLocalAgentContainer(root, folder)
const args = agentContainerCommandArgs(runtime, command, false)
// Unlike free-form commands, these fixed probes always have a container-side deadline.
args.splice(4, 0, 'timeout', '--signal=INT', '--kill-after=2s', '10s')
return host('docker', args)
}
payload = await inspectAgentRuntime(request, host, container)
if (request.name === 'network.inspect' && (payload as { container?: { error?: string } }).container?.error) result.truncated = true
if (request.name === 'ros.inspect') result.target += ':module-container'
}
result.data = JSON.stringify(payload)
if (result.data.length > 60_000) {
result.data = JSON.stringify({ message: '조회 결과가 용량 한도를 초과했습니다. 파일 범위나 검색어를 좁혀주세요.', excerpt: result.data.slice(0, 20_000) })
result.truncated = true
}
} catch (error) {
result.status = 'error'
result.data = JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) })
}
return result
}
let agentFileApplyPending = false
async function applyWorkspaceAgentFiles(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
if (agentFileApplyPending) throw new Error('다른 파일 변경안을 적용 중입니다.')
agentFileApplyPending = true
try { return await applyWorkspaceAgentFilesOnce(changes, workspaceRoot, remote) }
finally { agentFileApplyPending = false }
}
async function applyWorkspaceAgentFilesOnce(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
if (!Array.isArray(changes) || !changes.length || changes.length > 24) throw new Error('변경 파일 수를 확인하세요.')
const resolved: Array<AgentCheckedChange & { root: string }> = []
for (const change of changes) {
if (!change || typeof change.content !== 'string' || change.content.length > 200_000 || (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash))) throw new Error('검증된 파일 변경안이 아닙니다.')
validateAgentTool({ id: 'apply-check', name: 'files.read', moduleName: change.moduleName, args: { path: change.path } })
const folder = agentFolder(change.folderName)
const root = remote ? (await resolveRemoteGitModuleRoot(remote, change.moduleName, folder)).moduleRoot : await findWorkspaceModuleRoot(change.moduleName, workspaceRoot, folder)
if (!root) throw new Error(`변경 대상 모듈이 설치되지 않았습니다: ${change.moduleName}`)
resolved.push({ ...change, root })
}
if (remote) await runUnqueuedSshOutput(remote, 'bash --noprofile --norc -s', 30_000, agentRemoteFilesScript({ changes: resolved }))
else await applyAgentFiles(resolved)
return { ok: true }
}
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
@@ -6681,6 +6771,25 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
ipcMain.handle('local-workspace:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
const checkAgentSender = (event: Electron.IpcMainInvokeEvent): void => {
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error('허용되지 않은 에이전트 요청입니다.')
}
ipcMain.handle('local-workspace:agent-tool:read', (event, request: AgentToolRequest, workspaceRoot?: string, folderName?: string) => {
checkAgentSender(event)
return runWorkspaceAgentTool(request, workspaceRoot, folderName)
})
ipcMain.handle('remote-workspace:agent-tool:read', (event, target: RemoteWorkspaceTarget, request: AgentToolRequest, folderName?: string) => {
checkAgentSender(event)
return runWorkspaceAgentTool(request, undefined, folderName, target)
})
ipcMain.handle('local-workspace:agent-files:apply', (event, changes: AgentCheckedChange[], workspaceRoot?: string) => {
checkAgentSender(event)
return applyWorkspaceAgentFiles(changes, workspaceRoot)
})
ipcMain.handle('remote-workspace:agent-files:apply', (event, target: RemoteWorkspaceTarget, changes: AgentCheckedChange[]) => {
checkAgentSender(event)
return applyWorkspaceAgentFiles(changes, undefined, target)
})
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose))
ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
@@ -6927,6 +7036,7 @@ app.whenReady().then(() => {
return { opened: true }
})
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
ipcMain.handle('chat-text:context-menu', showChatTextMenu)
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
ipcMain.on('app-quit', () => app.quit())
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
+7
View File
@@ -117,6 +117,9 @@ console.info('[lms-preload] launcher auth resolved', {
})
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('chatTextMenu', {
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options)
})
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('lmsBootSplash', {
status: (message: string) => ipcRenderer.send('lms-boot-status', message),
@@ -216,6 +219,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
},
agent: {
readTool: (request: import('../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-tool:read', request, workspaceRoot, folderName),
applyFiles: (changes: import('../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:agent-files:apply', changes, workspaceRoot),
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-command:run', moduleName, command, workspaceRoot, folderName)
},
modules: {
@@ -304,6 +309,8 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
},
agent: {
readTool: (target: RemoteWorkspaceTarget, request: import('../shared/agentTools').AgentToolRequest, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-tool:read', target, request, folderName),
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../shared/agentTools').AgentCheckedChange[]) => ipcRenderer.invoke('remote-workspace:agent-files:apply', target, changes),
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-command:run', target, moduleName, command, folderName)
},
maps: {
+192 -42
View File
@@ -12,7 +12,7 @@ import type { Socket } from 'socket.io-client'
import Sidebar from './components/Sidebar'
import DockerSetupNotice from './components/DockerSetupNotice'
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
import { ConfirmStatusModal, ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
import TitleBar from './components/TitleBar'
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
@@ -58,6 +58,8 @@ import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminal
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
import { useQaChat } from './features/workspace/useQaChat'
import { useAiAgent } from './features/workspace/useAiAgent'
import { validateAgentOperation, validateAgentTool, type AgentToolRequest, type AgentToolResult } from '../../shared/agentTools'
import { AGENT_PRACTICE_PROFILES, inspectAgentPractice, prepareAgentPractice } from './features/workspace/agentPractice'
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
import type { ChatAttachment } from './components/chatAttachments'
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
@@ -566,6 +568,7 @@ function LocalPcWorkspaceMockup({
accessToken,
courseId: courseKey,
weekNumber,
revealInView: true,
apiBaseUrl: LMS_API_BASE_URL,
socketBaseUrl: LMS_SOCKET_ORIGIN,
socketPath: LMS_SOCKET_PATH,
@@ -626,6 +629,7 @@ function LocalPcWorkspaceMockup({
enabled: agentEnabled,
statusLoading: agentStatusLoading,
pending: agentPending,
activity: agentActivity,
messages: agentMessages,
persistenceError: agentPersistenceError,
requestPlan: requestAgentPlan,
@@ -642,6 +646,10 @@ function LocalPcWorkspaceMockup({
getFreshAccessToken,
onResponse: () => onTitlebarNotice(t("AI 에이전트 답변이 도착했습니다."), 'info', true)
})
const [agentActionBusy, setAgentActionBusy] = useState(false)
const agentApplying = agentMessages.some(message => message.applyState === 'applying')
const agentActionControllerRef = useRef<AbortController | null>(null)
const [agentPracticeApproval, setAgentPracticeApproval] = useState<{ messageId: string; command: AgentCommand; starts: string[]; modules: string[]; parameters: Record<string, string[]>; runtimeSettings: string } | null>(null)
const [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
const [teamGitSyncing, setTeamGitSyncing] = useState(false)
@@ -704,6 +712,8 @@ function LocalPcWorkspaceMockup({
finishTabDrop,
resetTabDrag
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
const agentEditorFilesRef = useRef(editorFiles)
agentEditorFilesRef.current = editorFiles
const [agentDiffPreviews, setAgentDiffPreviews] = useState<AgentDiffPreview[]>([])
const [activeAgentDiffPreviewId, setActiveAgentDiffPreviewId] = useState<string | null>(null)
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
@@ -756,6 +766,11 @@ function LocalPcWorkspaceMockup({
useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
if (agentPracticeApproval) {
event.preventDefault()
setAgentPracticeApproval(null)
return
}
if (pendingEditorCloseRequest) {
event.preventDefault()
setPendingEditorCloseRequest(null)
@@ -789,7 +804,7 @@ function LocalPcWorkspaceMockup({
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
}, [agentPracticeApproval, pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
useEffect(() => {
if (!controlEndsAt) return
setControlCountdownNow(Date.now())
@@ -1433,6 +1448,10 @@ function LocalPcWorkspaceMockup({
return () => window.removeEventListener('message', handleViewerMessage)
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, localVisualizationWorkspaceRoot, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, visualizationBridgeLocation, weekNumber])
const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
useEffect(() => {
setAgentPracticeApproval(null)
return () => { agentActionControllerRef.current?.abort() }
}, [courseKey, weekNumber, platformId, remoteWorkspaceTargetKey])
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
const hasRestorableWorkspaceUi = Boolean(
window.localStorage.getItem(workspaceUiSessionKey)
@@ -3502,6 +3521,7 @@ function LocalPcWorkspaceMockup({
folderName: module.folderName,
category: module.category,
state: module.state,
parameters: module.parameters,
files: [],
...(requestedNames.has(module.name.toLowerCase()) ? {
fileContext: {
@@ -3520,11 +3540,9 @@ function LocalPcWorkspaceMockup({
return /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|zsh|launch|urdf|xacro|rviz|proto|cmake)$/i.test(name)
|| ['dockerfile', 'cmakelists.txt', 'package.xml', 'setup.py', 'setup.cfg'].includes(name)
}
let totalCharacters = 0
let totalFiles = 0
for (const module of targetModules) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break
let totalCharacters = 0
let totalFiles = 0
try {
const modulePrefix = `modules/${module.name}`
const result = remoteWorkspace
@@ -3535,9 +3553,9 @@ function LocalPcWorkspaceMockup({
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
const sourcePriority = (path: string): number => {
if (path === preferredPath) return -100
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -60
if (/(^|\/)src(\/|$)/.test(path)) return -50
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30
return 0
}
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
@@ -3616,12 +3634,43 @@ function LocalPcWorkspaceMockup({
modules: await collectAgentWorkspace(request.modules),
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
})
const readAgentModules = async (): Promise<ModuleRow[]> => {
const response = remoteWorkspace
? await window.remoteWorkspace.modules.list(getRemoteWorkspaceTarget(), runtimeModules ?? [])
: await window.localWorkspace.modules.list(clonePathPreview, runtimeModules ?? [])
return applyModuleParameterOverrides(response.modules)
}
const executeWorkspaceAgentTool = async (input: AgentToolRequest): Promise<AgentToolResult> => {
const request = validateAgentTool(input)
const target = remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
try {
if (request.name === 'modules.list' || request.name === 'practice.inspect') {
const modules = await readAgentModules()
const payload = request.name === 'modules.list'
? modules.map(module => ({ name: module.name, state: module.state, category: module.category, parameters: module.parameters }))
: (request.args?.profile ? [request.args.profile] : AGENT_PRACTICE_PROFILES.map(profile => profile.id)).map(profile => inspectAgentPractice(profile, modules, remoteWorkspace ? 'remote' : 'local'))
return { ...result, data: JSON.stringify(payload) }
}
const matches = availableModules.filter(module => module.name === request.moduleName)
if (matches.length !== 1) throw new Error('대상 모듈이 없거나 같은 이름이 여러 개입니다.')
const module = matches[0]
return remoteWorkspace
? await window.remoteWorkspace.agent.readTool(getRemoteWorkspaceTarget(), request, module.folderName)
: await window.localWorkspace.agent.readTool(request, clonePathPreview, module.folderName)
} catch (error) {
return { ...result, status: 'error', data: JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) }) }
}
}
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) return
try {
const modules = await collectAgentWorkspace()
await requestAgentPlan(content, modules, [], {
attachments,
loadRequestedContext: loadAgentRequestedContext
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local'
})
} catch (error) {
console.error('[agent] workspace collection failed', error)
@@ -3637,32 +3686,16 @@ function LocalPcWorkspaceMockup({
}
const rendererPath = `modules/${module.name}/${change.path.replace(/^\/+/, '')}`
let originalContent = editorFiles[rendererPath]
if (originalContent === undefined) {
try {
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const normalizePath = (path: string): string => path.startsWith('modules/')
? path
: `modules/${module.name}/${path.replace(/^\/+/, '')}`
const fileContent = Object.entries(result.files).find(([path]) => normalizePath(path) === rendererPath)?.[1]
const fileExists = [...(result.filePaths ?? []), ...Object.keys(result.files)]
.some((path) => normalizePath(path) === rendererPath)
originalContent = fileContent
if (originalContent === undefined && fileExists && remoteWorkspace) {
originalContent = (await window.remoteWorkspace.moduleFiles.content(
getRemoteWorkspaceTarget(),
module.name,
rendererPath,
module.folderName
)).content
}
} catch (error) {
console.warn('[agent] failed to read original file for diff preview', error)
}
let originalContent: string | undefined
try {
const result = await executeWorkspaceAgentTool({ id: `preview-${Date.now()}`, name: 'files.read', moduleName: module.name, args: { path: change.path } })
const file = JSON.parse(result.data) as { content?: string; hash?: string | null; complete?: boolean }
if (result.status !== 'ok' || !file.complete || typeof file.content !== 'string') throw new Error('비교할 원본 파일 전체를 읽지 못했습니다. 다시 분석해주세요.')
if (change.expectedHash !== undefined && change.expectedHash !== file.hash) throw new Error('분석 이후 원본 파일이 변경되었습니다. 다시 분석해주세요.')
originalContent = file.content
} catch (error) {
onTitlebarNotice(error instanceof Error ? error.message : '비교할 원본 파일을 읽지 못했습니다.', 'error')
return
}
if (selectedModule?.name !== module.name) openModuleSource(module)
@@ -3692,7 +3725,79 @@ function LocalPcWorkspaceMockup({
return previews.filter((preview) => preview.messageId !== messageId)
})
}
const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record<string, string[]>; runtimeSettings: string }): Promise<void> => {
if (agentActionControllerRef.current || agentPending || agentApplying) return
const message = agentMessages.find(item => item.id === messageId)
if (!message?.plan || !command.operation) return
const controller = new AbortController()
agentActionControllerRef.current = controller
setAgentActionBusy(true)
const assertActive = (): void => { if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError') }
try {
const operation = validateAgentOperation(command.operation)
const runtimeSettings = JSON.stringify([runtimeSourceSettings, moduleRuntimeImages, vehicleRuntimeProfiles])
if (approval && approval.runtimeSettings !== runtimeSettings) throw new Error('승인 이후 ROS Domain 또는 실행 환경 설정이 바뀌었습니다. 다시 승인해주세요.')
const freshModules = await readAgentModules()
assertActive()
const candidates = freshModules.filter(item => item.name === command.moduleName)
if (candidates.length !== 1 || candidates[0].state === 'uninstalled') throw new Error('대상 모듈의 설치 상태를 확인해주세요.')
const module = candidates[0]
if (getDirtyModuleFiles(module.name).length) throw new Error('저장하지 않은 변경 사항이 있습니다. 저장 후 다시 승인해주세요.')
const environment = remoteWorkspace ? 'remote' as const : 'local' as const
if (operation.name === 'practice.prepare' && !approval) {
const inspection = inspectAgentPractice(operation.profile!, freshModules, environment)
if (inspection.blockers.length) throw new Error(inspection.blockers.join('\n'))
if (command.moduleName !== inspection.profile.modules.at(-1)) throw new Error('실습 구성표의 SLAM 모듈을 대상으로 지정해야 합니다.')
setAgentPracticeApproval({ messageId, command, starts: inspection.starts, modules: [...inspection.profile.modules], parameters: Object.fromEntries(inspection.required.map(item => [item.name, item.parameters])), runtimeSettings })
return
}
updateAgentCommandExecution(messageId, command.id, { status: 'running', output: '실행 전 상태 확인 중' })
let output: unknown
let ok: boolean
if (operation.name === 'practice.prepare') {
output = await prepareAgentPractice({
profileId: operation.profile!, environment, approvedStarts: approval!.starts, approvedParameters: approval!.parameters, signal: controller.signal,
list: readAgentModules,
start: async target => {
if (getDirtyModuleFiles(target.name).length) throw new Error(`${target.name}: 저장하지 않은 파일이 있습니다.`)
let failure: string | undefined
const ok = await runModuleControl(target, 'start', { background: true, freshSlam: true, onResult: result => { if (!result.ok) failure = result.message?.slice(-6000) } })
if (!ok && failure) throw new Error(`${target.name}: ${failure}`)
return ok
},
probe: (target, topics) => executeWorkspaceAgentTool({ id: `verify-${Date.now()}`, name: 'ros.inspect', moduleName: target.name, args: { topics } }),
progress: (text, steps) => updateAgentCommandExecution(messageId, command.id, { status: 'running', output: JSON.stringify({ phase: text, steps }, null, 2) })
})
ok = (output as { ready: boolean }).ready
} else {
let detail: string | undefined
ok = operation.name === 'modules.start' && module.state === 'running'
? true
: await runModuleControl(module, operation.name === 'modules.start' ? 'start' : 'build', { background: true, onResult: result => { detail = result.message?.slice(-6000) } })
output = { ok, operation: operation.name, moduleName: module.name, message: detail, note: operation.name === 'modules.start' ? '프로세스 시작 결과입니다. 센서·TF 정상 여부는 별도 검증이 필요합니다.' : '빌드 결과입니다. 전체 빌드 로그를 수집한 것은 아니며 경고 해소·기능 정상 여부는 별도 검증이 필요합니다.' }
}
assertActive()
const serialized = JSON.stringify(output, null, 2)
updateAgentCommandExecution(messageId, command.id, { status: ok ? 'completed' : 'failed', output: serialized, exitCode: ok ? 0 : 1 })
onTitlebarNotice(operation.name === 'practice.prepare' ? ok ? 'SLAM 실습 준비와 데이터 검증이 완료되었습니다.' : 'SLAM 준비 검증에 실패했습니다. 실행 결과를 확인해주세요.' : ok ? '에이전트 승인 작업이 완료되었습니다.' : '에이전트 승인 작업에 실패했습니다.', ok ? 'info' : 'error', true)
const messageIndex = agentMessages.findIndex(item => item.id === messageId)
const originalRequest = [...agentMessages.slice(0, messageIndex)].reverse().find(item => item.role === 'user')?.content ?? message.plan.summary
const currentModules = await readAgentModules()
assertActive()
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
showUserMessage: false, environment, executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
})
} catch (error) {
const reason = controller.signal.aborted ? '다음 단계 진행을 중단했습니다. 이미 시작된 모듈과 진행 중인 단일 작업은 자동 종료하지 않았습니다.' : error instanceof Error ? error.message : '승인 작업을 처리하지 못했습니다.'
updateAgentCommandExecution(messageId, command.id, { status: 'failed', error: reason, exitCode: null })
onTitlebarNotice(reason, 'error', true)
} finally {
if (agentActionControllerRef.current === controller) { agentActionControllerRef.current = null; setAgentActionBusy(false) }
}
}
const runWorkspaceAgentCommand = async (messageId: string, command: AgentCommand): Promise<void> => {
if (agentActionControllerRef.current || agentPending) return
const message = agentMessages.find((item) => item.id === messageId)
const currentExecution = message?.commandExecutions?.[command.id]
if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
@@ -3701,6 +3806,10 @@ function LocalPcWorkspaceMockup({
onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error')
return
}
if (command.operation) {
await runStructuredAgentCommand(messageId, command)
return
}
const module = availableModules.find((item) => item.name === command.moduleName)
?? (selectedModule?.name === command.moduleName ? selectedModule : null)
@@ -3756,7 +3865,9 @@ function LocalPcWorkspaceMockup({
].filter(Boolean).join('\n')
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], {
showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local'
})
} catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
@@ -3775,6 +3886,13 @@ function LocalPcWorkspaceMockup({
updateAgentCommandExecution(messageId, command.id, { status: 'rejected' })
}
const applyWorkspaceAgentPlan = async (plan: AgentPlan): Promise<void> => {
if (agentActionControllerRef.current || agentPending) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 적용해주세요.')
const editorSnapshot = agentEditorFilesRef.current
for (const change of plan.changes) {
if (plan.createModule && change.moduleName !== plan.createModule.name) throw new Error('새 모듈 생성과 기존 모듈 수정은 별도로 진행해주세요.')
if (getDirtyModuleFiles(change.moduleName).includes(`modules/${change.moduleName}/${change.path}`)) throw new Error(`저장하지 않은 파일입니다: ${change.moduleName}/${change.path}. 먼저 저장하거나 변경을 취소해주세요.`)
if (change.expectedHash === undefined && !plan.createModule) throw new Error('원본 파일 검증 정보가 없습니다. 다시 분석한 변경안을 적용해주세요.')
}
let createdModule: ModuleRow | null = null
if (plan.createModule) {
if (availableModules.some((module) => module.name === plan.createModule?.name)) {
@@ -3810,23 +3928,34 @@ function LocalPcWorkspaceMockup({
if (createdModule?.name === moduleName) return createdModule
return availableModules.find((module) => module.name === moduleName) ?? (selectedModule?.name === moduleName ? selectedModule : null)
}
if (!createdModule && plan.changes.length) {
const changes = plan.changes.map(change => {
const module = resolveModule(change.moduleName)
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
return { ...change, folderName: module.folderName, expectedHash: change.expectedHash! }
})
if (remoteWorkspace) await window.remoteWorkspace.agent.applyFiles(getRemoteWorkspaceTarget(), changes)
else await window.localWorkspace.agent.applyFiles(changes, clonePathPreview)
}
let firstChangedFile: { module: ModuleRow; path: string } | null = null
for (const change of plan.changes) {
const module = resolveModule(change.moduleName)
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
const rendererPath = `modules/${module.name}/${change.path}`
if (remoteWorkspace) {
if (createdModule && remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), module.name, rendererPath, 'file', module.folderName)
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), module.name, rendererPath, change.content, module.folderName)
} else {
} else if (createdModule) {
await window.localWorkspace.moduleFiles.create(module.name, rendererPath, 'file', clonePathPreview, module.folderName)
await window.localWorkspace.moduleFiles.write(module.name, rendererPath, change.content, clonePathPreview, module.folderName)
}
setEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
const editedDuringApply = agentEditorFilesRef.current[rendererPath] !== editorSnapshot[rendererPath]
setEditorFiles((files) => ({ ...files, [rendererPath]: files[rendererPath] !== editorSnapshot[rendererPath] ? files[rendererPath] : change.content }))
setSavedEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
setDirtyEditorFiles((files) => {
const next = { ...files }
delete next[rendererPath]
if (editedDuringApply) next[rendererPath] = true
else delete next[rendererPath]
return next
})
if (!firstChangedFile) firstChangedFile = { module, path: rendererPath }
@@ -4410,6 +4539,7 @@ function LocalPcWorkspaceMockup({
/>
<WorkspaceEditorPane
editingLocked={agentApplying}
fileLoading={remoteEditorFile.loading}
fileError={remoteEditorFile.error}
onRetryFile={remoteEditorFile.retry}
@@ -4430,7 +4560,7 @@ function LocalPcWorkspaceMockup({
beforeMount={configureMonacoTheme}
onMount={handleEditorMount}
onChange={(value) => {
if (!currentActiveEditorFile) return
if (!currentActiveEditorFile || agentApplying) return
updateEditorFileContent(currentActiveEditorFile, value ?? '', exampleActive)
}}
/>
@@ -4583,11 +4713,13 @@ function LocalPcWorkspaceMockup({
agentEnabled={agentEnabled}
agentStatusLoading={agentStatusLoading}
agentPending={agentPending}
agentActivity={agentActivity}
agentActionBusy={agentActionBusy || agentApplying}
agentPersistenceError={agentPersistenceError}
aiPending={aiPending}
teacherThreadStatus={teacherThreadStatus}
qaCollapseSignal={qaCollapseSignal}
inputBusy={qaMode === 'agent' ? agentPending : aiPending || Boolean(aiTypingMessageId)}
inputBusy={qaMode === 'agent' ? agentPending || agentActionBusy || agentApplying : aiPending || Boolean(aiTypingMessageId)}
inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))}
workspaceChatScrollRef={workspaceChatScrollRef}
onMinimize={() => setRightPanelMinimized(true)}
@@ -4595,7 +4727,7 @@ function LocalPcWorkspaceMockup({
setQaMode(mode)
if (mode === 'teacher') setTeacherChatFlash(false)
}}
onStop={qaMode === 'agent' ? stopAgent : stopAiResponse}
onStop={qaMode === 'agent' ? () => { agentActionControllerRef.current?.abort(); stopAgent() } : stopAiResponse}
onSubmit={(content, attachments) => {
if (qaMode === 'agent') void submitAgentRequest(content, attachments)
else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
@@ -4617,6 +4749,24 @@ function LocalPcWorkspaceMockup({
})}
/>
{agentPracticeApproval && (
<ConfirmStatusModal
dark={dark}
overlayClassName="fixed inset-0 z-[200] grid place-items-center bg-black/65 p-5"
icon={<Monitor size={18} className="text-[#2563eb]" />}
title="SLAM 실습 준비"
message={`시작할 모듈: ${agentPracticeApproval.starts.join(', ') || '없음 (이미 실행 중)'}`}
detail={`검증 대상: ${agentPracticeApproval.modules.join(', ')}. 순서대로 시작한 뒤 센서·지도·TF를 확인합니다. 차량 이동 명령은 보내지 않습니다. 실패해도 이미 시작된 모듈은 자동 종료하지 않습니다.`}
confirmLabel="승인하고 준비"
onCancel={() => setAgentPracticeApproval(null)}
onConfirm={() => {
const approval = agentPracticeApproval
setAgentPracticeApproval(null)
void runStructuredAgentCommand(approval.messageId, approval.command, approval)
}}
/>
)}
{workspaceRestoreLoading && (
<PlatformLoadingOverlay
title={t("{{v0}} 화면 불러오는 중", { v0: platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName })}
+54 -13
View File
@@ -59,23 +59,24 @@ export function ChatAvatar({
)
}
export function MarkdownMessage({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
export function MarkdownMessage({ content, dark, fadeFrom }: { content: string; dark: boolean; fadeFrom?: number }): React.JSX.Element {
useLocaleRender()
const textColor = dark ? 'text-white/76' : 'text-[#4f596b]'
const headingColor = dark ? 'text-white/92' : 'text-[#111827]'
const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]'
const codeBg = dark ? 'bg-[#171717] text-white/82' : 'bg-[#f1f5f9] text-[#334155]'
const fadeClass = (offset?: number): string => fadeFrom !== undefined && offset !== undefined && offset >= fadeFrom ? ' agent-response-fade' : ''
return (
<div className={`min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [overflow-wrap:anywhere] [word-break:break-word] ${textColor}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h1>,
h2: ({ children }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h2>,
h3: ({ children }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h3>,
h4: ({ children }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h4>,
p: ({ children }) => <p className="my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]">{children}</p>,
h1: ({ children, node }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h1>,
h2: ({ children, node }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h2>,
h3: ({ children, node }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h3>,
h4: ({ children, node }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h4>,
p: ({ children, node }) => <p className={`my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</p>,
strong: ({ children }) => <strong className={`font-black ${headingColor}`}>{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
a: ({ children, href }) => (
@@ -91,22 +92,22 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
),
ul: ({ children }) => <ul className="my-2 grid gap-1 pl-4">{children}</ul>,
ol: ({ children }) => <ol className="my-2 grid gap-1 pl-4">{children}</ol>,
li: ({ children }) => <li className="max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]">{children}</li>,
blockquote: ({ children }) => (
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}`}>
li: ({ children, node }) => <li className={`max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</li>,
blockquote: ({ children, node }) => (
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}${fadeClass(node?.position?.start.offset)}`}>
{children}
</blockquote>
),
hr: () => <hr className={`my-3 ${mutedBorder}`} />,
pre: ({ children }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}`}>{children}</pre>,
hr: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />,
pre: ({ children, node }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}${fadeClass(node?.position?.start.offset)}`}>{children}</pre>,
code: ({ children, className }) => {
if (className) {
return <code className="whitespace-pre-wrap break-words font-mono [overflow-wrap:anywhere]">{children}</code>
}
return <code className={`rounded px-1 py-0.5 font-mono text-[11px] ${dark ? 'bg-white/10 text-[#dbeafe]' : 'bg-[#eef2ff] text-[#1d4ed8]'}`}>{children}</code>
},
table: ({ children }) => (
<div className="my-2 max-w-full overflow-hidden">
table: ({ children, node }) => (
<div className={`my-2 max-w-full overflow-hidden${fadeClass(node?.position?.start.offset)}`}>
<table className={`w-full table-fixed border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
</div>
),
@@ -121,6 +122,33 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
)
}
export function ChatEmptyState({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<div className={['relative mx-4 min-w-0 max-w-full break-words rounded-lg border px-8 py-5', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')}>
<span>{children}</span>
<span aria-hidden="true" className={['absolute -bottom-[5px] left-7 size-2 rotate-45 border-b border-r', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')} />
</div>
</div>
)
}
export function ChatResponseSkeleton({ dark, label }: { dark: boolean; label: string }): React.JSX.Element {
return (
<div
role="status"
aria-label={label}
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
>
<div className="grid animate-pulse gap-2 motion-reduce:animate-none" aria-hidden="true">
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
</div>
</div>
)
}
export function ExpandableMarkdownMessage({
content,
dark,
@@ -167,6 +195,7 @@ export function ExpandableMarkdownMessage({
<button
type="button"
aria-label={expanded ? t("접기") : t("자세히 보기")}
aria-expanded={expanded}
title={expanded ? t("접기") : t("자세히 보기")}
onClick={() => setExpanded((value) => !value)}
className={['grid h-7 w-full cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white/80' : 'text-[#64748b] hover:bg-[#e2e8f0]/70 hover:text-[#2563eb]'].join(' ')}
@@ -296,6 +325,18 @@ export const QaInputBox = memo(function QaInputBox({
autoCorrect="off"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onContextMenu={(event) => {
if (!window.chatTextMenu) return
event.preventDefault()
event.stopPropagation()
const input = event.currentTarget
void window.chatTextMenu.show({
kind: 'input',
canCopy: input.selectionEnd > input.selectionStart,
canPaste: !input.disabled && !input.readOnly,
labels: { copy: t('복사'), paste: t('붙여넣기') }
}).catch((error) => console.error('[chat] input menu failed', error))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
@@ -0,0 +1,80 @@
import type React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { ExpandableMarkdownMessage, MarkdownMessage } from './ChatWidgets'
import { AGENT_REVEAL_INTERVAL, agentRevealFrame, createAgentRevealDocument } from './agentResponseReveal'
type ResponseMessage = {
content: string
revealStartedAt?: number
plan?: { summary: string; explanation: string }
provider?: string
model?: string
}
export function ProgressiveChatResponse({ message, dark, children, footerAction, collapseOnSignal = false, collapseSignal = 0, defaultCollapsed = message.revealStartedAt === undefined }: {
message: ResponseMessage
dark: boolean
children?: React.ReactNode
footerAction?: React.ReactNode
collapseOnSignal?: boolean
collapseSignal?: number
defaultCollapsed?: boolean
}): React.JSX.Element {
const content = message.plan?.explanation ?? message.content
const modelLabel = [message.provider, message.model].filter(Boolean).join(' · ')
const document = useMemo(() => createAgentRevealDocument(content), [content])
const [now, setNow] = useState(Date.now)
const [reducedMotion, setReducedMotion] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
const frame = agentRevealFrame(document, message.revealStartedAt, now, reducedMotion)
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = (): void => setReducedMotion(media.matches)
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
useEffect(() => {
if (message.revealStartedAt === undefined || reducedMotion) return
let timer: ReturnType<typeof setTimeout> | undefined
const startedAt = message.revealStartedAt
const tick = (): void => {
const current = Date.now()
setNow(current)
const remaining = frame.duration - (current - startedAt)
if (remaining > 0) timer = setTimeout(tick, Math.min(AGENT_REVEAL_INTERVAL, remaining))
}
tick()
return () => clearTimeout(timer)
}, [frame.duration, message.revealStartedAt, reducedMotion])
return (
<div className="grid gap-3" aria-busy={!frame.complete}>
<div>
{message.plan && (
<div className={['font-black', frame.fadeFrom === 0 ? 'agent-response-fade' : ''].join(' ')}>
<MarkdownMessage content={message.plan.summary} dark={dark} />
</div>
)}
<div className={message.plan ? 'mt-1' : ''}>
{frame.complete && frame.fadeFrom === undefined ? (
<ExpandableMarkdownMessage
content={content}
dark={dark}
defaultCollapsed={defaultCollapsed}
collapseOnSignal={collapseOnSignal}
collapseSignal={collapseSignal}
/>
) : <MarkdownMessage content={frame.content} dark={dark} fadeFrom={frame.fadeFrom} />}
</div>
</div>
{frame.complete ? children : null}
{frame.complete && (modelLabel || footerAction) ? (
<div className="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2">
<p title={modelLabel || undefined} className={['min-w-0 flex-1 truncate text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{modelLabel}</p>
{footerAction}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,45 @@
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
const parser = unified().use(remarkParse).use(remarkGfm)
export const AGENT_REVEAL_INTERVAL = 240
export const AGENT_REVEAL_FADE_DURATION = 220
export function createAgentRevealDocument(content: string) {
const tree = parser.parse(content)
const offsets: number[] = []
const definitions: string[] = []
for (const node of tree.children) {
if (node.type === 'definition' || node.type === 'footnoteDefinition') {
definitions.push(content.slice(node.position?.start.offset, node.position?.end.offset))
continue
}
const blocks = node.type === 'list' ? node.children : [node]
for (const block of blocks) {
if (block.position?.end.offset !== undefined) offsets.push(block.position.end.offset)
}
}
// Group unusually long answers without splitting Markdown blocks or list items.
const groupSize = Math.max(1, Math.ceil(offsets.length / 20))
const boundaries = offsets.filter((_, index) => (index + 1) % groupSize === 0 || index === offsets.length - 1)
if (boundaries.length) boundaries[boundaries.length - 1] = content.length
else boundaries.push(content.length)
return { content, boundaries, definitions: definitions.join('\n\n') }
}
export function agentRevealFrame(document: ReturnType<typeof createAgentRevealDocument>, startedAt: number | undefined, now: number, reducedMotion = false) {
const elapsed = startedAt === undefined ? Infinity : Math.max(0, now - startedAt)
const visible = reducedMotion ? document.boundaries.length : Math.min(document.boundaries.length, 1 + Math.floor(elapsed / AGENT_REVEAL_INTERVAL))
const complete = visible === document.boundaries.length
const end = document.boundaries[visible - 1]
const fadeFrom = !reducedMotion && elapsed < (visible - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
? (visible > 1 ? document.boundaries[visible - 2] : 0)
: undefined
return {
content: complete ? document.content : document.content.slice(0, end) + (document.definitions ? `\n\n${document.definitions}` : ''),
fadeFrom,
complete,
duration: (document.boundaries.length - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
}
}
+7
View File
@@ -67,6 +67,9 @@ interface Window {
openDevTools: () => Promise<{ opened: boolean; message?: string }>
close: () => void
}
chatTextMenu?: {
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
}
appDiagnostics?: {
read: () => Promise<{ path: string; content: string }>
}
@@ -92,6 +95,8 @@ interface Window {
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
}
agent: {
readTool: (request: import('../../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
applyFiles: (changes: import('../../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => Promise<{ ok: boolean }>
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => Promise<{
ok: boolean
output: string
@@ -320,6 +325,8 @@ interface Window {
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
}
agent: {
readTool: (target: RemoteWorkspaceTarget, request: import('../../shared/agentTools').AgentToolRequest, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../../shared/agentTools').AgentCheckedChange[]) => Promise<{ ok: boolean }>
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ReturnType<Window['agent']['runCommand']>
}
maps: {
@@ -3,6 +3,7 @@ import Editor, { DiffEditor, type BeforeMount, type OnMount } from '@monaco-edit
import { FileText, Loader2, RotateCw } from 'lucide-react'
type Props = {
editingLocked?: boolean
fileLoading?: boolean
fileError?: string
onRetryFile?: () => void
@@ -30,6 +31,7 @@ type Props = {
}
function WorkspaceEditorPane({
editingLocked = false,
fileLoading,
fileError,
onRetryFile,
@@ -66,6 +68,10 @@ function WorkspaceEditorPane({
language={editorLanguage}
theme={dark ? 'ads-ide-dark' : 'ads-ide-light'}
beforeMount={beforeMount}
onMount={(editor) => {
editor.getOriginalEditor().getModel()?.updateOptions({ tabSize })
editor.getModifiedEditor().getModel()?.updateOptions({ tabSize })
}}
options={{
automaticLayout: true,
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
@@ -75,7 +81,6 @@ function WorkspaceEditorPane({
readOnly: true,
renderSideBySide: true,
scrollBeyondLastLine: false,
tabSize,
wordWrap: wordWrap ? 'on' : 'off'
}}
/>
@@ -136,7 +141,7 @@ function WorkspaceEditorPane({
scrollBeyondLastLine: false,
tabSize,
wordWrap: wordWrap ? 'on' : 'off',
readOnly: exampleActive || editorValue === 'Binary or large file preview is not available.'
readOnly: editingLocked || exampleActive || editorValue === 'Binary or large file preview is not available.'
}}
/>
)}
@@ -1,6 +1,7 @@
import { t, useLocaleRender } from '../../i18n'
import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react'
import type React from 'react'
import { useId } from 'react'
export type WorkspaceGuideResourceMode = 'material' | 'reference'
@@ -97,6 +98,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
onDownloadResource
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
useLocaleRender()
const panelId = useId()
const textDim = dark ? inheritedTextDim : 'text-[#475569]'
return (
<aside
@@ -147,36 +149,35 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
const canToggle = allowFutureMaterialSteps || previous || active
const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl)
const youtubeVideoId = getYoutubeVideoId(item.videoUrl)
const contentId = `${panelId}-material-${index}`
return (
<section
key={`${item.title}-${index}`}
role={canToggle ? 'button' : undefined}
tabIndex={canToggle ? 0 : undefined}
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
onKeyDown={canToggle ? (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onToggleMaterial(index)
} : undefined}
className={[
'rounded-lg border transition',
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3',
canToggle ? 'cursor-pointer' : '',
'rounded-lg border px-3 pt-3 transition',
expanded ? 'pb-4' : 'pb-3',
active
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white'
: canToggle
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#c5cfdd] bg-white hover:border-[#9cacc2] hover:bg-[#f8fafc]'
? dark ? 'border-white/12 bg-[#292929] opacity-92' : 'border-[#c5cfdd] bg-white'
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-white'
].join(' ')}
>
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
<button
type="button"
disabled={!canToggle}
aria-expanded={expanded}
aria-controls={contentId}
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
className={['flex h-6 w-full min-w-0 items-center gap-2.5 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-[#60a5fa] disabled:cursor-default', canToggle ? 'cursor-pointer' : ''].join(' ')}
>
<span className={[
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
active ? dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#ddd0fa] text-[#5b21b6]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-[#cbd5e1] text-[#334155]' : dark ? 'bg-white/8 text-white/38' : 'bg-[#d5dbe5] text-[#64748b]'
].join(' ')}><span className="relative top-px leading-none">{index + 1}</span></span>
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="block min-w-0 flex-1">
<span className="flex min-w-0 items-center justify-between gap-2">
<strong title={item.title} className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
<span className="flex shrink-0 items-center gap-1.5">
{active && (
@@ -193,55 +194,55 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
/>
)}
</span>
</div>
{expanded && (
<div className="mt-2.5 grid gap-2.5">
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
{youtubeEmbedUrl && youtubeVideoId && (
<button
type="button"
className={['group relative aspect-video w-full cursor-pointer overflow-hidden rounded-md border bg-black text-left', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')}
onClick={(event) => {
event.stopPropagation()
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title }))
}}
>
<img
src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
alt=""
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
loading="lazy"
/>
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
<span className="absolute inset-0 grid place-items-center">
<span className="grid h-11 w-11 place-items-center rounded-full bg-white/92 text-[#2563eb] shadow-[0_14px_34px_rgba(0,0,0,0.32)] transition group-hover:scale-105">
<Play size={22} fill="currentColor" />
</span>
</span>
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span>
</button>
)}
<section>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
{item.description || topicDetails[index] || overview}
</p>
</section>
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("핵심 개념")}</span>
<div className="mt-2 grid gap-1.5">
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
<div key={concept} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#2563eb]" />
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{concept}</span>
</div>
))}
</div>
</section>
</div>
</span>
</span>
</button>
{expanded && (
<div id={contentId} className="ml-[34px] mt-2.5 grid select-text gap-2.5">
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
{youtubeEmbedUrl && youtubeVideoId && (
<button
type="button"
className={['group relative aspect-video w-full cursor-pointer overflow-hidden rounded-md border bg-black text-left', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')}
onClick={(event) => {
event.stopPropagation()
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title }))
}}
>
<img
src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
alt=""
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
loading="lazy"
/>
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
<span className="absolute inset-0 grid place-items-center">
<span className="grid h-11 w-11 place-items-center rounded-full bg-white/92 text-[#2563eb] shadow-[0_14px_34px_rgba(0,0,0,0.32)] transition group-hover:scale-105">
<Play size={22} fill="currentColor" />
</span>
</span>
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span>
</button>
)}
<section>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
{item.description || topicDetails[index] || overview}
</p>
</section>
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("핵심 개념")}</span>
<div className="mt-2 grid gap-1.5">
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
<div key={concept} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#2563eb]" />
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{concept}</span>
</div>
))}
</div>
</section>
</div>
</div>
)}
</section>
)
}) : (
@@ -4,7 +4,8 @@ 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 { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments'
@@ -16,6 +17,7 @@ type WorkspaceQaMessage = {
content: string
senderAvatarUrl?: string | null
isTyping?: boolean
revealStartedAt?: number
keepExpanded?: boolean
}
@@ -39,6 +41,8 @@ type WorkspaceQaPanelProps = {
agentEnabled: boolean
agentStatusLoading: boolean
agentPending: boolean
agentActivity?: string
agentActionBusy?: boolean
agentPersistenceError?: string | null
aiPending: boolean
teacherThreadStatus: string | null
@@ -74,7 +78,7 @@ function MessageCopyButton({ content, dark }: { content: string; dark: boolean }
aria-label={copied ? t("복사됨") : t("복사")}
onClick={copy}
className={[
'absolute bottom-2 right-2 z-10 grid h-6 w-6 place-items-center rounded opacity-0 transition group-hover:opacity-100 focus:opacity-100',
'grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded opacity-0 transition group-hover:opacity-100 focus-visible:opacity-100',
dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#64748b] hover:bg-[#ede9fe] hover:text-[#1e293b]'
].join(' ')}
>
@@ -108,6 +112,8 @@ export default function WorkspaceQaPanel({
agentEnabled,
agentStatusLoading,
agentPending,
agentActivity,
agentActionBusy,
agentPersistenceError,
aiPending,
teacherThreadStatus,
@@ -126,7 +132,7 @@ export default function WorkspaceQaPanel({
onRejectAgentCommand
}: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null)
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1]
@@ -207,10 +213,25 @@ export default function WorkspaceQaPanel({
])
const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
const target = event.target as HTMLElement | null
if (target?.closest('[data-qa-message="true"]')) return
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY })
event.stopPropagation()
const selection = window.getSelection()
const text = selection?.anchorNode && selection.focusNode && event.currentTarget.contains(selection.anchorNode) && event.currentTarget.contains(selection.focusNode)
? selection.toString()
: ''
if (window.chatTextMenu) {
setContextMenu(null)
void window.chatTextMenu.show({
kind: 'message',
canCopy: text.length > 0,
canClear: !clearDisabled,
labels: { copy: t('복사'), clear: t('대화내역 삭제') }
}).then((action) => {
if (action === 'clear') requestClearConversation()
}).catch((error) => console.error('[chat] message menu failed', error))
return
}
setContextMenu({ x: event.clientX, y: event.clientY, text })
}
return (
@@ -296,6 +317,8 @@ export default function WorkspaceQaPanel({
enabled={agentEnabled}
statusLoading={agentStatusLoading}
pending={agentPending}
activity={agentActivity}
busy={agentPending || agentActionBusy}
messages={agentMessages}
onApply={onApplyAgentPlan}
onOpenChange={onOpenAgentChange}
@@ -316,8 +339,19 @@ export default function WorkspaceQaPanel({
<div
className={['fixed z-[140] min-w-[150px] overflow-hidden rounded-md border py-1 shadow-[0_18px_42px_rgba(0,0,0,0.28)]', dark ? 'border-white/10 bg-[#2b2b2b]' : 'border-[#d5dbe7] bg-white'].join(' ')}
style={{ left: contextMenu.x, top: contextMenu.y }}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
disabled={!contextMenu.text}
className={['flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] font-semibold transition disabled:cursor-default disabled:opacity-35', dark ? 'text-white/78 enabled:hover:bg-white/10' : 'text-[#334155] enabled:hover:bg-[#f1f5f9]'].join(' ')}
onClick={() => {
const text = contextMenu.text
setContextMenu(null)
if (text) void navigator.clipboard.writeText(text).catch((error) => console.error('[chat] copy failed', error))
}}
><Copy size={13} />{t('복사')}</button>
<button
type="button"
disabled={clearDisabled}
@@ -362,6 +396,8 @@ function AgentMessages({
enabled,
statusLoading,
pending,
activity,
busy,
messages,
onApply,
onOpenChange,
@@ -372,6 +408,8 @@ function AgentMessages({
enabled: boolean
statusLoading: boolean
pending: boolean
activity?: string
busy?: boolean
messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void
onOpenChange: (messageId: string, change: AgentFileChange) => void
@@ -390,23 +428,23 @@ function AgentMessages({
if (!enabled) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<ChatEmptyState dark={dark}>
{t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")}
</div>
</ChatEmptyState>
)
}
if (!messages.length && !pending) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<ChatEmptyState dark={dark}>
{t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}<br />{t("변경 내용은 확인 후 직접 적용합니다.")}
</div>
</ChatEmptyState>
)
}
return (
<div className="grid gap-3">
{messages.map((message) => message.role === 'user' ? (
{messages.map((message, index) => message.role === 'user' ? (
<div key={message.id} data-qa-message="true" className="flex justify-end">
<div className="max-w-[82%] break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
</div>
@@ -414,18 +452,22 @@ function AgentMessages({
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div className="group relative min-w-0 max-w-[calc(100%-38px)]">
<div className={['overflow-hidden rounded-lg border px-3 pb-9 pt-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
{message.plan ? (
<div className="grid gap-3">
<div>
<div className="font-black">
<MarkdownMessage content={message.plan.summary} dark={dark} />
</div>
<div className="mt-1">
<MarkdownMessage content={message.plan.explanation} dark={dark} />
</div>
{message.provider && message.model ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{message.provider} · {message.model}</p> : null}
</div>
<div className={['overflow-hidden rounded-lg border px-3 py-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
{message.plan && (
<>
{!!message.plan.evidence?.length && (
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
{message.plan.evidence.map(result => (
<details key={result.id} className="mt-2 min-w-0 border-t border-current/15 pt-2">
<summary className="cursor-pointer break-words">{result.moduleName ? `${result.moduleName} · ` : ''}{result.name} · {result.status === 'ok' ? t("조회 완료") : t("조회 실패")}{result.truncated ? ` · ${t("일부 결과")}` : ''}</summary>
<p className="mt-1 break-all">{result.target} · {new Date(result.observedAt).toLocaleTimeString()}</p>
<pre className="thin-scrollbar mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px]">{result.data}</pre>
</details>
))}
</details>
)}
{message.plan.actionPlan.length > 0 ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{(() => {
@@ -457,10 +499,10 @@ function AgentMessages({
</div>
) : null}
{message.plan.commands.length > 0 ? (
<div className={['order-5 grid gap-3 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
<fieldset disabled={busy} className={['order-5 m-0 grid min-w-0 gap-3 border-0 border-t p-0 pt-2 disabled:cursor-wait', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{message.plan.commands.map((command) => {
const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const }
const moduleStartAction = command.command.trim() === 'lms module start'
const moduleStartAction = command.command.trim() === 'lms module start' || command.operation?.name === 'modules.start'
const finished = execution.status === 'completed' || execution.status === 'failed'
const policyRejected = execution.status === 'rejected'
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
@@ -507,7 +549,7 @@ function AgentMessages({
</div>
)
})}
</div>
</fieldset>
) : null}
{message.plan.createModule ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
@@ -541,7 +583,7 @@ function AgentMessages({
{message.plan.changes.length > 0 || message.plan.createModule ? (
<button
type="button"
disabled={message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
onClick={() => onApply(message.id, message.plan!)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
>
@@ -549,26 +591,19 @@ function AgentMessages({
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button>
) : null}
</div>
) : <p className="whitespace-pre-wrap">{message.content}</p>}
</>
)}
</ProgressiveChatResponse>
</div>
<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />
</div>
</div>
))}
{pending ? (
<div data-qa-message="true" className="flex gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div
role="status"
aria-label={t("코드 변경안을 만드는 중입니다.")}
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
>
<div className="grid animate-pulse gap-2" aria-hidden="true">
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
</div>
<div className="min-w-0 flex-1">
{activity ? <p role="status" className={['mb-2 break-words text-[11px]', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>{activity}</p> : null}
<ChatResponseSkeleton dark={dark} label={t("코드 변경안을 만드는 중입니다.")} />
</div>
</div>
) : null}
@@ -590,9 +625,9 @@ function AiMessages({
useLocaleRender()
if (!messages.length && !pending) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<span>{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}</span>
</div>
<ChatEmptyState dark={dark}>
{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}
</ChatEmptyState>
)
}
@@ -615,16 +650,13 @@ function AiMessages({
<Bot size={15} />
</span>
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg border px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'border-transparent bg-[#303030] text-white/75' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#334155]'].join(' ')}>
<ExpandableMarkdownMessage
<ProgressiveChatResponse
collapseOnSignal={index < messages.length - 1}
collapseSignal={collapseSignal}
content={message.content}
message={message}
dark={dark}
defaultCollapsed={!message.isTyping && !message.keepExpanded}
defaultCollapsed={!message.keepExpanded}
/>
{message.isTyping && (
<span className={['mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full', dark ? 'bg-white/65' : 'bg-[#2563eb]'].join(' ')} />
)}
</div>
</div>
)
@@ -634,10 +666,7 @@ function AiMessages({
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
<Bot size={15} />
</span>
<div className={['inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-[12px] font-medium', dark ? 'border-transparent bg-[#303030] text-white/55' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#475569]'].join(' ')}>
<span>{t("답변을 작성 중입니다...")}</span>
<span className={['h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
</div>
<ChatResponseSkeleton dark={dark} label={t("답변을 작성 중입니다...")} />
</div>
)}
</div>
@@ -660,9 +689,9 @@ function TeacherMessages({
useLocaleRender()
if (!messages.length) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<span>{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}</span>
</div>
<ChatEmptyState dark={dark}>
{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}
</ChatEmptyState>
)
}
@@ -1,6 +1,7 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[] }
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] }
export type AgentContextRequest = { modules: string[]; logs: string[] }
export type AgentPlanResponse = {
provider: string
@@ -9,52 +10,195 @@ export type AgentPlanResponse = {
plan: AgentPlan
}
export function recoverInterruptedAgentMessages(messages: AgentConversationMessage[]): AgentConversationMessage[] {
let changed = false
const recovered = messages.map(message => {
const interrupted = Object.entries(message.commandExecutions ?? {}).some(([, execution]) => execution.status === 'running')
if (!interrupted && message.applyState !== 'applying') return message
changed = true
const error = '이전 작업의 완료 결과를 확인할 수 없습니다. 현재 모듈·파일 상태를 다시 조회한 뒤 진행해주세요.'
return { ...message, ...(message.applyState === 'applying' ? { applyState: 'error' as const, applyError: error } : {}), commandExecutions: Object.fromEntries(Object.entries(message.commandExecutions ?? {}).map(([id, execution]) => [id, execution.status === 'running' ? { ...execution, status: 'failed' as const, error } : execution])) }
})
return changed ? recovered : messages
}
export function agentPlanRequestText(request: string, finalize = false): string {
if (!finalize) return request
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
return instruction + request.slice(0, 4000 - instruction.length)
}
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext {
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
const selected = new Map<string, AgentWorkspaceModule['files']>()
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
let fileCount = 0
let characters = 0
// Interleave complete files so a large module cannot consume every file slot.
while (fileCount < 24) {
let added = false
for (const candidate of candidates) {
while (candidate.index < candidate.files.length && fileCount < 24) {
const file = candidate.files[candidate.index++]
const size = file.path.length + file.content.length
if (file.path.length > 500 || file.content.length > 100_000 || characters + size > 160_000) continue
const files = selected.get(candidate.module.name) ?? []
files.push(file)
selected.set(candidate.module.name, files)
characters += size
fileCount += 1
added = true
break
}
}
if (!added) break
}
const modules = context.modules.map((module) => {
const files = selected.get(module.name) ?? []
if (files.length === module.files.length) return module
const note = `요청 용량 제한으로 전체 ${module.files.length}개 중 ${files.length}개 파일만 제공했습니다. 제외된 파일은 확인한 것으로 간주하지 말고 제공된 근거로 답하거나 필요한 자료를 구체적으로 안내하세요.`
return {
...module,
files,
fileContext: {
status: 'limited' as const,
message: `${note} ${module.fileContext?.message ?? ''}`.trim().slice(0, 500)
}
}
})
const logOrder = [...new Set([...preferredLogs, ...context.runtimeLogs.map((log) => log.moduleName).reverse()])]
const logMap = new Map(context.runtimeLogs.map((log) => [log.moduleName, log]))
const retainedLogs = logOrder.flatMap((name) => logMap.has(name) ? [logMap.get(name)!] : []).slice(0, 3)
const omitted = context.runtimeLogs.filter((log) => !retainedLogs.includes(log)).map((log) => log.moduleName)
const runtimeLogs = retainedLogs.map((log, index) => {
let notes = [
log.message,
index === 0 && omitted.length ? `로그 모듈 한도로 이전 로그 제외: ${omitted.join(', ')}. 제외된 로그를 확인했다고 주장하지 마세요.` : ''
].filter(Boolean).join(' ').slice(0, 500)
if (log.content.length + notes.length > 20_000) {
notes = `로그 용량 제한으로 최근 구간만 제공합니다. ${notes}`.trim().slice(0, 500)
}
// The server counts both log content and messages against its 60,000-character cap.
const content = log.content.slice(-(20_000 - notes.length))
return { ...log, content, ...(notes ? { message: notes } : {}) }
})
let toolSize = 0
const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => {
if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false
toolSize += result.data.length
return true
}).slice(0, 12).reverse()
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) }
}
export async function resolveAgentContext({
initialContext,
requestPlan,
loadRequestedContext,
executeTool,
onProgress,
signal
}: {
initialContext: AgentContext
requestPlan: (context: AgentContext) => Promise<AgentPlanResponse>
requestPlan: (context: AgentContext, finalize?: boolean) => Promise<AgentPlanResponse>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
onProgress?: (label: string) => void
signal: AbortSignal
}): Promise<AgentPlanResponse> {
let context = initialContext
const requestedModules = new Set<string>()
const requestedLogs = new Set<string>()
let cachedContext = initialContext
let context = fitAgentContext(cachedContext)
const requestedModules = new Set(initialContext.modules.filter((module) => module.files.length || module.fileContext).map((module) => module.name))
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
const queriedTools = new Set<string>()
let toolCount = 0
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
const usage: NonNullable<AgentConversationMessage['usage']> = {}
const checkAborted = (): void => {
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()
const addUsage = (response: AgentPlanResponse): void => {
for (const key of ['promptTokens', 'completionTokens', 'totalTokens'] as const) {
if (response.usage?.[key] !== undefined) usage[key] = (usage[key] ?? 0) + response.usage[key]
}
}
const finalize = async (): Promise<AgentPlanResponse> => {
checkAborted()
const response = await requestPlan(context, true)
checkAborted()
addUsage(response)
const stillRequesting = Boolean(response.plan.requestedModules?.length || response.plan.requestedLogs?.length || response.plan.toolRequests?.length)
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
)).join('\n').slice(0, 8000)
const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000)
const explanation = stillRequesting
? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`
: response.plan.explanation
return {
...response,
usage,
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
plan: {
...response.plan,
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
explanation,
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
}
}
}
const maxRounds = executeTool ? 8 : 5
for (let round = 0; round <= maxRounds; round += 1) {
checkAborted()
const response = await requestPlan(context)
checkAborted()
addUsage(response)
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 tools = response.plan.toolRequests ?? []
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
if (round === maxRounds) return finalize()
if (tools.length) {
if (!executeTool || tools.length > 6) return finalize()
const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool)))
if (!fresh.length || toolCount + fresh.length > 24) return finalize()
const results = [...(cachedContext.toolResults ?? [])]
for (const [index, tool] of fresh.entries()) {
checkAborted()
const key = fingerprint(tool)
if (queriedTools.has(key)) continue
queriedTools.add(key)
onProgress?.(`${tool.moduleName ? `${tool.moduleName} · ` : ''}${tool.name} 조회 중`)
const result = await executeTool({ ...tool, id: `query-${round}-${index}-${tool.id}`.slice(0, 80) })
checkAborted()
toolCount++
results.push(result)
}
cachedContext = { ...cachedContext, toolResults: results }
context = fitAgentContext(cachedContext)
onProgress?.('조회 결과 분석 중')
// A model may also use legacy source requests; process those on the next pass.
continue
}
if (!loadRequestedContext) return finalize()
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(', ')}). 해당 모듈의 설치·파일 조회 상태를 확인하고 다시 요청해주세요.`)
return finalize()
}
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 mergedModules = cachedContext.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.
@@ -63,14 +207,13 @@ export async function resolveAgentContext({
: 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()] }
const mergedLogs = new Map(cachedContext.runtimeLogs.map((log) => [log.moduleName, log]))
loaded.runtimeLogs.forEach((log) => {
mergedLogs.delete(log.moduleName)
mergedLogs.set(log.moduleName, log)
})
cachedContext = { ...cachedContext, modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
context = fitAgentContext(cachedContext, nextRequest.modules, nextRequest.logs)
}
throw new Error('AI 분석을 완료하지 못했습니다.')
}
@@ -0,0 +1,132 @@
import type { AgentToolResult } from '../../../../shared/agentTools'
import type { ModuleRow } from './types'
// The same module sequences are documented in the workspace module guide.
export const AGENT_PRACTICE_PROFILES = [
{ id: 'local-2d', environment: 'local', platform: 'turtlebot3', level: '2d', modules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
{ id: 'mkmini-2d', environment: 'remote', platform: 'mkmini', level: '2d', modules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
{ id: 'mkmini-3d', environment: 'remote', platform: 'mkmini', level: '3d', modules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'], topics: ['/lidar_filtered', '/odom', '/cloud_map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
{ id: 'yahboom-2d', environment: 'remote', platform: 'yahboom', level: '2d', modules: ['NAV2-Yahboom-Agent', 'NAV2-Yahboom', 'NAV2-Yahboom-Slam'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] }
] as const
export function inspectAgentPractice(profileId: string, modules: ModuleRow[], environment: 'local' | 'remote') {
const profile = AGENT_PRACTICE_PROFILES.find(item => item.id === profileId)
if (!profile) throw new Error('지원하지 않는 실습 구성입니다.')
const blockers: string[] = []
if (profile.environment !== environment) blockers.push(`현재 ${environment} 환경에서는 ${profile.id} 구성을 실행할 수 없습니다.`)
const required = profile.modules.map(name => {
const candidates = modules.filter(item => item.name === name)
const module = candidates[0]
if (candidates.length > 1) blockers.push(`${name}: 같은 이름의 모듈이 여러 개입니다.`)
if (!module || module.state === 'uninstalled') blockers.push(`${name}: 필수 모듈이 설치되지 않았습니다.`)
if (module) {
const params = new Map(module.parameters.map(parameter => {
const separator = parameter.indexOf(':=')
return [parameter.slice(0, separator), parameter.slice(separator + 2)]
}))
const expectedMode = environment === 'local' ? 'true' : 'false'
for (const [key, expected] of [['sim_mode', expectedMode], ['platform', profile.platform], ['robot', profile.platform], ['level', profile.level]]) {
if (params.has(key) && params.get(key) !== expected) blockers.push(`${name}: ${key}:=${params.get(key)} (필요 값: ${expected})`)
}
// Never guess hardware/simulation mode or start localization on an existing map.
if ((module.category.toLowerCase() === 'vehicle' || name === 'NAV2-MKMini' || name === 'NAV2-Yahboom') && !params.has('sim_mode')) blockers.push(`${name}: sim_mode 설정을 확인해야 합니다.`)
if (params.get('localization') === 'true') blockers.push(`${name}: 기존 지도 위치추정 모드입니다. 새 지도 SLAM 모드인지 확인해주세요.`)
}
return { name, state: module?.state ?? 'missing', parameters: module?.parameters ?? [] }
})
const conflicting = modules.filter(module => module.state === 'running' && !profile.modules.some(name => name === module.name)
&& module.name !== 'NAV2-Camera'
&& ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control'].includes(module.category.toLowerCase()))
if (conflicting.length) blockers.push(`다른 실습 모듈이 실행 중입니다: ${conflicting.map(module => module.name).join(', ')}. 자동 종료하지 않습니다.`)
return { profile, required, blockers, canPrepare: !blockers.length, starts: required.filter(module => module.state === 'stopped').map(module => module.name) }
}
export function verifyAgentPractice(profileId: string, probe: AgentToolResult): { ready: boolean; failures: string[]; evidence: unknown } {
const profile = AGENT_PRACTICE_PROFILES.find(item => item.id === profileId)
if (!profile || probe.status !== 'ok' || probe.truncated) return { ready: false, failures: ['ROS 상태를 확인하지 못했습니다.'], evidence: probe }
let data: { topics?: Array<{ name: string; publishers: number; samples: number; validSamples: number; frameIds?: string[]; error?: string }>; tfEdges?: Array<{ parent: string; child: string }> }
try { data = JSON.parse(probe.data) } catch { return { ready: false, failures: ['ROS 조회 결과 형식이 올바르지 않습니다.'], evidence: probe } }
if (!data || !Array.isArray(data.topics) || !Array.isArray(data.tfEdges)) return { ready: false, failures: ['ROS 조회 결과 형식이 올바르지 않습니다.'], evidence: probe }
const failures: string[] = []
for (const name of profile.topics) {
const topic = data.topics?.find(topic => topic.name === name)
if (!topic || ![topic.publishers, topic.samples, topic.validSamples].every(value => Number.isFinite(value) && value > 0) || topic.error) failures.push(`${name}: 관찰 구간에서 정상 수신을 확인하지 못했습니다.`)
}
const edges = data.tfEdges.filter(edge => edge && typeof edge.parent === 'string' && typeof edge.child === 'string')
const reaches = (source: string, target: string): boolean => {
const queue = [source]
const seen = new Set<string>()
while (queue.length) {
const current = queue.shift()!
if (current === target) return true
if (seen.has(current)) continue
seen.add(current)
edges.filter(edge => edge.parent.replace(/^\//, '') === current).forEach(edge => queue.push(edge.child.replace(/^\//, '')))
}
return false
}
for (const [source, target] of profile.frames) if (!reaches(source, target)) failures.push(`TF ${source} → ${target}: 연결을 확인하지 못했습니다.`)
const sensor = data.topics?.find(topic => topic.name === profile.topics[0])
if (!Array.isArray(sensor?.frameIds) || !sensor.frameIds.some(frame => typeof frame === 'string' && reaches('base_link', frame.replace(/^\//, '')))) failures.push('센서 프레임과 base_link의 TF 연결을 확인하지 못했습니다.')
return { ready: !failures.length, failures, evidence: probe }
}
export async function prepareAgentPractice(options: {
profileId: string
environment: 'local' | 'remote'
approvedStarts: string[]
approvedParameters?: Record<string, string[]>
list: () => Promise<ModuleRow[]>
start: (module: ModuleRow) => Promise<boolean>
probe: (module: ModuleRow, topics: string[]) => Promise<AgentToolResult>
progress: (text: string, steps?: Array<{ moduleName: string; status: string }>) => void
signal: AbortSignal
pause?: () => Promise<void>
}) {
const assertActive = (): void => { if (options.signal.aborted) throw new DOMException('Aborted', 'AbortError') }
assertActive()
let modules = await options.list()
assertActive()
const inspection = inspectAgentPractice(options.profileId, modules, options.environment)
if (inspection.blockers.length) throw new Error(inspection.blockers.join('\n'))
if (inspection.starts.some(name => !options.approvedStarts.includes(name))) throw new Error('승인 이후 시작 대상이 달라졌습니다. 다시 승인해주세요.')
const steps: Array<{ moduleName: string; status: string }> = []
const assertParameters = (modules: ModuleRow[]): void => {
for (const name of inspection.profile.modules) {
if (options.approvedParameters && JSON.stringify([...(modules.find(module => module.name === name)?.parameters ?? [])].sort()) !== JSON.stringify([...(options.approvedParameters[name] ?? [])].sort())) throw new Error(`${name}: 승인 이후 실행 파라미터가 바뀌었습니다. 다시 승인해주세요.`)
}
}
assertParameters(modules)
for (const name of inspection.profile.modules) {
assertActive()
modules = await options.list()
assertActive()
const latest = inspectAgentPractice(options.profileId, modules, options.environment)
assertParameters(modules)
if (latest.blockers.length) throw new Error(latest.blockers.join('\n'))
const module = modules.find(module => module.name === name)!
if (module.state === 'running') { steps.push({ moduleName: name, status: 'already-running' }); continue }
if (!options.approvedStarts.includes(name)) throw new Error(`승인받지 않은 시작 작업입니다: ${name}`)
options.progress(`${name} 시작 중`, steps)
if (!await options.start(module)) return { ready: false, steps: [...steps, { moduleName: name, status: 'failed' }], failures: [`${name} 시작에 실패했습니다. 이미 시작된 모듈은 자동 종료하지 않았습니다.`] }
steps.push({ moduleName: name, status: 'started' })
options.progress(`${name} 시작 완료`, steps)
if (module.category.toLowerCase() === 'simulator') await (options.pause?.() ?? new Promise(resolve => setTimeout(resolve, 4000)))
}
const topics = [...inspection.profile.topics, '/tf', '/tf_static']
let verification: ReturnType<typeof verifyAgentPractice> | undefined
for (let attempt = 0; attempt < 3; attempt++) {
assertActive()
options.progress(`SLAM 데이터·TF 확인 중 (${attempt + 1}/3)`, steps)
modules = await options.list()
assertActive()
const latest = inspectAgentPractice(options.profileId, modules, options.environment)
assertParameters(modules)
if (latest.blockers.length || latest.required.some(module => module.state !== 'running')) return { ready: false, steps, failures: [...latest.blockers, '필수 모듈의 실행 상태를 확인해주세요.'] }
verification = verifyAgentPractice(options.profileId, await options.probe(modules.find(module => module.name === inspection.profile.modules.at(-1))!, topics))
assertActive()
if (verification.ready) break
if (attempt < 2) await (options.pause?.() ?? new Promise(resolve => setTimeout(resolve, 2000)))
}
return { ...verification!, steps }
}
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync'
import { resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
import type { ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
export type AgentFileChange = {
expectedHash?: string | null
moduleName: string
path: string
content: string
@@ -20,6 +22,7 @@ export type AgentModulePlan = {
}
export type AgentCommand = {
operation?: AgentOperation
id: string
moduleName: string
command: string
@@ -34,6 +37,8 @@ export type AgentCommandExecution = {
}
export type AgentPlan = {
toolRequests?: AgentToolRequest[]
evidence?: AgentToolResult[]
summary: string
explanation: string
requestedModules: string[]
@@ -45,6 +50,7 @@ export type AgentPlan = {
}
export type AgentWorkspaceModule = {
parameters?: string[]
name: string
folderName?: string
category?: string
@@ -69,6 +75,7 @@ export type AgentConversationMessage = {
content: string
provider?: string
model?: string
revealStartedAt?: number
plan?: AgentPlan
applyState?: 'ready' | 'applying' | 'applied' | 'error'
applyError?: string
@@ -109,8 +116,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [activity, setActivity] = useState('')
const [messages, setMessages] = useState<AgentConversationMessage[]>([])
const abortControllerRef = useRef<AbortController | null>(null)
const applyingRef = useRef(false)
const tokenReaderRef = useRef(getFreshAccessToken)
tokenReaderRef.current = getFreshAccessToken
const onResponseRef = useRef(onResponse)
@@ -134,6 +143,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
}
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 구조화된 에이전트 도구를 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
}
return response.json() as Promise<T>
@@ -147,7 +157,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
body: JSON.stringify({
courseId,
week: Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : undefined,
messages: batch
messages: batch.map(({ revealStartedAt: _revealStartedAt, ...message }) => message)
})
})
let sync = sessionSyncs.get(scope)
@@ -204,7 +214,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}, [authenticated, session])
useEffect(() => {
const cached = authenticated ? session.restore([]) : []
const cached = authenticated ? recoverInterruptedAgentMessages(session.restore([])) : []
messageStateRef.current = { session, messages: cached }
setMessages(cached)
setHistoryError(null)
@@ -219,7 +229,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
requestApi<AgentSessionResponse>(`/chat/agent/session?${query.toString()}`)
.then((response) => {
if (!cancelled) {
const restored = session.restore(Array.isArray(response.messages) ? response.messages : [])
const restored = recoverInterruptedAgentMessages(session.restore(Array.isArray(response.messages) ? response.messages : []))
messageStateRef.current = { session, messages: restored }
setMessages(restored)
}
@@ -254,6 +264,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
showUserMessage?: boolean
attachments?: ChatAttachment[]
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote'
toolResults?: AgentToolResult[]
} = {}
): Promise<void> => {
const request = rawRequest.trim()
@@ -265,18 +278,21 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true)
setActivity('요청 분석 중')
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs },
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
signal: abortController.signal,
loadRequestedContext: options.loadRequestedContext,
requestPlan: (context) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
executeTool: options.executeTool,
onProgress: setActivity,
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST',
signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request, ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context })
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context })
})
})
if (abortController.signal.aborted) return
@@ -291,6 +307,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
updateMessages((current) => [...current, {
id: `agent-plan-${Date.now()}`,
role: 'assistant',
revealStartedAt: Date.now(),
content: plan.summary,
provider: response.provider,
model: response.model,
@@ -305,20 +322,24 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`,
role: 'assistant',
revealStartedAt: Date.now(),
content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.'
}])
onResponseRef.current?.()
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null
setPending(false)
setActivity('')
}
}
}
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
const message = messages.find((item) => item.id === messageId)
if (!message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return
applyingRef.current = true
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
try {
await apply(message.plan)
@@ -336,6 +357,8 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} catch (error) {
const applyError = error instanceof Error ? error.message : '변경안을 적용하지 못했습니다.'
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item))
} finally {
applyingRef.current = false
}
}
@@ -351,7 +374,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
...message,
commandExecutions: {
...message.commandExecutions,
[commandId]: execution
[commandId]: { ...message.commandExecutions?.[commandId], ...(execution.status === 'running' ? { error: undefined } : {}), ...execution }
}
}
: message))
@@ -382,6 +405,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
...status,
statusLoading: statusLoading || historyLoading,
pending,
activity,
messages,
persistenceError: persistenceError ?? historyError,
requestPlan,
@@ -11,6 +11,7 @@ export type QaChatMessage = {
createdAt?: string
senderAvatarUrl?: string | null
isTyping?: boolean
revealStartedAt?: number
keepExpanded?: boolean
status?: 'ACTIVE' | 'CANCELED'
}
@@ -55,6 +56,7 @@ type UseQaChatOptions = {
courseId: string
weekNumber: number
globalScope?: boolean
revealInView?: boolean
apiBaseUrl: string
socketBaseUrl: string
socketPath: string
@@ -77,6 +79,7 @@ export function useQaChat({
courseId,
weekNumber,
globalScope = false,
revealInView = false,
apiBaseUrl,
socketBaseUrl,
socketPath,
@@ -369,6 +372,17 @@ export function useQaChat({
})
expandedAiMessageIdsRef.current.add(response.message.id)
setAiPending(false)
if (revealInView) {
setAiMessages((messages) => [...messages, {
id: response.message.id,
role: 'assistant',
content: fullContent,
createdAt: response.message.createdAt,
revealStartedAt: Date.now(),
keepExpanded: true
}])
return
}
setAiTypingMessageId(response.message.id)
setAiMessages((messages) => [
...messages,
@@ -11,8 +11,10 @@ type ModuleActionResult = {
ok: boolean
message?: string
}
type ModuleControlOptions = { background?: boolean; freshSlam?: boolean; onResult?: (result: ModuleActionResult) => void }
type SavedMapPathOverrides = {
freshSlam?: boolean
savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null
savedMapWorldLayoutPath?: string | null
@@ -70,7 +72,7 @@ type UseWorkspaceModuleControlResult = {
startAllModules: () => Promise<void>
stopAllModules: () => Promise<void>
restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<boolean>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options?: ModuleControlOptions) => Promise<boolean>
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
}
@@ -108,10 +110,12 @@ export function useWorkspaceModuleControl({
const moduleActionIdRef = useRef(0)
const controlGenerationRef = useRef(0)
const workspaceActiveRef = useRef(workspaceActive)
const backgroundControlsRef = useRef(0)
useEffect(() => () => { controlGenerationRef.current += 1 }, [courseKey, weekNumber, clonePathPreview, remoteWorkspace])
useEffect(() => {
workspaceActiveRef.current = workspaceActive
if (workspaceActive) return
if (workspaceActive || backgroundControlsRef.current > 0) return
// Workspace pages remain mounted while another LMS page is shown. Do not
// leave their control buttons locked behind a detached IPC request.
@@ -126,6 +130,7 @@ export function useWorkspaceModuleControl({
), [bulkExcludedModuleKeys])
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: {
freshSlam?: boolean
simulatorWorldFile?: string | null
savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null
@@ -133,7 +138,7 @@ export function useWorkspaceModuleControl({
}): string => {
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const mapDatabasePath = overrides?.freshSlam ? null : overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
@@ -175,12 +180,13 @@ export function useWorkspaceModuleControl({
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
}
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '')
const databasePath = mapDatabasePath
const databasePath = overrides?.freshSlam ? null : mapDatabasePath
?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true')
}
if (overrides?.freshSlam && /slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=false')
return parameters.join(' ')
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
@@ -473,8 +479,8 @@ export function useWorkspaceModuleControl({
}
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<boolean> => {
if (!workspaceActiveRef.current) return false
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options: ModuleControlOptions = {}): Promise<boolean> => {
if (!workspaceActiveRef.current && !options.background) return false
// The parking area only opts modules out of bulk actions; those modules
// must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = (
@@ -489,17 +495,18 @@ export function useWorkspaceModuleControl({
const actionId = ++moduleActionIdRef.current
const controlGeneration = controlGenerationRef.current
queuedModuleActionsRef.current.set(module.name, actionId)
if (options.background) backgroundControlsRef.current += 1
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null)
const execute = async (): Promise<boolean> => {
const isCurrentControl = (): boolean => (
workspaceActiveRef.current
(workspaceActiveRef.current || options.background === true)
&& controlGenerationRef.current === controlGeneration
&& queuedModuleActionsRef.current.get(module.name) === actionId
)
try {
if (!isCurrentControl()) return false
const savedMapOverrides = action === 'start' || action === 'restart'
const savedMapOverrides = options.freshSlam ? { freshSlam: true } : action === 'start' || action === 'restart'
? await prepareSavedMap?.()
: undefined
const result = action === 'stop'
@@ -517,6 +524,7 @@ export function useWorkspaceModuleControl({
: remoteWorkspace
? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
options.onResult?.(result)
if (!result.ok) throw new Error(result.message)
if (!isCurrentControl()) return false
@@ -551,6 +559,7 @@ export function useWorkspaceModuleControl({
return true
} catch (error) {
console.error(`[modules] ${action} failed`, error)
options.onResult?.({ ok: false, message: error instanceof Error ? error.message : String(error) })
if (!isCurrentControl()) return false
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
const actionLabel = action === 'stop'
@@ -564,6 +573,7 @@ export function useWorkspaceModuleControl({
await loadAvailableModules({ silent: true })
return false
} finally {
if (options.background) backgroundControlsRef.current -= 1
if (queuedModuleActionsRef.current.get(module.name) === actionId) {
queuedModuleActionsRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
+7 -1
View File
@@ -703,5 +703,11 @@
"실행 실패": "Failed",
"다시 실행": "Run again",
"명령 출력이 없습니다.": "The command produced no output.",
"코드 변경안을 만드는 중입니다.": "Preparing code changes."
"코드 변경안을 만드는 중입니다.": "Preparing code changes.",
"조회 근거": "Evidence",
"조회 완료": "Retrieved",
"조회 실패": "Retrieval failed",
"일부 결과": "Partial results",
"SLAM 실습 준비": "Prepare SLAM practice",
"승인하고 준비": "Approve and prepare"
}
+15
View File
@@ -248,6 +248,21 @@
}
@layer utilities {
@keyframes agent-response-fade {
from { opacity: 0; transform: translateY(3px); }
to { opacity: 1; transform: translateY(0); }
}
.agent-response-fade {
animation: agent-response-fade 220ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.agent-response-fade {
animation: none;
}
}
.monaco-search-line-highlight {
background: rgba(47, 141, 255, 0.12);
}
+53
View File
@@ -0,0 +1,53 @@
export const AGENT_READ_TOOLS = ['modules.list', 'files.list', 'files.search', 'files.read', 'logs.read', 'network.inspect', 'ros.inspect', 'practice.inspect'] as const
export type AgentReadToolName = typeof AGENT_READ_TOOLS[number]
export const AGENT_RESULT_NAMES = [...AGENT_READ_TOOLS, 'action.result'] as const
export type AgentToolRequest = {
id: string
name: AgentReadToolName
moduleName?: string
args?: { path?: string; query?: string; startLine?: number; lineCount?: number; topics?: string[]; profile?: string }
}
export type AgentToolResult = {
id: string
name: typeof AGENT_RESULT_NAMES[number]
moduleName?: string
status: 'ok' | 'error'
observedAt: string
target: string
data: string
truncated: boolean
}
export type AgentOperation = { name: 'modules.start' | 'modules.build' | 'practice.prepare'; profile?: string }
export type AgentCheckedChange = { moduleName: string; folderName?: string; path: string; content: string; expectedHash: string | null }
const blockedParts = new Set(['node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'])
export function safeAgentPath(value: unknown, allowEmpty = false): string {
if (allowEmpty && (value === undefined || value === '')) return ''
if (typeof value !== 'string' || value.length > 500 || /[\\\0\r\n]/.test(value) || value.startsWith('/')) throw new Error('안전하지 않은 파일 경로입니다.')
const parts = value.split('/')
if (parts.some(part => !part || part.startsWith('.') || blockedParts.has(part.toLowerCase()) || /\.(pem|key|p12|pfx)$/i.test(part))) throw new Error('시스템·비밀 파일 또는 제외된 경로에는 접근할 수 없습니다.')
return value
}
export function validateAgentTool(input: AgentToolRequest): AgentToolRequest {
if (!input || typeof input !== 'object' || !/^[A-Za-z0-9._-]{1,80}$/.test(input.id) || !AGENT_READ_TOOLS.includes(input.name)) throw new Error('등록되지 않은 에이전트 도구입니다.')
if (input.moduleName !== undefined && (typeof input.moduleName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,119}$/.test(input.moduleName))) throw new Error('모듈명이 올바르지 않습니다.')
if (!['modules.list', 'practice.inspect'].includes(input.name) && !input.moduleName) throw new Error('대상 모듈이 필요합니다.')
const args = input.args ?? {}
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('도구 인자가 올바르지 않습니다.')
const allowed = input.name === 'files.read' ? ['path', 'startLine', 'lineCount'] : input.name === 'files.search' ? ['path', 'query'] : input.name === 'files.list' ? ['path'] : input.name === 'ros.inspect' ? ['topics'] : input.name === 'practice.inspect' ? ['profile'] : []
if (Object.keys(args).some(key => !allowed.includes(key))) throw new Error('허용되지 않은 도구 인자입니다.')
if (input.name.startsWith('files.')) safeAgentPath(args.path, input.name !== 'files.read')
if (input.name === 'files.search' && (typeof args.query !== 'string' || args.query.length < 2 || args.query.length > 200 || /[\0\r\n]/.test(args.query))) throw new Error('검색어는 2~200자의 한 줄 텍스트여야 합니다.')
for (const [key, max] of [['startLine', 100_000], ['lineCount', 400]] as const) {
if (args[key] !== undefined && (!Number.isInteger(args[key]) || args[key]! < 1 || args[key]! > max)) throw new Error('파일 조회 범위를 확인하세요.')
}
if (args.topics !== undefined && (!Array.isArray(args.topics) || args.topics.length > 12 || args.topics.some(topic => typeof topic !== 'string' || topic.length > 200 || !/^\/[A-Za-z0-9_/]+$/.test(topic)))) throw new Error('ROS 토픽 형식이 올바르지 않습니다.')
if (args.profile !== undefined && !['local-2d', 'mkmini-2d', 'mkmini-3d', 'yahboom-2d'].includes(args.profile)) throw new Error('지원하지 않는 실습 구성입니다.')
return { id: input.id, name: input.name, ...(input.moduleName ? { moduleName: input.moduleName } : {}), args }
}
export function validateAgentOperation(value: AgentOperation): AgentOperation {
if (!value || !['modules.start', 'modules.build', 'practice.prepare'].includes(value.name) || Object.keys(value).some(key => !['name', 'profile'].includes(key))) throw new Error('허용되지 않은 승인 작업입니다.')
if (value.name === 'practice.prepare' && !['local-2d', 'mkmini-2d', 'mkmini-3d', 'yahboom-2d'].includes(value.profile ?? '')) throw new Error('실습 구성을 선택해야 합니다.')
if (value.name !== 'practice.prepare' && value.profile !== undefined) throw new Error('허용되지 않은 승인 작업 인자입니다.')
return value
}
+9
View File
@@ -0,0 +1,9 @@
export type ChatTextMenuOptions = {
kind: 'message' | 'input'
canCopy: boolean
canPaste?: boolean
canClear?: boolean
labels: { copy: string; paste?: string; clear?: string }
}
export type ChatTextMenuAction = 'clear' | null