Compare commits

..

2 Commits

Author SHA1 Message Date
chyoo 105342fbf3 chore: bump app version to 1.2.16 2026-10-19 11:37:32 +09:00
chyoo cc589fd8cf feat: add structured agent tools and improve workspace chat UX 2026-10-19 11:30:23 +09:00
35 changed files with 2834 additions and 250 deletions
+5 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.15", "version": "1.2.16",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.15", "version": "1.2.16",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
@@ -23,8 +23,10 @@
"react-i18next": "^17.0.15", "react-i18next": "^17.0.15",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "11.0.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"three": "^0.181.2" "three": "^0.181.2",
"unified": "11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.15", "version": "1.2.16",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
@@ -64,8 +64,10 @@
"react-i18next": "^17.0.15", "react-i18next": "^17.0.15",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "11.0.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"three": "^0.181.2" "three": "^0.181.2",
"unified": "11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1", "@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) 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 let loads = 0
await assert.rejects(resolveAgentContext(options({ let calls = 0
requestPlan: async () => response(['Control']), const result = await resolveAgentContext(options({
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } } 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(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 () => { 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 () => { test('bounds the number of distinct automatic context requests', async () => {
let calls = 0 let calls = 0
await assert.rejects(resolveAgentContext(options({ let loads = 0
requestPlan: async () => response([`Module${calls++}`]), const result = await resolveAgentContext(options({
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] }) requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response([`Module${calls}`]) },
})), /5회/) loadRequestedContext: async request => { loads++; return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
assert.equal(calls, 6) }))
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 () => { 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 () => { test('excludes oversized source files with an explicit notice and continues with complete smaller files', async () => {
await assert.rejects(resolveAgentContext(options({ let calls = 0
requestPlan: async () => response(['Control']), const smallFile = { path: 'config/steering.yaml', content: 'kinematics: ackermann' }
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }])], runtimeLogs: [] }) 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 () => { test('requests a final answer rather than leaving a context-only response when no loader is available', async () => {
await assert.rejects(resolveAgentContext(options({ requestPlan: async () => response(['Control']) })), /읽을 수 없는/) 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 () => { 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: [] }) 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 fs = require('node:fs')
const vm = require('node:vm') const vm = require('node:vm')
const ts = require('typescript') 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) const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression let payloadExpression
@@ -15,9 +19,10 @@ function visit(node) {
visit(source) visit(source)
assert.ok(payloadExpression, 'Agent request JSON serialization must exist') assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
function serialize(attachments) { function serialize(attachments, finalize = false, request = '설정 위치 알려줘') {
return JSON.parse(vm.runInNewContext(payloadExpression, { 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: [] } 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' }] const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
assert.deepEqual(serialize(attachments).attachments, attachments) 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))] 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 state = []
const effects = [] const effects = []
let index = 0 let index = 0
@@ -34,6 +34,7 @@ function setup(overrides = {}) {
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', { const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
'../../i18n': i18n, '../../i18n': i18n,
'../../components/AppStatusModals': { ConfirmStatusModal }, '../../components/AppStatusModals': { ConfirmStatusModal },
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
'../../components/ChatWidgets': { QaInputBox }, '../../components/ChatWidgets': { QaInputBox },
'react-dom': { createPortal: child => child }, 'react-dom': { createPortal: child => child },
react: { react: {
@@ -45,7 +46,7 @@ function setup(overrides = {}) {
return [state[slot], value => { state[slot] = value }] return [state[slot], value => { state[slot] = value }]
} }
} }
}, { document: { body: {} } }).default }, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default
const props = { const props = {
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }], dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null }, visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
@@ -82,13 +83,46 @@ test('context menu deletion also requests confirmation', () => {
const harness = setup() const harness = setup()
const root = harness.render() const root = harness.render()
const area = elements(root, node => Boolean(node.props.onContextMenu))[0] 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] const clear = elements(harness.render(), node => node.type === 'button' && node.props.children === '대화내역 삭제')[0]
clear.props.onClick() clear.props.onClick()
assert.equal(harness.cleared(), 0) assert.equal(harness.cleared(), 0)
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1) 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: [] }]) { for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => { test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
const harness = setup(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 { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { appLog } from './appLogging' import { appLog } from './appLogging'
import { checkDockerStatus } from './dockerStatus' import { checkDockerStatus } from './dockerStatus'
import { showChatTextMenu } from './chatTextMenu'
import { cameraRuntimeArtifacts } from './cameraRuntime' import { cameraRuntimeArtifacts } from './cameraRuntime'
import { remoteModuleFilesScript } from './remoteModuleFiles' 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 { resolveContainerTerminal } from './containerTerminal'
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell' import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport' 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() const readProgress = createModuleProgressParser()
return new Promise<string>((resolvePromise, rejectPromise) => { return new Promise<string>((resolvePromise, rejectPromise) => {
let output = '' let output = ''
@@ -1803,7 +1808,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
const child = spawn(command, args, { const child = spawn(command, args, {
cwd, cwd,
detached: process.platform !== 'win32', detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'], stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
...env, ...env,
@@ -1826,13 +1831,17 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`)) rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
}, timeoutMs) }, timeoutMs)
child.stdout.setEncoding('utf8') if (input !== undefined) {
child.stdout.on('data', (data: string) => { 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() stdout += data.toString()
output += data.toString() output += data.toString()
readProgress(data.toString()) readProgress(data.toString())
}) })
child.stderr.on('data', (data: Buffer) => { child.stderr?.on('data', (data: Buffer) => {
output += data.toString() output += data.toString()
}) })
child.on('error', (error) => { 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) const normalized = normalizeRemoteTarget(target)
return runCommandOutput( return runCommandOutput(
'ssh', 'ssh',
@@ -2249,6 +2258,7 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri
remoteShellEnvironment(process.env), remoteShellEnvironment(process.env),
timeoutMs, timeoutMs,
'stdout', '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> { async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName) const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] } 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: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: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)) 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: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: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)) ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
@@ -6927,6 +7036,7 @@ app.whenReady().then(() => {
return { opened: true } return { opened: true }
}) })
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics()) 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('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
ipcMain.on('app-quit', () => app.quit()) ipcMain.on('app-quit', () => app.quit())
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) 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('electron', electronAPI)
contextBridge.exposeInMainWorld('chatTextMenu', {
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options)
})
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth) contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('lmsBootSplash', { contextBridge.exposeInMainWorld('lmsBootSplash', {
status: (message: string) => ipcRenderer.send('lms-boot-status', message), 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) delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
}, },
agent: { 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) runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-command:run', moduleName, command, workspaceRoot, folderName)
}, },
modules: { 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) delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
}, },
agent: { 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) runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-command:run', target, moduleName, command, folderName)
}, },
maps: { maps: {
+192 -42
View File
@@ -12,7 +12,7 @@ import type { Socket } from 'socket.io-client'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import DockerSetupNotice from './components/DockerSetupNotice' import DockerSetupNotice from './components/DockerSetupNotice'
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay' 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 TitleBar from './components/TitleBar'
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig' 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' 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 WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
import { useQaChat } from './features/workspace/useQaChat' import { useQaChat } from './features/workspace/useQaChat'
import { useAiAgent } from './features/workspace/useAiAgent' import { useAiAgent } from './features/workspace/useAiAgent'
import { 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 { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
import type { ChatAttachment } from './components/chatAttachments' import type { ChatAttachment } from './components/chatAttachments'
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime' import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
@@ -566,6 +568,7 @@ function LocalPcWorkspaceMockup({
accessToken, accessToken,
courseId: courseKey, courseId: courseKey,
weekNumber, weekNumber,
revealInView: true,
apiBaseUrl: LMS_API_BASE_URL, apiBaseUrl: LMS_API_BASE_URL,
socketBaseUrl: LMS_SOCKET_ORIGIN, socketBaseUrl: LMS_SOCKET_ORIGIN,
socketPath: LMS_SOCKET_PATH, socketPath: LMS_SOCKET_PATH,
@@ -626,6 +629,7 @@ function LocalPcWorkspaceMockup({
enabled: agentEnabled, enabled: agentEnabled,
statusLoading: agentStatusLoading, statusLoading: agentStatusLoading,
pending: agentPending, pending: agentPending,
activity: agentActivity,
messages: agentMessages, messages: agentMessages,
persistenceError: agentPersistenceError, persistenceError: agentPersistenceError,
requestPlan: requestAgentPlan, requestPlan: requestAgentPlan,
@@ -642,6 +646,10 @@ function LocalPcWorkspaceMockup({
getFreshAccessToken, getFreshAccessToken,
onResponse: () => onTitlebarNotice(t("AI 에이전트 답변이 도착했습니다."), 'info', true) 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 [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false) const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
const [teamGitSyncing, setTeamGitSyncing] = useState(false) const [teamGitSyncing, setTeamGitSyncing] = useState(false)
@@ -704,6 +712,8 @@ function LocalPcWorkspaceMockup({
finishTabDrop, finishTabDrop,
resetTabDrag resetTabDrag
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive) } = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
const agentEditorFilesRef = useRef(editorFiles)
agentEditorFilesRef.current = editorFiles
const [agentDiffPreviews, setAgentDiffPreviews] = useState<AgentDiffPreview[]>([]) const [agentDiffPreviews, setAgentDiffPreviews] = useState<AgentDiffPreview[]>([])
const [activeAgentDiffPreviewId, setActiveAgentDiffPreviewId] = useState<string | null>(null) const [activeAgentDiffPreviewId, setActiveAgentDiffPreviewId] = useState<string | null>(null)
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null) const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
@@ -756,6 +766,11 @@ function LocalPcWorkspaceMockup({
useEffect(() => { useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => { const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return if (event.key !== 'Escape') return
if (agentPracticeApproval) {
event.preventDefault()
setAgentPracticeApproval(null)
return
}
if (pendingEditorCloseRequest) { if (pendingEditorCloseRequest) {
event.preventDefault() event.preventDefault()
setPendingEditorCloseRequest(null) setPendingEditorCloseRequest(null)
@@ -789,7 +804,7 @@ function LocalPcWorkspaceMockup({
window.addEventListener('keydown', handleEscape) window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape)
}, [pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog]) }, [agentPracticeApproval, pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
useEffect(() => { useEffect(() => {
if (!controlEndsAt) return if (!controlEndsAt) return
setControlCountdownNow(Date.now()) setControlCountdownNow(Date.now())
@@ -1433,6 +1448,10 @@ function LocalPcWorkspaceMockup({
return () => window.removeEventListener('message', handleViewerMessage) 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]) }, [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()) const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
useEffect(() => {
setAgentPracticeApproval(null)
return () => { agentActionControllerRef.current?.abort() }
}, [courseKey, weekNumber, platformId, remoteWorkspaceTargetKey])
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId) const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
const hasRestorableWorkspaceUi = Boolean( const hasRestorableWorkspaceUi = Boolean(
window.localStorage.getItem(workspaceUiSessionKey) window.localStorage.getItem(workspaceUiSessionKey)
@@ -3502,6 +3521,7 @@ function LocalPcWorkspaceMockup({
folderName: module.folderName, folderName: module.folderName,
category: module.category, category: module.category,
state: module.state, state: module.state,
parameters: module.parameters,
files: [], files: [],
...(requestedNames.has(module.name.toLowerCase()) ? { ...(requestedNames.has(module.name.toLowerCase()) ? {
fileContext: { 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) 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) || ['dockerfile', 'cmakelists.txt', 'package.xml', 'setup.py', 'setup.cfg'].includes(name)
} }
let totalCharacters = 0
let totalFiles = 0
for (const module of targetModules) { for (const module of targetModules) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break let totalCharacters = 0
let totalFiles = 0
try { try {
const modulePrefix = `modules/${module.name}` const modulePrefix = `modules/${module.name}`
const result = remoteWorkspace const result = remoteWorkspace
@@ -3535,9 +3553,9 @@ function LocalPcWorkspaceMockup({
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content])) const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
const sourcePriority = (path: string): number => { const sourcePriority = (path: string): number => {
if (path === preferredPath) return -100 if (path === preferredPath) return -100
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -60
if (/(^|\/)src(\/|$)/.test(path)) return -50 if (/(^|\/)src(\/|$)/.test(path)) return -50
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40 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 return 0
} }
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath))) 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), modules: await collectAgentWorkspace(request.modules),
runtimeLogs: await collectAgentRuntimeLogs(request.logs) 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> => { const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) return
try { try {
const modules = await collectAgentWorkspace() const modules = await collectAgentWorkspace()
await requestAgentPlan(content, modules, [], { await requestAgentPlan(content, modules, [], {
attachments, attachments,
loadRequestedContext: loadAgentRequestedContext loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local'
}) })
} catch (error) { } catch (error) {
console.error('[agent] workspace collection failed', error) console.error('[agent] workspace collection failed', error)
@@ -3637,32 +3686,16 @@ function LocalPcWorkspaceMockup({
} }
const rendererPath = `modules/${module.name}/${change.path.replace(/^\/+/, '')}` const rendererPath = `modules/${module.name}/${change.path.replace(/^\/+/, '')}`
let originalContent = editorFiles[rendererPath] let originalContent: string | undefined
try {
if (originalContent === undefined) { const result = await executeWorkspaceAgentTool({ id: `preview-${Date.now()}`, name: 'files.read', moduleName: module.name, args: { path: change.path } })
try { const file = JSON.parse(result.data) as { content?: string; hash?: string | null; complete?: boolean }
const result = remoteWorkspace if (result.status !== 'ok' || !file.complete || typeof file.content !== 'string') throw new Error('비교할 원본 파일 전체를 읽지 못했습니다. 다시 분석해주세요.')
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName) if (change.expectedHash !== undefined && change.expectedHash !== file.hash) throw new Error('분석 이후 원본 파일이 변경되었습니다. 다시 분석해주세요.')
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName) originalContent = file.content
const normalizePath = (path: string): string => path.startsWith('modules/') } catch (error) {
? path onTitlebarNotice(error instanceof Error ? error.message : '비교할 원본 파일을 읽지 못했습니다.', 'error')
: `modules/${module.name}/${path.replace(/^\/+/, '')}` return
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)
}
} }
if (selectedModule?.name !== module.name) openModuleSource(module) if (selectedModule?.name !== module.name) openModuleSource(module)
@@ -3692,7 +3725,79 @@ function LocalPcWorkspaceMockup({
return previews.filter((preview) => preview.messageId !== messageId) 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> => { const runWorkspaceAgentCommand = async (messageId: string, command: AgentCommand): Promise<void> => {
if (agentActionControllerRef.current || agentPending) return
const message = agentMessages.find((item) => item.id === messageId) const message = agentMessages.find((item) => item.id === messageId)
const currentExecution = message?.commandExecutions?.[command.id] const currentExecution = message?.commandExecutions?.[command.id]
if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
@@ -3701,6 +3806,10 @@ function LocalPcWorkspaceMockup({
onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error') onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error')
return return
} }
if (command.operation) {
await runStructuredAgentCommand(messageId, command)
return
}
const module = availableModules.find((item) => item.name === command.moduleName) const module = availableModules.find((item) => item.name === command.moduleName)
?? (selectedModule?.name === command.moduleName ? selectedModule : null) ?? (selectedModule?.name === command.moduleName ? selectedModule : null)
@@ -3756,7 +3865,9 @@ function LocalPcWorkspaceMockup({
].filter(Boolean).join('\n') ].filter(Boolean).join('\n')
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], { await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], {
showUserMessage: false, showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local'
}) })
} catch (error) { } catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.') const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
@@ -3775,6 +3886,13 @@ function LocalPcWorkspaceMockup({
updateAgentCommandExecution(messageId, command.id, { status: 'rejected' }) updateAgentCommandExecution(messageId, command.id, { status: 'rejected' })
} }
const applyWorkspaceAgentPlan = async (plan: AgentPlan): Promise<void> => { 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 let createdModule: ModuleRow | null = null
if (plan.createModule) { if (plan.createModule) {
if (availableModules.some((module) => module.name === plan.createModule?.name)) { if (availableModules.some((module) => module.name === plan.createModule?.name)) {
@@ -3810,23 +3928,34 @@ function LocalPcWorkspaceMockup({
if (createdModule?.name === moduleName) return createdModule if (createdModule?.name === moduleName) return createdModule
return availableModules.find((module) => module.name === moduleName) ?? (selectedModule?.name === moduleName ? selectedModule : null) 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 let firstChangedFile: { module: ModuleRow; path: string } | null = null
for (const change of plan.changes) { for (const change of plan.changes) {
const module = resolveModule(change.moduleName) const module = resolveModule(change.moduleName)
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`) if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
const rendererPath = `modules/${module.name}/${change.path}` 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.create(getRemoteWorkspaceTarget(), module.name, rendererPath, 'file', module.folderName)
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), module.name, rendererPath, change.content, 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.create(module.name, rendererPath, 'file', clonePathPreview, module.folderName)
await window.localWorkspace.moduleFiles.write(module.name, rendererPath, change.content, 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 })) setSavedEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
setDirtyEditorFiles((files) => { setDirtyEditorFiles((files) => {
const next = { ...files } const next = { ...files }
delete next[rendererPath] if (editedDuringApply) next[rendererPath] = true
else delete next[rendererPath]
return next return next
}) })
if (!firstChangedFile) firstChangedFile = { module, path: rendererPath } if (!firstChangedFile) firstChangedFile = { module, path: rendererPath }
@@ -4410,6 +4539,7 @@ function LocalPcWorkspaceMockup({
/> />
<WorkspaceEditorPane <WorkspaceEditorPane
editingLocked={agentApplying}
fileLoading={remoteEditorFile.loading} fileLoading={remoteEditorFile.loading}
fileError={remoteEditorFile.error} fileError={remoteEditorFile.error}
onRetryFile={remoteEditorFile.retry} onRetryFile={remoteEditorFile.retry}
@@ -4430,7 +4560,7 @@ function LocalPcWorkspaceMockup({
beforeMount={configureMonacoTheme} beforeMount={configureMonacoTheme}
onMount={handleEditorMount} onMount={handleEditorMount}
onChange={(value) => { onChange={(value) => {
if (!currentActiveEditorFile) return if (!currentActiveEditorFile || agentApplying) return
updateEditorFileContent(currentActiveEditorFile, value ?? '', exampleActive) updateEditorFileContent(currentActiveEditorFile, value ?? '', exampleActive)
}} }}
/> />
@@ -4583,11 +4713,13 @@ function LocalPcWorkspaceMockup({
agentEnabled={agentEnabled} agentEnabled={agentEnabled}
agentStatusLoading={agentStatusLoading} agentStatusLoading={agentStatusLoading}
agentPending={agentPending} agentPending={agentPending}
agentActivity={agentActivity}
agentActionBusy={agentActionBusy || agentApplying}
agentPersistenceError={agentPersistenceError} agentPersistenceError={agentPersistenceError}
aiPending={aiPending} aiPending={aiPending}
teacherThreadStatus={teacherThreadStatus} teacherThreadStatus={teacherThreadStatus}
qaCollapseSignal={qaCollapseSignal} qaCollapseSignal={qaCollapseSignal}
inputBusy={qaMode === 'agent' ? agentPending : aiPending || Boolean(aiTypingMessageId)} inputBusy={qaMode === 'agent' ? agentPending || agentActionBusy || agentApplying : aiPending || Boolean(aiTypingMessageId)}
inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))} inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))}
workspaceChatScrollRef={workspaceChatScrollRef} workspaceChatScrollRef={workspaceChatScrollRef}
onMinimize={() => setRightPanelMinimized(true)} onMinimize={() => setRightPanelMinimized(true)}
@@ -4595,7 +4727,7 @@ function LocalPcWorkspaceMockup({
setQaMode(mode) setQaMode(mode)
if (mode === 'teacher') setTeacherChatFlash(false) if (mode === 'teacher') setTeacherChatFlash(false)
}} }}
onStop={qaMode === 'agent' ? stopAgent : stopAiResponse} onStop={qaMode === 'agent' ? () => { agentActionControllerRef.current?.abort(); stopAgent() } : stopAiResponse}
onSubmit={(content, attachments) => { onSubmit={(content, attachments) => {
if (qaMode === 'agent') void submitAgentRequest(content, attachments) if (qaMode === 'agent') void submitAgentRequest(content, attachments)
else void sendQaMessage(content, { contextWeek: curriculumWeek.week }) 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 && ( {workspaceRestoreLoading && (
<PlatformLoadingOverlay <PlatformLoadingOverlay
title={t("{{v0}} 화면 불러오는 중", { v0: platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName })} 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() useLocaleRender()
const textColor = dark ? 'text-white/76' : 'text-[#4f596b]' const textColor = dark ? 'text-white/76' : 'text-[#4f596b]'
const headingColor = dark ? 'text-white/92' : 'text-[#111827]' const headingColor = dark ? 'text-white/92' : 'text-[#111827]'
const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]' const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]'
const codeBg = dark ? 'bg-[#171717] text-white/82' : 'bg-[#f1f5f9] text-[#334155]' 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 ( 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}`}> <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 <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
h1: ({ children }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h1>, 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 }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h2>, 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 }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h3>, 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 }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h4>, 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 }) => <p className="my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]">{children}</p>, 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>, strong: ({ children }) => <strong className={`font-black ${headingColor}`}>{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>, em: ({ children }) => <em className="italic">{children}</em>,
a: ({ children, href }) => ( 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>, 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>, 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>, 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 }) => ( blockquote: ({ children, node }) => (
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}`}> <blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}${fadeClass(node?.position?.start.offset)}`}>
{children} {children}
</blockquote> </blockquote>
), ),
hr: () => <hr className={`my-3 ${mutedBorder}`} />, hr: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />,
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>, 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 }) => { code: ({ children, className }) => {
if (className) { if (className) {
return <code className="whitespace-pre-wrap break-words font-mono [overflow-wrap:anywhere]">{children}</code> 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> 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 }) => ( table: ({ children, node }) => (
<div className="my-2 max-w-full overflow-hidden"> <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> <table className={`w-full table-fixed border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
</div> </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({ export function ExpandableMarkdownMessage({
content, content,
dark, dark,
@@ -167,6 +195,7 @@ export function ExpandableMarkdownMessage({
<button <button
type="button" type="button"
aria-label={expanded ? t("접기") : t("자세히 보기")} aria-label={expanded ? t("접기") : t("자세히 보기")}
aria-expanded={expanded}
title={expanded ? t("접기") : t("자세히 보기")} title={expanded ? t("접기") : t("자세히 보기")}
onClick={() => setExpanded((value) => !value)} 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(' ')} 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" autoCorrect="off"
value={draft} value={draft}
onChange={(event) => setDraft(event.target.value)} 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) => { onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault() 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 }> openDevTools: () => Promise<{ opened: boolean; message?: string }>
close: () => void close: () => void
} }
chatTextMenu?: {
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
}
appDiagnostics?: { appDiagnostics?: {
read: () => Promise<{ path: string; content: string }> read: () => Promise<{ path: string; content: string }>
} }
@@ -92,6 +95,8 @@ interface Window {
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
} }
agent: { 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<{ runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => Promise<{
ok: boolean ok: boolean
output: string output: string
@@ -320,6 +325,8 @@ interface Window {
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']> delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
} }
agent: { 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']> runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ReturnType<Window['agent']['runCommand']>
} }
maps: { maps: {
@@ -3,6 +3,7 @@ import Editor, { DiffEditor, type BeforeMount, type OnMount } from '@monaco-edit
import { FileText, Loader2, RotateCw } from 'lucide-react' import { FileText, Loader2, RotateCw } from 'lucide-react'
type Props = { type Props = {
editingLocked?: boolean
fileLoading?: boolean fileLoading?: boolean
fileError?: string fileError?: string
onRetryFile?: () => void onRetryFile?: () => void
@@ -30,6 +31,7 @@ type Props = {
} }
function WorkspaceEditorPane({ function WorkspaceEditorPane({
editingLocked = false,
fileLoading, fileLoading,
fileError, fileError,
onRetryFile, onRetryFile,
@@ -66,6 +68,10 @@ function WorkspaceEditorPane({
language={editorLanguage} language={editorLanguage}
theme={dark ? 'ads-ide-dark' : 'ads-ide-light'} theme={dark ? 'ads-ide-dark' : 'ads-ide-light'}
beforeMount={beforeMount} beforeMount={beforeMount}
onMount={(editor) => {
editor.getOriginalEditor().getModel()?.updateOptions({ tabSize })
editor.getModifiedEditor().getModel()?.updateOptions({ tabSize })
}}
options={{ options={{
automaticLayout: true, automaticLayout: true,
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace', fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
@@ -75,7 +81,6 @@ function WorkspaceEditorPane({
readOnly: true, readOnly: true,
renderSideBySide: true, renderSideBySide: true,
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
tabSize,
wordWrap: wordWrap ? 'on' : 'off' wordWrap: wordWrap ? 'on' : 'off'
}} }}
/> />
@@ -136,7 +141,7 @@ function WorkspaceEditorPane({
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
tabSize, tabSize,
wordWrap: wordWrap ? 'on' : 'off', 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 { t, useLocaleRender } from '../../i18n'
import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react' import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { useId } from 'react'
export type WorkspaceGuideResourceMode = 'material' | 'reference' export type WorkspaceGuideResourceMode = 'material' | 'reference'
@@ -97,6 +98,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
onDownloadResource onDownloadResource
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element { }: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
useLocaleRender() useLocaleRender()
const panelId = useId()
const textDim = dark ? inheritedTextDim : 'text-[#475569]' const textDim = dark ? inheritedTextDim : 'text-[#475569]'
return ( return (
<aside <aside
@@ -147,36 +149,35 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
const canToggle = allowFutureMaterialSteps || previous || active const canToggle = allowFutureMaterialSteps || previous || active
const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl) const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl)
const youtubeVideoId = getYoutubeVideoId(item.videoUrl) const youtubeVideoId = getYoutubeVideoId(item.videoUrl)
const contentId = `${panelId}-material-${index}`
return ( return (
<section <section
key={`${item.title}-${index}`} 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={[ className={[
'rounded-lg border transition', 'rounded-lg border px-3 pt-3 transition',
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3', expanded ? 'pb-4' : 'pb-3',
canToggle ? 'cursor-pointer' : '',
active active
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white' ? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white'
: canToggle : 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' : dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-white'
].join(' ')} ].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={[ <span className={[
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold', '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]' 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> ].join(' ')}><span className="relative top-px leading-none">{index + 1}</span></span>
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}> <span className="block min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-2"> <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> <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"> <span className="flex shrink-0 items-center gap-1.5">
{active && ( {active && (
@@ -193,55 +194,55 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
/> />
)} )}
</span> </span>
</div> </span>
{expanded && ( </span>
<div className="mt-2.5 grid gap-2.5"> </button>
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} /> {expanded && (
{youtubeEmbedUrl && youtubeVideoId && ( <div id={contentId} className="ml-[34px] mt-2.5 grid select-text gap-2.5">
<button <div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
type="button" {youtubeEmbedUrl && youtubeVideoId && (
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(' ')} <button
onClick={(event) => { type="button"
event.stopPropagation() 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(' ')}
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title })) 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="" <img
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100" src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
loading="lazy" alt=""
/> className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" /> loading="lazy"
<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"> <span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
<Play size={22} fill="currentColor" /> <span className="absolute inset-0 grid place-items-center">
</span> <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">
</span> <Play size={22} fill="currentColor" />
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span> </span>
</button> </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>
<section> </button>
<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>
)} )}
<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>
</div> )}
</section> </section>
) )
}) : ( }) : (
@@ -4,7 +4,8 @@ import type React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { ConfirmStatusModal } from '../../components/AppStatusModals' 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 { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments' import type { ChatAttachment } from '../../components/chatAttachments'
@@ -16,6 +17,7 @@ type WorkspaceQaMessage = {
content: string content: string
senderAvatarUrl?: string | null senderAvatarUrl?: string | null
isTyping?: boolean isTyping?: boolean
revealStartedAt?: number
keepExpanded?: boolean keepExpanded?: boolean
} }
@@ -39,6 +41,8 @@ type WorkspaceQaPanelProps = {
agentEnabled: boolean agentEnabled: boolean
agentStatusLoading: boolean agentStatusLoading: boolean
agentPending: boolean agentPending: boolean
agentActivity?: string
agentActionBusy?: boolean
agentPersistenceError?: string | null agentPersistenceError?: string | null
aiPending: boolean aiPending: boolean
teacherThreadStatus: string | null teacherThreadStatus: string | null
@@ -74,7 +78,7 @@ function MessageCopyButton({ content, dark }: { content: string; dark: boolean }
aria-label={copied ? t("복사됨") : t("복사")} aria-label={copied ? t("복사됨") : t("복사")}
onClick={copy} onClick={copy}
className={[ 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]' dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#64748b] hover:bg-[#ede9fe] hover:text-[#1e293b]'
].join(' ')} ].join(' ')}
> >
@@ -108,6 +112,8 @@ export default function WorkspaceQaPanel({
agentEnabled, agentEnabled,
agentStatusLoading, agentStatusLoading,
agentPending, agentPending,
agentActivity,
agentActionBusy,
agentPersistenceError, agentPersistenceError,
aiPending, aiPending,
teacherThreadStatus, teacherThreadStatus,
@@ -126,7 +132,7 @@ export default function WorkspaceQaPanel({
onRejectAgentCommand onRejectAgentCommand
}: WorkspaceQaPanelProps): React.JSX.Element { }: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender() useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null)
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false) const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1] const lastMessage = activeMessages[activeMessages.length - 1]
@@ -207,10 +213,25 @@ export default function WorkspaceQaPanel({
]) ])
const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => { const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
const target = event.target as HTMLElement | null
if (target?.closest('[data-qa-message="true"]')) return
event.preventDefault() 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 ( return (
@@ -296,6 +317,8 @@ export default function WorkspaceQaPanel({
enabled={agentEnabled} enabled={agentEnabled}
statusLoading={agentStatusLoading} statusLoading={agentStatusLoading}
pending={agentPending} pending={agentPending}
activity={agentActivity}
busy={agentPending || agentActionBusy}
messages={agentMessages} messages={agentMessages}
onApply={onApplyAgentPlan} onApply={onApplyAgentPlan}
onOpenChange={onOpenAgentChange} onOpenChange={onOpenAgentChange}
@@ -316,8 +339,19 @@ export default function WorkspaceQaPanel({
<div <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(' ')} 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 }} style={{ left: contextMenu.x, top: contextMenu.y }}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()} 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 <button
type="button" type="button"
disabled={clearDisabled} disabled={clearDisabled}
@@ -362,6 +396,8 @@ function AgentMessages({
enabled, enabled,
statusLoading, statusLoading,
pending, pending,
activity,
busy,
messages, messages,
onApply, onApply,
onOpenChange, onOpenChange,
@@ -372,6 +408,8 @@ function AgentMessages({
enabled: boolean enabled: boolean
statusLoading: boolean statusLoading: boolean
pending: boolean pending: boolean
activity?: string
busy?: boolean
messages: AgentConversationMessage[] messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void onApply: (messageId: string, plan: AgentPlan) => void
onOpenChange: (messageId: string, change: AgentFileChange) => void onOpenChange: (messageId: string, change: AgentFileChange) => void
@@ -390,23 +428,23 @@ function AgentMessages({
if (!enabled) { if (!enabled) {
return ( 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 에이전트를 설정하면 사용할 수 있습니다.")} {t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")}
</div> </ChatEmptyState>
) )
} }
if (!messages.length && !pending) { if (!messages.length && !pending) {
return ( 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("변경 내용은 확인 후 직접 적용합니다.")} {t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}<br />{t("변경 내용은 확인 후 직접 적용합니다.")}
</div> </ChatEmptyState>
) )
} }
return ( return (
<div className="grid gap-3"> <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 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 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> </div>
@@ -414,18 +452,22 @@ function AgentMessages({
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5"> <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> <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="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(' ')}> <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(' ')}>
{message.plan ? ( <ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
<div className="grid gap-3"> {message.plan && (
<div> <>
<div className="font-black"> {!!message.plan.evidence?.length && (
<MarkdownMessage content={message.plan.summary} dark={dark} /> <details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
</div> <summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
<div className="mt-1"> {message.plan.evidence.map(result => (
<MarkdownMessage content={message.plan.explanation} dark={dark} /> <details key={result.id} className="mt-2 min-w-0 border-t border-current/15 pt-2">
</div> <summary className="cursor-pointer break-words">{result.moduleName ? `${result.moduleName} · ` : ''}{result.name} · {result.status === 'ok' ? t("조회 완료") : t("조회 실패")}{result.truncated ? ` · ${t("일부 결과")}` : ''}</summary>
{message.provider && message.model ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{message.provider} · {message.model}</p> : null} <p className="mt-1 break-all">{result.target} · {new Date(result.observedAt).toLocaleTimeString()}</p>
</div> <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 ? ( {message.plan.actionPlan.length > 0 ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}> <div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{(() => { {(() => {
@@ -457,10 +499,10 @@ function AgentMessages({
</div> </div>
) : null} ) : null}
{message.plan.commands.length > 0 ? ( {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) => { {message.plan.commands.map((command) => {
const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const } 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 finished = execution.status === 'completed' || execution.status === 'failed'
const policyRejected = execution.status === 'rejected' const policyRejected = execution.status === 'rejected'
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? '')) || (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
@@ -507,7 +549,7 @@ function AgentMessages({
</div> </div>
) )
})} })}
</div> </fieldset>
) : null} ) : null}
{message.plan.createModule ? ( {message.plan.createModule ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}> <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 ? ( {message.plan.changes.length > 0 || message.plan.createModule ? (
<button <button
type="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!)} 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]" 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("변경 적용")} {message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button> </button>
) : null} ) : null}
</div> </>
) : <p className="whitespace-pre-wrap">{message.content}</p>} )}
</ProgressiveChatResponse>
</div> </div>
<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />
</div> </div>
</div> </div>
))} ))}
{pending ? ( {pending ? (
<div data-qa-message="true" className="flex gap-2.5"> <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> <span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div <div className="min-w-0 flex-1">
role="status" {activity ? <p role="status" className={['mb-2 break-words text-[11px]', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>{activity}</p> : null}
aria-label={t("코드 변경안을 만드는 중입니다.")} <ChatResponseSkeleton dark={dark} 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> </div>
</div> </div>
) : null} ) : null}
@@ -590,9 +625,9 @@ function AiMessages({
useLocaleRender() useLocaleRender()
if (!messages.length && !pending) { if (!messages.length && !pending) {
return ( 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(' ')}> <ChatEmptyState dark={dark}>
<span>{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}</span> {t("AI에게 질문할")}<br />{t("내용을 입력하세요")}
</div> </ChatEmptyState>
) )
} }
@@ -615,16 +650,13 @@ function AiMessages({
<Bot size={15} /> <Bot size={15} />
</span> </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(' ')}> <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} collapseOnSignal={index < messages.length - 1}
collapseSignal={collapseSignal} collapseSignal={collapseSignal}
content={message.content} message={message}
dark={dark} 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>
</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"> <span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
<Bot size={15} /> <Bot size={15} />
</span> </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(' ')}> <ChatResponseSkeleton dark={dark} label={t("답변을 작성 중입니다...")} />
<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>
</div> </div>
)} )}
</div> </div>
@@ -660,9 +689,9 @@ function TeacherMessages({
useLocaleRender() useLocaleRender()
if (!messages.length) { if (!messages.length) {
return ( 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(' ')}> <ChatEmptyState dark={dark}>
<span>{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}</span> {t("강사에게 문의할")}<br />{t("내용을 입력하세요")}
</div> </ChatEmptyState>
) )
} }
@@ -1,6 +1,7 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent' 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 AgentContextRequest = { modules: string[]; logs: string[] }
export type AgentPlanResponse = { export type AgentPlanResponse = {
provider: string provider: string
@@ -9,52 +10,195 @@ export type AgentPlanResponse = {
plan: AgentPlan 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({ export async function resolveAgentContext({
initialContext, initialContext,
requestPlan, requestPlan,
loadRequestedContext, loadRequestedContext,
executeTool,
onProgress,
signal signal
}: { }: {
initialContext: AgentContext initialContext: AgentContext
requestPlan: (context: AgentContext) => Promise<AgentPlanResponse> requestPlan: (context: AgentContext, finalize?: boolean) => Promise<AgentPlanResponse>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext> loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
onProgress?: (label: string) => void
signal: AbortSignal signal: AbortSignal
}): Promise<AgentPlanResponse> { }): Promise<AgentPlanResponse> {
let context = initialContext let cachedContext = initialContext
const requestedModules = new Set<string>() let context = fitAgentContext(cachedContext)
const requestedLogs = new Set<string>() 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 usage: NonNullable<AgentConversationMessage['usage']> = {}
const checkAborted = (): void => { const checkAborted = (): void => {
if (signal.aborted) throw new DOMException('Aborted', 'AbortError') if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
} }
const addUsage = (response: AgentPlanResponse): void => {
// Initial request plus at most five context fetches; only return a finished response.
for (let round = 0; round <= 5; round += 1) {
checkAborted()
const response = await requestPlan(context)
checkAborted()
for (const key of ['promptTokens', 'completionTokens', 'totalTokens'] as const) { for (const key of ['promptTokens', 'completionTokens', 'totalTokens'] as const) {
if (response.usage?.[key] !== undefined) usage[key] = (usage[key] ?? 0) + response.usage[key] 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 modules = [...new Set(response.plan.requestedModules ?? [])]
const logs = [...new Set(response.plan.requestedLogs ?? [])] const logs = [...new Set(response.plan.requestedLogs ?? [])]
if (!modules.length && !logs.length) return { ...response, usage } const tools = response.plan.toolRequests ?? []
if (!loadRequestedContext) throw new Error('추가 파일·로그를 읽을 수 없는 요청입니다. 워크스페이스에서 질문을 다시 보내주세요.') if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
if (round === 5) throw new Error('추가 자료 조회 횟수(5회)를 초과했습니다. 확인할 모듈이나 설정 파일 범위를 좁혀 다시 요청해주세요.') 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 = { const nextRequest = {
modules: modules.filter((name) => !requestedModules.has(name)), modules: modules.filter((name) => !requestedModules.has(name)),
logs: logs.filter((name) => !requestedLogs.has(name)) logs: logs.filter((name) => !requestedLogs.has(name))
} }
if (!nextRequest.modules.length && !nextRequest.logs.length) { if (!nextRequest.modules.length && !nextRequest.logs.length) {
throw new Error(`AI가 이미 조회한 자료를 반복 요청하여 분석을 완료하지 못했습니다 (${[...modules, ...logs].join(', ')}). 해당 모듈의 설치·파일 조회 상태를 확인하고 다시 요청해주세요.`) return finalize()
} }
nextRequest.modules.forEach((name) => requestedModules.add(name)) nextRequest.modules.forEach((name) => requestedModules.add(name))
nextRequest.logs.forEach((name) => requestedLogs.add(name)) nextRequest.logs.forEach((name) => requestedLogs.add(name))
const loaded = await loadRequestedContext(nextRequest) const loaded = await loadRequestedContext(nextRequest)
checkAborted() checkAborted()
const loadedModules = new Map(loaded.modules.map((module) => [module.name, module])) 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) const updated = loadedModules.get(module.name)
loadedModules.delete(module.name) loadedModules.delete(module.name)
// Collectors also return metadata-only entries for other modules. Keep earlier sources. // Collectors also return metadata-only entries for other modules. Keep earlier sources.
@@ -63,14 +207,13 @@ export async function resolveAgentContext({
: module : module
}) })
mergedModules.push(...loadedModules.values()) mergedModules.push(...loadedModules.values())
const mergedLogs = new Map(context.runtimeLogs.map((log) => [log.moduleName, log])) const mergedLogs = new Map(cachedContext.runtimeLogs.map((log) => [log.moduleName, log]))
loaded.runtimeLogs.forEach((log) => mergedLogs.set(log.moduleName, log)) loaded.runtimeLogs.forEach((log) => {
const fileCount = mergedModules.reduce((sum, module) => sum + module.files.length, 0) mergedLogs.delete(log.moduleName)
const characters = mergedModules.reduce((sum, module) => sum + module.files.reduce((count, file) => count + file.path.length + file.content.length, 0), 0) mergedLogs.set(log.moduleName, log)
if (fileCount > 24 || characters > 160_000 || mergedLogs.size > 3) { })
throw new Error('추가 자료가 조회 한도(파일 24개·16만 자, 로그 모듈 3개)를 초과했습니다. 대상 모듈이나 파일 범위를 좁혀 다시 요청해주세요.') cachedContext = { ...cachedContext, modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
} context = fitAgentContext(cachedContext, nextRequest.modules, nextRequest.logs)
context = { modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
} }
throw new Error('AI 분석을 완료하지 못했습니다.') 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 { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync' import { createAgentSessionSync } from './agentSessionSync'
import { resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext' import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
import type { ChatAttachment } from '../../components/chatAttachments' import type { ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
export type AgentFileChange = { export type AgentFileChange = {
expectedHash?: string | null
moduleName: string moduleName: string
path: string path: string
content: string content: string
@@ -20,6 +22,7 @@ export type AgentModulePlan = {
} }
export type AgentCommand = { export type AgentCommand = {
operation?: AgentOperation
id: string id: string
moduleName: string moduleName: string
command: string command: string
@@ -34,6 +37,8 @@ export type AgentCommandExecution = {
} }
export type AgentPlan = { export type AgentPlan = {
toolRequests?: AgentToolRequest[]
evidence?: AgentToolResult[]
summary: string summary: string
explanation: string explanation: string
requestedModules: string[] requestedModules: string[]
@@ -45,6 +50,7 @@ export type AgentPlan = {
} }
export type AgentWorkspaceModule = { export type AgentWorkspaceModule = {
parameters?: string[]
name: string name: string
folderName?: string folderName?: string
category?: string category?: string
@@ -69,6 +75,7 @@ export type AgentConversationMessage = {
content: string content: string
provider?: string provider?: string
model?: string model?: string
revealStartedAt?: number
plan?: AgentPlan plan?: AgentPlan
applyState?: 'ready' | 'applying' | 'applied' | 'error' applyState?: 'ready' | 'applying' | 'applied' | 'error'
applyError?: string applyError?: string
@@ -109,8 +116,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId)) const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null) const [historyError, setHistoryError] = useState<string | null>(null)
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
const [activity, setActivity] = useState('')
const [messages, setMessages] = useState<AgentConversationMessage[]>([]) const [messages, setMessages] = useState<AgentConversationMessage[]>([])
const abortControllerRef = useRef<AbortController | null>(null) const abortControllerRef = useRef<AbortController | null>(null)
const applyingRef = useRef(false)
const tokenReaderRef = useRef(getFreshAccessToken) const tokenReaderRef = useRef(getFreshAccessToken)
tokenReaderRef.current = getFreshAccessToken tokenReaderRef.current = getFreshAccessToken
const onResponseRef = useRef(onResponse) 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')) { if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.') throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
} }
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 구조화된 에이전트 도구를 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.') throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
} }
return response.json() as Promise<T> return response.json() as Promise<T>
@@ -147,7 +157,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
body: JSON.stringify({ body: JSON.stringify({
courseId, courseId,
week: Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : undefined, week: Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : undefined,
messages: batch messages: batch.map(({ revealStartedAt: _revealStartedAt, ...message }) => message)
}) })
}) })
let sync = sessionSyncs.get(scope) let sync = sessionSyncs.get(scope)
@@ -204,7 +214,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}, [authenticated, session]) }, [authenticated, session])
useEffect(() => { useEffect(() => {
const cached = authenticated ? session.restore([]) : [] const cached = authenticated ? recoverInterruptedAgentMessages(session.restore([])) : []
messageStateRef.current = { session, messages: cached } messageStateRef.current = { session, messages: cached }
setMessages(cached) setMessages(cached)
setHistoryError(null) setHistoryError(null)
@@ -219,7 +229,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
requestApi<AgentSessionResponse>(`/chat/agent/session?${query.toString()}`) requestApi<AgentSessionResponse>(`/chat/agent/session?${query.toString()}`)
.then((response) => { .then((response) => {
if (!cancelled) { 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 } messageStateRef.current = { session, messages: restored }
setMessages(restored) setMessages(restored)
} }
@@ -254,6 +264,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
showUserMessage?: boolean showUserMessage?: boolean
attachments?: ChatAttachment[] attachments?: ChatAttachment[]
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext> loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote'
toolResults?: AgentToolResult[]
} = {} } = {}
): Promise<void> => { ): Promise<void> => {
const request = rawRequest.trim() const request = rawRequest.trim()
@@ -265,18 +278,21 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} }
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage]) if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true) setPending(true)
setActivity('요청 분석 중')
const abortController = new AbortController() const abortController = new AbortController()
abortControllerRef.current = abortController abortControllerRef.current = abortController
try { try {
const response = await resolveAgentContext({ const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs }, initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
signal: abortController.signal, signal: abortController.signal,
loadRequestedContext: options.loadRequestedContext, 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', method: 'POST',
signal: abortController.signal, 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 if (abortController.signal.aborted) return
@@ -291,6 +307,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
updateMessages((current) => [...current, { updateMessages((current) => [...current, {
id: `agent-plan-${Date.now()}`, id: `agent-plan-${Date.now()}`,
role: 'assistant', role: 'assistant',
revealStartedAt: Date.now(),
content: plan.summary, content: plan.summary,
provider: response.provider, provider: response.provider,
model: response.model, model: response.model,
@@ -305,20 +322,24 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
updateMessages((current) => [...current, { updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`, id: `agent-error-${Date.now()}`,
role: 'assistant', role: 'assistant',
revealStartedAt: Date.now(),
content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.' content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.'
}]) }])
onResponseRef.current?.()
} finally { } finally {
if (abortControllerRef.current === abortController) { if (abortControllerRef.current === abortController) {
abortControllerRef.current = null abortControllerRef.current = null
setPending(false) setPending(false)
setActivity('')
} }
} }
} }
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => { const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
const message = messages.find((item) => item.id === messageId) 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 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)) updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
try { try {
await apply(message.plan) await apply(message.plan)
@@ -336,6 +357,8 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} catch (error) { } catch (error) {
const applyError = error instanceof Error ? error.message : '변경안을 적용하지 못했습니다.' const applyError = error instanceof Error ? error.message : '변경안을 적용하지 못했습니다.'
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item)) 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, ...message,
commandExecutions: { commandExecutions: {
...message.commandExecutions, ...message.commandExecutions,
[commandId]: execution [commandId]: { ...message.commandExecutions?.[commandId], ...(execution.status === 'running' ? { error: undefined } : {}), ...execution }
} }
} }
: message)) : message))
@@ -382,6 +405,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
...status, ...status,
statusLoading: statusLoading || historyLoading, statusLoading: statusLoading || historyLoading,
pending, pending,
activity,
messages, messages,
persistenceError: persistenceError ?? historyError, persistenceError: persistenceError ?? historyError,
requestPlan, requestPlan,
@@ -11,6 +11,7 @@ export type QaChatMessage = {
createdAt?: string createdAt?: string
senderAvatarUrl?: string | null senderAvatarUrl?: string | null
isTyping?: boolean isTyping?: boolean
revealStartedAt?: number
keepExpanded?: boolean keepExpanded?: boolean
status?: 'ACTIVE' | 'CANCELED' status?: 'ACTIVE' | 'CANCELED'
} }
@@ -55,6 +56,7 @@ type UseQaChatOptions = {
courseId: string courseId: string
weekNumber: number weekNumber: number
globalScope?: boolean globalScope?: boolean
revealInView?: boolean
apiBaseUrl: string apiBaseUrl: string
socketBaseUrl: string socketBaseUrl: string
socketPath: string socketPath: string
@@ -77,6 +79,7 @@ export function useQaChat({
courseId, courseId,
weekNumber, weekNumber,
globalScope = false, globalScope = false,
revealInView = false,
apiBaseUrl, apiBaseUrl,
socketBaseUrl, socketBaseUrl,
socketPath, socketPath,
@@ -369,6 +372,17 @@ export function useQaChat({
}) })
expandedAiMessageIdsRef.current.add(response.message.id) expandedAiMessageIdsRef.current.add(response.message.id)
setAiPending(false) 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) setAiTypingMessageId(response.message.id)
setAiMessages((messages) => [ setAiMessages((messages) => [
...messages, ...messages,
@@ -11,8 +11,10 @@ type ModuleActionResult = {
ok: boolean ok: boolean
message?: string message?: string
} }
type ModuleControlOptions = { background?: boolean; freshSlam?: boolean; onResult?: (result: ModuleActionResult) => void }
type SavedMapPathOverrides = { type SavedMapPathOverrides = {
freshSlam?: boolean
savedMapYamlPath?: string | null savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null savedMapDatabasePath?: string | null
savedMapWorldLayoutPath?: string | null savedMapWorldLayoutPath?: string | null
@@ -70,7 +72,7 @@ type UseWorkspaceModuleControlResult = {
startAllModules: () => Promise<void> startAllModules: () => Promise<void>
stopAllModules: () => Promise<void> stopAllModules: () => Promise<void>
restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean> 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> updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
} }
@@ -108,10 +110,12 @@ export function useWorkspaceModuleControl({
const moduleActionIdRef = useRef(0) const moduleActionIdRef = useRef(0)
const controlGenerationRef = useRef(0) const controlGenerationRef = useRef(0)
const workspaceActiveRef = useRef(workspaceActive) const workspaceActiveRef = useRef(workspaceActive)
const backgroundControlsRef = useRef(0)
useEffect(() => () => { controlGenerationRef.current += 1 }, [courseKey, weekNumber, clonePathPreview, remoteWorkspace])
useEffect(() => { useEffect(() => {
workspaceActiveRef.current = workspaceActive workspaceActiveRef.current = workspaceActive
if (workspaceActive) return if (workspaceActive || backgroundControlsRef.current > 0) return
// Workspace pages remain mounted while another LMS page is shown. Do not // Workspace pages remain mounted while another LMS page is shown. Do not
// leave their control buttons locked behind a detached IPC request. // leave their control buttons locked behind a detached IPC request.
@@ -126,6 +130,7 @@ export function useWorkspaceModuleControl({
), [bulkExcludedModuleKeys]) ), [bulkExcludedModuleKeys])
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { const getModuleRosParams = useCallback((module: ModuleRow, overrides?: {
freshSlam?: boolean
simulatorWorldFile?: string | null simulatorWorldFile?: string | null
savedMapYamlPath?: string | null savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null savedMapDatabasePath?: string | null
@@ -133,7 +138,7 @@ export function useWorkspaceModuleControl({
}): string => { }): string => {
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath const mapDatabasePath = overrides?.freshSlam ? null : overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '') const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini') const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
@@ -175,12 +180,13 @@ export function useWorkspaceModuleControl({
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`) if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
} }
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '') 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) ?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) { if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`) parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true') 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(' ') return parameters.join(' ')
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles]) }, [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]) }, [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> => { const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options: ModuleControlOptions = {}): Promise<boolean> => {
if (!workspaceActiveRef.current) return false if (!workspaceActiveRef.current && !options.background) return false
// The parking area only opts modules out of bulk actions; those modules // The parking area only opts modules out of bulk actions; those modules
// must remain individually controllable while the rest are stopping. // must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = ( const canControlExcludedModuleDuringBulkStop = (
@@ -489,17 +495,18 @@ export function useWorkspaceModuleControl({
const actionId = ++moduleActionIdRef.current const actionId = ++moduleActionIdRef.current
const controlGeneration = controlGenerationRef.current const controlGeneration = controlGenerationRef.current
queuedModuleActionsRef.current.set(module.name, actionId) queuedModuleActionsRef.current.set(module.name, actionId)
if (options.background) backgroundControlsRef.current += 1
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name])) setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null) setModuleInstallError(null)
const execute = async (): Promise<boolean> => { const execute = async (): Promise<boolean> => {
const isCurrentControl = (): boolean => ( const isCurrentControl = (): boolean => (
workspaceActiveRef.current (workspaceActiveRef.current || options.background === true)
&& controlGenerationRef.current === controlGeneration && controlGenerationRef.current === controlGeneration
&& queuedModuleActionsRef.current.get(module.name) === actionId && queuedModuleActionsRef.current.get(module.name) === actionId
) )
try { try {
if (!isCurrentControl()) return false if (!isCurrentControl()) return false
const savedMapOverrides = action === 'start' || action === 'restart' const savedMapOverrides = options.freshSlam ? { freshSlam: true } : action === 'start' || action === 'restart'
? await prepareSavedMap?.() ? await prepareSavedMap?.()
: undefined : undefined
const result = action === 'stop' const result = action === 'stop'
@@ -517,6 +524,7 @@ export function useWorkspaceModuleControl({
: remoteWorkspace : remoteWorkspace
? await remoteModuleStart(module, savedMapOverrides) ? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork) : 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 (!result.ok) throw new Error(result.message)
if (!isCurrentControl()) return false if (!isCurrentControl()) return false
@@ -551,6 +559,7 @@ export function useWorkspaceModuleControl({
return true return true
} catch (error) { } catch (error) {
console.error(`[modules] ${action} failed`, error) console.error(`[modules] ${action} failed`, error)
options.onResult?.({ ok: false, message: error instanceof Error ? error.message : String(error) })
if (!isCurrentControl()) return false if (!isCurrentControl()) return false
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패') setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
@@ -564,6 +573,7 @@ export function useWorkspaceModuleControl({
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
return false return false
} finally { } finally {
if (options.background) backgroundControlsRef.current -= 1
if (queuedModuleActionsRef.current.get(module.name) === actionId) { if (queuedModuleActionsRef.current.get(module.name) === actionId) {
queuedModuleActionsRef.current.delete(module.name) queuedModuleActionsRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name)) setControllingModuleNames((names) => names.filter((name) => name !== module.name))
+7 -1
View File
@@ -703,5 +703,11 @@
"실행 실패": "Failed", "실행 실패": "Failed",
"다시 실행": "Run again", "다시 실행": "Run again",
"명령 출력이 없습니다.": "The command produced no output.", "명령 출력이 없습니다.": "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 { @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 { .monaco-search-line-highlight {
background: rgba(47, 141, 255, 0.12); 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