Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 105342fbf3 | |||
| cc589fd8cf |
Generated
+5
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.15",
|
||||
"version": "1.2.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lms",
|
||||
"version": "1.2.15",
|
||||
"version": "1.2.16",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
@@ -23,8 +23,10 @@
|
||||
"react-i18next": "^17.0.15",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"three": "^0.181.2"
|
||||
"three": "^0.181.2",
|
||||
"unified": "11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
|
||||
+4
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.15",
|
||||
"version": "1.2.16",
|
||||
"description": "Autonomy Studio LMS Electron app",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
@@ -64,8 +64,10 @@
|
||||
"react-i18next": "^17.0.15",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"three": "^0.181.2"
|
||||
"three": "^0.181.2",
|
||||
"unified": "11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
|
||||
+249
-17
@@ -58,13 +58,81 @@ test('keeps logs while loading a later source module', async () => {
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('rejects repeated requests instead of returning a stalled plan', async () => {
|
||||
test('makes one analysis-only completion when the model repeats a context request', async () => {
|
||||
let loads = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response(['Control']),
|
||||
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
|
||||
})), /반복 요청/)
|
||||
let calls = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async (context, finalize) => {
|
||||
calls++
|
||||
if (!finalize) return response(['Control'])
|
||||
assert.equal(context.modules.find(module => module.name === 'Control').files[0].path, 'controller.py')
|
||||
return { ...response(), plan: { ...response().plan, explanation: 'controller.py에서 조향 설정을 확인했습니다.' } }
|
||||
},
|
||||
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'steering = 1' }])], runtimeLogs: [] } }
|
||||
}))
|
||||
assert.equal(loads, 1)
|
||||
assert.equal(calls, 3)
|
||||
assert.match(result.plan.explanation, /조향 설정을 확인/)
|
||||
assert.equal(result.usage.totalTokens, 30)
|
||||
})
|
||||
|
||||
test('finishes honestly without an infinite loop if the model ignores the finalization request', async () => {
|
||||
let loads = 0
|
||||
let calls = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async () => { calls++; return response(['Control']) },
|
||||
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
|
||||
}))
|
||||
assert.equal(loads, 1)
|
||||
assert.equal(calls, 3)
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
assert.deepEqual(plain(result.plan.actionPlan), [])
|
||||
assert.match(result.plan.explanation, /결론을 확정하지 못했습니다/)
|
||||
assert.match(result.plan.explanation, /Control: 제공된 파일 0개/)
|
||||
assert.equal(result.plan.createModule, null)
|
||||
})
|
||||
|
||||
test('uses already provided source files without loading the same module again', async () => {
|
||||
let loads = 0
|
||||
let calls = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'steering = 1' }])], runtimeLogs: [] },
|
||||
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) },
|
||||
loadRequestedContext: async () => { loads++; return { modules: [], runtimeLogs: [] } }
|
||||
}))
|
||||
assert.equal(loads, 0)
|
||||
assert.equal(calls, 2)
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
})
|
||||
|
||||
test('recovery cannot turn repeated source requests into executable commands or edits', async () => {
|
||||
const result = await resolveAgentContext(options({
|
||||
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'source' }])], runtimeLogs: [] },
|
||||
requestPlan: async (_, finalize) => finalize ? {
|
||||
...response(),
|
||||
plan: {
|
||||
...response().plan, actionPlan: ['execute'], commands: [{ id: 'run', command: './run.sh' }],
|
||||
changes: [{ moduleName: 'Control', path: 'control.py', content: 'changed' }], createModule: { name: 'new' }
|
||||
}
|
||||
} : response(['Control']),
|
||||
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
|
||||
}))
|
||||
for (const field of ['commands', 'changes', 'actionPlan']) assert.deepEqual(plain(result.plan[field]), [])
|
||||
assert.equal(result.plan.createModule, null)
|
||||
})
|
||||
|
||||
test('does not publish a finalization response received after cancellation', async () => {
|
||||
const controller = new AbortController()
|
||||
let calls = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
signal: controller.signal,
|
||||
requestPlan: async (_, finalize) => {
|
||||
calls++
|
||||
if (finalize) controller.abort()
|
||||
return finalize ? response() : response(['Control'])
|
||||
}
|
||||
})), { name: 'AbortError' })
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('requests only new resources when the model also repeats an earlier module', async () => {
|
||||
@@ -90,11 +158,14 @@ test('does not start another API call after cancellation during context loading'
|
||||
|
||||
test('bounds the number of distinct automatic context requests', async () => {
|
||||
let calls = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response([`Module${calls++}`]),
|
||||
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] })
|
||||
})), /5회/)
|
||||
assert.equal(calls, 6)
|
||||
let loads = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response([`Module${calls}`]) },
|
||||
loadRequestedContext: async request => { loads++; return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
|
||||
}))
|
||||
assert.equal(calls, 7)
|
||||
assert.equal(loads, 5)
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
})
|
||||
|
||||
test('passes file-read failures to the model for a concrete final explanation', async () => {
|
||||
@@ -109,15 +180,30 @@ test('passes file-read failures to the model for a concrete final explanation',
|
||||
}))
|
||||
})
|
||||
|
||||
test('does not silently truncate accumulated source context beyond API limits', async () => {
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
requestPlan: async () => response(['Control']),
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }])], runtimeLogs: [] })
|
||||
})), /조회 한도/)
|
||||
test('excludes oversized source files with an explicit notice and continues with complete smaller files', async () => {
|
||||
let calls = 0
|
||||
const smallFile = { path: 'config/steering.yaml', content: 'kinematics: ackermann' }
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['Control'])
|
||||
const control = context.modules.find(module => module.name === 'Control')
|
||||
assert.deepEqual(plain(control.files), [smallFile])
|
||||
assert.equal(control.fileContext.status, 'limited')
|
||||
assert.match(control.fileContext.message, /전체 2개 중 1개/)
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }, smallFile])], runtimeLogs: [] })
|
||||
}))
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('does not display a context-only response when no loader is available', async () => {
|
||||
await assert.rejects(resolveAgentContext(options({ requestPlan: async () => response(['Control']) })), /읽을 수 없는/)
|
||||
test('requests a final answer rather than leaving a context-only response when no loader is available', async () => {
|
||||
let calls = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) }
|
||||
}))
|
||||
assert.equal(calls, 2)
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
})
|
||||
|
||||
test('keeps a just-started module running when a stale collector supplies source files', async () => {
|
||||
@@ -133,3 +219,149 @@ test('keeps a just-started module running when a stale collector supplies source
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
|
||||
const files = (name, count, size = 10) => Array.from({ length: count }, (_, index) => ({ path: `${name}-${index}.py`, content: String(index).padEnd(size, 'x') }))
|
||||
const sourceSize = modules => modules.reduce((sum, module) => sum + module.files.reduce((total, file) => total + file.path.length + file.content.length, 0), 0)
|
||||
|
||||
test('balances 24 old and 24 new files instead of failing the second module lookup', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
calls++
|
||||
if (calls === 1) return response(['MKMini'])
|
||||
if (calls === 2) return response(['Control'])
|
||||
assert.equal(context.modules.reduce((sum, module) => sum + module.files.length, 0), 24)
|
||||
for (const name of ['MKMini', 'Control']) {
|
||||
const module = context.modules.find(module => module.name === name)
|
||||
assert.equal(module.files.length, 12)
|
||||
assert.equal(module.fileContext.status, 'limited')
|
||||
assert.match(module.fileContext.message, /전체 24개 중 12개/)
|
||||
}
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name, files(name, 24))), runtimeLogs: [] })
|
||||
}))
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('enforces the combined character budget without slicing source file contents', async () => {
|
||||
let calls = 0
|
||||
const contents = new Set(['a'.repeat(60_000), 'b'.repeat(60_000)])
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['Control'])
|
||||
assert.ok(sourceSize(context.modules) <= 160_000)
|
||||
for (const module of context.modules) {
|
||||
assert.equal(module.files.length, 1)
|
||||
assert.ok(contents.has(module.files[0].content))
|
||||
assert.equal(module.fileContext.status, 'limited')
|
||||
}
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: ['MKMini', 'Control'].map((name, index) => moduleInfo(name, [
|
||||
{ path: 'one.py', content: [...contents][index] },
|
||||
{ path: 'two.py', content: [...contents][index] }
|
||||
])), runtimeLogs: [] }),
|
||||
initialContext: { modules: [moduleInfo('MKMini', [{ path: 'one.py', content: [...contents][0] }, { path: 'two.py', content: [...contents][0] }]), moduleInfo('Control')], runtimeLogs: [] }
|
||||
}))
|
||||
})
|
||||
|
||||
test('prioritizes newly requested source when earlier context nearly fills the character budget', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
initialContext: { modules: [moduleInfo('MKMini', files('old', 2, 70_000)), moduleInfo('Control')], runtimeLogs: [] },
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['Control'])
|
||||
assert.equal(context.modules.find(module => module.name === 'Control').files[0].content, 'new'.repeat(20_000))
|
||||
assert.ok(context.modules.find(module => module.name === 'MKMini').files.length > 0)
|
||||
assert.ok(sourceSize(context.modules) <= 160_000)
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'new'.repeat(20_000) }])], runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
|
||||
test('budgets initial source context before sending the first API request', async () => {
|
||||
const initialContext = { modules: [moduleInfo('Control', files('source', 30))], runtimeLogs: [] }
|
||||
await resolveAgentContext(options({
|
||||
initialContext,
|
||||
requestPlan: async context => {
|
||||
assert.equal(context.modules[0].files.length, 24)
|
||||
assert.equal(context.modules[0].fileContext.status, 'limited')
|
||||
return response()
|
||||
}
|
||||
}))
|
||||
assert.equal(initialContext.modules[0].files.length, 30)
|
||||
assert.equal(initialContext.modules[0].fileContext, undefined)
|
||||
})
|
||||
|
||||
test('rotates older logs out with a notice rather than failing on a fourth module', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${moduleName} log` })) },
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response([], ['D'])
|
||||
assert.deepEqual(plain(context.runtimeLogs.map(log => log.moduleName)), ['D', 'C', 'B'])
|
||||
assert.match(context.runtimeLogs[0].message, /이전 로그 제외: A/)
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [{ moduleName: 'D', state: 'running', content: 'latest error' }] })
|
||||
}))
|
||||
})
|
||||
|
||||
test('counts log messages in the server budget and keeps recent log content', async () => {
|
||||
await resolveAgentContext(options({
|
||||
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${'x'.repeat(20_000)}LATEST`, message: 'm'.repeat(500) })) },
|
||||
requestPlan: async context => {
|
||||
assert.equal(context.runtimeLogs.length, 3)
|
||||
assert.ok(context.runtimeLogs.reduce((sum, log) => sum + log.content.length + (log.message?.length ?? 0), 0) <= 60_000)
|
||||
for (const log of context.runtimeLogs) {
|
||||
assert.ok(log.content.endsWith('LATEST'))
|
||||
assert.match(log.message, /최근 구간/)
|
||||
assert.ok(log.message.length <= 500)
|
||||
}
|
||||
return response()
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
test('collects candidates from every requested module and prioritizes configuration before large source trees', async () => {
|
||||
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
let collector
|
||||
function visit(node) {
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'collectAgentWorkspace') collector = node.initializer.getText(source)
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
assert.ok(collector)
|
||||
const api = {}
|
||||
vm.runInNewContext(ts.transpileModule(`exports.collect = ${collector}`, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, {
|
||||
exports: api,
|
||||
availableModules: ['MKMini', 'Control'].map(name => ({ name, state: 'stopped' })),
|
||||
remoteWorkspace: false,
|
||||
clonePathPreview: '/workspace',
|
||||
currentActiveEditorFile: null,
|
||||
editorFiles: {},
|
||||
window: { localWorkspace: { moduleFiles: { read: async name => ({ files: Object.fromEntries([
|
||||
...files(name, 30).map((file, index) => [`src/node${index}.py`, file.content]),
|
||||
['config/steering.yaml', 'kinematics: ackermann']
|
||||
]) }) } } }
|
||||
})
|
||||
const modules = await api.collect(['MKMini', 'Control'])
|
||||
assert.deepEqual(plain(modules.map(module => module.files.length)), [24, 24])
|
||||
for (const module of modules) assert.equal(module.files[0].path, 'config/steering.yaml')
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['MKMini', 'Control'])
|
||||
for (const module of context.modules) {
|
||||
assert.equal(module.files.length, 12)
|
||||
assert.equal(module.files[0].path, 'config/steering.yaml')
|
||||
}
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async request => ({ modules: await api.collect(request.modules), runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
@@ -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 =>/)
|
||||
})
|
||||
@@ -3,6 +3,10 @@ const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const contextApi = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: contextApi, DOMException })
|
||||
|
||||
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
let payloadExpression
|
||||
@@ -15,9 +19,10 @@ function visit(node) {
|
||||
visit(source)
|
||||
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
|
||||
|
||||
function serialize(attachments) {
|
||||
function serialize(attachments, finalize = false, request = '설정 위치 알려줘') {
|
||||
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
||||
courseId: 'course-1', weekNumber: 3, request: '설정 위치 알려줘', options: { attachments },
|
||||
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
||||
agentPlanRequestText: contextApi.agentPlanRequestText,
|
||||
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
||||
}))
|
||||
}
|
||||
@@ -36,3 +41,18 @@ test('preserves actual attachment contents rather than silently dropping them',
|
||||
const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
|
||||
assert.deepEqual(serialize(attachments).attachments, attachments)
|
||||
})
|
||||
|
||||
test('finalization instruction reaches the API without sending unsupported DTO fields', () => {
|
||||
const body = serialize(undefined, true)
|
||||
assert.match(body.request, /자료 조회는 종료/)
|
||||
assert.match(body.request, /설정 위치 알려줘/)
|
||||
assert.match(body.request, /최종 답변/)
|
||||
assert.equal(Object.hasOwn(body, 'finalize'), false)
|
||||
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'request', 'runtimeLogs', 'week'])
|
||||
})
|
||||
|
||||
test('finalization keeps the request within the backend 4000-character limit', () => {
|
||||
const body = serialize(undefined, true, 'x'.repeat(4000))
|
||||
assert.equal(body.request.length, 4000)
|
||||
assert.match(body.request, /원래 학생 질문/)
|
||||
})
|
||||
|
||||
@@ -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, '복사됨')
|
||||
})
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -25,7 +25,7 @@ function elements(root, predicate) {
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||
}
|
||||
|
||||
function setup(overrides = {}) {
|
||||
function setup(overrides = {}, browser = {}) {
|
||||
const state = []
|
||||
const effects = []
|
||||
let index = 0
|
||||
@@ -34,6 +34,7 @@ function setup(overrides = {}) {
|
||||
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
|
||||
'../../i18n': i18n,
|
||||
'../../components/AppStatusModals': { ConfirmStatusModal },
|
||||
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
|
||||
'../../components/ChatWidgets': { QaInputBox },
|
||||
'react-dom': { createPortal: child => child },
|
||||
react: {
|
||||
@@ -45,7 +46,7 @@ function setup(overrides = {}) {
|
||||
return [state[slot], value => { state[slot] = value }]
|
||||
}
|
||||
}
|
||||
}, { document: { body: {} } }).default
|
||||
}, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default
|
||||
const props = {
|
||||
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
|
||||
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
|
||||
@@ -82,13 +83,46 @@ test('context menu deletion also requests confirmation', () => {
|
||||
const harness = setup()
|
||||
const root = harness.render()
|
||||
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
|
||||
area.props.onContextMenu({ target: { closest: () => null }, preventDefault() {}, clientX: 10, clientY: 10 })
|
||||
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
|
||||
const clear = elements(harness.render(), node => node.type === 'button' && node.props.children === '대화내역 삭제')[0]
|
||||
clear.props.onClick()
|
||||
assert.equal(harness.cleared(), 0)
|
||||
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
|
||||
})
|
||||
|
||||
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
|
||||
test(`native chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
|
||||
let request
|
||||
const node = {}
|
||||
const harness = setup({}, {
|
||||
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
|
||||
chatTextMenu: { show: async options => { request = options; return null } }
|
||||
})
|
||||
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
|
||||
area.props.onContextMenu({
|
||||
currentTarget: { contains: () => inside }, target: { closest: () => ({}) },
|
||||
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
|
||||
})
|
||||
await Promise.resolve()
|
||||
assert.equal(request.kind, 'message')
|
||||
assert.equal(request.canCopy, selected && inside)
|
||||
assert.equal(request.canClear, true)
|
||||
assert.equal(harness.cleared(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('native menu deletion still goes through the shared confirmation dialog', async () => {
|
||||
const harness = setup({}, { chatTextMenu: { show: async () => 'clear' } })
|
||||
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
|
||||
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
|
||||
await Promise.resolve()
|
||||
assert.equal(harness.cleared(), 0)
|
||||
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
||||
assert.ok(modal)
|
||||
modal.props.onConfirm()
|
||||
assert.equal(harness.cleared(), 1)
|
||||
})
|
||||
|
||||
for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
|
||||
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
|
||||
const harness = setup(props)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
`
|
||||
@@ -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('지원하지 않는 런타임 조회입니다.')
|
||||
}
|
||||
@@ -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
@@ -1,8 +1,13 @@
|
||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
||||
import { appLog } from './appLogging'
|
||||
import { checkDockerStatus } from './dockerStatus'
|
||||
import { showChatTextMenu } from './chatTextMenu'
|
||||
import { cameraRuntimeArtifacts } from './cameraRuntime'
|
||||
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
||||
import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
|
||||
import { agentRemoteFilesScript } from './agentRemoteFiles'
|
||||
import { inspectAgentRuntime } from './agentRuntimeTools'
|
||||
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
|
||||
import { resolveContainerTerminal } from './containerTerminal'
|
||||
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
||||
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
||||
@@ -1794,7 +1799,7 @@ function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMa
|
||||
})
|
||||
}
|
||||
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise<string> {
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined', input?: string): Promise<string> {
|
||||
const readProgress = createModuleProgressParser()
|
||||
return new Promise<string>((resolvePromise, rejectPromise) => {
|
||||
let output = ''
|
||||
@@ -1803,7 +1808,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
@@ -1826,13 +1831,17 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (data: string) => {
|
||||
if (input !== undefined) {
|
||||
child.stdin?.on('error', () => { /* Process exit is reported by close/error. */ })
|
||||
child.stdin?.end(input)
|
||||
}
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (data: string) => {
|
||||
stdout += data.toString()
|
||||
output += data.toString()
|
||||
readProgress(data.toString())
|
||||
})
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
output += data.toString()
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
@@ -2219,7 +2228,7 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
|
||||
))
|
||||
}
|
||||
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise<string> {
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number, input?: string): Promise<string> {
|
||||
const normalized = normalizeRemoteTarget(target)
|
||||
return runCommandOutput(
|
||||
'ssh',
|
||||
@@ -2249,6 +2258,7 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri
|
||||
remoteShellEnvironment(process.env),
|
||||
timeoutMs,
|
||||
'stdout',
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6016,6 +6026,86 @@ async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleN
|
||||
}
|
||||
}
|
||||
|
||||
function agentFolder(folderName?: string | null): string | undefined {
|
||||
if (folderName == null) return undefined
|
||||
if (!/^[A-Za-z0-9[\](). _-]{1,160}$/.test(folderName) || folderName === '.' || folderName === '..') throw new Error('모듈 폴더명이 올바르지 않습니다.')
|
||||
return folderName
|
||||
}
|
||||
|
||||
async function runWorkspaceAgentTool(input: AgentToolRequest, workspaceRoot?: string, folderName?: string, remote?: RemoteWorkspaceTarget): Promise<AgentToolResult> {
|
||||
const request = validateAgentTool(input)
|
||||
const target = remote ? `ssh:${normalizeRemoteTarget(remote).host}` : 'local-host'
|
||||
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
|
||||
try {
|
||||
const folder = agentFolder(folderName)
|
||||
const resolved = remote ? await resolveRemoteGitModuleRoot(remote, request.moduleName!, folder) : null
|
||||
const root = resolved?.moduleRoot ?? await findWorkspaceModuleRoot(request.moduleName!, workspaceRoot, folder)
|
||||
if (!root) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||
let payload: unknown
|
||||
if (request.name.startsWith('files.')) {
|
||||
const response = remote
|
||||
? JSON.parse(await runUnqueuedSshOutput(resolved!.target, agentRemoteFilesScript({ root, request }), 30_000))
|
||||
: await runAgentFileTool(root, request)
|
||||
payload = response.data
|
||||
result.truncated = response.truncated
|
||||
} else if (request.name === 'logs.read') {
|
||||
const response = remote
|
||||
? await readRemoteWorkspaceModuleLogs(remote, request.moduleName!, folder)
|
||||
: await readWorkspaceModuleLogs(request.moduleName!, workspaceRoot, folder)
|
||||
payload = { ...response, logs: response.logs.slice(-20_000) }
|
||||
if (!response.ok) result.status = 'error'
|
||||
result.truncated = response.logs.length > 20_000
|
||||
} else {
|
||||
const host = (executable: string, args: string[]): Promise<string> => remote
|
||||
? runUnqueuedSshOutput(resolved!.target, [executable, ...args].map(quoteShellSingle).join(' '), 12_000)
|
||||
: runCommandOutput(executable, args, undefined, undefined, 12_000, 'stdout')
|
||||
const container = async (command: string): Promise<string> => {
|
||||
const runtime = remote ? await resolveRemoteAgentContainer(resolved!.target, root, folder) : await resolveLocalAgentContainer(root, folder)
|
||||
const args = agentContainerCommandArgs(runtime, command, false)
|
||||
// Unlike free-form commands, these fixed probes always have a container-side deadline.
|
||||
args.splice(4, 0, 'timeout', '--signal=INT', '--kill-after=2s', '10s')
|
||||
return host('docker', args)
|
||||
}
|
||||
payload = await inspectAgentRuntime(request, host, container)
|
||||
if (request.name === 'network.inspect' && (payload as { container?: { error?: string } }).container?.error) result.truncated = true
|
||||
if (request.name === 'ros.inspect') result.target += ':module-container'
|
||||
}
|
||||
result.data = JSON.stringify(payload)
|
||||
if (result.data.length > 60_000) {
|
||||
result.data = JSON.stringify({ message: '조회 결과가 용량 한도를 초과했습니다. 파일 범위나 검색어를 좁혀주세요.', excerpt: result.data.slice(0, 20_000) })
|
||||
result.truncated = true
|
||||
}
|
||||
} catch (error) {
|
||||
result.status = 'error'
|
||||
result.data = JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
let agentFileApplyPending = false
|
||||
async function applyWorkspaceAgentFiles(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
||||
if (agentFileApplyPending) throw new Error('다른 파일 변경안을 적용 중입니다.')
|
||||
agentFileApplyPending = true
|
||||
try { return await applyWorkspaceAgentFilesOnce(changes, workspaceRoot, remote) }
|
||||
finally { agentFileApplyPending = false }
|
||||
}
|
||||
|
||||
async function applyWorkspaceAgentFilesOnce(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
||||
if (!Array.isArray(changes) || !changes.length || changes.length > 24) throw new Error('변경 파일 수를 확인하세요.')
|
||||
const resolved: Array<AgentCheckedChange & { root: string }> = []
|
||||
for (const change of changes) {
|
||||
if (!change || typeof change.content !== 'string' || change.content.length > 200_000 || (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash))) throw new Error('검증된 파일 변경안이 아닙니다.')
|
||||
validateAgentTool({ id: 'apply-check', name: 'files.read', moduleName: change.moduleName, args: { path: change.path } })
|
||||
const folder = agentFolder(change.folderName)
|
||||
const root = remote ? (await resolveRemoteGitModuleRoot(remote, change.moduleName, folder)).moduleRoot : await findWorkspaceModuleRoot(change.moduleName, workspaceRoot, folder)
|
||||
if (!root) throw new Error(`변경 대상 모듈이 설치되지 않았습니다: ${change.moduleName}`)
|
||||
resolved.push({ ...change, root })
|
||||
}
|
||||
if (remote) await runUnqueuedSshOutput(remote, 'bash --noprofile --norc -s', 30_000, agentRemoteFilesScript({ changes: resolved }))
|
||||
else await applyAgentFiles(resolved)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
|
||||
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
|
||||
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
|
||||
@@ -6681,6 +6771,25 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
||||
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
|
||||
const checkAgentSender = (event: Electron.IpcMainInvokeEvent): void => {
|
||||
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error('허용되지 않은 에이전트 요청입니다.')
|
||||
}
|
||||
ipcMain.handle('local-workspace:agent-tool:read', (event, request: AgentToolRequest, workspaceRoot?: string, folderName?: string) => {
|
||||
checkAgentSender(event)
|
||||
return runWorkspaceAgentTool(request, workspaceRoot, folderName)
|
||||
})
|
||||
ipcMain.handle('remote-workspace:agent-tool:read', (event, target: RemoteWorkspaceTarget, request: AgentToolRequest, folderName?: string) => {
|
||||
checkAgentSender(event)
|
||||
return runWorkspaceAgentTool(request, undefined, folderName, target)
|
||||
})
|
||||
ipcMain.handle('local-workspace:agent-files:apply', (event, changes: AgentCheckedChange[], workspaceRoot?: string) => {
|
||||
checkAgentSender(event)
|
||||
return applyWorkspaceAgentFiles(changes, workspaceRoot)
|
||||
})
|
||||
ipcMain.handle('remote-workspace:agent-files:apply', (event, target: RemoteWorkspaceTarget, changes: AgentCheckedChange[]) => {
|
||||
checkAgentSender(event)
|
||||
return applyWorkspaceAgentFiles(changes, undefined, target)
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
|
||||
ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose))
|
||||
ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
|
||||
@@ -6927,6 +7036,7 @@ app.whenReady().then(() => {
|
||||
return { opened: true }
|
||||
})
|
||||
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
|
||||
ipcMain.handle('chat-text:context-menu', showChatTextMenu)
|
||||
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
|
||||
ipcMain.on('app-quit', () => app.quit())
|
||||
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
|
||||
|
||||
@@ -117,6 +117,9 @@ console.info('[lms-preload] launcher auth resolved', {
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('chatTextMenu', {
|
||||
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options)
|
||||
})
|
||||
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
||||
contextBridge.exposeInMainWorld('lmsBootSplash', {
|
||||
status: (message: string) => ipcRenderer.send('lms-boot-status', message),
|
||||
@@ -216,6 +219,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
|
||||
},
|
||||
agent: {
|
||||
readTool: (request: import('../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-tool:read', request, workspaceRoot, folderName),
|
||||
applyFiles: (changes: import('../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:agent-files:apply', changes, workspaceRoot),
|
||||
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-command:run', moduleName, command, workspaceRoot, folderName)
|
||||
},
|
||||
modules: {
|
||||
@@ -304,6 +309,8 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
||||
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
|
||||
},
|
||||
agent: {
|
||||
readTool: (target: RemoteWorkspaceTarget, request: import('../shared/agentTools').AgentToolRequest, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-tool:read', target, request, folderName),
|
||||
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../shared/agentTools').AgentCheckedChange[]) => ipcRenderer.invoke('remote-workspace:agent-files:apply', target, changes),
|
||||
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-command:run', target, moduleName, command, folderName)
|
||||
},
|
||||
maps: {
|
||||
|
||||
+189
-39
@@ -12,7 +12,7 @@ import type { Socket } from 'socket.io-client'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import DockerSetupNotice from './components/DockerSetupNotice'
|
||||
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
|
||||
import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import { ConfirmStatusModal, ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
|
||||
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
|
||||
@@ -58,6 +58,8 @@ import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminal
|
||||
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
|
||||
import { useQaChat } from './features/workspace/useQaChat'
|
||||
import { useAiAgent } from './features/workspace/useAiAgent'
|
||||
import { validateAgentOperation, validateAgentTool, type AgentToolRequest, type AgentToolResult } from '../../shared/agentTools'
|
||||
import { AGENT_PRACTICE_PROFILES, inspectAgentPractice, prepareAgentPractice } from './features/workspace/agentPractice'
|
||||
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
|
||||
import type { ChatAttachment } from './components/chatAttachments'
|
||||
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
|
||||
@@ -566,6 +568,7 @@ function LocalPcWorkspaceMockup({
|
||||
accessToken,
|
||||
courseId: courseKey,
|
||||
weekNumber,
|
||||
revealInView: true,
|
||||
apiBaseUrl: LMS_API_BASE_URL,
|
||||
socketBaseUrl: LMS_SOCKET_ORIGIN,
|
||||
socketPath: LMS_SOCKET_PATH,
|
||||
@@ -626,6 +629,7 @@ function LocalPcWorkspaceMockup({
|
||||
enabled: agentEnabled,
|
||||
statusLoading: agentStatusLoading,
|
||||
pending: agentPending,
|
||||
activity: agentActivity,
|
||||
messages: agentMessages,
|
||||
persistenceError: agentPersistenceError,
|
||||
requestPlan: requestAgentPlan,
|
||||
@@ -642,6 +646,10 @@ function LocalPcWorkspaceMockup({
|
||||
getFreshAccessToken,
|
||||
onResponse: () => onTitlebarNotice(t("AI 에이전트 답변이 도착했습니다."), 'info', true)
|
||||
})
|
||||
const [agentActionBusy, setAgentActionBusy] = useState(false)
|
||||
const agentApplying = agentMessages.some(message => message.applyState === 'applying')
|
||||
const agentActionControllerRef = useRef<AbortController | null>(null)
|
||||
const [agentPracticeApproval, setAgentPracticeApproval] = useState<{ messageId: string; command: AgentCommand; starts: string[]; modules: string[]; parameters: Record<string, string[]>; runtimeSettings: string } | null>(null)
|
||||
const [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
|
||||
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
|
||||
const [teamGitSyncing, setTeamGitSyncing] = useState(false)
|
||||
@@ -704,6 +712,8 @@ function LocalPcWorkspaceMockup({
|
||||
finishTabDrop,
|
||||
resetTabDrag
|
||||
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
|
||||
const agentEditorFilesRef = useRef(editorFiles)
|
||||
agentEditorFilesRef.current = editorFiles
|
||||
const [agentDiffPreviews, setAgentDiffPreviews] = useState<AgentDiffPreview[]>([])
|
||||
const [activeAgentDiffPreviewId, setActiveAgentDiffPreviewId] = useState<string | null>(null)
|
||||
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
|
||||
@@ -756,6 +766,11 @@ function LocalPcWorkspaceMockup({
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (agentPracticeApproval) {
|
||||
event.preventDefault()
|
||||
setAgentPracticeApproval(null)
|
||||
return
|
||||
}
|
||||
if (pendingEditorCloseRequest) {
|
||||
event.preventDefault()
|
||||
setPendingEditorCloseRequest(null)
|
||||
@@ -789,7 +804,7 @@ function LocalPcWorkspaceMockup({
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
|
||||
}, [agentPracticeApproval, pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
|
||||
useEffect(() => {
|
||||
if (!controlEndsAt) return
|
||||
setControlCountdownNow(Date.now())
|
||||
@@ -1433,6 +1448,10 @@ function LocalPcWorkspaceMockup({
|
||||
return () => window.removeEventListener('message', handleViewerMessage)
|
||||
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, localVisualizationWorkspaceRoot, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, visualizationBridgeLocation, weekNumber])
|
||||
const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
|
||||
useEffect(() => {
|
||||
setAgentPracticeApproval(null)
|
||||
return () => { agentActionControllerRef.current?.abort() }
|
||||
}, [courseKey, weekNumber, platformId, remoteWorkspaceTargetKey])
|
||||
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
|
||||
const hasRestorableWorkspaceUi = Boolean(
|
||||
window.localStorage.getItem(workspaceUiSessionKey)
|
||||
@@ -3502,6 +3521,7 @@ function LocalPcWorkspaceMockup({
|
||||
folderName: module.folderName,
|
||||
category: module.category,
|
||||
state: module.state,
|
||||
parameters: module.parameters,
|
||||
files: [],
|
||||
...(requestedNames.has(module.name.toLowerCase()) ? {
|
||||
fileContext: {
|
||||
@@ -3520,11 +3540,9 @@ function LocalPcWorkspaceMockup({
|
||||
return /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|zsh|launch|urdf|xacro|rviz|proto|cmake)$/i.test(name)
|
||||
|| ['dockerfile', 'cmakelists.txt', 'package.xml', 'setup.py', 'setup.cfg'].includes(name)
|
||||
}
|
||||
for (const module of targetModules) {
|
||||
let totalCharacters = 0
|
||||
let totalFiles = 0
|
||||
|
||||
for (const module of targetModules) {
|
||||
if (totalFiles >= 24 || totalCharacters >= 140_000) break
|
||||
try {
|
||||
const modulePrefix = `modules/${module.name}`
|
||||
const result = remoteWorkspace
|
||||
@@ -3535,9 +3553,9 @@ function LocalPcWorkspaceMockup({
|
||||
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
|
||||
const sourcePriority = (path: string): number => {
|
||||
if (path === preferredPath) return -100
|
||||
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -60
|
||||
if (/(^|\/)src(\/|$)/.test(path)) return -50
|
||||
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
|
||||
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30
|
||||
return 0
|
||||
}
|
||||
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
|
||||
@@ -3616,12 +3634,43 @@ function LocalPcWorkspaceMockup({
|
||||
modules: await collectAgentWorkspace(request.modules),
|
||||
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
|
||||
})
|
||||
const readAgentModules = async (): Promise<ModuleRow[]> => {
|
||||
const response = remoteWorkspace
|
||||
? await window.remoteWorkspace.modules.list(getRemoteWorkspaceTarget(), runtimeModules ?? [])
|
||||
: await window.localWorkspace.modules.list(clonePathPreview, runtimeModules ?? [])
|
||||
return applyModuleParameterOverrides(response.modules)
|
||||
}
|
||||
const executeWorkspaceAgentTool = async (input: AgentToolRequest): Promise<AgentToolResult> => {
|
||||
const request = validateAgentTool(input)
|
||||
const target = remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
|
||||
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
|
||||
try {
|
||||
if (request.name === 'modules.list' || request.name === 'practice.inspect') {
|
||||
const modules = await readAgentModules()
|
||||
const payload = request.name === 'modules.list'
|
||||
? modules.map(module => ({ name: module.name, state: module.state, category: module.category, parameters: module.parameters }))
|
||||
: (request.args?.profile ? [request.args.profile] : AGENT_PRACTICE_PROFILES.map(profile => profile.id)).map(profile => inspectAgentPractice(profile, modules, remoteWorkspace ? 'remote' : 'local'))
|
||||
return { ...result, data: JSON.stringify(payload) }
|
||||
}
|
||||
const matches = availableModules.filter(module => module.name === request.moduleName)
|
||||
if (matches.length !== 1) throw new Error('대상 모듈이 없거나 같은 이름이 여러 개입니다.')
|
||||
const module = matches[0]
|
||||
return remoteWorkspace
|
||||
? await window.remoteWorkspace.agent.readTool(getRemoteWorkspaceTarget(), request, module.folderName)
|
||||
: await window.localWorkspace.agent.readTool(request, clonePathPreview, module.folderName)
|
||||
} catch (error) {
|
||||
return { ...result, status: 'error', data: JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) }) }
|
||||
}
|
||||
}
|
||||
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentApplying) return
|
||||
try {
|
||||
const modules = await collectAgentWorkspace()
|
||||
await requestAgentPlan(content, modules, [], {
|
||||
attachments,
|
||||
loadRequestedContext: loadAgentRequestedContext
|
||||
loadRequestedContext: loadAgentRequestedContext,
|
||||
executeTool: executeWorkspaceAgentTool,
|
||||
environment: remoteWorkspace ? 'remote' : 'local'
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[agent] workspace collection failed', error)
|
||||
@@ -3637,32 +3686,16 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
|
||||
const rendererPath = `modules/${module.name}/${change.path.replace(/^\/+/, '')}`
|
||||
let originalContent = editorFiles[rendererPath]
|
||||
|
||||
if (originalContent === undefined) {
|
||||
let originalContent: string | undefined
|
||||
try {
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
||||
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const normalizePath = (path: string): string => path.startsWith('modules/')
|
||||
? path
|
||||
: `modules/${module.name}/${path.replace(/^\/+/, '')}`
|
||||
const fileContent = Object.entries(result.files).find(([path]) => normalizePath(path) === rendererPath)?.[1]
|
||||
const fileExists = [...(result.filePaths ?? []), ...Object.keys(result.files)]
|
||||
.some((path) => normalizePath(path) === rendererPath)
|
||||
|
||||
originalContent = fileContent
|
||||
if (originalContent === undefined && fileExists && remoteWorkspace) {
|
||||
originalContent = (await window.remoteWorkspace.moduleFiles.content(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
rendererPath,
|
||||
module.folderName
|
||||
)).content
|
||||
}
|
||||
const result = await executeWorkspaceAgentTool({ id: `preview-${Date.now()}`, name: 'files.read', moduleName: module.name, args: { path: change.path } })
|
||||
const file = JSON.parse(result.data) as { content?: string; hash?: string | null; complete?: boolean }
|
||||
if (result.status !== 'ok' || !file.complete || typeof file.content !== 'string') throw new Error('비교할 원본 파일 전체를 읽지 못했습니다. 다시 분석해주세요.')
|
||||
if (change.expectedHash !== undefined && change.expectedHash !== file.hash) throw new Error('분석 이후 원본 파일이 변경되었습니다. 다시 분석해주세요.')
|
||||
originalContent = file.content
|
||||
} catch (error) {
|
||||
console.warn('[agent] failed to read original file for diff preview', error)
|
||||
}
|
||||
onTitlebarNotice(error instanceof Error ? error.message : '비교할 원본 파일을 읽지 못했습니다.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedModule?.name !== module.name) openModuleSource(module)
|
||||
@@ -3692,7 +3725,79 @@ function LocalPcWorkspaceMockup({
|
||||
return previews.filter((preview) => preview.messageId !== messageId)
|
||||
})
|
||||
}
|
||||
const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record<string, string[]>; runtimeSettings: string }): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentPending || agentApplying) return
|
||||
const message = agentMessages.find(item => item.id === messageId)
|
||||
if (!message?.plan || !command.operation) return
|
||||
const controller = new AbortController()
|
||||
agentActionControllerRef.current = controller
|
||||
setAgentActionBusy(true)
|
||||
const assertActive = (): void => { if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError') }
|
||||
try {
|
||||
const operation = validateAgentOperation(command.operation)
|
||||
const runtimeSettings = JSON.stringify([runtimeSourceSettings, moduleRuntimeImages, vehicleRuntimeProfiles])
|
||||
if (approval && approval.runtimeSettings !== runtimeSettings) throw new Error('승인 이후 ROS Domain 또는 실행 환경 설정이 바뀌었습니다. 다시 승인해주세요.')
|
||||
const freshModules = await readAgentModules()
|
||||
assertActive()
|
||||
const candidates = freshModules.filter(item => item.name === command.moduleName)
|
||||
if (candidates.length !== 1 || candidates[0].state === 'uninstalled') throw new Error('대상 모듈의 설치 상태를 확인해주세요.')
|
||||
const module = candidates[0]
|
||||
if (getDirtyModuleFiles(module.name).length) throw new Error('저장하지 않은 변경 사항이 있습니다. 저장 후 다시 승인해주세요.')
|
||||
const environment = remoteWorkspace ? 'remote' as const : 'local' as const
|
||||
if (operation.name === 'practice.prepare' && !approval) {
|
||||
const inspection = inspectAgentPractice(operation.profile!, freshModules, environment)
|
||||
if (inspection.blockers.length) throw new Error(inspection.blockers.join('\n'))
|
||||
if (command.moduleName !== inspection.profile.modules.at(-1)) throw new Error('실습 구성표의 SLAM 모듈을 대상으로 지정해야 합니다.')
|
||||
setAgentPracticeApproval({ messageId, command, starts: inspection.starts, modules: [...inspection.profile.modules], parameters: Object.fromEntries(inspection.required.map(item => [item.name, item.parameters])), runtimeSettings })
|
||||
return
|
||||
}
|
||||
updateAgentCommandExecution(messageId, command.id, { status: 'running', output: '실행 전 상태 확인 중' })
|
||||
let output: unknown
|
||||
let ok: boolean
|
||||
if (operation.name === 'practice.prepare') {
|
||||
output = await prepareAgentPractice({
|
||||
profileId: operation.profile!, environment, approvedStarts: approval!.starts, approvedParameters: approval!.parameters, signal: controller.signal,
|
||||
list: readAgentModules,
|
||||
start: async target => {
|
||||
if (getDirtyModuleFiles(target.name).length) throw new Error(`${target.name}: 저장하지 않은 파일이 있습니다.`)
|
||||
let failure: string | undefined
|
||||
const ok = await runModuleControl(target, 'start', { background: true, freshSlam: true, onResult: result => { if (!result.ok) failure = result.message?.slice(-6000) } })
|
||||
if (!ok && failure) throw new Error(`${target.name}: ${failure}`)
|
||||
return ok
|
||||
},
|
||||
probe: (target, topics) => executeWorkspaceAgentTool({ id: `verify-${Date.now()}`, name: 'ros.inspect', moduleName: target.name, args: { topics } }),
|
||||
progress: (text, steps) => updateAgentCommandExecution(messageId, command.id, { status: 'running', output: JSON.stringify({ phase: text, steps }, null, 2) })
|
||||
})
|
||||
ok = (output as { ready: boolean }).ready
|
||||
} else {
|
||||
let detail: string | undefined
|
||||
ok = operation.name === 'modules.start' && module.state === 'running'
|
||||
? true
|
||||
: await runModuleControl(module, operation.name === 'modules.start' ? 'start' : 'build', { background: true, onResult: result => { detail = result.message?.slice(-6000) } })
|
||||
output = { ok, operation: operation.name, moduleName: module.name, message: detail, note: operation.name === 'modules.start' ? '프로세스 시작 결과입니다. 센서·TF 정상 여부는 별도 검증이 필요합니다.' : '빌드 결과입니다. 전체 빌드 로그를 수집한 것은 아니며 경고 해소·기능 정상 여부는 별도 검증이 필요합니다.' }
|
||||
}
|
||||
assertActive()
|
||||
const serialized = JSON.stringify(output, null, 2)
|
||||
updateAgentCommandExecution(messageId, command.id, { status: ok ? 'completed' : 'failed', output: serialized, exitCode: ok ? 0 : 1 })
|
||||
onTitlebarNotice(operation.name === 'practice.prepare' ? ok ? 'SLAM 실습 준비와 데이터 검증이 완료되었습니다.' : 'SLAM 준비 검증에 실패했습니다. 실행 결과를 확인해주세요.' : ok ? '에이전트 승인 작업이 완료되었습니다.' : '에이전트 승인 작업에 실패했습니다.', ok ? 'info' : 'error', true)
|
||||
const messageIndex = agentMessages.findIndex(item => item.id === messageId)
|
||||
const originalRequest = [...agentMessages.slice(0, messageIndex)].reverse().find(item => item.role === 'user')?.content ?? message.plan.summary
|
||||
const currentModules = await readAgentModules()
|
||||
assertActive()
|
||||
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
|
||||
showUserMessage: false, environment, executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
|
||||
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
|
||||
})
|
||||
} catch (error) {
|
||||
const reason = controller.signal.aborted ? '다음 단계 진행을 중단했습니다. 이미 시작된 모듈과 진행 중인 단일 작업은 자동 종료하지 않았습니다.' : error instanceof Error ? error.message : '승인 작업을 처리하지 못했습니다.'
|
||||
updateAgentCommandExecution(messageId, command.id, { status: 'failed', error: reason, exitCode: null })
|
||||
onTitlebarNotice(reason, 'error', true)
|
||||
} finally {
|
||||
if (agentActionControllerRef.current === controller) { agentActionControllerRef.current = null; setAgentActionBusy(false) }
|
||||
}
|
||||
}
|
||||
const runWorkspaceAgentCommand = async (messageId: string, command: AgentCommand): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentPending) return
|
||||
const message = agentMessages.find((item) => item.id === messageId)
|
||||
const currentExecution = message?.commandExecutions?.[command.id]
|
||||
if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
|
||||
@@ -3701,6 +3806,10 @@ function LocalPcWorkspaceMockup({
|
||||
onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error')
|
||||
return
|
||||
}
|
||||
if (command.operation) {
|
||||
await runStructuredAgentCommand(messageId, command)
|
||||
return
|
||||
}
|
||||
|
||||
const module = availableModules.find((item) => item.name === command.moduleName)
|
||||
?? (selectedModule?.name === command.moduleName ? selectedModule : null)
|
||||
@@ -3756,7 +3865,9 @@ function LocalPcWorkspaceMockup({
|
||||
].filter(Boolean).join('\n')
|
||||
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], {
|
||||
showUserMessage: false,
|
||||
loadRequestedContext: loadAgentRequestedContext
|
||||
loadRequestedContext: loadAgentRequestedContext,
|
||||
executeTool: executeWorkspaceAgentTool,
|
||||
environment: remoteWorkspace ? 'remote' : 'local'
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
|
||||
@@ -3775,6 +3886,13 @@ function LocalPcWorkspaceMockup({
|
||||
updateAgentCommandExecution(messageId, command.id, { status: 'rejected' })
|
||||
}
|
||||
const applyWorkspaceAgentPlan = async (plan: AgentPlan): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentPending) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 적용해주세요.')
|
||||
const editorSnapshot = agentEditorFilesRef.current
|
||||
for (const change of plan.changes) {
|
||||
if (plan.createModule && change.moduleName !== plan.createModule.name) throw new Error('새 모듈 생성과 기존 모듈 수정은 별도로 진행해주세요.')
|
||||
if (getDirtyModuleFiles(change.moduleName).includes(`modules/${change.moduleName}/${change.path}`)) throw new Error(`저장하지 않은 파일입니다: ${change.moduleName}/${change.path}. 먼저 저장하거나 변경을 취소해주세요.`)
|
||||
if (change.expectedHash === undefined && !plan.createModule) throw new Error('원본 파일 검증 정보가 없습니다. 다시 분석한 변경안을 적용해주세요.')
|
||||
}
|
||||
let createdModule: ModuleRow | null = null
|
||||
if (plan.createModule) {
|
||||
if (availableModules.some((module) => module.name === plan.createModule?.name)) {
|
||||
@@ -3810,23 +3928,34 @@ function LocalPcWorkspaceMockup({
|
||||
if (createdModule?.name === moduleName) return createdModule
|
||||
return availableModules.find((module) => module.name === moduleName) ?? (selectedModule?.name === moduleName ? selectedModule : null)
|
||||
}
|
||||
if (!createdModule && plan.changes.length) {
|
||||
const changes = plan.changes.map(change => {
|
||||
const module = resolveModule(change.moduleName)
|
||||
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
|
||||
return { ...change, folderName: module.folderName, expectedHash: change.expectedHash! }
|
||||
})
|
||||
if (remoteWorkspace) await window.remoteWorkspace.agent.applyFiles(getRemoteWorkspaceTarget(), changes)
|
||||
else await window.localWorkspace.agent.applyFiles(changes, clonePathPreview)
|
||||
}
|
||||
let firstChangedFile: { module: ModuleRow; path: string } | null = null
|
||||
for (const change of plan.changes) {
|
||||
const module = resolveModule(change.moduleName)
|
||||
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
|
||||
const rendererPath = `modules/${module.name}/${change.path}`
|
||||
if (remoteWorkspace) {
|
||||
if (createdModule && remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), module.name, rendererPath, 'file', module.folderName)
|
||||
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), module.name, rendererPath, change.content, module.folderName)
|
||||
} else {
|
||||
} else if (createdModule) {
|
||||
await window.localWorkspace.moduleFiles.create(module.name, rendererPath, 'file', clonePathPreview, module.folderName)
|
||||
await window.localWorkspace.moduleFiles.write(module.name, rendererPath, change.content, clonePathPreview, module.folderName)
|
||||
}
|
||||
setEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
|
||||
const editedDuringApply = agentEditorFilesRef.current[rendererPath] !== editorSnapshot[rendererPath]
|
||||
setEditorFiles((files) => ({ ...files, [rendererPath]: files[rendererPath] !== editorSnapshot[rendererPath] ? files[rendererPath] : change.content }))
|
||||
setSavedEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
|
||||
setDirtyEditorFiles((files) => {
|
||||
const next = { ...files }
|
||||
delete next[rendererPath]
|
||||
if (editedDuringApply) next[rendererPath] = true
|
||||
else delete next[rendererPath]
|
||||
return next
|
||||
})
|
||||
if (!firstChangedFile) firstChangedFile = { module, path: rendererPath }
|
||||
@@ -4410,6 +4539,7 @@ function LocalPcWorkspaceMockup({
|
||||
/>
|
||||
|
||||
<WorkspaceEditorPane
|
||||
editingLocked={agentApplying}
|
||||
fileLoading={remoteEditorFile.loading}
|
||||
fileError={remoteEditorFile.error}
|
||||
onRetryFile={remoteEditorFile.retry}
|
||||
@@ -4430,7 +4560,7 @@ function LocalPcWorkspaceMockup({
|
||||
beforeMount={configureMonacoTheme}
|
||||
onMount={handleEditorMount}
|
||||
onChange={(value) => {
|
||||
if (!currentActiveEditorFile) return
|
||||
if (!currentActiveEditorFile || agentApplying) return
|
||||
updateEditorFileContent(currentActiveEditorFile, value ?? '', exampleActive)
|
||||
}}
|
||||
/>
|
||||
@@ -4583,11 +4713,13 @@ function LocalPcWorkspaceMockup({
|
||||
agentEnabled={agentEnabled}
|
||||
agentStatusLoading={agentStatusLoading}
|
||||
agentPending={agentPending}
|
||||
agentActivity={agentActivity}
|
||||
agentActionBusy={agentActionBusy || agentApplying}
|
||||
agentPersistenceError={agentPersistenceError}
|
||||
aiPending={aiPending}
|
||||
teacherThreadStatus={teacherThreadStatus}
|
||||
qaCollapseSignal={qaCollapseSignal}
|
||||
inputBusy={qaMode === 'agent' ? agentPending : aiPending || Boolean(aiTypingMessageId)}
|
||||
inputBusy={qaMode === 'agent' ? agentPending || agentActionBusy || agentApplying : aiPending || Boolean(aiTypingMessageId)}
|
||||
inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))}
|
||||
workspaceChatScrollRef={workspaceChatScrollRef}
|
||||
onMinimize={() => setRightPanelMinimized(true)}
|
||||
@@ -4595,7 +4727,7 @@ function LocalPcWorkspaceMockup({
|
||||
setQaMode(mode)
|
||||
if (mode === 'teacher') setTeacherChatFlash(false)
|
||||
}}
|
||||
onStop={qaMode === 'agent' ? stopAgent : stopAiResponse}
|
||||
onStop={qaMode === 'agent' ? () => { agentActionControllerRef.current?.abort(); stopAgent() } : stopAiResponse}
|
||||
onSubmit={(content, attachments) => {
|
||||
if (qaMode === 'agent') void submitAgentRequest(content, attachments)
|
||||
else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
|
||||
@@ -4617,6 +4749,24 @@ function LocalPcWorkspaceMockup({
|
||||
})}
|
||||
/>
|
||||
|
||||
{agentPracticeApproval && (
|
||||
<ConfirmStatusModal
|
||||
dark={dark}
|
||||
overlayClassName="fixed inset-0 z-[200] grid place-items-center bg-black/65 p-5"
|
||||
icon={<Monitor size={18} className="text-[#2563eb]" />}
|
||||
title="SLAM 실습 준비"
|
||||
message={`시작할 모듈: ${agentPracticeApproval.starts.join(', ') || '없음 (이미 실행 중)'}`}
|
||||
detail={`검증 대상: ${agentPracticeApproval.modules.join(', ')}. 순서대로 시작한 뒤 센서·지도·TF를 확인합니다. 차량 이동 명령은 보내지 않습니다. 실패해도 이미 시작된 모듈은 자동 종료하지 않습니다.`}
|
||||
confirmLabel="승인하고 준비"
|
||||
onCancel={() => setAgentPracticeApproval(null)}
|
||||
onConfirm={() => {
|
||||
const approval = agentPracticeApproval
|
||||
setAgentPracticeApproval(null)
|
||||
void runStructuredAgentCommand(approval.messageId, approval.command, approval)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspaceRestoreLoading && (
|
||||
<PlatformLoadingOverlay
|
||||
title={t("{{v0}} 화면 불러오는 중", { v0: platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName })}
|
||||
|
||||
@@ -59,23 +59,24 @@ export function ChatAvatar({
|
||||
)
|
||||
}
|
||||
|
||||
export function MarkdownMessage({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
|
||||
export function MarkdownMessage({ content, dark, fadeFrom }: { content: string; dark: boolean; fadeFrom?: number }): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const textColor = dark ? 'text-white/76' : 'text-[#4f596b]'
|
||||
const headingColor = dark ? 'text-white/92' : 'text-[#111827]'
|
||||
const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]'
|
||||
const codeBg = dark ? 'bg-[#171717] text-white/82' : 'bg-[#f1f5f9] text-[#334155]'
|
||||
const fadeClass = (offset?: number): string => fadeFrom !== undefined && offset !== undefined && offset >= fadeFrom ? ' agent-response-fade' : ''
|
||||
|
||||
return (
|
||||
<div className={`min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [overflow-wrap:anywhere] [word-break:break-word] ${textColor}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h3>,
|
||||
h4: ({ children }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h4>,
|
||||
p: ({ children }) => <p className="my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]">{children}</p>,
|
||||
h1: ({ children, node }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h1>,
|
||||
h2: ({ children, node }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h2>,
|
||||
h3: ({ children, node }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h3>,
|
||||
h4: ({ children, node }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h4>,
|
||||
p: ({ children, node }) => <p className={`my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</p>,
|
||||
strong: ({ children }) => <strong className={`font-black ${headingColor}`}>{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
a: ({ children, href }) => (
|
||||
@@ -91,22 +92,22 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
|
||||
),
|
||||
ul: ({ children }) => <ul className="my-2 grid gap-1 pl-4">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-2 grid gap-1 pl-4">{children}</ol>,
|
||||
li: ({ children }) => <li className="max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}`}>
|
||||
li: ({ children, node }) => <li className={`max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</li>,
|
||||
blockquote: ({ children, node }) => (
|
||||
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}${fadeClass(node?.position?.start.offset)}`}>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className={`my-3 ${mutedBorder}`} />,
|
||||
pre: ({ children }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}`}>{children}</pre>,
|
||||
hr: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />,
|
||||
pre: ({ children, node }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}${fadeClass(node?.position?.start.offset)}`}>{children}</pre>,
|
||||
code: ({ children, className }) => {
|
||||
if (className) {
|
||||
return <code className="whitespace-pre-wrap break-words font-mono [overflow-wrap:anywhere]">{children}</code>
|
||||
}
|
||||
return <code className={`rounded px-1 py-0.5 font-mono text-[11px] ${dark ? 'bg-white/10 text-[#dbeafe]' : 'bg-[#eef2ff] text-[#1d4ed8]'}`}>{children}</code>
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="my-2 max-w-full overflow-hidden">
|
||||
table: ({ children, node }) => (
|
||||
<div className={`my-2 max-w-full overflow-hidden${fadeClass(node?.position?.start.offset)}`}>
|
||||
<table className={`w-full table-fixed border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
|
||||
</div>
|
||||
),
|
||||
@@ -121,6 +122,33 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatEmptyState({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<div className={['relative mx-4 min-w-0 max-w-full break-words rounded-lg border px-8 py-5', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')}>
|
||||
<span>{children}</span>
|
||||
<span aria-hidden="true" className={['absolute -bottom-[5px] left-7 size-2 rotate-45 border-b border-r', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatResponseSkeleton({ dark, label }: { dark: boolean; label: string }): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
|
||||
>
|
||||
<div className="grid animate-pulse gap-2 motion-reduce:animate-none" aria-hidden="true">
|
||||
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
|
||||
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
|
||||
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExpandableMarkdownMessage({
|
||||
content,
|
||||
dark,
|
||||
@@ -167,6 +195,7 @@ export function ExpandableMarkdownMessage({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? t("접기") : t("자세히 보기")}
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? t("접기") : t("자세히 보기")}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className={['grid h-7 w-full cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white/80' : 'text-[#64748b] hover:bg-[#e2e8f0]/70 hover:text-[#2563eb]'].join(' ')}
|
||||
@@ -296,6 +325,18 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
autoCorrect="off"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onContextMenu={(event) => {
|
||||
if (!window.chatTextMenu) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const input = event.currentTarget
|
||||
void window.chatTextMenu.show({
|
||||
kind: 'input',
|
||||
canCopy: input.selectionEnd > input.selectionStart,
|
||||
canPaste: !input.disabled && !input.readOnly,
|
||||
labels: { copy: t('복사'), paste: t('붙여넣기') }
|
||||
}).catch((error) => console.error('[chat] input menu failed', error))
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ExpandableMarkdownMessage, MarkdownMessage } from './ChatWidgets'
|
||||
import { AGENT_REVEAL_INTERVAL, agentRevealFrame, createAgentRevealDocument } from './agentResponseReveal'
|
||||
|
||||
type ResponseMessage = {
|
||||
content: string
|
||||
revealStartedAt?: number
|
||||
plan?: { summary: string; explanation: string }
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export function ProgressiveChatResponse({ message, dark, children, footerAction, collapseOnSignal = false, collapseSignal = 0, defaultCollapsed = message.revealStartedAt === undefined }: {
|
||||
message: ResponseMessage
|
||||
dark: boolean
|
||||
children?: React.ReactNode
|
||||
footerAction?: React.ReactNode
|
||||
collapseOnSignal?: boolean
|
||||
collapseSignal?: number
|
||||
defaultCollapsed?: boolean
|
||||
}): React.JSX.Element {
|
||||
const content = message.plan?.explanation ?? message.content
|
||||
const modelLabel = [message.provider, message.model].filter(Boolean).join(' · ')
|
||||
const document = useMemo(() => createAgentRevealDocument(content), [content])
|
||||
const [now, setNow] = useState(Date.now)
|
||||
const [reducedMotion, setReducedMotion] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
||||
const frame = agentRevealFrame(document, message.revealStartedAt, now, reducedMotion)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const update = (): void => setReducedMotion(media.matches)
|
||||
media.addEventListener('change', update)
|
||||
return () => media.removeEventListener('change', update)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (message.revealStartedAt === undefined || reducedMotion) return
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const startedAt = message.revealStartedAt
|
||||
const tick = (): void => {
|
||||
const current = Date.now()
|
||||
setNow(current)
|
||||
const remaining = frame.duration - (current - startedAt)
|
||||
if (remaining > 0) timer = setTimeout(tick, Math.min(AGENT_REVEAL_INTERVAL, remaining))
|
||||
}
|
||||
tick()
|
||||
return () => clearTimeout(timer)
|
||||
}, [frame.duration, message.revealStartedAt, reducedMotion])
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" aria-busy={!frame.complete}>
|
||||
<div>
|
||||
{message.plan && (
|
||||
<div className={['font-black', frame.fadeFrom === 0 ? 'agent-response-fade' : ''].join(' ')}>
|
||||
<MarkdownMessage content={message.plan.summary} dark={dark} />
|
||||
</div>
|
||||
)}
|
||||
<div className={message.plan ? 'mt-1' : ''}>
|
||||
{frame.complete && frame.fadeFrom === undefined ? (
|
||||
<ExpandableMarkdownMessage
|
||||
content={content}
|
||||
dark={dark}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
collapseOnSignal={collapseOnSignal}
|
||||
collapseSignal={collapseSignal}
|
||||
/>
|
||||
) : <MarkdownMessage content={frame.content} dark={dark} fadeFrom={frame.fadeFrom} />}
|
||||
</div>
|
||||
</div>
|
||||
{frame.complete ? children : null}
|
||||
{frame.complete && (modelLabel || footerAction) ? (
|
||||
<div className="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2">
|
||||
<p title={modelLabel || undefined} className={['min-w-0 flex-1 truncate text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{modelLabel}</p>
|
||||
{footerAction}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const parser = unified().use(remarkParse).use(remarkGfm)
|
||||
export const AGENT_REVEAL_INTERVAL = 240
|
||||
export const AGENT_REVEAL_FADE_DURATION = 220
|
||||
|
||||
export function createAgentRevealDocument(content: string) {
|
||||
const tree = parser.parse(content)
|
||||
const offsets: number[] = []
|
||||
const definitions: string[] = []
|
||||
for (const node of tree.children) {
|
||||
if (node.type === 'definition' || node.type === 'footnoteDefinition') {
|
||||
definitions.push(content.slice(node.position?.start.offset, node.position?.end.offset))
|
||||
continue
|
||||
}
|
||||
const blocks = node.type === 'list' ? node.children : [node]
|
||||
for (const block of blocks) {
|
||||
if (block.position?.end.offset !== undefined) offsets.push(block.position.end.offset)
|
||||
}
|
||||
}
|
||||
// Group unusually long answers without splitting Markdown blocks or list items.
|
||||
const groupSize = Math.max(1, Math.ceil(offsets.length / 20))
|
||||
const boundaries = offsets.filter((_, index) => (index + 1) % groupSize === 0 || index === offsets.length - 1)
|
||||
if (boundaries.length) boundaries[boundaries.length - 1] = content.length
|
||||
else boundaries.push(content.length)
|
||||
return { content, boundaries, definitions: definitions.join('\n\n') }
|
||||
}
|
||||
|
||||
export function agentRevealFrame(document: ReturnType<typeof createAgentRevealDocument>, startedAt: number | undefined, now: number, reducedMotion = false) {
|
||||
const elapsed = startedAt === undefined ? Infinity : Math.max(0, now - startedAt)
|
||||
const visible = reducedMotion ? document.boundaries.length : Math.min(document.boundaries.length, 1 + Math.floor(elapsed / AGENT_REVEAL_INTERVAL))
|
||||
const complete = visible === document.boundaries.length
|
||||
const end = document.boundaries[visible - 1]
|
||||
const fadeFrom = !reducedMotion && elapsed < (visible - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
|
||||
? (visible > 1 ? document.boundaries[visible - 2] : 0)
|
||||
: undefined
|
||||
return {
|
||||
content: complete ? document.content : document.content.slice(0, end) + (document.definitions ? `\n\n${document.definitions}` : ''),
|
||||
fadeFrom,
|
||||
complete,
|
||||
duration: (document.boundaries.length - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -67,6 +67,9 @@ interface Window {
|
||||
openDevTools: () => Promise<{ opened: boolean; message?: string }>
|
||||
close: () => void
|
||||
}
|
||||
chatTextMenu?: {
|
||||
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
|
||||
}
|
||||
appDiagnostics?: {
|
||||
read: () => Promise<{ path: string; content: string }>
|
||||
}
|
||||
@@ -92,6 +95,8 @@ interface Window {
|
||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
}
|
||||
agent: {
|
||||
readTool: (request: import('../../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
|
||||
applyFiles: (changes: import('../../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => Promise<{ ok: boolean }>
|
||||
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => Promise<{
|
||||
ok: boolean
|
||||
output: string
|
||||
@@ -320,6 +325,8 @@ interface Window {
|
||||
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
|
||||
}
|
||||
agent: {
|
||||
readTool: (target: RemoteWorkspaceTarget, request: import('../../shared/agentTools').AgentToolRequest, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
|
||||
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../../shared/agentTools').AgentCheckedChange[]) => Promise<{ ok: boolean }>
|
||||
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ReturnType<Window['agent']['runCommand']>
|
||||
}
|
||||
maps: {
|
||||
|
||||
@@ -3,6 +3,7 @@ import Editor, { DiffEditor, type BeforeMount, type OnMount } from '@monaco-edit
|
||||
import { FileText, Loader2, RotateCw } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
editingLocked?: boolean
|
||||
fileLoading?: boolean
|
||||
fileError?: string
|
||||
onRetryFile?: () => void
|
||||
@@ -30,6 +31,7 @@ type Props = {
|
||||
}
|
||||
|
||||
function WorkspaceEditorPane({
|
||||
editingLocked = false,
|
||||
fileLoading,
|
||||
fileError,
|
||||
onRetryFile,
|
||||
@@ -66,6 +68,10 @@ function WorkspaceEditorPane({
|
||||
language={editorLanguage}
|
||||
theme={dark ? 'ads-ide-dark' : 'ads-ide-light'}
|
||||
beforeMount={beforeMount}
|
||||
onMount={(editor) => {
|
||||
editor.getOriginalEditor().getModel()?.updateOptions({ tabSize })
|
||||
editor.getModifiedEditor().getModel()?.updateOptions({ tabSize })
|
||||
}}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
|
||||
@@ -75,7 +81,6 @@ function WorkspaceEditorPane({
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize,
|
||||
wordWrap: wordWrap ? 'on' : 'off'
|
||||
}}
|
||||
/>
|
||||
@@ -136,7 +141,7 @@ function WorkspaceEditorPane({
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize,
|
||||
wordWrap: wordWrap ? 'on' : 'off',
|
||||
readOnly: exampleActive || editorValue === 'Binary or large file preview is not available.'
|
||||
readOnly: editingLocked || exampleActive || editorValue === 'Binary or large file preview is not available.'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { useId } from 'react'
|
||||
|
||||
export type WorkspaceGuideResourceMode = 'material' | 'reference'
|
||||
|
||||
@@ -97,6 +98,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
onDownloadResource
|
||||
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const panelId = useId()
|
||||
const textDim = dark ? inheritedTextDim : 'text-[#475569]'
|
||||
return (
|
||||
<aside
|
||||
@@ -147,36 +149,35 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
const canToggle = allowFutureMaterialSteps || previous || active
|
||||
const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl)
|
||||
const youtubeVideoId = getYoutubeVideoId(item.videoUrl)
|
||||
const contentId = `${panelId}-material-${index}`
|
||||
|
||||
return (
|
||||
<section
|
||||
key={`${item.title}-${index}`}
|
||||
role={canToggle ? 'button' : undefined}
|
||||
tabIndex={canToggle ? 0 : undefined}
|
||||
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
|
||||
onKeyDown={canToggle ? (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
onToggleMaterial(index)
|
||||
} : undefined}
|
||||
className={[
|
||||
'rounded-lg border transition',
|
||||
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3',
|
||||
canToggle ? 'cursor-pointer' : '',
|
||||
'rounded-lg border px-3 pt-3 transition',
|
||||
expanded ? 'pb-4' : 'pb-3',
|
||||
active
|
||||
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white'
|
||||
: canToggle
|
||||
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#c5cfdd] bg-white hover:border-[#9cacc2] hover:bg-[#f8fafc]'
|
||||
? dark ? 'border-white/12 bg-[#292929] opacity-92' : 'border-[#c5cfdd] bg-white'
|
||||
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-white'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
|
||||
className={['flex h-6 w-full min-w-0 items-center gap-2.5 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-[#60a5fa] disabled:cursor-default', canToggle ? 'cursor-pointer' : ''].join(' ')}
|
||||
>
|
||||
<span className={[
|
||||
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
|
||||
active ? dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#ddd0fa] text-[#5b21b6]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-[#cbd5e1] text-[#334155]' : dark ? 'bg-white/8 text-white/38' : 'bg-[#d5dbe5] text-[#64748b]'
|
||||
].join(' ')}><span className="relative top-px leading-none">{index + 1}</span></span>
|
||||
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="block min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center justify-between gap-2">
|
||||
<strong title={item.title} className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{active && (
|
||||
@@ -193,9 +194,11 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2.5 grid gap-2.5">
|
||||
<div id={contentId} className="ml-[34px] mt-2.5 grid select-text gap-2.5">
|
||||
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
|
||||
{youtubeEmbedUrl && youtubeVideoId && (
|
||||
<button
|
||||
@@ -240,8 +243,6 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}) : (
|
||||
|
||||
@@ -4,7 +4,8 @@ import type React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ConfirmStatusModal } from '../../components/AppStatusModals'
|
||||
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||
import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
|
||||
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
|
||||
import type { ChatAttachment } from '../../components/chatAttachments'
|
||||
|
||||
@@ -16,6 +17,7 @@ type WorkspaceQaMessage = {
|
||||
content: string
|
||||
senderAvatarUrl?: string | null
|
||||
isTyping?: boolean
|
||||
revealStartedAt?: number
|
||||
keepExpanded?: boolean
|
||||
}
|
||||
|
||||
@@ -39,6 +41,8 @@ type WorkspaceQaPanelProps = {
|
||||
agentEnabled: boolean
|
||||
agentStatusLoading: boolean
|
||||
agentPending: boolean
|
||||
agentActivity?: string
|
||||
agentActionBusy?: boolean
|
||||
agentPersistenceError?: string | null
|
||||
aiPending: boolean
|
||||
teacherThreadStatus: string | null
|
||||
@@ -74,7 +78,7 @@ function MessageCopyButton({ content, dark }: { content: string; dark: boolean }
|
||||
aria-label={copied ? t("복사됨") : t("복사")}
|
||||
onClick={copy}
|
||||
className={[
|
||||
'absolute bottom-2 right-2 z-10 grid h-6 w-6 place-items-center rounded opacity-0 transition group-hover:opacity-100 focus:opacity-100',
|
||||
'grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded opacity-0 transition group-hover:opacity-100 focus-visible:opacity-100',
|
||||
dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#64748b] hover:bg-[#ede9fe] hover:text-[#1e293b]'
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -108,6 +112,8 @@ export default function WorkspaceQaPanel({
|
||||
agentEnabled,
|
||||
agentStatusLoading,
|
||||
agentPending,
|
||||
agentActivity,
|
||||
agentActionBusy,
|
||||
agentPersistenceError,
|
||||
aiPending,
|
||||
teacherThreadStatus,
|
||||
@@ -126,7 +132,7 @@ export default function WorkspaceQaPanel({
|
||||
onRejectAgentCommand
|
||||
}: WorkspaceQaPanelProps): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null)
|
||||
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
|
||||
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
|
||||
const lastMessage = activeMessages[activeMessages.length - 1]
|
||||
@@ -207,10 +213,25 @@ export default function WorkspaceQaPanel({
|
||||
])
|
||||
|
||||
const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.closest('[data-qa-message="true"]')) return
|
||||
event.preventDefault()
|
||||
setContextMenu({ x: event.clientX, y: event.clientY })
|
||||
event.stopPropagation()
|
||||
const selection = window.getSelection()
|
||||
const text = selection?.anchorNode && selection.focusNode && event.currentTarget.contains(selection.anchorNode) && event.currentTarget.contains(selection.focusNode)
|
||||
? selection.toString()
|
||||
: ''
|
||||
if (window.chatTextMenu) {
|
||||
setContextMenu(null)
|
||||
void window.chatTextMenu.show({
|
||||
kind: 'message',
|
||||
canCopy: text.length > 0,
|
||||
canClear: !clearDisabled,
|
||||
labels: { copy: t('복사'), clear: t('대화내역 삭제') }
|
||||
}).then((action) => {
|
||||
if (action === 'clear') requestClearConversation()
|
||||
}).catch((error) => console.error('[chat] message menu failed', error))
|
||||
return
|
||||
}
|
||||
setContextMenu({ x: event.clientX, y: event.clientY, text })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -296,6 +317,8 @@ export default function WorkspaceQaPanel({
|
||||
enabled={agentEnabled}
|
||||
statusLoading={agentStatusLoading}
|
||||
pending={agentPending}
|
||||
activity={agentActivity}
|
||||
busy={agentPending || agentActionBusy}
|
||||
messages={agentMessages}
|
||||
onApply={onApplyAgentPlan}
|
||||
onOpenChange={onOpenAgentChange}
|
||||
@@ -316,8 +339,19 @@ export default function WorkspaceQaPanel({
|
||||
<div
|
||||
className={['fixed z-[140] min-w-[150px] overflow-hidden rounded-md border py-1 shadow-[0_18px_42px_rgba(0,0,0,0.28)]', dark ? 'border-white/10 bg-[#2b2b2b]' : 'border-[#d5dbe7] bg-white'].join(' ')}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!contextMenu.text}
|
||||
className={['flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] font-semibold transition disabled:cursor-default disabled:opacity-35', dark ? 'text-white/78 enabled:hover:bg-white/10' : 'text-[#334155] enabled:hover:bg-[#f1f5f9]'].join(' ')}
|
||||
onClick={() => {
|
||||
const text = contextMenu.text
|
||||
setContextMenu(null)
|
||||
if (text) void navigator.clipboard.writeText(text).catch((error) => console.error('[chat] copy failed', error))
|
||||
}}
|
||||
><Copy size={13} />{t('복사')}</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={clearDisabled}
|
||||
@@ -362,6 +396,8 @@ function AgentMessages({
|
||||
enabled,
|
||||
statusLoading,
|
||||
pending,
|
||||
activity,
|
||||
busy,
|
||||
messages,
|
||||
onApply,
|
||||
onOpenChange,
|
||||
@@ -372,6 +408,8 @@ function AgentMessages({
|
||||
enabled: boolean
|
||||
statusLoading: boolean
|
||||
pending: boolean
|
||||
activity?: string
|
||||
busy?: boolean
|
||||
messages: AgentConversationMessage[]
|
||||
onApply: (messageId: string, plan: AgentPlan) => void
|
||||
onOpenChange: (messageId: string, change: AgentFileChange) => void
|
||||
@@ -390,23 +428,23 @@ function AgentMessages({
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<ChatEmptyState dark={dark}>
|
||||
{t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")}
|
||||
</div>
|
||||
</ChatEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
if (!messages.length && !pending) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<ChatEmptyState dark={dark}>
|
||||
{t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}<br />{t("변경 내용은 확인 후 직접 적용합니다.")}
|
||||
</div>
|
||||
</ChatEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{messages.map((message) => message.role === 'user' ? (
|
||||
{messages.map((message, index) => message.role === 'user' ? (
|
||||
<div key={message.id} data-qa-message="true" className="flex justify-end">
|
||||
<div className="max-w-[82%] break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
|
||||
</div>
|
||||
@@ -414,18 +452,22 @@ function AgentMessages({
|
||||
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
|
||||
<div className="group relative min-w-0 max-w-[calc(100%-38px)]">
|
||||
<div className={['overflow-hidden rounded-lg border px-3 pb-9 pt-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
{message.plan ? (
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<div className="font-black">
|
||||
<MarkdownMessage content={message.plan.summary} dark={dark} />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<MarkdownMessage content={message.plan.explanation} dark={dark} />
|
||||
</div>
|
||||
{message.provider && message.model ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{message.provider} · {message.model}</p> : null}
|
||||
</div>
|
||||
<div className={['overflow-hidden rounded-lg border px-3 py-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
|
||||
{message.plan && (
|
||||
<>
|
||||
{!!message.plan.evidence?.length && (
|
||||
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
|
||||
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
|
||||
{message.plan.evidence.map(result => (
|
||||
<details key={result.id} className="mt-2 min-w-0 border-t border-current/15 pt-2">
|
||||
<summary className="cursor-pointer break-words">{result.moduleName ? `${result.moduleName} · ` : ''}{result.name} · {result.status === 'ok' ? t("조회 완료") : t("조회 실패")}{result.truncated ? ` · ${t("일부 결과")}` : ''}</summary>
|
||||
<p className="mt-1 break-all">{result.target} · {new Date(result.observedAt).toLocaleTimeString()}</p>
|
||||
<pre className="thin-scrollbar mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px]">{result.data}</pre>
|
||||
</details>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
{message.plan.actionPlan.length > 0 ? (
|
||||
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
{(() => {
|
||||
@@ -457,10 +499,10 @@ function AgentMessages({
|
||||
</div>
|
||||
) : null}
|
||||
{message.plan.commands.length > 0 ? (
|
||||
<div className={['order-5 grid gap-3 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
<fieldset disabled={busy} className={['order-5 m-0 grid min-w-0 gap-3 border-0 border-t p-0 pt-2 disabled:cursor-wait', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
{message.plan.commands.map((command) => {
|
||||
const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const }
|
||||
const moduleStartAction = command.command.trim() === 'lms module start'
|
||||
const moduleStartAction = command.command.trim() === 'lms module start' || command.operation?.name === 'modules.start'
|
||||
const finished = execution.status === 'completed' || execution.status === 'failed'
|
||||
const policyRejected = execution.status === 'rejected'
|
||||
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
|
||||
@@ -507,7 +549,7 @@ function AgentMessages({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
{message.plan.createModule ? (
|
||||
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
@@ -541,7 +583,7 @@ function AgentMessages({
|
||||
{message.plan.changes.length > 0 || message.plan.createModule ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
|
||||
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
|
||||
onClick={() => onApply(message.id, message.plan!)}
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
|
||||
>
|
||||
@@ -549,26 +591,19 @@ function AgentMessages({
|
||||
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ProgressiveChatResponse>
|
||||
</div>
|
||||
) : <p className="whitespace-pre-wrap">{message.content}</p>}
|
||||
</div>
|
||||
<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pending ? (
|
||||
<div data-qa-message="true" className="flex gap-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t("코드 변경안을 만드는 중입니다.")}
|
||||
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
|
||||
>
|
||||
<div className="grid animate-pulse gap-2" aria-hidden="true">
|
||||
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
|
||||
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
|
||||
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{activity ? <p role="status" className={['mb-2 break-words text-[11px]', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>{activity}</p> : null}
|
||||
<ChatResponseSkeleton dark={dark} label={t("코드 변경안을 만드는 중입니다.")} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -590,9 +625,9 @@ function AiMessages({
|
||||
useLocaleRender()
|
||||
if (!messages.length && !pending) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<span>{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}</span>
|
||||
</div>
|
||||
<ChatEmptyState dark={dark}>
|
||||
{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}
|
||||
</ChatEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -615,16 +650,13 @@ function AiMessages({
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg border px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'border-transparent bg-[#303030] text-white/75' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
<ExpandableMarkdownMessage
|
||||
<ProgressiveChatResponse
|
||||
collapseOnSignal={index < messages.length - 1}
|
||||
collapseSignal={collapseSignal}
|
||||
content={message.content}
|
||||
message={message}
|
||||
dark={dark}
|
||||
defaultCollapsed={!message.isTyping && !message.keepExpanded}
|
||||
defaultCollapsed={!message.keepExpanded}
|
||||
/>
|
||||
{message.isTyping && (
|
||||
<span className={['mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full', dark ? 'bg-white/65' : 'bg-[#2563eb]'].join(' ')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -634,10 +666,7 @@ function AiMessages({
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<div className={['inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-[12px] font-medium', dark ? 'border-transparent bg-[#303030] text-white/55' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#475569]'].join(' ')}>
|
||||
<span>{t("답변을 작성 중입니다...")}</span>
|
||||
<span className={['h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
|
||||
</div>
|
||||
<ChatResponseSkeleton dark={dark} label={t("답변을 작성 중입니다...")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -660,9 +689,9 @@ function TeacherMessages({
|
||||
useLocaleRender()
|
||||
if (!messages.length) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<span>{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}</span>
|
||||
</div>
|
||||
<ChatEmptyState dark={dark}>
|
||||
{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}
|
||||
</ChatEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
|
||||
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||
|
||||
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[] }
|
||||
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] }
|
||||
export type AgentContextRequest = { modules: string[]; logs: string[] }
|
||||
export type AgentPlanResponse = {
|
||||
provider: string
|
||||
@@ -9,52 +10,195 @@ export type AgentPlanResponse = {
|
||||
plan: AgentPlan
|
||||
}
|
||||
|
||||
export function recoverInterruptedAgentMessages(messages: AgentConversationMessage[]): AgentConversationMessage[] {
|
||||
let changed = false
|
||||
const recovered = messages.map(message => {
|
||||
const interrupted = Object.entries(message.commandExecutions ?? {}).some(([, execution]) => execution.status === 'running')
|
||||
if (!interrupted && message.applyState !== 'applying') return message
|
||||
changed = true
|
||||
const error = '이전 작업의 완료 결과를 확인할 수 없습니다. 현재 모듈·파일 상태를 다시 조회한 뒤 진행해주세요.'
|
||||
return { ...message, ...(message.applyState === 'applying' ? { applyState: 'error' as const, applyError: error } : {}), commandExecutions: Object.fromEntries(Object.entries(message.commandExecutions ?? {}).map(([id, execution]) => [id, execution.status === 'running' ? { ...execution, status: 'failed' as const, error } : execution])) }
|
||||
})
|
||||
return changed ? recovered : messages
|
||||
}
|
||||
|
||||
export function agentPlanRequestText(request: string, finalize = false): string {
|
||||
if (!finalize) return request
|
||||
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
|
||||
return instruction + request.slice(0, 4000 - instruction.length)
|
||||
}
|
||||
|
||||
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext {
|
||||
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
|
||||
const selected = new Map<string, AgentWorkspaceModule['files']>()
|
||||
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
|
||||
let fileCount = 0
|
||||
let characters = 0
|
||||
|
||||
// Interleave complete files so a large module cannot consume every file slot.
|
||||
while (fileCount < 24) {
|
||||
let added = false
|
||||
for (const candidate of candidates) {
|
||||
while (candidate.index < candidate.files.length && fileCount < 24) {
|
||||
const file = candidate.files[candidate.index++]
|
||||
const size = file.path.length + file.content.length
|
||||
if (file.path.length > 500 || file.content.length > 100_000 || characters + size > 160_000) continue
|
||||
const files = selected.get(candidate.module.name) ?? []
|
||||
files.push(file)
|
||||
selected.set(candidate.module.name, files)
|
||||
characters += size
|
||||
fileCount += 1
|
||||
added = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!added) break
|
||||
}
|
||||
|
||||
const modules = context.modules.map((module) => {
|
||||
const files = selected.get(module.name) ?? []
|
||||
if (files.length === module.files.length) return module
|
||||
const note = `요청 용량 제한으로 전체 ${module.files.length}개 중 ${files.length}개 파일만 제공했습니다. 제외된 파일은 확인한 것으로 간주하지 말고 제공된 근거로 답하거나 필요한 자료를 구체적으로 안내하세요.`
|
||||
return {
|
||||
...module,
|
||||
files,
|
||||
fileContext: {
|
||||
status: 'limited' as const,
|
||||
message: `${note} ${module.fileContext?.message ?? ''}`.trim().slice(0, 500)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const logOrder = [...new Set([...preferredLogs, ...context.runtimeLogs.map((log) => log.moduleName).reverse()])]
|
||||
const logMap = new Map(context.runtimeLogs.map((log) => [log.moduleName, log]))
|
||||
const retainedLogs = logOrder.flatMap((name) => logMap.has(name) ? [logMap.get(name)!] : []).slice(0, 3)
|
||||
const omitted = context.runtimeLogs.filter((log) => !retainedLogs.includes(log)).map((log) => log.moduleName)
|
||||
const runtimeLogs = retainedLogs.map((log, index) => {
|
||||
let notes = [
|
||||
log.message,
|
||||
index === 0 && omitted.length ? `로그 모듈 한도로 이전 로그 제외: ${omitted.join(', ')}. 제외된 로그를 확인했다고 주장하지 마세요.` : ''
|
||||
].filter(Boolean).join(' ').slice(0, 500)
|
||||
if (log.content.length + notes.length > 20_000) {
|
||||
notes = `로그 용량 제한으로 최근 구간만 제공합니다. ${notes}`.trim().slice(0, 500)
|
||||
}
|
||||
// The server counts both log content and messages against its 60,000-character cap.
|
||||
const content = log.content.slice(-(20_000 - notes.length))
|
||||
return { ...log, content, ...(notes ? { message: notes } : {}) }
|
||||
})
|
||||
let toolSize = 0
|
||||
const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => {
|
||||
if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false
|
||||
toolSize += result.data.length
|
||||
return true
|
||||
}).slice(0, 12).reverse()
|
||||
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) }
|
||||
}
|
||||
|
||||
export async function resolveAgentContext({
|
||||
initialContext,
|
||||
requestPlan,
|
||||
loadRequestedContext,
|
||||
executeTool,
|
||||
onProgress,
|
||||
signal
|
||||
}: {
|
||||
initialContext: AgentContext
|
||||
requestPlan: (context: AgentContext) => Promise<AgentPlanResponse>
|
||||
requestPlan: (context: AgentContext, finalize?: boolean) => Promise<AgentPlanResponse>
|
||||
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
||||
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
||||
onProgress?: (label: string) => void
|
||||
signal: AbortSignal
|
||||
}): Promise<AgentPlanResponse> {
|
||||
let context = initialContext
|
||||
const requestedModules = new Set<string>()
|
||||
const requestedLogs = new Set<string>()
|
||||
let cachedContext = initialContext
|
||||
let context = fitAgentContext(cachedContext)
|
||||
const requestedModules = new Set(initialContext.modules.filter((module) => module.files.length || module.fileContext).map((module) => module.name))
|
||||
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
|
||||
const queriedTools = new Set<string>()
|
||||
let toolCount = 0
|
||||
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
|
||||
const usage: NonNullable<AgentConversationMessage['usage']> = {}
|
||||
const checkAborted = (): void => {
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
}
|
||||
|
||||
// Initial request plus at most five context fetches; only return a finished response.
|
||||
for (let round = 0; round <= 5; round += 1) {
|
||||
checkAborted()
|
||||
const response = await requestPlan(context)
|
||||
checkAborted()
|
||||
const addUsage = (response: AgentPlanResponse): void => {
|
||||
for (const key of ['promptTokens', 'completionTokens', 'totalTokens'] as const) {
|
||||
if (response.usage?.[key] !== undefined) usage[key] = (usage[key] ?? 0) + response.usage[key]
|
||||
}
|
||||
}
|
||||
const finalize = async (): Promise<AgentPlanResponse> => {
|
||||
checkAborted()
|
||||
const response = await requestPlan(context, true)
|
||||
checkAborted()
|
||||
addUsage(response)
|
||||
const stillRequesting = Boolean(response.plan.requestedModules?.length || response.plan.requestedLogs?.length || response.plan.toolRequests?.length)
|
||||
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
|
||||
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
|
||||
)).join('\n').slice(0, 8000)
|
||||
const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000)
|
||||
const explanation = stillRequesting
|
||||
? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`
|
||||
: response.plan.explanation
|
||||
return {
|
||||
...response,
|
||||
usage,
|
||||
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
|
||||
plan: {
|
||||
...response.plan,
|
||||
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
|
||||
explanation,
|
||||
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxRounds = executeTool ? 8 : 5
|
||||
for (let round = 0; round <= maxRounds; round += 1) {
|
||||
checkAborted()
|
||||
const response = await requestPlan(context)
|
||||
checkAborted()
|
||||
addUsage(response)
|
||||
const modules = [...new Set(response.plan.requestedModules ?? [])]
|
||||
const logs = [...new Set(response.plan.requestedLogs ?? [])]
|
||||
if (!modules.length && !logs.length) return { ...response, usage }
|
||||
if (!loadRequestedContext) throw new Error('추가 파일·로그를 읽을 수 없는 요청입니다. 워크스페이스에서 질문을 다시 보내주세요.')
|
||||
if (round === 5) throw new Error('추가 자료 조회 횟수(5회)를 초과했습니다. 확인할 모듈이나 설정 파일 범위를 좁혀 다시 요청해주세요.')
|
||||
const tools = response.plan.toolRequests ?? []
|
||||
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
|
||||
if (round === maxRounds) return finalize()
|
||||
if (tools.length) {
|
||||
if (!executeTool || tools.length > 6) return finalize()
|
||||
const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool)))
|
||||
if (!fresh.length || toolCount + fresh.length > 24) return finalize()
|
||||
const results = [...(cachedContext.toolResults ?? [])]
|
||||
for (const [index, tool] of fresh.entries()) {
|
||||
checkAborted()
|
||||
const key = fingerprint(tool)
|
||||
if (queriedTools.has(key)) continue
|
||||
queriedTools.add(key)
|
||||
onProgress?.(`${tool.moduleName ? `${tool.moduleName} · ` : ''}${tool.name} 조회 중`)
|
||||
const result = await executeTool({ ...tool, id: `query-${round}-${index}-${tool.id}`.slice(0, 80) })
|
||||
checkAborted()
|
||||
toolCount++
|
||||
results.push(result)
|
||||
}
|
||||
cachedContext = { ...cachedContext, toolResults: results }
|
||||
context = fitAgentContext(cachedContext)
|
||||
onProgress?.('조회 결과 분석 중')
|
||||
// A model may also use legacy source requests; process those on the next pass.
|
||||
continue
|
||||
}
|
||||
if (!loadRequestedContext) return finalize()
|
||||
|
||||
const nextRequest = {
|
||||
modules: modules.filter((name) => !requestedModules.has(name)),
|
||||
logs: logs.filter((name) => !requestedLogs.has(name))
|
||||
}
|
||||
if (!nextRequest.modules.length && !nextRequest.logs.length) {
|
||||
throw new Error(`AI가 이미 조회한 자료를 반복 요청하여 분석을 완료하지 못했습니다 (${[...modules, ...logs].join(', ')}). 해당 모듈의 설치·파일 조회 상태를 확인하고 다시 요청해주세요.`)
|
||||
return finalize()
|
||||
}
|
||||
nextRequest.modules.forEach((name) => requestedModules.add(name))
|
||||
nextRequest.logs.forEach((name) => requestedLogs.add(name))
|
||||
const loaded = await loadRequestedContext(nextRequest)
|
||||
checkAborted()
|
||||
const loadedModules = new Map(loaded.modules.map((module) => [module.name, module]))
|
||||
const mergedModules = context.modules.map((module) => {
|
||||
const mergedModules = cachedContext.modules.map((module) => {
|
||||
const updated = loadedModules.get(module.name)
|
||||
loadedModules.delete(module.name)
|
||||
// Collectors also return metadata-only entries for other modules. Keep earlier sources.
|
||||
@@ -63,14 +207,13 @@ export async function resolveAgentContext({
|
||||
: module
|
||||
})
|
||||
mergedModules.push(...loadedModules.values())
|
||||
const mergedLogs = new Map(context.runtimeLogs.map((log) => [log.moduleName, log]))
|
||||
loaded.runtimeLogs.forEach((log) => mergedLogs.set(log.moduleName, log))
|
||||
const fileCount = mergedModules.reduce((sum, module) => sum + module.files.length, 0)
|
||||
const characters = mergedModules.reduce((sum, module) => sum + module.files.reduce((count, file) => count + file.path.length + file.content.length, 0), 0)
|
||||
if (fileCount > 24 || characters > 160_000 || mergedLogs.size > 3) {
|
||||
throw new Error('추가 자료가 조회 한도(파일 24개·16만 자, 로그 모듈 3개)를 초과했습니다. 대상 모듈이나 파일 범위를 좁혀 다시 요청해주세요.')
|
||||
}
|
||||
context = { modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
|
||||
const mergedLogs = new Map(cachedContext.runtimeLogs.map((log) => [log.moduleName, log]))
|
||||
loaded.runtimeLogs.forEach((log) => {
|
||||
mergedLogs.delete(log.moduleName)
|
||||
mergedLogs.set(log.moduleName, log)
|
||||
})
|
||||
cachedContext = { ...cachedContext, modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
|
||||
context = fitAgentContext(cachedContext, nextRequest.modules, nextRequest.logs)
|
||||
}
|
||||
throw new Error('AI 분석을 완료하지 못했습니다.')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AgentToolResult } from '../../../../shared/agentTools'
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
// The same module sequences are documented in the workspace module guide.
|
||||
export const AGENT_PRACTICE_PROFILES = [
|
||||
{ id: 'local-2d', environment: 'local', platform: 'turtlebot3', level: '2d', modules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
|
||||
{ id: 'mkmini-2d', environment: 'remote', platform: 'mkmini', level: '2d', modules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
|
||||
{ id: 'mkmini-3d', environment: 'remote', platform: 'mkmini', level: '3d', modules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'], topics: ['/lidar_filtered', '/odom', '/cloud_map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
|
||||
{ id: 'yahboom-2d', environment: 'remote', platform: 'yahboom', level: '2d', modules: ['NAV2-Yahboom-Agent', 'NAV2-Yahboom', 'NAV2-Yahboom-Slam'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] }
|
||||
] as const
|
||||
|
||||
export function inspectAgentPractice(profileId: string, modules: ModuleRow[], environment: 'local' | 'remote') {
|
||||
const profile = AGENT_PRACTICE_PROFILES.find(item => item.id === profileId)
|
||||
if (!profile) throw new Error('지원하지 않는 실습 구성입니다.')
|
||||
const blockers: string[] = []
|
||||
if (profile.environment !== environment) blockers.push(`현재 ${environment} 환경에서는 ${profile.id} 구성을 실행할 수 없습니다.`)
|
||||
const required = profile.modules.map(name => {
|
||||
const candidates = modules.filter(item => item.name === name)
|
||||
const module = candidates[0]
|
||||
if (candidates.length > 1) blockers.push(`${name}: 같은 이름의 모듈이 여러 개입니다.`)
|
||||
if (!module || module.state === 'uninstalled') blockers.push(`${name}: 필수 모듈이 설치되지 않았습니다.`)
|
||||
if (module) {
|
||||
const params = new Map(module.parameters.map(parameter => {
|
||||
const separator = parameter.indexOf(':=')
|
||||
return [parameter.slice(0, separator), parameter.slice(separator + 2)]
|
||||
}))
|
||||
const expectedMode = environment === 'local' ? 'true' : 'false'
|
||||
for (const [key, expected] of [['sim_mode', expectedMode], ['platform', profile.platform], ['robot', profile.platform], ['level', profile.level]]) {
|
||||
if (params.has(key) && params.get(key) !== expected) blockers.push(`${name}: ${key}:=${params.get(key)} (필요 값: ${expected})`)
|
||||
}
|
||||
// Never guess hardware/simulation mode or start localization on an existing map.
|
||||
if ((module.category.toLowerCase() === 'vehicle' || name === 'NAV2-MKMini' || name === 'NAV2-Yahboom') && !params.has('sim_mode')) blockers.push(`${name}: sim_mode 설정을 확인해야 합니다.`)
|
||||
if (params.get('localization') === 'true') blockers.push(`${name}: 기존 지도 위치추정 모드입니다. 새 지도 SLAM 모드인지 확인해주세요.`)
|
||||
}
|
||||
return { name, state: module?.state ?? 'missing', parameters: module?.parameters ?? [] }
|
||||
})
|
||||
const conflicting = modules.filter(module => module.state === 'running' && !profile.modules.some(name => name === module.name)
|
||||
&& module.name !== 'NAV2-Camera'
|
||||
&& ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control'].includes(module.category.toLowerCase()))
|
||||
if (conflicting.length) blockers.push(`다른 실습 모듈이 실행 중입니다: ${conflicting.map(module => module.name).join(', ')}. 자동 종료하지 않습니다.`)
|
||||
return { profile, required, blockers, canPrepare: !blockers.length, starts: required.filter(module => module.state === 'stopped').map(module => module.name) }
|
||||
}
|
||||
|
||||
export function verifyAgentPractice(profileId: string, probe: AgentToolResult): { ready: boolean; failures: string[]; evidence: unknown } {
|
||||
const profile = AGENT_PRACTICE_PROFILES.find(item => item.id === profileId)
|
||||
if (!profile || probe.status !== 'ok' || probe.truncated) return { ready: false, failures: ['ROS 상태를 확인하지 못했습니다.'], evidence: probe }
|
||||
let data: { topics?: Array<{ name: string; publishers: number; samples: number; validSamples: number; frameIds?: string[]; error?: string }>; tfEdges?: Array<{ parent: string; child: string }> }
|
||||
try { data = JSON.parse(probe.data) } catch { return { ready: false, failures: ['ROS 조회 결과 형식이 올바르지 않습니다.'], evidence: probe } }
|
||||
if (!data || !Array.isArray(data.topics) || !Array.isArray(data.tfEdges)) return { ready: false, failures: ['ROS 조회 결과 형식이 올바르지 않습니다.'], evidence: probe }
|
||||
const failures: string[] = []
|
||||
for (const name of profile.topics) {
|
||||
const topic = data.topics?.find(topic => topic.name === name)
|
||||
if (!topic || ![topic.publishers, topic.samples, topic.validSamples].every(value => Number.isFinite(value) && value > 0) || topic.error) failures.push(`${name}: 관찰 구간에서 정상 수신을 확인하지 못했습니다.`)
|
||||
}
|
||||
const edges = data.tfEdges.filter(edge => edge && typeof edge.parent === 'string' && typeof edge.child === 'string')
|
||||
const reaches = (source: string, target: string): boolean => {
|
||||
const queue = [source]
|
||||
const seen = new Set<string>()
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!
|
||||
if (current === target) return true
|
||||
if (seen.has(current)) continue
|
||||
seen.add(current)
|
||||
edges.filter(edge => edge.parent.replace(/^\//, '') === current).forEach(edge => queue.push(edge.child.replace(/^\//, '')))
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (const [source, target] of profile.frames) if (!reaches(source, target)) failures.push(`TF ${source} → ${target}: 연결을 확인하지 못했습니다.`)
|
||||
const sensor = data.topics?.find(topic => topic.name === profile.topics[0])
|
||||
if (!Array.isArray(sensor?.frameIds) || !sensor.frameIds.some(frame => typeof frame === 'string' && reaches('base_link', frame.replace(/^\//, '')))) failures.push('센서 프레임과 base_link의 TF 연결을 확인하지 못했습니다.')
|
||||
return { ready: !failures.length, failures, evidence: probe }
|
||||
}
|
||||
|
||||
export async function prepareAgentPractice(options: {
|
||||
profileId: string
|
||||
environment: 'local' | 'remote'
|
||||
approvedStarts: string[]
|
||||
approvedParameters?: Record<string, string[]>
|
||||
list: () => Promise<ModuleRow[]>
|
||||
start: (module: ModuleRow) => Promise<boolean>
|
||||
probe: (module: ModuleRow, topics: string[]) => Promise<AgentToolResult>
|
||||
progress: (text: string, steps?: Array<{ moduleName: string; status: string }>) => void
|
||||
signal: AbortSignal
|
||||
pause?: () => Promise<void>
|
||||
}) {
|
||||
const assertActive = (): void => { if (options.signal.aborted) throw new DOMException('Aborted', 'AbortError') }
|
||||
assertActive()
|
||||
let modules = await options.list()
|
||||
assertActive()
|
||||
const inspection = inspectAgentPractice(options.profileId, modules, options.environment)
|
||||
if (inspection.blockers.length) throw new Error(inspection.blockers.join('\n'))
|
||||
if (inspection.starts.some(name => !options.approvedStarts.includes(name))) throw new Error('승인 이후 시작 대상이 달라졌습니다. 다시 승인해주세요.')
|
||||
const steps: Array<{ moduleName: string; status: string }> = []
|
||||
const assertParameters = (modules: ModuleRow[]): void => {
|
||||
for (const name of inspection.profile.modules) {
|
||||
if (options.approvedParameters && JSON.stringify([...(modules.find(module => module.name === name)?.parameters ?? [])].sort()) !== JSON.stringify([...(options.approvedParameters[name] ?? [])].sort())) throw new Error(`${name}: 승인 이후 실행 파라미터가 바뀌었습니다. 다시 승인해주세요.`)
|
||||
}
|
||||
}
|
||||
assertParameters(modules)
|
||||
for (const name of inspection.profile.modules) {
|
||||
assertActive()
|
||||
modules = await options.list()
|
||||
assertActive()
|
||||
const latest = inspectAgentPractice(options.profileId, modules, options.environment)
|
||||
assertParameters(modules)
|
||||
if (latest.blockers.length) throw new Error(latest.blockers.join('\n'))
|
||||
const module = modules.find(module => module.name === name)!
|
||||
if (module.state === 'running') { steps.push({ moduleName: name, status: 'already-running' }); continue }
|
||||
if (!options.approvedStarts.includes(name)) throw new Error(`승인받지 않은 시작 작업입니다: ${name}`)
|
||||
options.progress(`${name} 시작 중`, steps)
|
||||
if (!await options.start(module)) return { ready: false, steps: [...steps, { moduleName: name, status: 'failed' }], failures: [`${name} 시작에 실패했습니다. 이미 시작된 모듈은 자동 종료하지 않았습니다.`] }
|
||||
steps.push({ moduleName: name, status: 'started' })
|
||||
options.progress(`${name} 시작 완료`, steps)
|
||||
if (module.category.toLowerCase() === 'simulator') await (options.pause?.() ?? new Promise(resolve => setTimeout(resolve, 4000)))
|
||||
}
|
||||
const topics = [...inspection.profile.topics, '/tf', '/tf_static']
|
||||
let verification: ReturnType<typeof verifyAgentPractice> | undefined
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
assertActive()
|
||||
options.progress(`SLAM 데이터·TF 확인 중 (${attempt + 1}/3)`, steps)
|
||||
modules = await options.list()
|
||||
assertActive()
|
||||
const latest = inspectAgentPractice(options.profileId, modules, options.environment)
|
||||
assertParameters(modules)
|
||||
if (latest.blockers.length || latest.required.some(module => module.state !== 'running')) return { ready: false, steps, failures: [...latest.blockers, '필수 모듈의 실행 상태를 확인해주세요.'] }
|
||||
verification = verifyAgentPractice(options.profileId, await options.probe(modules.find(module => module.name === inspection.profile.modules.at(-1))!, topics))
|
||||
assertActive()
|
||||
if (verification.ready) break
|
||||
if (attempt < 2) await (options.pause?.() ?? new Promise(resolve => setTimeout(resolve, 2000)))
|
||||
}
|
||||
return { ...verification!, steps }
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createAgentSessionSync } from './agentSessionSync'
|
||||
import { resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
|
||||
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
|
||||
import type { ChatAttachment } from '../../components/chatAttachments'
|
||||
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||
|
||||
export type AgentFileChange = {
|
||||
expectedHash?: string | null
|
||||
moduleName: string
|
||||
path: string
|
||||
content: string
|
||||
@@ -20,6 +22,7 @@ export type AgentModulePlan = {
|
||||
}
|
||||
|
||||
export type AgentCommand = {
|
||||
operation?: AgentOperation
|
||||
id: string
|
||||
moduleName: string
|
||||
command: string
|
||||
@@ -34,6 +37,8 @@ export type AgentCommandExecution = {
|
||||
}
|
||||
|
||||
export type AgentPlan = {
|
||||
toolRequests?: AgentToolRequest[]
|
||||
evidence?: AgentToolResult[]
|
||||
summary: string
|
||||
explanation: string
|
||||
requestedModules: string[]
|
||||
@@ -45,6 +50,7 @@ export type AgentPlan = {
|
||||
}
|
||||
|
||||
export type AgentWorkspaceModule = {
|
||||
parameters?: string[]
|
||||
name: string
|
||||
folderName?: string
|
||||
category?: string
|
||||
@@ -69,6 +75,7 @@ export type AgentConversationMessage = {
|
||||
content: string
|
||||
provider?: string
|
||||
model?: string
|
||||
revealStartedAt?: number
|
||||
plan?: AgentPlan
|
||||
applyState?: 'ready' | 'applying' | 'applied' | 'error'
|
||||
applyError?: string
|
||||
@@ -109,8 +116,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [activity, setActivity] = useState('')
|
||||
const [messages, setMessages] = useState<AgentConversationMessage[]>([])
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const applyingRef = useRef(false)
|
||||
const tokenReaderRef = useRef(getFreshAccessToken)
|
||||
tokenReaderRef.current = getFreshAccessToken
|
||||
const onResponseRef = useRef(onResponse)
|
||||
@@ -134,6 +143,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
|
||||
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
|
||||
}
|
||||
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 구조화된 에이전트 도구를 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
|
||||
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
@@ -147,7 +157,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
body: JSON.stringify({
|
||||
courseId,
|
||||
week: Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : undefined,
|
||||
messages: batch
|
||||
messages: batch.map(({ revealStartedAt: _revealStartedAt, ...message }) => message)
|
||||
})
|
||||
})
|
||||
let sync = sessionSyncs.get(scope)
|
||||
@@ -204,7 +214,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
}, [authenticated, session])
|
||||
|
||||
useEffect(() => {
|
||||
const cached = authenticated ? session.restore([]) : []
|
||||
const cached = authenticated ? recoverInterruptedAgentMessages(session.restore([])) : []
|
||||
messageStateRef.current = { session, messages: cached }
|
||||
setMessages(cached)
|
||||
setHistoryError(null)
|
||||
@@ -219,7 +229,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
requestApi<AgentSessionResponse>(`/chat/agent/session?${query.toString()}`)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
const restored = session.restore(Array.isArray(response.messages) ? response.messages : [])
|
||||
const restored = recoverInterruptedAgentMessages(session.restore(Array.isArray(response.messages) ? response.messages : []))
|
||||
messageStateRef.current = { session, messages: restored }
|
||||
setMessages(restored)
|
||||
}
|
||||
@@ -254,6 +264,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
showUserMessage?: boolean
|
||||
attachments?: ChatAttachment[]
|
||||
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
||||
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
||||
environment?: 'local' | 'remote'
|
||||
toolResults?: AgentToolResult[]
|
||||
} = {}
|
||||
): Promise<void> => {
|
||||
const request = rawRequest.trim()
|
||||
@@ -265,18 +278,21 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
}
|
||||
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
|
||||
setPending(true)
|
||||
setActivity('요청 분석 중')
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
|
||||
try {
|
||||
const response = await resolveAgentContext({
|
||||
initialContext: { modules, runtimeLogs },
|
||||
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
|
||||
signal: abortController.signal,
|
||||
loadRequestedContext: options.loadRequestedContext,
|
||||
requestPlan: (context) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
||||
executeTool: options.executeTool,
|
||||
onProgress: setActivity,
|
||||
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
||||
method: 'POST',
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({ courseId, week: weekNumber, request, ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context })
|
||||
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context })
|
||||
})
|
||||
})
|
||||
if (abortController.signal.aborted) return
|
||||
@@ -291,6 +307,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
updateMessages((current) => [...current, {
|
||||
id: `agent-plan-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
revealStartedAt: Date.now(),
|
||||
content: plan.summary,
|
||||
provider: response.provider,
|
||||
model: response.model,
|
||||
@@ -305,20 +322,24 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
updateMessages((current) => [...current, {
|
||||
id: `agent-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
revealStartedAt: Date.now(),
|
||||
content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.'
|
||||
}])
|
||||
onResponseRef.current?.()
|
||||
} finally {
|
||||
if (abortControllerRef.current === abortController) {
|
||||
abortControllerRef.current = null
|
||||
setPending(false)
|
||||
setActivity('')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
|
||||
const message = messages.find((item) => item.id === messageId)
|
||||
if (!message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
|
||||
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
|
||||
if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return
|
||||
applyingRef.current = true
|
||||
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
|
||||
try {
|
||||
await apply(message.plan)
|
||||
@@ -336,6 +357,8 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
} catch (error) {
|
||||
const applyError = error instanceof Error ? error.message : '변경안을 적용하지 못했습니다.'
|
||||
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item))
|
||||
} finally {
|
||||
applyingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,7 +374,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
...message,
|
||||
commandExecutions: {
|
||||
...message.commandExecutions,
|
||||
[commandId]: execution
|
||||
[commandId]: { ...message.commandExecutions?.[commandId], ...(execution.status === 'running' ? { error: undefined } : {}), ...execution }
|
||||
}
|
||||
}
|
||||
: message))
|
||||
@@ -382,6 +405,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
...status,
|
||||
statusLoading: statusLoading || historyLoading,
|
||||
pending,
|
||||
activity,
|
||||
messages,
|
||||
persistenceError: persistenceError ?? historyError,
|
||||
requestPlan,
|
||||
|
||||
@@ -11,6 +11,7 @@ export type QaChatMessage = {
|
||||
createdAt?: string
|
||||
senderAvatarUrl?: string | null
|
||||
isTyping?: boolean
|
||||
revealStartedAt?: number
|
||||
keepExpanded?: boolean
|
||||
status?: 'ACTIVE' | 'CANCELED'
|
||||
}
|
||||
@@ -55,6 +56,7 @@ type UseQaChatOptions = {
|
||||
courseId: string
|
||||
weekNumber: number
|
||||
globalScope?: boolean
|
||||
revealInView?: boolean
|
||||
apiBaseUrl: string
|
||||
socketBaseUrl: string
|
||||
socketPath: string
|
||||
@@ -77,6 +79,7 @@ export function useQaChat({
|
||||
courseId,
|
||||
weekNumber,
|
||||
globalScope = false,
|
||||
revealInView = false,
|
||||
apiBaseUrl,
|
||||
socketBaseUrl,
|
||||
socketPath,
|
||||
@@ -369,6 +372,17 @@ export function useQaChat({
|
||||
})
|
||||
expandedAiMessageIdsRef.current.add(response.message.id)
|
||||
setAiPending(false)
|
||||
if (revealInView) {
|
||||
setAiMessages((messages) => [...messages, {
|
||||
id: response.message.id,
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
createdAt: response.message.createdAt,
|
||||
revealStartedAt: Date.now(),
|
||||
keepExpanded: true
|
||||
}])
|
||||
return
|
||||
}
|
||||
setAiTypingMessageId(response.message.id)
|
||||
setAiMessages((messages) => [
|
||||
...messages,
|
||||
|
||||
@@ -11,8 +11,10 @@ type ModuleActionResult = {
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
type ModuleControlOptions = { background?: boolean; freshSlam?: boolean; onResult?: (result: ModuleActionResult) => void }
|
||||
|
||||
type SavedMapPathOverrides = {
|
||||
freshSlam?: boolean
|
||||
savedMapYamlPath?: string | null
|
||||
savedMapDatabasePath?: string | null
|
||||
savedMapWorldLayoutPath?: string | null
|
||||
@@ -70,7 +72,7 @@ type UseWorkspaceModuleControlResult = {
|
||||
startAllModules: () => Promise<void>
|
||||
stopAllModules: () => Promise<void>
|
||||
restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean>
|
||||
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<boolean>
|
||||
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options?: ModuleControlOptions) => Promise<boolean>
|
||||
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -108,10 +110,12 @@ export function useWorkspaceModuleControl({
|
||||
const moduleActionIdRef = useRef(0)
|
||||
const controlGenerationRef = useRef(0)
|
||||
const workspaceActiveRef = useRef(workspaceActive)
|
||||
const backgroundControlsRef = useRef(0)
|
||||
useEffect(() => () => { controlGenerationRef.current += 1 }, [courseKey, weekNumber, clonePathPreview, remoteWorkspace])
|
||||
|
||||
useEffect(() => {
|
||||
workspaceActiveRef.current = workspaceActive
|
||||
if (workspaceActive) return
|
||||
if (workspaceActive || backgroundControlsRef.current > 0) return
|
||||
|
||||
// Workspace pages remain mounted while another LMS page is shown. Do not
|
||||
// leave their control buttons locked behind a detached IPC request.
|
||||
@@ -126,6 +130,7 @@ export function useWorkspaceModuleControl({
|
||||
), [bulkExcludedModuleKeys])
|
||||
|
||||
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: {
|
||||
freshSlam?: boolean
|
||||
simulatorWorldFile?: string | null
|
||||
savedMapYamlPath?: string | null
|
||||
savedMapDatabasePath?: string | null
|
||||
@@ -133,7 +138,7 @@ export function useWorkspaceModuleControl({
|
||||
}): string => {
|
||||
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
|
||||
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
|
||||
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
|
||||
const mapDatabasePath = overrides?.freshSlam ? null : overrides?.savedMapDatabasePath ?? savedMapDatabasePath
|
||||
const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath
|
||||
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
|
||||
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
|
||||
@@ -175,12 +180,13 @@ export function useWorkspaceModuleControl({
|
||||
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
|
||||
}
|
||||
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '')
|
||||
const databasePath = mapDatabasePath
|
||||
const databasePath = overrides?.freshSlam ? null : mapDatabasePath
|
||||
?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
|
||||
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
|
||||
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
|
||||
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true')
|
||||
}
|
||||
if (overrides?.freshSlam && /slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=false')
|
||||
return parameters.join(' ')
|
||||
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
|
||||
|
||||
@@ -473,8 +479,8 @@ export function useWorkspaceModuleControl({
|
||||
}
|
||||
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
|
||||
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<boolean> => {
|
||||
if (!workspaceActiveRef.current) return false
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options: ModuleControlOptions = {}): Promise<boolean> => {
|
||||
if (!workspaceActiveRef.current && !options.background) return false
|
||||
// The parking area only opts modules out of bulk actions; those modules
|
||||
// must remain individually controllable while the rest are stopping.
|
||||
const canControlExcludedModuleDuringBulkStop = (
|
||||
@@ -489,17 +495,18 @@ export function useWorkspaceModuleControl({
|
||||
const actionId = ++moduleActionIdRef.current
|
||||
const controlGeneration = controlGenerationRef.current
|
||||
queuedModuleActionsRef.current.set(module.name, actionId)
|
||||
if (options.background) backgroundControlsRef.current += 1
|
||||
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
||||
setModuleInstallError(null)
|
||||
const execute = async (): Promise<boolean> => {
|
||||
const isCurrentControl = (): boolean => (
|
||||
workspaceActiveRef.current
|
||||
(workspaceActiveRef.current || options.background === true)
|
||||
&& controlGenerationRef.current === controlGeneration
|
||||
&& queuedModuleActionsRef.current.get(module.name) === actionId
|
||||
)
|
||||
try {
|
||||
if (!isCurrentControl()) return false
|
||||
const savedMapOverrides = action === 'start' || action === 'restart'
|
||||
const savedMapOverrides = options.freshSlam ? { freshSlam: true } : action === 'start' || action === 'restart'
|
||||
? await prepareSavedMap?.()
|
||||
: undefined
|
||||
const result = action === 'stop'
|
||||
@@ -517,6 +524,7 @@ export function useWorkspaceModuleControl({
|
||||
: remoteWorkspace
|
||||
? await remoteModuleStart(module, savedMapOverrides)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
|
||||
options.onResult?.(result)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
if (!isCurrentControl()) return false
|
||||
|
||||
@@ -551,6 +559,7 @@ export function useWorkspaceModuleControl({
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[modules] ${action} failed`, error)
|
||||
options.onResult?.({ ok: false, message: error instanceof Error ? error.message : String(error) })
|
||||
if (!isCurrentControl()) return false
|
||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
||||
const actionLabel = action === 'stop'
|
||||
@@ -564,6 +573,7 @@ export function useWorkspaceModuleControl({
|
||||
await loadAvailableModules({ silent: true })
|
||||
return false
|
||||
} finally {
|
||||
if (options.background) backgroundControlsRef.current -= 1
|
||||
if (queuedModuleActionsRef.current.get(module.name) === actionId) {
|
||||
queuedModuleActionsRef.current.delete(module.name)
|
||||
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
|
||||
|
||||
@@ -703,5 +703,11 @@
|
||||
"실행 실패": "Failed",
|
||||
"다시 실행": "Run again",
|
||||
"명령 출력이 없습니다.": "The command produced no output.",
|
||||
"코드 변경안을 만드는 중입니다.": "Preparing code changes."
|
||||
"코드 변경안을 만드는 중입니다.": "Preparing code changes.",
|
||||
"조회 근거": "Evidence",
|
||||
"조회 완료": "Retrieved",
|
||||
"조회 실패": "Retrieval failed",
|
||||
"일부 결과": "Partial results",
|
||||
"SLAM 실습 준비": "Prepare SLAM practice",
|
||||
"승인하고 준비": "Approve and prepare"
|
||||
}
|
||||
|
||||
@@ -248,6 +248,21 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes agent-response-fade {
|
||||
from { opacity: 0; transform: translateY(3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.agent-response-fade {
|
||||
animation: agent-response-fade 220ms ease-out both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-response-fade {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.monaco-search-line-highlight {
|
||||
background: rgba(47, 141, 255, 0.12);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user