Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 017093077f | |||
| af6dc560ea |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.23",
|
"version": "1.2.25",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.23",
|
"version": "1.2.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.2.23",
|
"version": "1.2.25",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
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/agentConversationContext.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: api })
|
||||||
|
const { agentConversationWorkspace: scope, buildAgentConversationContext: build } = api
|
||||||
|
const now = Date.now()
|
||||||
|
const local = scope('local', 'local-host', '/workspace/a')
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const turn = (n, workspace = local, createdAt = now) => [
|
||||||
|
{ id: `u${n}`, role: 'user', content: `question-${n}`, conversationScope: { workspace, createdAt } },
|
||||||
|
{ id: `a${n}`, role: 'assistant', content: `summary-${n}`, conversationScope: { workspace, createdAt }, plan: {
|
||||||
|
summary: `summary-${n}`, explanation: `answer-${n}`, conversationSummary: `누적 기억-${n}`, changes: [], commands: [], requestedModules: [], requestedLogs: [], createModule: null
|
||||||
|
} }
|
||||||
|
]
|
||||||
|
|
||||||
|
test('uses the latest cumulative summary with only two recent exchanges and no duplicate current question', () => {
|
||||||
|
const messages = Array.from({ length: 20 }, (_, i) => turn(i)).flat()
|
||||||
|
messages.push({ ...turn(20)[0], content: 'pending current question' })
|
||||||
|
const context = build(messages, local, now)
|
||||||
|
assert.deepEqual(plain(context.recentMessages).map(item => item.content), ['question-18', 'summary-18\nanswer-18', 'question-19', 'summary-19\nanswer-19'])
|
||||||
|
assert.equal(context.summary, '누적 기억-19')
|
||||||
|
assert.doesNotMatch(JSON.stringify(context), /question-17|pending current question/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hard caps payload text even with huge original answers and requests', () => {
|
||||||
|
const messages = Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const pair = turn(i)
|
||||||
|
pair[0].content = 'q'.repeat(4000)
|
||||||
|
pair[1].plan.summary = 's'.repeat(500)
|
||||||
|
pair[1].plan.explanation = 'e'.repeat(12000)
|
||||||
|
pair[1].plan.conversationSummary = 'm'.repeat(2000)
|
||||||
|
return pair
|
||||||
|
}).flat()
|
||||||
|
const context = build(messages, local, now)
|
||||||
|
assert.ok(context.summary.length <= 1000)
|
||||||
|
assert.equal(context.recentMessages.length, 4)
|
||||||
|
assert.ok(context.recentMessages.every(message => message.content.length <= 700))
|
||||||
|
assert.ok(context.summary.length + context.recentMessages.reduce((sum, item) => sum + item.content.length, 0) <= 3800)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not mix hosts, roots, SSH identities, unscoped legacy messages or expired history', () => {
|
||||||
|
for (const other of [scope('remote', 'ssh:a', '/workspace/a'), scope('local', 'local-host', '/workspace/b'), scope('remote', 'ssh:b', '/workspace/a')]) {
|
||||||
|
assert.equal(build(turn(1, other), local, now), undefined)
|
||||||
|
}
|
||||||
|
assert.equal(build(turn(1, local, now - 86400000), local, now), undefined)
|
||||||
|
assert.equal(build(turn(1, local, now + 1), local, now), undefined)
|
||||||
|
assert.equal(build(turn(1).map(({ conversationScope, ...message }) => message), local, now), undefined)
|
||||||
|
assert.equal(scope('local', 'local-host'), undefined)
|
||||||
|
assert.equal(build(turn(1), undefined, now), undefined)
|
||||||
|
assert.equal(build([], local, now), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing or empty generated summary preserves earlier valid memory without an extra model call', () => {
|
||||||
|
const first = turn(1), second = turn(2)
|
||||||
|
delete second[1].plan.conversationSummary
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
|
||||||
|
second[1].plan.conversationSummary = ''
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
|
||||||
|
delete first[1].plan.conversationSummary
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '')
|
||||||
|
second[1].plan.conversationSummary = '새 누적 요약'
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '새 누적 요약')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps file identities and action outcomes, but excludes payloads and tool evidence', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
pair[1].plan.explanation = '설명\n```python\nPRIVATE_SOURCE\n```\n끝'
|
||||||
|
pair[1].plan.changes = [{ moduleName: 'Control', path: 'config.yaml', content: 'PRIVATE_CHANGE' }]
|
||||||
|
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build', command: 'PRIVATE_COMMAND' }]
|
||||||
|
pair[1].plan.evidence = [{ data: 'PRIVATE_LOG' }]
|
||||||
|
pair[1].plan.codeReferences = [{ moduleName: 'Control', path: 'node.py', startLine: 20, endLine: 23, snippet: 'PRIVATE_SNIPPET' }]
|
||||||
|
pair[1].commandExecutions = { build: { status: 'completed', output: 'PRIVATE_OUTPUT' } }
|
||||||
|
pair[1].applyState = 'cancelled'
|
||||||
|
const context = JSON.stringify(build(pair, local, now))
|
||||||
|
assert.match(context, /Control\/node.py:20-23/)
|
||||||
|
assert.match(context, /file change: cancelled/)
|
||||||
|
assert.match(context, /build: completed/)
|
||||||
|
assert.doesNotMatch(context, /PRIVATE_/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores errors, interrupted turns and initial lookup responses', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
const error = { ...pair[1], plan: undefined, content: 'network error' }
|
||||||
|
assert.equal(build([pair[0], error], local, now), undefined)
|
||||||
|
pair[1].plan.toolRequests = [{ name: 'files.read' }]
|
||||||
|
assert.equal(build(pair, local, now), undefined)
|
||||||
|
assert.equal(build([turn(1)[0], ...turn(2, scope('remote', 'ssh:x', '/workspace'))], local, now), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rebuilds from persisted metadata and reflects updated execution outcomes without new memory storage', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build' }]
|
||||||
|
assert.match(JSON.stringify(build(plain(pair), local, now)), /pending/)
|
||||||
|
pair[1].commandExecutions = { build: { status: 'failed' } }
|
||||||
|
assert.match(JSON.stringify(build(plain(pair), local, now)), /failed/)
|
||||||
|
const followUp = { ...pair[1], content: 'result', plan: { ...pair[1].plan, summary: '실행 결과 분석', explanation: '실패했습니다.' } }
|
||||||
|
assert.match(JSON.stringify(build([...pair, followUp], local, now)), /실행 결과 분석/)
|
||||||
|
assert.equal(build([], local, now), undefined)
|
||||||
|
})
|
||||||
@@ -15,6 +15,7 @@ const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src
|
|||||||
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.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 }
|
require: name => { assert.equal(name, './agentEvidence'); return evidenceApi }
|
||||||
})
|
})
|
||||||
|
const conversation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'))
|
||||||
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
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)))
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
|
||||||
@@ -29,10 +30,10 @@ function harness(requestApi) {
|
|||||||
let now = 1000
|
let now = 1000
|
||||||
class Clock extends Date { static now() { return now } }
|
class Clock extends Date { static now() { return now } }
|
||||||
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
|
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
|
||||||
const messageStateRef = { current: { session } }
|
const messageStateRef = { current: { session, messages: [] } }
|
||||||
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
|
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
|
||||||
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
|
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
|
||||||
...context, session, messageStateRef, AbortController, FormData,
|
...context, ...conversation, session, messageStateRef, AbortController, FormData,
|
||||||
Date: Clock,
|
Date: Clock,
|
||||||
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
|
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
|
||||||
selectedModel,
|
selectedModel,
|
||||||
@@ -41,12 +42,98 @@ function harness(requestApi) {
|
|||||||
setRequestStartedAt: value => { state.requestStartedAt = value },
|
setRequestStartedAt: value => { state.requestStartedAt = value },
|
||||||
setActivity: value => { state.activity = value },
|
setActivity: value => { state.activity = value },
|
||||||
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
|
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
|
||||||
updateMessages: update => { state.messages = update(state.messages); state.saved.push(plain(state.messages)) },
|
updateMessages: update => { state.messages = update(state.messages); messageStateRef.current.messages = state.messages; state.saved.push(plain(state.messages)) },
|
||||||
onResponseRef: { current: () => { state.notices++ } }, requestApi
|
onResponseRef: { current: () => { state.notices++ } }, requestApi
|
||||||
})
|
})
|
||||||
return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
|
return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('shows and persists the question, pending state and timer before a slow module snapshot finishes', async () => {
|
||||||
|
const snapshot = deferred(), completion = deferred()
|
||||||
|
const bodies = []
|
||||||
|
let loads = 0
|
||||||
|
const h = harness((_, request) => { bodies.push(JSON.parse(request.body)); return completion.promise })
|
||||||
|
const running = h.requestPlan('원격 차량 속도 제한 위치', [], [], {
|
||||||
|
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10', executeTool: async () => result(),
|
||||||
|
loadInitialContext: signal => { loads++; assert.equal(signal.aborted, false); return snapshot.promise }
|
||||||
|
})
|
||||||
|
assert.equal(h.state.messages[0].content, '원격 차량 속도 제한 위치')
|
||||||
|
assert.equal(h.state.saved[0][0].role, 'user')
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.requestStartedAt, 1000)
|
||||||
|
assert.equal(h.state.activity, '모듈 상태 조회 중')
|
||||||
|
assert.equal(bodies.length, 0)
|
||||||
|
await h.requestPlan('중복 전송', [], [], { loadInitialContext: () => { loads++; return snapshot.promise } })
|
||||||
|
assert.equal(loads, 1)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
h.advance(5000)
|
||||||
|
h.selectedModel.provider = 'OPENAI'
|
||||||
|
snapshot.resolve({ modules: [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], runtimeLogs: [] })
|
||||||
|
await tick()
|
||||||
|
assert.equal(bodies.length, 1)
|
||||||
|
assert.equal(bodies[0].provider, 'QWEN')
|
||||||
|
assert.equal(bodies[0].modules[0].state, 'running')
|
||||||
|
assert.deepEqual(bodies[0].modules[0].parameters, ['sim_mode:=false'])
|
||||||
|
h.advance(2000)
|
||||||
|
completion.resolve(response())
|
||||||
|
await running
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 7000)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const reject of [false, true]) test(`cancels during initial module lookup and ignores its late ${reject ? 'failure' : 'result'} without affecting a new request`, async () => {
|
||||||
|
const old = deferred(), next = deferred()
|
||||||
|
let calls = 0, signal
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const first = h.requestPlan('첫 질문', [], [], { loadInitialContext: current => { signal = current; return old.promise } })
|
||||||
|
h.stop()
|
||||||
|
assert.equal(signal.aborted, true)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
const second = h.requestPlan('다음 질문', [], [], { loadInitialContext: () => next.promise })
|
||||||
|
if (reject) old.reject(Error('늦게 도착한 SSH 오류'))
|
||||||
|
else old.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await first
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.activity, '모듈 상태 조회 중')
|
||||||
|
assert.equal(h.state.messages.length, 2)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
next.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await second
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
assert.equal(h.state.messages.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('initial snapshot errors preserve the question and show a timed error without calling the model', async () => {
|
||||||
|
const snapshot = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const running = h.requestPlan('질문', [], [], { loadInitialContext: () => snapshot.promise })
|
||||||
|
h.advance(3000)
|
||||||
|
snapshot.reject(Error('원격 모듈 상태 조회 실패'))
|
||||||
|
await running
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.messages[0].content, '질문')
|
||||||
|
assert.equal(h.state.messages[1].content, '원격 모듈 상태 조회 실패')
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 3000)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
assert.equal(h.state.activity, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope changes during initial snapshot loading suppress later API calls and errors', async () => {
|
||||||
|
const snapshot = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const running = h.requestPlan('이전 강좌 질문', [], [], { loadInitialContext: () => snapshot.promise })
|
||||||
|
h.messageStateRef.current.session = {}
|
||||||
|
snapshot.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await running
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
})
|
||||||
|
|
||||||
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
|
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
|
||||||
const first = deferred(), tool = deferred(), final = deferred()
|
const first = deferred(), tool = deferred(), final = deferred()
|
||||||
let calls = 0
|
let calls = 0
|
||||||
@@ -133,6 +220,30 @@ test('remote target and observed source ranges reach each provider request inclu
|
|||||||
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
|
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('sends only prior same-workspace context and keeps it stable throughout tool lookups', async () => {
|
||||||
|
const bodies = []
|
||||||
|
const h = harness(async (_, request) => {
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
const reply = bodies.length === 2 ? response([{ id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config.yaml' } }]) : response()
|
||||||
|
return { ...reply, plan: { ...reply.plan, conversationSummary: 'Control의 config.yaml 설명을 진행 중입니다.' } }
|
||||||
|
})
|
||||||
|
const options = { environment: 'local', workspaceTarget: 'local-host', workspaceRoot: '/workspace/a', executeTool: async () => result() }
|
||||||
|
await h.requestPlan('Control 설정 위치', [], [], options)
|
||||||
|
assert.equal(bodies[0].conversationContext, undefined)
|
||||||
|
await h.requestPlan('그 파일 설명해줘', [], [], options)
|
||||||
|
assert.equal(bodies.length, 3)
|
||||||
|
assert.equal(bodies[1].conversationContext.recentMessages[0].content, 'Control 설정 위치')
|
||||||
|
assert.equal(bodies[1].conversationContext.summary, 'Control의 config.yaml 설명을 진행 중입니다.')
|
||||||
|
assert.equal(h.state.saved[1][1].plan.conversationSummary, 'Control의 config.yaml 설명을 진행 중입니다.')
|
||||||
|
assert.equal(JSON.stringify(bodies[1].conversationContext).includes('그 파일 설명해줘'), false)
|
||||||
|
assert.deepEqual(bodies[1].conversationContext, bodies[2].conversationContext)
|
||||||
|
assert.equal(bodies[1].workspaceRoot, undefined)
|
||||||
|
await h.requestPlan('다른 원격 질문', [], [], { ...options, environment: 'remote', workspaceTarget: 'ssh:device' })
|
||||||
|
assert.equal(bodies.at(-1).conversationContext, undefined)
|
||||||
|
await h.requestPlan('다른 로컬 폴더', [], [], { ...options, workspaceRoot: '/workspace/b' })
|
||||||
|
assert.equal(bodies.at(-1).conversationContext, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
test('errors replace temporary progress with the existing error reply', async () => {
|
test('errors replace temporary progress with the existing error reply', async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
const h = harness(async () => {
|
const h = harness(async () => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ test('user messages preserve only typed text while attachments remain separate m
|
|||||||
find(source)
|
find(source)
|
||||||
assert.ok(initializer)
|
assert.ok(initializer)
|
||||||
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
|
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
|
||||||
const message = vm.runInNewContext(`(${initializer})`, { request, options: { attachments: [{ name: '스크린샷.png' }] } })
|
const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
|
||||||
assert.equal(message.content, request)
|
assert.equal(message.content, request)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -40,7 +40,7 @@ test('user messages preserve only typed text while attachments remain separate m
|
|||||||
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
|
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
|
||||||
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
||||||
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
||||||
textAttachments: attachments ?? [], imageTokens,
|
textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
|
||||||
selection: provider ? { provider } : {},
|
selection: provider ? { provider } : {},
|
||||||
agentPlanRequestText: contextApi.agentPlanRequestText,
|
agentPlanRequestText: contextApi.agentPlanRequestText,
|
||||||
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ function renderResponse(message, now, reducedMotion = false, extra = {}) {
|
|||||||
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
|
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('internal cumulative memory is not rendered in the response bubble', () => {
|
||||||
|
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
|
||||||
|
assert.match(html, /화면 설명/)
|
||||||
|
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
|
||||||
|
})
|
||||||
|
|
||||||
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
|
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
|
||||||
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
|
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
|
||||||
assert.ok(html.includes('A-Copilot'))
|
assert.ok(html.includes('A-Copilot'))
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const plain = value => JSON.parse(JSON.stringify(value))
|
|||||||
function harness(remoteWorkspace) {
|
function harness(remoteWorkspace) {
|
||||||
const executions = []
|
const executions = []
|
||||||
const calls = []
|
const calls = []
|
||||||
|
const contexts = []
|
||||||
|
const events = []
|
||||||
const errors = []
|
const errors = []
|
||||||
const target = { host: '100.64.0.10', user: 'fixture', workspaceRoot: '/fixture/workspace' }
|
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 live = [{ name: 'NAV2-MKMini', folderName: 'vehicle', state: 'running', parameters: ['sim_mode:=false', 'robot:=mkmini'] }]
|
||||||
@@ -26,10 +28,14 @@ function harness(remoteWorkspace) {
|
|||||||
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
|
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
|
||||||
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
|
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
|
||||||
window: {
|
window: {
|
||||||
remoteWorkspace: { modules: { list: async () => ({ modules: live }) }, agent: { readTool: readTool('remote') } },
|
remoteWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('remote') } },
|
||||||
localWorkspace: { modules: { list: async () => ({ modules: live }) }, agent: { readTool: readTool('local') } }
|
localWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('local') } }
|
||||||
|
},
|
||||||
|
requestAgentPlan: async (...args) => {
|
||||||
|
calls.push(args)
|
||||||
|
events.push('plan-start')
|
||||||
|
contexts.push(await args[3].loadInitialContext(new AbortController().signal))
|
||||||
},
|
},
|
||||||
requestAgentPlan: async (...args) => { calls.push(args) },
|
|
||||||
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
|
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
|
||||||
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
|
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
|
||||||
}
|
}
|
||||||
@@ -37,16 +43,19 @@ function harness(remoteWorkspace) {
|
|||||||
vm.runInNewContext(ts.transpileModule(names.map(name => `const ${name} = ${declarations.get(name)}; exports.${name} = ${name}`).join('\n'), {
|
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 }
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
}).outputText, globals)
|
}).outputText, globals)
|
||||||
return { ...exports, executions, calls, errors }
|
return { ...exports, executions, calls, contexts, events, errors }
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const remote of [false, true]) {
|
for (const remote of [false, true]) {
|
||||||
test(`${remote ? 'remote' : 'local'}: a new question refreshes state and start settings before sending context`, async () => {
|
test(`${remote ? 'remote' : 'local'}: enters the chat lifecycle immediately and refreshes state before the provider request`, async () => {
|
||||||
const h = harness(remote)
|
const h = harness(remote)
|
||||||
await h.submitAgentRequest('최대 속도 제한 코드 위치')
|
await h.submitAgentRequest('최대 속도 제한 코드 위치')
|
||||||
assert.deepEqual(h.errors, [])
|
assert.deepEqual(h.errors, [])
|
||||||
assert.equal(h.calls.length, 1)
|
assert.equal(h.calls.length, 1)
|
||||||
const [, modules, , options] = h.calls[0]
|
const [, initialModules, , options] = h.calls[0]
|
||||||
|
assert.deepEqual(plain(initialModules), [])
|
||||||
|
assert.deepEqual(h.events, ['plan-start', 'snapshot-read'])
|
||||||
|
const { modules } = h.contexts[0]
|
||||||
assert.equal(modules[0].state, 'running')
|
assert.equal(modules[0].state, 'running')
|
||||||
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
|
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
|
||||||
assert.deepEqual(plain(modules[0].files), [])
|
assert.deepEqual(plain(modules[0].files), [])
|
||||||
|
|||||||
@@ -3679,13 +3679,18 @@ function LocalPcWorkspaceMockup({
|
|||||||
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
|
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
|
||||||
if (agentActionControllerRef.current || agentApplying) return
|
if (agentActionControllerRef.current || agentApplying) return
|
||||||
try {
|
try {
|
||||||
const modules = await collectAgentWorkspace([], await readAgentModules())
|
await requestAgentPlan(content, [], [], {
|
||||||
await requestAgentPlan(content, modules, [], {
|
|
||||||
attachments,
|
attachments,
|
||||||
|
loadInitialContext: async (signal) => {
|
||||||
|
const modules = await readAgentModules()
|
||||||
|
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||||
|
return { modules: await collectAgentWorkspace([], modules), runtimeLogs: [] }
|
||||||
|
},
|
||||||
loadRequestedContext: loadAgentRequestedContext,
|
loadRequestedContext: loadAgentRequestedContext,
|
||||||
executeTool: executeWorkspaceAgentTool,
|
executeTool: executeWorkspaceAgentTool,
|
||||||
environment: remoteWorkspace ? 'remote' : 'local',
|
environment: remoteWorkspace ? 'remote' : 'local',
|
||||||
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
|
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||||
|
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[agent] workspace collection failed', error)
|
console.error('[agent] workspace collection failed', error)
|
||||||
@@ -3827,7 +3832,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
const currentModules = await readAgentModules()
|
const currentModules = await readAgentModules()
|
||||||
assertActive()
|
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: [] })), [], {
|
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, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
|
showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||||
|
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview,
|
||||||
|
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 }]
|
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) {
|
} catch (error) {
|
||||||
@@ -3910,7 +3917,8 @@ function LocalPcWorkspaceMockup({
|
|||||||
loadRequestedContext: loadAgentRequestedContext,
|
loadRequestedContext: loadAgentRequestedContext,
|
||||||
executeTool: executeWorkspaceAgentTool,
|
executeTool: executeWorkspaceAgentTool,
|
||||||
environment: remoteWorkspace ? 'remote' : 'local',
|
environment: remoteWorkspace ? 'remote' : 'local',
|
||||||
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
|
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||||
|
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
|
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { AgentConversationMessage } from './useAiAgent'
|
||||||
|
|
||||||
|
export type AgentConversationScope = { workspace: string; createdAt: number }
|
||||||
|
export type AgentConversationContext = {
|
||||||
|
environment: 'local' | 'remote'
|
||||||
|
workspaceTarget: string
|
||||||
|
summary: string
|
||||||
|
recentMessages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||||
|
const RECENT_TURNS = 2
|
||||||
|
const MESSAGE_LIMIT = 700
|
||||||
|
const SUMMARY_LIMIT = 1000
|
||||||
|
|
||||||
|
const clip = (text: string, limit: number): string => text.length > limit ? `${text.slice(0, limit - 3)}...` : text
|
||||||
|
|
||||||
|
export function agentConversationWorkspace(environment?: 'local' | 'remote', target?: string, root?: string): string | undefined {
|
||||||
|
if (!environment || !target || !root) return undefined
|
||||||
|
const workspace = JSON.stringify([environment, target, root])
|
||||||
|
return workspace.length <= 1000 ? workspace : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseContext(message: AgentConversationMessage, limit: number): string {
|
||||||
|
const plan = message.plan!
|
||||||
|
// Keep identities and outcomes, never replay source contents, logs, or executable payloads.
|
||||||
|
const details = [
|
||||||
|
...(plan.codeReferences ?? []).slice(0, 3).map(item => `${item.moduleName}/${item.path}:${item.startLine}-${item.endLine}`),
|
||||||
|
...plan.changes.slice(0, 2).map(item => `${item.moduleName}/${item.path} (file change: ${message.applyState ?? 'ready'})`),
|
||||||
|
...plan.commands.slice(0, 2).map(item => `${item.moduleName} (${clip(item.purpose, 60)}: ${message.commandExecutions?.[item.id]?.status ?? 'pending'})`)
|
||||||
|
].join('; ')
|
||||||
|
const targets = details ? `\nHistorical references/outcomes: ${clip(details, Math.min(240, Math.floor(limit / 2)))}` : ''
|
||||||
|
const answer = (limit > 300 ? `${plan.summary}\n${plan.explanation}` : plan.summary).replace(/```[^\n]*\n[\s\S]*?(?:```|$)/g, '[code block omitted]')
|
||||||
|
return `${clip(answer.trim(), limit - targets.length)}${targets}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildAgentConversationContext(
|
||||||
|
messages: AgentConversationMessage[],
|
||||||
|
workspace: string | undefined,
|
||||||
|
now = Date.now()
|
||||||
|
): AgentConversationContext | undefined {
|
||||||
|
if (!workspace) return undefined
|
||||||
|
const [environment, workspaceTarget] = JSON.parse(workspace) as ['local' | 'remote', string, string]
|
||||||
|
const turns: Array<{ user: AgentConversationMessage; assistant: AgentConversationMessage }> = []
|
||||||
|
let summary = ''
|
||||||
|
let user: AgentConversationMessage | undefined
|
||||||
|
const inScope = (message: AgentConversationMessage): boolean => message.conversationScope?.workspace === workspace
|
||||||
|
&& Number.isFinite(message.conversationScope.createdAt)
|
||||||
|
&& message.conversationScope.createdAt <= now
|
||||||
|
&& now - message.conversationScope.createdAt < MAX_AGE_MS
|
||||||
|
|
||||||
|
for (const message of messages) {
|
||||||
|
if (message.role === 'user') {
|
||||||
|
user = inScope(message) ? message : undefined
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!user || !inScope(message) || !message.plan || message.plan.toolRequests?.length
|
||||||
|
|| message.plan.requestedModules.length || message.plan.requestedLogs.length) continue
|
||||||
|
const previous = turns.at(-1)
|
||||||
|
if (previous?.user === user) previous.assistant = message
|
||||||
|
else turns.push({ user, assistant: message })
|
||||||
|
if (turns.length > RECENT_TURNS) turns.shift()
|
||||||
|
if (message.plan.conversationSummary?.trim()) summary = clip(message.plan.conversationSummary.trim(), SUMMARY_LIMIT)
|
||||||
|
}
|
||||||
|
if (!turns.length) return undefined
|
||||||
|
const recent = turns.slice(-RECENT_TURNS)
|
||||||
|
return {
|
||||||
|
environment, workspaceTarget, summary,
|
||||||
|
recentMessages: recent.flatMap(turn => [
|
||||||
|
{ role: 'user' as const, content: clip(turn.user.content, MESSAGE_LIMIT) },
|
||||||
|
{ role: 'assistant' as const, content: responseContext(turn.assistant, MESSAGE_LIMIT) }
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentCont
|
|||||||
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
|
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
|
||||||
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||||
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
|
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
|
||||||
|
import { agentConversationWorkspace, buildAgentConversationContext, type AgentConversationScope } from './agentConversationContext'
|
||||||
|
|
||||||
export type AgentFileChange = {
|
export type AgentFileChange = {
|
||||||
expectedHash?: string | null
|
expectedHash?: string | null
|
||||||
@@ -54,6 +55,7 @@ export type AgentCommandExecution = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AgentPlan = {
|
export type AgentPlan = {
|
||||||
|
conversationSummary?: string
|
||||||
codeReferences?: AgentCodeReference[]
|
codeReferences?: AgentCodeReference[]
|
||||||
toolRequests?: AgentToolRequest[]
|
toolRequests?: AgentToolRequest[]
|
||||||
evidence?: AgentToolResult[]
|
evidence?: AgentToolResult[]
|
||||||
@@ -88,6 +90,7 @@ export type AgentRuntimeLog = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AgentConversationMessage = {
|
export type AgentConversationMessage = {
|
||||||
|
conversationScope?: AgentConversationScope
|
||||||
id: string
|
id: string
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
@@ -177,7 +180,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
|
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
|
||||||
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
|
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
|
||||||
}
|
}
|
||||||
if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
|
if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens|conversationContext) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
|
||||||
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
|
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
|
||||||
}
|
}
|
||||||
return response.json() as Promise<T>
|
return response.json() as Promise<T>
|
||||||
@@ -316,10 +319,12 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
options: {
|
options: {
|
||||||
showUserMessage?: boolean
|
showUserMessage?: boolean
|
||||||
attachments?: ChatAttachment[]
|
attachments?: ChatAttachment[]
|
||||||
|
loadInitialContext?: (signal: AbortSignal) => Promise<AgentContext>
|
||||||
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
||||||
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
||||||
environment?: 'local' | 'remote'
|
environment?: 'local' | 'remote'
|
||||||
workspaceTarget?: string
|
workspaceTarget?: string
|
||||||
|
workspaceRoot?: string
|
||||||
toolResults?: AgentToolResult[]
|
toolResults?: AgentToolResult[]
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
@@ -328,10 +333,14 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
const requestModel = selectedModel
|
const requestModel = selectedModel
|
||||||
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
|
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
|
const workspace = agentConversationWorkspace(options.environment, options.workspaceTarget, options.workspaceRoot)
|
||||||
|
const conversationScope = workspace ? { workspace, createdAt: startedAt } : undefined
|
||||||
|
const conversationContext = buildAgentConversationContext(messageStateRef.current.messages, workspace, startedAt)
|
||||||
const userMessage: AgentConversationMessage = {
|
const userMessage: AgentConversationMessage = {
|
||||||
id: `agent-user-${Date.now()}`,
|
id: `agent-user-${Date.now()}`,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: request
|
content: request,
|
||||||
|
...(conversationScope ? { conversationScope } : {})
|
||||||
}
|
}
|
||||||
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
|
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
|
||||||
setPending(true)
|
setPending(true)
|
||||||
@@ -346,6 +355,13 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
let initialContext: AgentContext = { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }
|
||||||
|
if (options.loadInitialContext) {
|
||||||
|
setActivity('모듈 상태 조회 중')
|
||||||
|
const loaded = await options.loadInitialContext(abortController.signal)
|
||||||
|
if (!currentRequest()) return
|
||||||
|
initialContext = { ...initialContext, ...loaded }
|
||||||
|
}
|
||||||
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
|
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
|
||||||
const imageAttachments: AgentImageReference[] = []
|
const imageAttachments: AgentImageReference[] = []
|
||||||
for (const file of options.attachments ?? []) {
|
for (const file of options.attachments ?? []) {
|
||||||
@@ -358,15 +374,15 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
|
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
|
||||||
body.append('file', file.file, file.name)
|
body.append('file', file.file, file.name)
|
||||||
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
|
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
|
||||||
|
if (!currentRequest()) return
|
||||||
imageAttachments.push(uploaded)
|
imageAttachments.push(uploaded)
|
||||||
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
|
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
|
||||||
if (abortController.signal.aborted) return
|
|
||||||
appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
|
appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
|
||||||
}
|
}
|
||||||
const imageTokens = imageAttachments.map(image => image.token)
|
const imageTokens = imageAttachments.map(image => image.token)
|
||||||
setActivity('요청 분석 중')
|
setActivity('요청 분석 중')
|
||||||
const response = await resolveAgentContext({
|
const response = await resolveAgentContext({
|
||||||
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
|
initialContext,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
loadRequestedContext: options.loadRequestedContext,
|
loadRequestedContext: options.loadRequestedContext,
|
||||||
executeTool: options.executeTool,
|
executeTool: options.executeTool,
|
||||||
@@ -375,10 +391,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
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 })
|
body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), environment: options.environment, workspaceTarget: options.workspaceTarget, ...(options.executeTool ? { toolProtocol: 1 } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context, ...(conversationContext ? { conversationContext } : {}) })
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if (abortController.signal.aborted) return
|
if (!currentRequest()) return
|
||||||
const plan: AgentPlan = {
|
const plan: AgentPlan = {
|
||||||
...response.plan,
|
...response.plan,
|
||||||
requestedModules: response.plan.requestedModules ?? [],
|
requestedModules: response.plan.requestedModules ?? [],
|
||||||
@@ -393,6 +409,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
revealStartedAt: Date.now(),
|
revealStartedAt: Date.now(),
|
||||||
durationMs: Math.max(0, Date.now() - startedAt),
|
durationMs: Math.max(0, Date.now() - startedAt),
|
||||||
content: plan.summary,
|
content: plan.summary,
|
||||||
|
...(conversationScope ? { conversationScope } : {}),
|
||||||
provider: response.provider,
|
provider: response.provider,
|
||||||
model: response.model,
|
model: response.model,
|
||||||
usage: response.usage,
|
usage: response.usage,
|
||||||
@@ -402,7 +419,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
|||||||
}])
|
}])
|
||||||
onResponseRef.current?.()
|
onResponseRef.current?.()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (abortController.signal.aborted) return
|
if (!currentRequest()) return
|
||||||
updateMessages((current) => [...current, {
|
updateMessages((current) => [...current, {
|
||||||
id: `agent-error-${Date.now()}`,
|
id: `agent-error-${Date.now()}`,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
|
|||||||
@@ -741,6 +741,7 @@
|
|||||||
"소스 파일": "Source files",
|
"소스 파일": "Source files",
|
||||||
"{{count}}건": "{{count}} items",
|
"{{count}}건": "{{count}} items",
|
||||||
"요청 분석 중": "Analyzing request",
|
"요청 분석 중": "Analyzing request",
|
||||||
|
"모듈 상태 조회 중": "Checking module status",
|
||||||
"조회 결과 분석 중": "Analyzing retrieved results",
|
"조회 결과 분석 중": "Analyzing retrieved results",
|
||||||
"추가 자료 조회 중": "Retrieving additional context",
|
"추가 자료 조회 중": "Retrieving additional context",
|
||||||
"최종 답변 정리 중": "Preparing the final answer",
|
"최종 답변 정리 중": "Preparing the final answer",
|
||||||
|
|||||||
Reference in New Issue
Block a user