Compare commits

...

2 Commits

22 changed files with 834 additions and 70 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "lms",
"version": "1.2.22",
"version": "1.2.23",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "lms",
"version": "1.2.22",
"version": "1.2.23",
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.2.22",
"version": "1.2.23",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
+2 -1
View File
@@ -188,7 +188,8 @@ test('a new question cancels pending navigation without leaving its old state st
})
test('restored interrupted navigation becomes retryable without reopening any file', () => {
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'))
const evidence = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {}, name => name === './agentEvidence' ? evidence : require(name))
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
const restored = recoverInterruptedAgentMessages([message])[0]
assert.equal(restored.codeReferenceStates.pending.status, 'error')
+5 -1
View File
@@ -5,9 +5,13 @@ const vm = require('node:vm')
const ts = require('typescript')
const output = {}
const evidenceApi = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: evidenceApi })
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, DOMException })
}).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const { resolveAgentContext } = output
const plain = value => JSON.parse(JSON.stringify(value))
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
+143
View File
@@ -0,0 +1,143 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ts = require('typescript')
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, filename)
const { compactAgentEvidence, missingAgentRead, readFileEvidence } = require('../src/renderer/src/features/workspace/agentEvidence.ts')
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
const file = (startLine, endLine, extra = {}, result = {}) => ({
id: `read-${startLine}-${endLine}`, name: 'files.read', moduleName: 'Control', target: 'ssh:jetson', status: 'ok', observedAt: '2026-10-20T00:00:00.000Z', truncated: true,
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), content: Array.from({ length: endLine - startLine + 1 }, (_, i) => `line${startLine + i}`).join('\n'),
startLine, endLine, totalLines: 100, complete: false, contentTruncated: false, ...extra }), ...result
})
const read = (startLine, lineCount, path = 'config/car.yaml') => ({ id: 'read', name: 'files.read', moduleName: 'Control', args: { path, ...(startLine === undefined ? {} : { startLine }), ...(lineCount === undefined ? {} : { lineCount }) } })
const answer = toolRequests => ({ provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'verified code', toolRequests, changes: [], commands: [], actionPlan: [], createModule: null } })
test('overlapping and adjacent ranges merge without promoting partial evidence to editable full files', () => {
const results = compactAgentEvidence([file(1, 10), file(8, 15), file(16, 20)])
assert.equal(results.length, 1)
const data = readFileEvidence(results[0])
assert.equal(data.startLine, 1)
assert.equal(data.endLine, 20)
assert.equal(data.content.split('\n').length, 20)
assert.equal(data.complete, false)
assert.equal(results[0].truncated, true)
assert.equal(missingAgentRead(read(3, 16), results), undefined)
assert.deepEqual(missingAgentRead(read(), results), read())
})
test('queries only the first missing range and never joins disjoint or conflicting content', () => {
const results = compactAgentEvidence([file(1, 5), file(10, 15)])
assert.equal(results.length, 2)
assert.deepEqual(missingAgentRead(read(3, 15), results).args, { path: 'config/car.yaml', startLine: 6, lineCount: 4 })
assert.equal(compactAgentEvidence([file(1, 10), file(8, 15, { content: 'different\ncontent' })]).length, 2)
})
test('host boundaries and file revisions cannot be combined or used to fill each other’s gaps', () => {
const changed = compactAgentEvidence([file(1, 10), file(11, 20, { hash: 'b'.repeat(64) })])
assert.equal(changed.length, 1)
assert.equal(missingAgentRead(read(1, 20), changed).args.startLine, 1)
const hosts = compactAgentEvidence([file(1, 10, {}, { target: 'local-host' }), file(11, 20)])
assert.equal(hosts.length, 2)
assert.equal(missingAgentRead(read(1, 20), hosts).args.startLine, 1)
})
test('full and missing observations are reused but partial character fragments are never coverage', () => {
const full = file(1, 10, { complete: true }, { truncated: false })
assert.equal(missingAgentRead(read(5, 100), [full]), undefined)
assert.equal(compactAgentEvidence([full, file(5, 8)]).length, 1)
const missing = file(1, 1, { exists: false, content: '', hash: null, complete: true }, { truncated: false })
assert.equal(missingAgentRead(read(), [missing]), undefined)
const cut = file(1, 2, { content: 'whole\ncut', endLine: 1, contentTruncated: true })
assert.equal(missingAgentRead(read(2, 1), [cut]).args.startLine, 2)
const legacy = file(1, 2, { endLine: undefined, contentTruncated: undefined })
assert.equal(missingAgentRead(read(2, 1), [legacy]).args.startLine, 2)
})
test('malformed or oversized evidence never becomes reusable complete coverage', () => {
assert.equal(readFileEvidence(file(1, 10, { hash: 'not-a-hash' })), undefined)
assert.equal(readFileEvidence(file(1, 10, { endLine: 999 })), undefined)
const large = compactAgentEvidence([file(1, 1, { content: 'a'.repeat(30_000) }), file(2, 2, { content: 'b'.repeat(30_000) })])
assert.equal(large.length, 2)
})
test('overlap requests execute only missing lines and finish once the requested range is cached', async () => {
const executions = []
let calls = 0
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
executeTool: async tool => { executions.push(tool.args); return file(tool.args.startLine, tool.args.startLine + tool.args.lineCount - 1) },
requestPlan: async (context, final) => {
calls++
if (final) {
assert.equal(context.toolResults.length, 1)
assert.equal(JSON.parse(context.toolResults[0].data).endLine, 15)
return answer([])
}
return answer([[read(1, 10)], [read(5, 11)], [read(4, 10)]][calls - 1])
}
})
assert.deepEqual(executions, [read(1, 10).args, read(11, 5).args])
assert.equal(calls, 4)
})
test('source evidence survives many later searches and omissions are reported within DTO limits', async () => {
const searchResults = Array.from({ length: 15 }, (_, i) => ({ ...file(1, 1), id: `search-${i}`, name: 'files.search', data: JSON.stringify({ query: `speed${i}`, matches: [] }) }))
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10), ...searchResults] }, signal: new AbortController().signal,
requestPlan: async context => {
assert.equal(context.toolResults.length, 12)
assert.equal(context.toolResults[0].name, 'files.read')
assert.match(context.contextNotes[0], /4개/)
assert.ok(context.contextNotes.length <= 12)
assert.ok(context.contextNotes.every(note => note.length <= 500))
return answer([])
}
})
})
test('a cached file omitted by budgets is restored when the model requests it again', async () => {
let calls = 0
const results = Array.from({ length: 13 }, (_, i) => file(1, 10, { path: `file${i}.yaml` }))
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: results }, signal: new AbortController().signal,
executeTool: async () => { throw Error('cached file must not be queried again') },
requestPlan: async (context, final) => {
calls++
if (!final) return answer([read(1, 5, 'file0.yaml')])
assert.ok(context.toolResults.some(result => JSON.parse(result.data).path === 'file0.yaml'))
return answer([])
}
})
assert.equal(calls, 2)
})
test('finalization retains verified code locations while stripping all proposed mutations', async () => {
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
executeTool: async () => { throw Error('already cached') },
requestPlan: async (_, final) => ({ ...answer([read(1, 5)]), plan: { ...answer([read(1, 5)]).plan, ...(final ? { changes: [{ path: 'unsafe' }], commands: [{ command: 'unsafe' }] } : { codeReferences: [reference] }) } })
})
assert.equal(result.plan.codeReferences.length, 1)
assert.match(result.plan.explanation, /verified code/)
assert.deepEqual(result.plan.changes, [])
assert.deepEqual(result.plan.commands, [])
})
test('recovery never resurrects code locations after a different file revision was observed', async () => {
let calls = 0
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
executeTool: async () => file(11, 20, { hash: 'b'.repeat(64) }),
requestPlan: async (_, final) => {
calls++
return !final && calls === 1 ? { ...answer([read(11, 10)]), plan: { ...answer([read(11, 10)]).plan, codeReferences: [reference] } } : answer([read(11, 10)])
}
})
assert.deepEqual(result.plan.codeReferences, [])
assert.match(result.plan.summary, /확정하지 못했습니다/)
})
+99
View File
@@ -0,0 +1,99 @@
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(path, overrides = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(path), 'utf8')
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText,
{ exports, document: { body: {} }, require: name => overrides[name] ?? require(name) })
return exports
}
const models = compile('../src/renderer/src/features/workspace/agentModels.ts')
const options = [{ provider: 'OPENAI', model: 'gpt-6.1-sol', imagesEnabled: false }, { provider: 'QWEN', model: 'qwen3.8-flash', imagesEnabled: true }]
const plain = value => JSON.parse(JSON.stringify(value))
test('normalizes enabled models, validates IDs and preserves old single-model servers', () => {
const status = models.normalizeAgentStatus({ enabled: true, models: [options[1], options[0], options[0], { provider: 'FORGED', model: 'bad' }, { provider: 'GEMINI', model: '' }] })
assert.deepEqual(plain(status.models), options)
assert.equal(status.selectionSupported, true)
assert.deepEqual(plain(models.normalizeAgentStatus({ enabled: true, ...options[1] }).models), [options[1]])
assert.equal(models.normalizeAgentStatus({ enabled: true, ...options[1] }).selectionSupported, false)
assert.equal(models.normalizeAgentStatus({ enabled: false, models: options }).models.length, 0)
assert.equal(models.normalizeAgentStatus({ enabled: true, models: [] }).enabled, false)
})
test('selects the saved provider only while it is available, and never invents one', () => {
assert.equal(models.selectedAgentModel(options, 'QWEN'), options[1])
assert.equal(models.selectedAgentModel(options, 'ANTHROPIC'), options[0])
assert.equal(models.selectedAgentModel([options[0]], 'QWEN'), options[0])
assert.equal(models.selectedAgentModel([], 'QWEN'), null)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function selectorHarness(props) {
const state = [], refs = []
let stateIndex = 0, refIndex = 0
const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', {
'../i18n': { t: value => value }, '../features/workspace/agentModels': models,
'react-dom': { createPortal: child => child },
react: { ...React, useId: () => 'model-menu', useEffect() {}, useLayoutEffect() {},
useState(initial) { const index = stateIndex++; if (!(index in state)) state[index] = initial; return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] },
useRef(initial) { return refs[refIndex++] ?? (refs[refIndex - 1] = { current: initial }) }
}
})
return { render(patch = {}) { stateIndex = 0; refIndex = 0; return AgentModelSelector({ ...props, ...patch }) } }
}
for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => {
let selected
const h = selectorHarness({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } })
const tree = h.render()
const trigger = elements(tree, node => node.type === 'button')[0]
assert.equal(trigger.props['aria-haspopup'], 'menu')
assert.equal(trigger.props['aria-expanded'], false)
assert.equal(trigger.props.title, 'Qwen · qwen3.8-flash')
assert.equal(elements(tree, node => node.props.role === 'menu').length, 0)
assert.equal(elements(tree, node => node.type === 'select').length, 0)
trigger.props.onClick()
const menu = elements(h.render(), node => node.props.role === 'menu')[0]
assert.match(menu.props.className, dark ? /bg-\[#252526\]/ : /bg-white/)
assert.match(menu.props.className, /fixed/)
const items = elements(menu, node => node.props.role === 'menuitemradio')
assert.deepEqual(items.map(node => node.props.title), ['OpenAI · gpt-6.1-sol', 'Qwen · qwen3.8-flash'])
assert.deepEqual(items.map(node => node.props['aria-checked']), [false, true])
assert.ok(items.every(node => elements(node, item => item.type === 'span' && item.props.className.includes('truncate')).length === 1))
items[0].props.onClick()
assert.equal(selected, 'OPENAI')
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 0)
assert.ok(elements(h.render({ disabled: true }), node => node.type === 'button')[0].props.disabled)
const empty = elements(h.render({ models: [], provider: null }), node => node.type === 'button')[0]
assert.ok(empty.props.disabled)
assert.equal(empty.props.title, '사용 가능한 모델 없음')
})
test('model menu opens from arrow keys and closes when a request blocks the selector', () => {
const h = selectorHarness({ models: options, provider: 'QWEN', dark: true, disabled: false, onChange() {} })
const trigger = elements(h.render(), node => node.type === 'button')[0]
trigger.props.onKeyDown({ key: 'ArrowUp', preventDefault() {} })
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 1)
assert.equal(elements(h.render({ disabled: true }), node => node.props.role === 'menu').length, 0)
})
test('input footer places the model action before the attachment and clear controls', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
const row = source.slice(source.indexOf('{(attachmentsEnabled || onClearConversation || footerAction)'))
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'파일 첨부\')}'))
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'대화내역 삭제\')}'))
})
test('A-Copilot has a single managed label', () => {
assert.equal(models.agentModelLabel({ provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }), 'A-Copilot')
assert.equal(models.agentModelLabel(options[0]), 'OpenAI · gpt-6.1-sol')
})
+62 -5
View File
@@ -11,7 +11,10 @@ function compile(source, globals = {}) {
}).outputText, { exports, DOMException, Error, ...globals })
return exports
}
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'))
const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
require: name => { assert.equal(name, './agentEvidence'); return evidenceApi }
})
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
@@ -27,11 +30,13 @@ function harness(requestApi) {
class Clock extends Date { static now() { return now } }
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
const messageStateRef = { current: { session } }
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
...context, session, messageStateRef, AbortController,
...context, session, messageStateRef, AbortController, FormData,
Date: Clock,
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false,
status: { enabled: true }, abortControllerRef: { current: null },
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
selectedModel,
status: { enabled: true, selectionSupported: true }, abortControllerRef: { current: null },
setPending: value => { state.pending = value },
setRequestStartedAt: value => { state.requestStartedAt = value },
setActivity: value => { state.activity = value },
@@ -39,7 +44,7 @@ function harness(requestApi) {
updateMessages: update => { state.messages = update(state.messages); state.saved.push(plain(state.messages)) },
onResponseRef: { current: () => { state.notices++ } }, requestApi
})
return { ...api, state, messageStateRef, advance: ms => { now += ms } }
return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
}
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
@@ -76,6 +81,58 @@ test('publishes completed stages immediately without persisting progress or addi
assert.equal(h.state.notices, 1)
})
test('image uploads and every lookup round retain the original provider without uploading the image twice', async () => {
const bodies = []
let uploads = 0
const h = harness(async (path, request) => {
if (path.endsWith('/attachments/images')) {
uploads++
assert.equal(request.body.get('provider'), 'QWEN')
assert.equal(request.body.get('courseId'), 'course')
assert.equal(request.body.get('week'), '3')
assert.equal(request.body.get('file').name, 'screen.png')
return { token: 'image-reference', name: 'screen.png', url: 'https://images.test/screen.png' }
}
bodies.push(JSON.parse(request.body))
h.selectedModel.provider = 'OPENAI'
return bodies.length === 1
? { ...response(), plan: { ...response().plan, requestedModules: ['Control'] } }
: response()
})
await h.requestPlan('이미지 확인', [{ name: 'Control', files: [] }], [], {
attachments: [{ kind: 'image', name: 'screen.png', file: new Blob(['fixture'], { type: 'image/png' }) }],
loadRequestedContext: async () => ({ modules: [{ name: 'Control', files: [{ path: 'config/control.yaml', content: 'speed: 0.3' }] }], runtimeLogs: [] })
})
assert.equal(uploads, 1)
assert.equal(bodies.length, 2)
assert.ok(bodies.every(body => body.provider === 'QWEN'))
assert.ok(bodies.every(body => body.imageTokens[0] === 'image-reference'))
assert.equal(h.state.messages.at(-1).model, 'fixture')
})
test('remote target and observed source ranges reach each provider request including finalization', async () => {
const bodies = []
const tool = { id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config/car.yaml', startLine: 20, lineCount: 1 } }
const h = harness(async (_, request) => {
bodies.push(JSON.parse(request.body))
return bodies.length < 3 ? response([tool]) : response()
})
await h.requestPlan('속도 제한 코드 위치', [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], [], {
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10',
executeTool: async input => result({ id: input.id, moduleName: 'Control', target: 'ssh:100.64.0.10', truncated: true,
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), complete: false, content: 'max_speed: 0.4', startLine: 20, endLine: 20, contentTruncated: false, totalLines: 100 }) })
})
assert.equal(bodies.length, 3)
for (const body of bodies) {
assert.equal(body.environment, 'remote')
assert.equal(body.workspaceTarget, 'ssh:100.64.0.10')
assert.deepEqual(body.modules[0].parameters, ['sim_mode:=false'])
}
assert.equal(JSON.parse(bodies[1].toolResults[0].data).content, 'max_speed: 0.4')
assert.equal(JSON.parse(bodies[2].toolResults[0].data).endLine, 20)
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
})
test('errors replace temporary progress with the existing error reply', async () => {
let calls = 0
const h = harness(async () => {
+19 -3
View File
@@ -4,9 +4,13 @@ const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const contextApi = {}
const evidenceApi = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: evidenceApi })
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 })
}).outputText, { exports: contextApi, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression
@@ -33,10 +37,11 @@ test('user messages preserve only typed text while attachments remain separate m
}
})
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = []) {
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
return JSON.parse(vm.runInNewContext(payloadExpression, {
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
textAttachments: attachments ?? [], imageTokens,
selection: provider ? { provider } : {},
agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
}))
@@ -70,7 +75,7 @@ test('finalization instruction reaches the API without sending unsupported DTO f
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'])
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week'])
})
test('finalization keeps the request within the backend 4000-character limit', () => {
@@ -78,3 +83,14 @@ test('finalization keeps the request within the backend 4000-character limit', (
assert.equal(body.request.length, 4000)
assert.match(body.request, /원래 학생 질문/)
})
test('sends only a provider selection rather than student-supplied models or keys', () => {
for (const finalize of [false, true]) {
const body = serialize(undefined, finalize, '확인', [], 'OPENAI')
assert.equal(body.provider, 'OPENAI')
assert.equal(Object.hasOwn(body, 'model'), false)
assert.equal(Object.hasOwn(body, 'apiKey'), false)
assert.equal(Object.hasOwn(body, 'endpointUrl'), false)
}
assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false)
})
+15
View File
@@ -63,15 +63,30 @@ for (const backend of ['local', 'remote']) {
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 rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 }))
assert.equal(rangedFull.data.complete, true)
assert.equal(rangedFull.truncated, false)
assert.equal(rangedFull.data.endLine, 3)
assert.equal(rangedFull.data.contentTruncated, false)
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)
assert.equal(partial.data.endLine, 2)
assert.equal(partial.data.contentTruncated, false)
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}: character truncation never advertises a cut line as fully observed`, async t => {
const root = await fixture(t)
await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`)
const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 }))
assert.equal(readout.data.complete, false)
assert.equal(readout.data.contentTruncated, true)
assert.equal(readout.data.endLine, 1)
})
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'))
+68
View File
@@ -0,0 +1,68 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ts = require('typescript')
const vm = require('node:vm')
const app = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = new Map()
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) declarations.set(node.name.text, node.initializer?.getText(app))
ts.forEachChild(node, visit)
}
visit(app)
const plain = value => JSON.parse(JSON.stringify(value))
function harness(remoteWorkspace) {
const executions = []
const calls = []
const errors = []
const target = { host: '100.64.0.10', user: 'fixture', workspaceRoot: '/fixture/workspace' }
const live = [{ name: 'NAV2-MKMini', folderName: 'vehicle', state: 'running', parameters: ['sim_mode:=false', 'robot:=mkmini'] }]
const readTool = backend => async (...args) => { executions.push({ backend, args: plain(args) }); return { status: 'ok', data: '{"content":"remote source"}' } }
const exports = {}
const globals = {
exports, remoteWorkspace, availableModules: [{ ...live[0], state: 'stopped', parameters: ['sim_mode:=true'] }],
clonePathPreview: '/local/workspace', runtimeModules: [], agentActionControllerRef: { current: null }, agentApplying: false,
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
window: {
remoteWorkspace: { modules: { list: async () => ({ modules: live }) }, agent: { readTool: readTool('remote') } },
localWorkspace: { modules: { list: async () => ({ modules: live }) }, agent: { readTool: readTool('local') } }
},
requestAgentPlan: async (...args) => { calls.push(args) },
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}
const names = ['collectAgentWorkspace', 'readAgentModules', 'executeWorkspaceAgentTool', 'submitAgentRequest']
vm.runInNewContext(ts.transpileModule(names.map(name => `const ${name} = ${declarations.get(name)}; exports.${name} = ${name}`).join('\n'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, globals)
return { ...exports, executions, calls, errors }
}
for (const remote of [false, true]) {
test(`${remote ? 'remote' : 'local'}: a new question refreshes state and start settings before sending context`, async () => {
const h = harness(remote)
await h.submitAgentRequest('최대 속도 제한 코드 위치')
assert.deepEqual(h.errors, [])
assert.equal(h.calls.length, 1)
const [, modules, , options] = h.calls[0]
assert.equal(modules[0].state, 'running')
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
assert.deepEqual(plain(modules[0].files), [])
assert.equal(options.environment, remote ? 'remote' : 'local')
assert.equal(options.workspaceTarget, remote ? 'ssh:100.64.0.10' : 'local-host')
})
test(`${remote ? 'remote' : 'local'}: source reads use only the connected workspace transport and module folder`, async () => {
const h = harness(remote)
const tool = { id: 'read-1', name: 'files.read', moduleName: 'NAV2-MKMini', args: { path: 'src/profiles.py', startLine: 10, lineCount: 20 } }
await h.executeWorkspaceAgentTool(tool)
assert.equal(h.executions.length, 1)
assert.equal(h.executions[0].backend, remote ? 'remote' : 'local')
const args = h.executions[0].args
assert.deepEqual(args[remote ? 1 : 0], tool)
assert.equal(args[2], 'vehicle')
assert.equal(remote ? args[0].host : args[1], remote ? '100.64.0.10' : '/local/workspace')
})
}
+1
View File
@@ -42,6 +42,7 @@ function setup(overrides = {}, browser = {}) {
'../../components/AgentProgressBubble': {},
'../../components/AgentWorkDuration': {},
'../../components/AgentCodeReferences': {},
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
'react-dom': { createPortal: child => child },
react: {
...React,
+7 -3
View File
@@ -43,10 +43,14 @@ export async function runAgentFileTool(root: string, request: AgentToolRequest):
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 }
const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content
const selected = selection.slice(0, 40_000)
const contentTruncated = selected.length < selection.length
const complete = startLine === 1 && selected === content
const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated))
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// A missing leaf in an existing directory is evidence for a new file.
+8 -3
View File
@@ -116,10 +116,15 @@ else:
try:
content = read(root, path)
lines = content.split('\n')
partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000
ranged = '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)}
selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
selected = selection[:40000]
content_truncated = len(selected) < len(selection)
complete = start == 1 and selected == content
partial = not complete
end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated))
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)}
except FileNotFoundError:
checked(root, path, True)
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
+13 -7
View File
@@ -630,6 +630,8 @@ function LocalPcWorkspaceMockup({
enabled: agentEnabled,
provider: agentProvider,
imagesEnabled: agentImagesEnabled,
models: agentModels,
selectProvider: selectAgentProvider,
statusLoading: agentStatusLoading,
pending: agentPending,
activity: agentActivity,
@@ -3526,9 +3528,9 @@ function LocalPcWorkspaceMockup({
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
await createCustomModuleRecord(input)
}
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => {
const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise<AgentWorkspaceModule[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({
const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({
name: module.name,
folderName: module.folderName,
category: module.category,
@@ -3542,7 +3544,7 @@ function LocalPcWorkspaceMockup({
}
} : {})
}))
const targetModules = availableModules.filter((module) => (
const targetModules = moduleSnapshot.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
))
if (!targetModules.length) return workspaceModules
@@ -3677,12 +3679,13 @@ function LocalPcWorkspaceMockup({
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) return
try {
const modules = await collectAgentWorkspace()
const modules = await collectAgentWorkspace([], await readAgentModules())
await requestAgentPlan(content, modules, [], {
attachments,
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local'
environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
})
} catch (error) {
console.error('[agent] workspace collection failed', error)
@@ -3824,7 +3827,7 @@ function LocalPcWorkspaceMockup({
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,
showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', 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) {
@@ -3906,7 +3909,8 @@ function LocalPcWorkspaceMockup({
showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local'
environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
})
} catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
@@ -4752,6 +4756,8 @@ function LocalPcWorkspaceMockup({
agentEnabled={agentEnabled}
agentProvider={agentProvider}
agentImagesEnabled={Boolean(agentImagesEnabled)}
agentModels={agentModels}
onAgentProviderChange={selectAgentProvider}
agentStatusLoading={agentStatusLoading}
agentPending={agentPending}
agentActivity={agentActivity}
@@ -0,0 +1,119 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Bot, Check } from 'lucide-react'
import { t } from '../i18n'
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
models: AgentModelOption[]
provider: string | null
disabled: boolean
dark: boolean
onChange: (provider: string) => void
}): React.JSX.Element {
const selected = models.find(model => model.provider === provider)
const blocked = disabled || models.length === 0
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 })
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const visible = open && !blocked
const close = (restoreFocus = false): void => {
setOpen(false)
if (restoreFocus) buttonRef.current?.focus({ preventScroll: true })
}
useEffect(() => {
if (blocked) setOpen(false)
}, [blocked])
useLayoutEffect(() => {
if (!visible || !buttonRef.current) return
const bounds = buttonRef.current.getBoundingClientRect()
const width = Math.min(320, window.innerWidth - 16)
setPosition({
left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)),
bottom: window.innerHeight - bounds.top + 6,
width,
maxHeight: Math.max(0, Math.min(320, bounds.top - 14))
})
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
const index = Math.max(0, models.findIndex(model => model.provider === provider))
items?.[index]?.focus({ preventScroll: true })
}, [visible, models, provider])
useEffect(() => {
if (!visible) return
const outside = (event: Event): void => {
const target = event.target as Node
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
}
const dismiss = (): void => setOpen(false)
window.addEventListener('pointerdown', outside, true)
window.addEventListener('contextmenu', outside)
window.addEventListener('scroll', outside, true)
window.addEventListener('resize', dismiss)
window.addEventListener('blur', dismiss)
return () => {
window.removeEventListener('pointerdown', outside, true)
window.removeEventListener('contextmenu', outside)
window.removeEventListener('scroll', outside, true)
window.removeEventListener('resize', dismiss)
window.removeEventListener('blur', dismiss)
}
}, [visible])
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t('에이전트 모델 선택')}
aria-haspopup="menu"
aria-expanded={visible}
aria-controls={visible ? menuId : undefined}
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
disabled={blocked}
onClick={() => setOpen(current => !current)}
onKeyDown={event => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) }
else if (event.key === 'Escape') close()
}}
className={['grid size-7 shrink-0 cursor-pointer place-items-center rounded outline-none transition focus-visible:ring-1 focus-visible:ring-[#8b5cf6] disabled:cursor-default disabled:opacity-35',
dark ? 'text-white/60 hover:bg-white/10 hover:text-white/90' : 'text-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]',
visible ? dark ? 'bg-white/10 text-white/90' : 'bg-[#eef1f7] text-[#111520]' : ''].join(' ')}
>
<Bot size={15} aria-hidden="true" />
</button>
{visible && createPortal(
<div ref={menuRef} id={menuId} role="menu" aria-label={t('에이전트 모델 선택')}
className={['fixed z-[160] overflow-y-auto overscroll-contain rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'].join(' ')}
style={position}
onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}
onKeyDown={event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); return }
if (event.key === 'Tab') { close(true); return }
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
const current = items.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
items[next]?.focus()
}}
>
{models.map(model => <button key={model.provider} type="button" role="menuitemradio" aria-checked={model.provider === provider}
title={agentModelLabel(model)} tabIndex={-1}
onClick={() => { if (!blocked) { onChange(model.provider); close(true) } }}
className={['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left outline-none transition',
dark ? 'hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520] focus:bg-[#eef1f7] focus:text-[#111520]'].join(' ')}>
<Check size={13} className={['shrink-0', model.provider === provider ? '' : 'opacity-0'].join(' ')} aria-hidden="true" />
<span className="min-w-0 truncate">{agentModelLabel(model)}</span>
</button>)}
</div>, document.body
)}
</>
)
}
+5 -2
View File
@@ -298,6 +298,7 @@ export const QaInputBox = memo(function QaInputBox({
imagesEnabled = false,
onClearConversation,
clearDisabled = false,
footerAction,
onStop,
onSubmit
}: {
@@ -309,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({
imagesEnabled?: boolean
onClearConversation?: () => void
clearDisabled?: boolean
footerAction?: React.ReactNode
onStop: () => void
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
}): React.JSX.Element {
@@ -456,8 +458,9 @@ export const QaInputBox = memo(function QaInputBox({
)}
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
{(attachmentsEnabled || onClearConversation) && (
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
{(attachmentsEnabled || onClearConversation || footerAction) && (
<div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
{footerAction}
{attachmentsEnabled && (
<>
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
@@ -10,6 +10,8 @@ import { ChatImageAttachments } from '../../components/ChatImageAttachments'
import { AgentProgressBubble } from '../../components/AgentProgressBubble'
import { AgentWorkDuration } from '../../components/AgentWorkDuration'
import { AgentCodeReferences } from '../../components/AgentCodeReferences'
import { AgentModelSelector } from '../../components/AgentModelSelector'
import type { AgentModelOption } from './agentModels'
import type { AgentProgressStep } from './agentContext'
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
@@ -47,6 +49,8 @@ type WorkspaceQaPanelProps = {
agentEnabled: boolean
agentProvider?: string | null
agentImagesEnabled?: boolean
agentModels?: AgentModelOption[]
onAgentProviderChange?: (provider: string) => void
agentStatusLoading: boolean
agentPending: boolean
agentActivity?: string
@@ -124,6 +128,8 @@ export default function WorkspaceQaPanel({
agentEnabled,
agentProvider,
agentImagesEnabled = false,
agentModels = [],
onAgentProviderChange,
agentStatusLoading,
agentPending,
agentActivity,
@@ -348,6 +354,7 @@ export default function WorkspaceQaPanel({
imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
onClearConversation={requestClearConversation}
clearDisabled={clearDisabled}
footerAction={qaMode === 'agent' && onAgentProviderChange ? <AgentModelSelector models={agentModels} provider={agentProvider ?? null} dark={dark} disabled={inputBusy || agentStatusLoading || inputDisabled} onChange={onAgentProviderChange} /> : undefined}
/>
{clearConfirmationOpen && createPortal(
<ConfirmStatusModal
@@ -1,7 +1,8 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { compactAgentEvidence, describeAgentEvidence, missingAgentRead, readFileEvidence } from './agentEvidence'
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] }
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[]; contextNotes?: string[] }
export type AgentContextRequest = { modules: string[]; logs: string[] }
export type AgentProgressStep = {
id: string
@@ -49,11 +50,11 @@ export function recoverInterruptedAgentMessages(messages: AgentConversationMessa
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'
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 코드 위치와 역할 설명은 전체 파일이 아닌 확인된 줄 범위만으로 답할 수 있습니다. 확인된 내용은 codeReferences와 함께 답하고 미확인 항목만 별도로 구분하세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
return instruction + request.slice(0, 4000 - instruction.length)
}
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext {
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = [], preferredReads: AgentToolRequest[] = []): 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 }))
@@ -111,12 +112,28 @@ function fitAgentContext(context: AgentContext, preferredModules: string[] = [],
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
const evidence = compactAgentEvidence(context.toolResults ?? [])
const priority = (result: AgentToolResult): number => {
const file = readFileEvidence(result)
if (file && preferredReads.some(tool => tool.name === 'files.read' && tool.moduleName === result.moduleName && tool.args?.path === file.path)) return 6
return result.name === 'action.result' ? 5 : !result.name.startsWith('files.') ? 4
: file || result.status === 'error' ? 3 : 1
}
const candidatesByPriority = evidence.map((result, index) => ({ result, index }))
.sort((a, b) => priority(b.result) - priority(a.result) || b.index - a.index)
const retained = new Set<AgentToolResult>()
for (const { result } of candidatesByPriority) {
if (retained.size >= 12 || result.data.length > 60_000 || toolSize + result.data.length > 180_000) continue
retained.add(result)
toolSize += result.data.length
return true
}).slice(0, 12).reverse()
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) }
}
const toolResults = evidence.filter(result => retained.has(result))
const omittedTools = evidence.filter(result => !retained.has(result))
const contextNotes = omittedTools.length
? [`용량·개수 한도로 조회 근거 ${omittedTools.length}개를 제외했습니다. 제외된 내용을 확인했다고 주장하지 마세요.`,
...omittedTools.slice(-10).map(result => describeAgentEvidence(result).slice(0, 500))]
: []
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults, contextNotes } : {}) }
}
export async function resolveAgentContext({
@@ -142,6 +159,7 @@ export async function resolveAgentContext({
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
const queriedTools = new Set<string>()
let toolCount = 0
let confirmedAnswer: AgentPlanResponse | undefined
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 => {
@@ -162,9 +180,17 @@ export async function resolveAgentContext({
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 toolStatus = (context.toolResults ?? []).map(result => `- ${describeAgentEvidence(result)}`).join('\n').slice(0, 8000)
const candidate = response.plan.codeReferences?.length ? response : confirmedAnswer
const references = candidate?.plan.codeReferences?.filter(reference => {
const latest = (cachedContext.toolResults ?? []).filter(result => result.moduleName === reference.moduleName)
.map(readFileEvidence).filter(file => file?.path === reference.path).at(-1)
return !latest || latest.exists && latest.hash === reference.expectedHash
})
const confirmed = references?.length && candidate ? { ...candidate, plan: { ...candidate.plan, codeReferences: references } } : undefined
const confirmedReferences = Boolean(confirmed?.plan.codeReferences?.length)
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반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`
? `${confirmedReferences ? `${confirmed!.plan.explanation}\n\n추가 분석은 완료하지 못했지만 확인된 코드 위치는 유지했습니다.` : 'AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.'}\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`.slice(0, 12_000)
: response.plan.explanation
return {
...response,
@@ -172,7 +198,8 @@ export async function resolveAgentContext({
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
plan: {
...response.plan,
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}),
explanation,
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
}
@@ -186,19 +213,31 @@ export async function resolveAgentContext({
const response = await requestPlan(context)
checkAborted()
addUsage(response)
if (response.plan.codeReferences?.length) confirmedAnswer = response
const modules = [...new Set(response.plan.requestedModules ?? [])]
const logs = [...new Set(response.plan.requestedLogs ?? [])]
const tools = response.plan.toolRequests ?? []
onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length })
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 (round === maxRounds) {
context = fitAgentContext(cachedContext, [], [], tools)
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 fresh = tools.filter(tool => {
const missing = missingAgentRead(tool, cachedContext.toolResults ?? [])
return missing && !queriedTools.has(fingerprint(missing))
})
if (!fresh.length || toolCount + fresh.length > 24) {
context = fitAgentContext(cachedContext, [], [], tools)
return finalize()
}
const results = [...(cachedContext.toolResults ?? [])]
for (const [index, tool] of fresh.entries()) {
for (const [index, proposedTool] of fresh.entries()) {
checkAborted()
const tool = missingAgentRead(proposedTool, results)
if (!tool) continue
const key = fingerprint(tool)
if (queriedTools.has(key)) continue
queriedTools.add(key)
@@ -209,8 +248,8 @@ export async function resolveAgentContext({
toolCount++
results.push(result)
}
cachedContext = { ...cachedContext, toolResults: results }
context = fitAgentContext(cachedContext)
cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) }
context = fitAgentContext(cachedContext, [], [], tools)
onProgress?.('조회 결과 분석 중')
// A model may also use legacy source requests; process those on the next pass.
continue
@@ -0,0 +1,107 @@
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
type FileEvidence = {
path: string; exists: boolean; content: string; hash: string | null
complete: boolean; startLine: number; endLine: number; totalLines: number
contentTruncated?: boolean
}
export function readFileEvidence(result: AgentToolResult): FileEvidence | undefined {
if (result.name !== 'files.read' || result.status !== 'ok') return
try {
const data = JSON.parse(result.data)
if (typeof data.path !== 'string' || typeof data.content !== 'string' || typeof data.complete !== 'boolean'
|| data.contentTruncated !== undefined && typeof data.contentTruncated !== 'boolean') return
if (data.exists === false && data.complete === true && data.hash === null && data.content === '') {
return { ...data, startLine: 1, endLine: 0, totalLines: 0 }
}
if (data.exists !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) return
const startLine = data.startLine ?? 1
const last = startLine + data.content.split('\n').length - 1
// Older clients cannot distinguish a ranged read from a cut final line.
const endLine = data.endLine ?? last - Number(result.truncated)
if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(endLine)
|| endLine < startLine - 1 || endLine > last || !Number.isSafeInteger(data.totalLines) || data.totalLines < 1 || endLine > data.totalLines
|| data.contentTruncated === true && endLine >= last) return
return { ...data, startLine, endLine }
} catch { return }
}
const fileKey = (result: AgentToolResult, file: FileEvidence): string => JSON.stringify([result.target, result.moduleName, file.path])
// Only combine observations of the same file revision on the same host.
export function compactAgentEvidence(results: AgentToolResult[]): AgentToolResult[] {
const compacted: AgentToolResult[] = []
for (const result of results) {
const file = readFileEvidence(result)
if (!file) { compacted.push(result); continue }
const key = fileKey(result, file)
let current = result
let currentFile = file
for (let index = compacted.length - 1; index >= 0; index--) {
const previous = compacted[index]
const prior = readFileEvidence(previous)
if (!prior || fileKey(previous, prior) !== key) continue
if (prior.hash !== file.hash || prior.exists !== file.exists) {
compacted.splice(index, 1)
continue
}
if (currentFile.complete) { compacted.splice(index, 1); continue }
if (prior.complete) { current = previous; currentFile = prior; compacted.splice(index, 1); continue }
if (prior.contentTruncated !== false || currentFile.contentTruncated !== false
|| prior.totalLines !== currentFile.totalLines
|| prior.endLine + 1 < currentFile.startLine || currentFile.endLine + 1 < prior.startLine) continue
const lines = new Map<number, string>()
let conflict = false
for (const part of [prior, currentFile]) {
for (const [offset, line] of part.content.split('\n').entries()) {
const number = part.startLine + offset
if (number > part.endLine) break
if (lines.has(number) && lines.get(number) !== line) conflict = true
lines.set(number, line)
}
}
if (conflict) continue
const startLine = Math.min(prior.startLine, currentFile.startLine)
const endLine = Math.max(prior.endLine, currentFile.endLine)
const content = Array.from({ length: endLine - startLine + 1 }, (_, offset) => lines.get(startLine + offset)!).join('\n')
// Merged ranges remain read-only evidence: no unverified full-file hash promotion.
const merged = { ...currentFile, startLine, endLine, content, complete: false, contentTruncated: false }
const data = JSON.stringify(merged)
if (content.length > 40_000 || data.length > 60_000) continue
currentFile = merged
current = { ...result, data, truncated: true }
compacted.splice(index, 1)
}
compacted.push(current)
}
return compacted
}
export function missingAgentRead(tool: AgentToolRequest, results: AgentToolResult[]): AgentToolRequest | undefined {
if (tool.name !== 'files.read') return tool
const observations = results.filter(result => result.moduleName === tool.moduleName)
.flatMap(result => { const file = readFileEvidence(result); return file && file.path === tool.args?.path ? [{ file, target: result.target }] : [] })
const latest = observations.at(-1)
const files = observations.filter(item => item.target === latest?.target && item.file.hash === latest?.file.hash).map(item => item.file)
if (files.some(file => file.complete)) return
// An unbounded read may be needed for an approved edit; partial ranges do not replace it.
if (tool.args?.startLine === undefined && tool.args?.lineCount === undefined) return tool
const start = tool.args?.startLine ?? 1
const total = files[0]?.totalLines
const end = Math.min(start + (tool.args?.lineCount ?? 200) - 1, total ?? Infinity)
let next = start
const ranges = files.map(file => [file.startLine, file.endLine]).sort((a, b) => a[0] - b[0])
for (const [from, to] of ranges) {
if (from > next) return { ...tool, args: { ...tool.args, startLine: next, lineCount: Math.min(end, from - 1) - next + 1 } }
if (to >= next) next = to + 1
if (next > end) return
}
if (next > end) return
return { ...tool, args: { ...tool.args, startLine: next, lineCount: end - next + 1 } }
}
export function describeAgentEvidence(result: AgentToolResult): string {
const file = readFileEvidence(result)
return `${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${file ? ` · ${file.path}${file.exists ? ` · ${file.startLine}–${file.endLine}/${file.totalLines}줄` : ' · 파일 없음'}` : ''}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`
}
@@ -0,0 +1,34 @@
export const AGENT_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const
export type AgentProvider = typeof AGENT_PROVIDERS[number]
export type AgentModelOption = { provider: AgentProvider; model: string; imagesEnabled: boolean }
export type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
imagesEnabled?: boolean
models: AgentModelOption[]
selectionSupported: boolean
}
export const AGENT_PROVIDER_LABELS: Record<AgentProvider, string> = {
A_COPILOT: 'A-Copilot', OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen'
}
export function normalizeAgentStatus(value: Partial<AgentStatus>): AgentStatus {
const selectionSupported = Array.isArray(value.models)
const candidates = value.enabled ? selectionSupported ? value.models! : [value] : []
const models = AGENT_PROVIDERS.flatMap(provider => {
const item = candidates.find(item => item.provider === provider && typeof item.model === 'string' && item.model.trim() && item.model.length <= 120)
return item ? [{ provider, model: item.model!.trim(), imagesEnabled: provider === 'QWEN' && item.imagesEnabled === true }] : []
})
return { enabled: models.length > 0, provider: models[0]?.provider ?? null, model: models[0]?.model ?? null,
imagesEnabled: models[0]?.imagesEnabled ?? false, models, selectionSupported }
}
export function selectedAgentModel(models: AgentModelOption[], preference: string | null): AgentModelOption | null {
return models.find(model => model.provider === preference) ?? models[0] ?? null
}
export function agentModelLabel(model: AgentModelOption): string {
return model.provider === 'A_COPILOT' ? 'A-Copilot' : `${AGENT_PROVIDER_LABELS[model.provider]} · ${model.model}`
}
@@ -3,6 +3,7 @@ import { createAgentSessionSync } from './agentSessionSync'
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
export type AgentFileChange = {
expectedHash?: string | null
@@ -117,13 +118,6 @@ type UseAiAgentOptions = {
onResponse?: () => void
}
type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
imagesEnabled?: boolean
}
type AgentSessionResponse = {
sessionId: string | null
messages: AgentConversationMessage[]
@@ -132,7 +126,11 @@ type AgentSessionResponse = {
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
const [status, setStatus] = useState<AgentStatus>({ enabled: false, provider: null, model: null })
const modelPreferenceKey = `autonomy-studio.lms.agent-model:${JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId])}`
const [statusSnapshot, setStatusSnapshot] = useState<{ scope: string; status: AgentStatus }>({ scope: '', status: normalizeAgentStatus({}) })
const status = statusSnapshot.scope === modelPreferenceKey ? statusSnapshot.status : normalizeAgentStatus({})
const [modelPreference, setModelPreference] = useState<{ scope: string; provider: string | null }>({ scope: '', provider: null })
const selectedModel = selectedAgentModel(status.models, modelPreference.scope === modelPreferenceKey ? modelPreference.provider : null)
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null)
@@ -150,6 +148,18 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
onResponseRef.current = onResponse
const authenticated = Boolean(accessToken && courseId && studentEmail)
const selectProvider = (provider: string): void => {
if (pending || applyingRef.current || abortControllerRef.current || !status.models.some(model => model.provider === provider)) return
setModelPreference({ scope: modelPreferenceKey, provider })
try { window.localStorage.setItem(modelPreferenceKey, provider) } catch { /* Selection still works without persistent storage. */ }
}
useEffect(() => {
let provider: string | null = null
try { provider = window.localStorage.getItem(modelPreferenceKey) } catch { /* Use the first available model. */ }
setModelPreference({ scope: modelPreferenceKey, provider })
}, [modelPreferenceKey])
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
const authorization = new Headers(options?.headers).get('Authorization')
?? `Bearer ${await tokenReaderRef.current()}`
@@ -167,7 +177,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|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
}
return response.json() as Promise<T>
@@ -205,27 +215,43 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
useEffect(() => {
if (!accessToken || !courseId) {
setStatus({ enabled: false, provider: null, model: null })
setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
setStatusLoading(false)
return
}
let cancelled = false
setStatusLoading(true)
let revision = 0
let initialLoad = true
const refresh = (): void => {
const requestRevision = ++revision
if (initialLoad) setStatusLoading(true)
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
.then((nextStatus) => {
if (!cancelled) setStatus(nextStatus)
.then(nextStatus => {
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) })
})
.catch(() => {
if (!cancelled) setStatus({ enabled: false, provider: null, model: null })
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
})
.finally(() => {
if (!cancelled) setStatusLoading(false)
if (!cancelled && requestRevision === revision) {
initialLoad = false
setStatusLoading(false)
}
})
}
const refreshVisible = (): void => { if (!document.hidden) refresh() }
refresh()
window.addEventListener('focus', refresh)
document.addEventListener('visibilitychange', refreshVisible)
const timer = window.setInterval(refreshVisible, 30_000)
return () => {
cancelled = true
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refreshVisible)
window.clearInterval(timer)
}
}, [accessToken, courseId, requestApi, weekNumber])
}, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber])
// The workspace stays mounted while other app pages or Q&A tabs are shown.
// Request cancellation follows the conversation lifetime, not its visibility.
@@ -293,11 +319,14 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote'
workspaceTarget?: string
toolResults?: AgentToolResult[]
} = {}
): Promise<void> => {
const request = rawRequest.trim()
if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return
if (!request || pending || historyLoading || statusLoading || abortControllerRef.current || !status.enabled || !selectedModel) return
const requestModel = selectedModel
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
const startedAt = Date.now()
const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`,
@@ -321,10 +350,11 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const imageAttachments: AgentImageReference[] = []
for (const file of options.attachments ?? []) {
if (file.kind !== 'image') continue
if (!status.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
setActivity(`이미지 업로드 중: ${file.name}`)
const body = new FormData()
body.append('courseId', courseId)
if (selection.provider) body.append('provider', selection.provider)
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
body.append('file', file.file, file.name)
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
@@ -345,7 +375,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST',
signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context })
body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment, workspaceTarget: options.workspaceTarget } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context })
})
})
if (abortController.signal.aborted) return
@@ -504,6 +534,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
return {
...status,
provider: selectedModel?.provider ?? null,
model: selectedModel?.model ?? null,
imagesEnabled: selectedModel?.imagesEnabled ?? false,
selectProvider,
statusLoading: statusLoading || historyLoading,
pending,
activity,
+2
View File
@@ -1,4 +1,6 @@
{
"에이전트 모델 선택": "Agent model",
"사용 가능한 모델 없음": "No models available",
"전송": "Send",
"파일 첨부": "Attach files",
"첨부 제거": "Remove attachment",