feat: add structured agent tools and improve workspace chat UX
This commit is contained in:
+116
-6
@@ -1,8 +1,13 @@
|
||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
||||
import { appLog } from './appLogging'
|
||||
import { checkDockerStatus } from './dockerStatus'
|
||||
import { showChatTextMenu } from './chatTextMenu'
|
||||
import { cameraRuntimeArtifacts } from './cameraRuntime'
|
||||
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
||||
import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
|
||||
import { agentRemoteFilesScript } from './agentRemoteFiles'
|
||||
import { inspectAgentRuntime } from './agentRuntimeTools'
|
||||
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
|
||||
import { resolveContainerTerminal } from './containerTerminal'
|
||||
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
||||
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
||||
@@ -1794,7 +1799,7 @@ function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMa
|
||||
})
|
||||
}
|
||||
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise<string> {
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined', input?: string): Promise<string> {
|
||||
const readProgress = createModuleProgressParser()
|
||||
return new Promise<string>((resolvePromise, rejectPromise) => {
|
||||
let output = ''
|
||||
@@ -1803,7 +1808,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
@@ -1826,13 +1831,17 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (data: string) => {
|
||||
if (input !== undefined) {
|
||||
child.stdin?.on('error', () => { /* Process exit is reported by close/error. */ })
|
||||
child.stdin?.end(input)
|
||||
}
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (data: string) => {
|
||||
stdout += data.toString()
|
||||
output += data.toString()
|
||||
readProgress(data.toString())
|
||||
})
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
output += data.toString()
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
@@ -2219,7 +2228,7 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
|
||||
))
|
||||
}
|
||||
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise<string> {
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number, input?: string): Promise<string> {
|
||||
const normalized = normalizeRemoteTarget(target)
|
||||
return runCommandOutput(
|
||||
'ssh',
|
||||
@@ -2249,6 +2258,7 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri
|
||||
remoteShellEnvironment(process.env),
|
||||
timeoutMs,
|
||||
'stdout',
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6016,6 +6026,86 @@ async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleN
|
||||
}
|
||||
}
|
||||
|
||||
function agentFolder(folderName?: string | null): string | undefined {
|
||||
if (folderName == null) return undefined
|
||||
if (!/^[A-Za-z0-9[\](). _-]{1,160}$/.test(folderName) || folderName === '.' || folderName === '..') throw new Error('모듈 폴더명이 올바르지 않습니다.')
|
||||
return folderName
|
||||
}
|
||||
|
||||
async function runWorkspaceAgentTool(input: AgentToolRequest, workspaceRoot?: string, folderName?: string, remote?: RemoteWorkspaceTarget): Promise<AgentToolResult> {
|
||||
const request = validateAgentTool(input)
|
||||
const target = remote ? `ssh:${normalizeRemoteTarget(remote).host}` : 'local-host'
|
||||
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
|
||||
try {
|
||||
const folder = agentFolder(folderName)
|
||||
const resolved = remote ? await resolveRemoteGitModuleRoot(remote, request.moduleName!, folder) : null
|
||||
const root = resolved?.moduleRoot ?? await findWorkspaceModuleRoot(request.moduleName!, workspaceRoot, folder)
|
||||
if (!root) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||
let payload: unknown
|
||||
if (request.name.startsWith('files.')) {
|
||||
const response = remote
|
||||
? JSON.parse(await runUnqueuedSshOutput(resolved!.target, agentRemoteFilesScript({ root, request }), 30_000))
|
||||
: await runAgentFileTool(root, request)
|
||||
payload = response.data
|
||||
result.truncated = response.truncated
|
||||
} else if (request.name === 'logs.read') {
|
||||
const response = remote
|
||||
? await readRemoteWorkspaceModuleLogs(remote, request.moduleName!, folder)
|
||||
: await readWorkspaceModuleLogs(request.moduleName!, workspaceRoot, folder)
|
||||
payload = { ...response, logs: response.logs.slice(-20_000) }
|
||||
if (!response.ok) result.status = 'error'
|
||||
result.truncated = response.logs.length > 20_000
|
||||
} else {
|
||||
const host = (executable: string, args: string[]): Promise<string> => remote
|
||||
? runUnqueuedSshOutput(resolved!.target, [executable, ...args].map(quoteShellSingle).join(' '), 12_000)
|
||||
: runCommandOutput(executable, args, undefined, undefined, 12_000, 'stdout')
|
||||
const container = async (command: string): Promise<string> => {
|
||||
const runtime = remote ? await resolveRemoteAgentContainer(resolved!.target, root, folder) : await resolveLocalAgentContainer(root, folder)
|
||||
const args = agentContainerCommandArgs(runtime, command, false)
|
||||
// Unlike free-form commands, these fixed probes always have a container-side deadline.
|
||||
args.splice(4, 0, 'timeout', '--signal=INT', '--kill-after=2s', '10s')
|
||||
return host('docker', args)
|
||||
}
|
||||
payload = await inspectAgentRuntime(request, host, container)
|
||||
if (request.name === 'network.inspect' && (payload as { container?: { error?: string } }).container?.error) result.truncated = true
|
||||
if (request.name === 'ros.inspect') result.target += ':module-container'
|
||||
}
|
||||
result.data = JSON.stringify(payload)
|
||||
if (result.data.length > 60_000) {
|
||||
result.data = JSON.stringify({ message: '조회 결과가 용량 한도를 초과했습니다. 파일 범위나 검색어를 좁혀주세요.', excerpt: result.data.slice(0, 20_000) })
|
||||
result.truncated = true
|
||||
}
|
||||
} catch (error) {
|
||||
result.status = 'error'
|
||||
result.data = JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
let agentFileApplyPending = false
|
||||
async function applyWorkspaceAgentFiles(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
||||
if (agentFileApplyPending) throw new Error('다른 파일 변경안을 적용 중입니다.')
|
||||
agentFileApplyPending = true
|
||||
try { return await applyWorkspaceAgentFilesOnce(changes, workspaceRoot, remote) }
|
||||
finally { agentFileApplyPending = false }
|
||||
}
|
||||
|
||||
async function applyWorkspaceAgentFilesOnce(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
||||
if (!Array.isArray(changes) || !changes.length || changes.length > 24) throw new Error('변경 파일 수를 확인하세요.')
|
||||
const resolved: Array<AgentCheckedChange & { root: string }> = []
|
||||
for (const change of changes) {
|
||||
if (!change || typeof change.content !== 'string' || change.content.length > 200_000 || (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash))) throw new Error('검증된 파일 변경안이 아닙니다.')
|
||||
validateAgentTool({ id: 'apply-check', name: 'files.read', moduleName: change.moduleName, args: { path: change.path } })
|
||||
const folder = agentFolder(change.folderName)
|
||||
const root = remote ? (await resolveRemoteGitModuleRoot(remote, change.moduleName, folder)).moduleRoot : await findWorkspaceModuleRoot(change.moduleName, workspaceRoot, folder)
|
||||
if (!root) throw new Error(`변경 대상 모듈이 설치되지 않았습니다: ${change.moduleName}`)
|
||||
resolved.push({ ...change, root })
|
||||
}
|
||||
if (remote) await runUnqueuedSshOutput(remote, 'bash --noprofile --norc -s', 30_000, agentRemoteFilesScript({ changes: resolved }))
|
||||
else await applyAgentFiles(resolved)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
|
||||
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
|
||||
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
|
||||
@@ -6681,6 +6771,25 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
||||
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
|
||||
const checkAgentSender = (event: Electron.IpcMainInvokeEvent): void => {
|
||||
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error('허용되지 않은 에이전트 요청입니다.')
|
||||
}
|
||||
ipcMain.handle('local-workspace:agent-tool:read', (event, request: AgentToolRequest, workspaceRoot?: string, folderName?: string) => {
|
||||
checkAgentSender(event)
|
||||
return runWorkspaceAgentTool(request, workspaceRoot, folderName)
|
||||
})
|
||||
ipcMain.handle('remote-workspace:agent-tool:read', (event, target: RemoteWorkspaceTarget, request: AgentToolRequest, folderName?: string) => {
|
||||
checkAgentSender(event)
|
||||
return runWorkspaceAgentTool(request, undefined, folderName, target)
|
||||
})
|
||||
ipcMain.handle('local-workspace:agent-files:apply', (event, changes: AgentCheckedChange[], workspaceRoot?: string) => {
|
||||
checkAgentSender(event)
|
||||
return applyWorkspaceAgentFiles(changes, workspaceRoot)
|
||||
})
|
||||
ipcMain.handle('remote-workspace:agent-files:apply', (event, target: RemoteWorkspaceTarget, changes: AgentCheckedChange[]) => {
|
||||
checkAgentSender(event)
|
||||
return applyWorkspaceAgentFiles(changes, undefined, target)
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
|
||||
ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose))
|
||||
ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
|
||||
@@ -6927,6 +7036,7 @@ app.whenReady().then(() => {
|
||||
return { opened: true }
|
||||
})
|
||||
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
|
||||
ipcMain.handle('chat-text:context-menu', showChatTextMenu)
|
||||
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
|
||||
ipcMain.on('app-quit', () => app.quit())
|
||||
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
|
||||
|
||||
Reference in New Issue
Block a user