diff --git a/package-lock.json b/package-lock.json index 67e1bf9..6a61540 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index acd5c1c..644e62f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-agent-context.cjs b/scripts/test-agent-context.cjs index 7a2519e..8998173 100644 --- a/scripts/test-agent-context.cjs +++ b/scripts/test-agent-context.cjs @@ -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: [] }) + })) +}) diff --git a/scripts/test-agent-editor-lock.cjs b/scripts/test-agent-editor-lock.cjs new file mode 100644 index 0000000..7d97251 --- /dev/null +++ b/scripts/test-agent-editor-lock.cjs @@ -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]) +}) diff --git a/scripts/test-agent-practice.cjs b/scripts/test-agent-practice.cjs new file mode 100644 index 0000000..e4a9fcc --- /dev/null +++ b/scripts/test-agent-practice.cjs @@ -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, //) +}) diff --git a/scripts/test-agent-request-payload.cjs b/scripts/test-agent-request-payload.cjs index 649a38c..879f253 100644 --- a/scripts/test-agent-request-payload.cjs +++ b/scripts/test-agent-request-payload.cjs @@ -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, /원래 학생 질문/) +}) diff --git a/scripts/test-agent-response-reveal.cjs b/scripts/test-agent-response-reveal.cjs new file mode 100644 index 0000000..4e0aeae --- /dev/null +++ b/scripts/test-agent-response-reveal.cjs @@ -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">]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p> - )} -
- {t("학습 내용")} -

- {item.description || topicDetails[index] || overview} -

-
-
- {t("핵심 개념")} -
- {(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => ( -
- - {concept} -
- ))} -
-
- + + + + {expanded && ( +
+
+ {youtubeEmbedUrl && youtubeVideoId && ( + )} +
+ {t("학습 내용")} +

+ {item.description || topicDetails[index] || overview} +

+
+
+ {t("핵심 개념")} +
+ {(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => ( +
+ + {concept} +
+ ))} +
+
-
+ )} ) }) : ( diff --git a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx index ffe6abf..289cac8 100644 --- a/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx +++ b/src/renderer/src/features/workspace/WorkspaceQaPanel.tsx @@ -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): 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({
event.preventDefault()} onClick={(event) => event.stopPropagation()} > + ) : null} -
- ) :

{message.content}

} + + )} + - ))} {pending ? (
-
- +
+ {activity ?

{activity}

: null} +
) : null} @@ -590,9 +625,9 @@ function AiMessages({ useLocaleRender() if (!messages.length && !pending) { return ( -
- {t("AI에게 질문할")}
{t("내용을 입력하세요")}
-
+ + {t("AI에게 질문할")}
{t("내용을 입력하세요")} +
) } @@ -615,16 +650,13 @@ function AiMessages({
- - {message.isTyping && ( - - )}
) @@ -634,10 +666,7 @@ function AiMessages({ -
- {t("답변을 작성 중입니다...")} - -
+ )} @@ -660,9 +689,9 @@ function TeacherMessages({ useLocaleRender() if (!messages.length) { return ( -
- {t("강사에게 문의할")}
{t("내용을 입력하세요")}
-
+ + {t("강사에게 문의할")}
{t("내용을 입력하세요")} +
) } diff --git a/src/renderer/src/features/workspace/agentContext.ts b/src/renderer/src/features/workspace/agentContext.ts index 9a1195e..b8214fc 100644 --- a/src/renderer/src/features/workspace/agentContext.ts +++ b/src/renderer/src/features/workspace/agentContext.ts @@ -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() + 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 + requestPlan: (context: AgentContext, finalize?: boolean) => Promise loadRequestedContext?: (request: AgentContextRequest) => Promise + executeTool?: (request: AgentToolRequest) => Promise + onProgress?: (label: string) => void signal: AbortSignal }): Promise { - let context = initialContext - const requestedModules = new Set() - const requestedLogs = new Set() + 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() + 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 = {} 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 => { + 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 분석을 완료하지 못했습니다.') } diff --git a/src/renderer/src/features/workspace/agentPractice.ts b/src/renderer/src/features/workspace/agentPractice.ts new file mode 100644 index 0000000..037ff7a --- /dev/null +++ b/src/renderer/src/features/workspace/agentPractice.ts @@ -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() + 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 + list: () => Promise + start: (module: ModuleRow) => Promise + probe: (module: ModuleRow, topics: string[]) => Promise + progress: (text: string, steps?: Array<{ moduleName: string; status: string }>) => void + signal: AbortSignal + pause?: () => Promise +}) { + 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 | 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 } +} diff --git a/src/renderer/src/features/workspace/useAiAgent.ts b/src/renderer/src/features/workspace/useAiAgent.ts index 3118299..76366fc 100644 --- a/src/renderer/src/features/workspace/useAiAgent.ts +++ b/src/renderer/src/features/workspace/useAiAgent.ts @@ -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(null) const [pending, setPending] = useState(false) + const [activity, setActivity] = useState('') const [messages, setMessages] = useState([]) const abortControllerRef = useRef(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 @@ -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(`/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 + executeTool?: (request: AgentToolRequest) => Promise + environment?: 'local' | 'remote' + toolResults?: AgentToolResult[] } = {} ): Promise => { 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('/chat/agent/plan', { + executeTool: options.executeTool, + onProgress: setActivity, + requestPlan: (context, finalize) => requestApi('/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): Promise => { 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, diff --git a/src/renderer/src/features/workspace/useQaChat.ts b/src/renderer/src/features/workspace/useQaChat.ts index 7d3799d..8227b88 100644 --- a/src/renderer/src/features/workspace/useQaChat.ts +++ b/src/renderer/src/features/workspace/useQaChat.ts @@ -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, diff --git a/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts b/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts index f728819..6ca900e 100644 --- a/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts +++ b/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts @@ -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 stopAllModules: () => Promise restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise - runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise + runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options?: ModuleControlOptions) => Promise updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise } @@ -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 => { - if (!workspaceActiveRef.current) return false + const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options: ModuleControlOptions = {}): Promise => { + 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 => { 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)) diff --git a/src/renderer/src/i18n/en.json b/src/renderer/src/i18n/en.json index 3346db9..3d23a89 100644 --- a/src/renderer/src/i18n/en.json +++ b/src/renderer/src/i18n/en.json @@ -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" } diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 4293ed4..b8fef92 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -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); } diff --git a/src/shared/agentTools.ts b/src/shared/agentTools.ts new file mode 100644 index 0000000..8469aad --- /dev/null +++ b/src/shared/agentTools.ts @@ -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 +} diff --git a/src/shared/chatTextMenu.ts b/src/shared/chatTextMenu.ts new file mode 100644 index 0000000..19f6879 --- /dev/null +++ b/src/shared/chatTextMenu.ts @@ -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