feat: add structured agent tools and improve workspace chat UX
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { chmod, lstat, readdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import { safeAgentPath, type AgentCheckedChange, type AgentToolRequest } from '../shared/agentTools'
|
||||
|
||||
const textFile = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$/i
|
||||
export const agentFileHash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
|
||||
|
||||
async function checkedPath(root: string, path: string, allowMissing = false): Promise<string> {
|
||||
safeAgentPath(path)
|
||||
const base = await realpath(root)
|
||||
const absolute = resolve(base, path)
|
||||
if (!absolute.startsWith(base + sep)) throw new Error('모듈 경로를 벗어날 수 없습니다.')
|
||||
let current = base
|
||||
const parts = relative(base, absolute).split(sep)
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
current = join(current, parts[i])
|
||||
try {
|
||||
const info = await lstat(current)
|
||||
if (info.isSymbolicLink() || (i < parts.length - 1 && !info.isDirectory())) throw new Error('심볼릭 링크와 일반 파일이 아닌 경로는 사용할 수 없습니다.')
|
||||
} catch (error) {
|
||||
if (allowMissing && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') return absolute
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
async function readText(root: string, path: string): Promise<string> {
|
||||
if (!textFile.test(path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 조회·수정할 수 있습니다.')
|
||||
const absolute = await checkedPath(root, path)
|
||||
const info = await lstat(absolute)
|
||||
if (!info.isFile() || info.size > 1_048_576) throw new Error('일반 텍스트 파일(1MB 이하)만 조회할 수 있습니다.')
|
||||
const text = await readFile(absolute, 'utf8')
|
||||
if (text.includes('\0') || text.includes('\uFFFD')) throw new Error('UTF-8 텍스트 파일이 아닙니다.')
|
||||
return text
|
||||
}
|
||||
|
||||
export async function runAgentFileTool(root: string, request: AgentToolRequest): Promise<{ data: unknown; truncated: boolean }> {
|
||||
const args = request.args ?? {}
|
||||
if (request.name === 'files.read') {
|
||||
const path = safeAgentPath(args.path)
|
||||
try {
|
||||
const content = await readText(root, path)
|
||||
const lines = content.split('\n')
|
||||
const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
|
||||
const startLine = args.startLine ?? 1
|
||||
const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content
|
||||
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
// A missing leaf in an existing directory is evidence for a new file.
|
||||
await checkedPath(root, path, true)
|
||||
return { data: { path, exists: false, hash: null, content: '', complete: true }, truncated: false }
|
||||
}
|
||||
}
|
||||
const prefix = safeAgentPath(args.path, true)
|
||||
if (prefix) await checkedPath(root, prefix)
|
||||
const paths: string[] = []
|
||||
const matches: Array<{ path: string; line: number; text: string }> = []
|
||||
let visited = 0
|
||||
let scanned = 0
|
||||
let truncated = false
|
||||
const visit = async (directory: string, depth: number): Promise<void> => {
|
||||
if (depth > 10 || visited >= 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; return }
|
||||
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
|
||||
for (const entry of entries) {
|
||||
const path = relative(root, join(directory, entry.name)).split(sep).join('/')
|
||||
if (prefix && path !== prefix && !path.startsWith(prefix + '/') && !prefix.startsWith(path + '/')) continue
|
||||
if (++visited > 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; break }
|
||||
try { safeAgentPath(path) } catch { continue }
|
||||
if (entry.isSymbolicLink()) continue
|
||||
if (entry.isDirectory()) { await visit(join(directory, entry.name), depth + 1); continue }
|
||||
if (!entry.isFile() || !textFile.test(entry.name)) continue
|
||||
if (prefix && path !== prefix && !path.startsWith(prefix + '/')) continue
|
||||
if (request.name === 'files.list') { paths.push(path); continue }
|
||||
let content: string
|
||||
try { content = await readText(root, path) } catch { continue }
|
||||
scanned += content.length
|
||||
for (const [index, line] of content.split('\n').entries()) {
|
||||
if (line.toLowerCase().includes(args.query!.toLowerCase())) {
|
||||
matches.push({ path, line: index + 1, text: line.slice(0, 500) })
|
||||
if (matches.length >= 80) { truncated = true; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(root, 0)
|
||||
return { data: request.name === 'files.list' ? { paths } : { query: args.query, matches }, truncated }
|
||||
}
|
||||
|
||||
export async function applyAgentFiles(changes: Array<AgentCheckedChange & { root: string }>): Promise<void> {
|
||||
if (!changes.length || changes.length > 24 || changes.reduce((n, c) => n + c.content.length, 0) > 1_000_000) throw new Error('변경 파일 용량을 확인하세요.')
|
||||
const prepared: Array<{ path: string; temp: string; original: string | null; mode: number }> = []
|
||||
const committed: typeof prepared = []
|
||||
const seen = new Set<string>()
|
||||
try {
|
||||
for (const change of changes) {
|
||||
if (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash)) throw new Error('원본 파일 조회 정보가 없습니다. 다시 분석해주세요.')
|
||||
if (!textFile.test(change.path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 수정할 수 있습니다.')
|
||||
const path = await checkedPath(change.root, change.path, true)
|
||||
if (seen.has(path)) throw new Error('중복된 변경 파일입니다.')
|
||||
seen.add(path)
|
||||
let original: string | null = null
|
||||
let mode = 0o644
|
||||
try { original = await readText(change.root, change.path); mode = (await lstat(path)).mode } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if ((original === null ? null : agentFileHash(original)) !== change.expectedHash) throw new Error(`분석 이후 파일이 변경되었습니다: ${change.moduleName}/${change.path}. 다시 분석해주세요.`)
|
||||
prepared.push({ path, original, mode, temp: join(dirname(path), `.lms-agent-${randomUUID()}.tmp`) })
|
||||
}
|
||||
for (const [index, file] of prepared.entries()) {
|
||||
await writeFile(file.temp, changes[index].content, { encoding: 'utf8', mode: file.mode, flag: 'wx' })
|
||||
await chmod(file.temp, file.mode & 0o777)
|
||||
}
|
||||
// Recheck the entire batch immediately before committing any file.
|
||||
for (const file of prepared) {
|
||||
let latest: string | null = null
|
||||
try { latest = await readFile(file.path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
||||
if (latest !== file.original) throw new Error('적용 직전 파일이 변경되었습니다. 다시 분석해주세요.')
|
||||
}
|
||||
for (const file of prepared) { await rename(file.temp, file.path); committed.push(file) }
|
||||
} catch (error) {
|
||||
const failures: string[] = []
|
||||
for (const file of committed.reverse()) {
|
||||
try { if (file.original === null) await unlink(file.path); else await writeFile(file.path, file.original, { mode: file.mode }) } catch { failures.push(file.path) }
|
||||
}
|
||||
if (failures.length) throw new Error(`일부 파일 복구에 실패했습니다: ${failures.join(', ')}. ${String(error)}`)
|
||||
throw error
|
||||
} finally {
|
||||
await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import type { AgentCheckedChange, AgentToolRequest } from '../shared/agentTools'
|
||||
|
||||
export function agentRemoteFilesScript(input: { root: string; request: AgentToolRequest } | { changes: Array<AgentCheckedChange & { root: string }> }): string {
|
||||
const encoded = Buffer.from(JSON.stringify(input)).toString('base64')
|
||||
return `python3 - <<'LMS_AGENT_FILES'
|
||||
import sys
|
||||
sys.argv = ['lms-agent', '${encoded}']
|
||||
${agentRemoteFilesPython}
|
||||
LMS_AGENT_FILES`
|
||||
}
|
||||
|
||||
export const agentRemoteFilesPython = String.raw`
|
||||
import base64, hashlib, json, os, re, stat, sys, tempfile
|
||||
args = json.loads(base64.b64decode(sys.argv[1]))
|
||||
blocked = {'node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'}
|
||||
text_pattern = re.compile(r'\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$', re.I)
|
||||
def safe(value):
|
||||
if not isinstance(value, str) or not value or len(value) > 500 or value.startswith('/') or any(c in value for c in '\\\x00\r\n'):
|
||||
raise ValueError('Invalid source path')
|
||||
if any(not p or p.startswith('.') or p.lower() in blocked or re.search(r'\.(pem|key|p12|pfx)$', p, re.I) for p in value.split('/')):
|
||||
raise ValueError('Private or excluded source path')
|
||||
return value
|
||||
def checked(root, path, missing=False):
|
||||
safe(path)
|
||||
root = os.path.realpath(root)
|
||||
current = root
|
||||
parts = path.split('/')
|
||||
for i, part in enumerate(parts):
|
||||
current = os.path.join(current, part)
|
||||
try:
|
||||
info = os.lstat(current)
|
||||
if stat.S_ISLNK(info.st_mode) or (i < len(parts)-1 and not stat.S_ISDIR(info.st_mode)):
|
||||
raise ValueError('Symlink or non-directory source path')
|
||||
except FileNotFoundError:
|
||||
if not (missing and i == len(parts)-1):
|
||||
raise
|
||||
if os.path.commonpath([root, current]) != root:
|
||||
raise ValueError('Source path escapes module')
|
||||
return current
|
||||
def read(root, path):
|
||||
if not text_pattern.search(os.path.basename(path)):
|
||||
raise ValueError('Only text source files are supported')
|
||||
absolute = checked(root, path)
|
||||
info = os.lstat(absolute)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > 1048576:
|
||||
raise ValueError('Only regular source files up to 1 MB are supported')
|
||||
with open(absolute, 'rb') as fp:
|
||||
content = fp.read(1048577)
|
||||
if len(content) > 1048576 or b'\x00' in content:
|
||||
raise ValueError('Binary or oversized file')
|
||||
return content.decode('utf-8')
|
||||
def digest(content):
|
||||
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
||||
if 'changes' in args:
|
||||
changes = args['changes']
|
||||
if not changes or len(changes) > 24 or sum(len(c['content']) for c in changes) > 1000000:
|
||||
raise ValueError('Invalid change batch size')
|
||||
prepared, committed, seen = [], [], set()
|
||||
try:
|
||||
for c in changes:
|
||||
path = checked(c['root'], c['path'], True)
|
||||
if path in seen or not text_pattern.search(os.path.basename(path)):
|
||||
raise ValueError('Duplicate or unsupported change file')
|
||||
seen.add(path)
|
||||
try:
|
||||
original = read(c['root'], c['path'])
|
||||
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||
except FileNotFoundError:
|
||||
original, mode = None, 0o644
|
||||
if (None if original is None else digest(original)) != c.get('expectedHash', 'missing'):
|
||||
raise ValueError('File changed after analysis: ' + c['moduleName'] + '/' + c['path'])
|
||||
prepared.append({'path': path, 'original': original, 'mode': mode, 'content': c['content'], 'temp': None})
|
||||
for p in prepared:
|
||||
fd, p['temp'] = tempfile.mkstemp(prefix='.lms-agent-', dir=os.path.dirname(p['path']))
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as fp:
|
||||
fp.write(p['content'])
|
||||
os.chmod(p['temp'], p['mode'])
|
||||
for p in prepared:
|
||||
try:
|
||||
with open(p['path'], 'r', encoding='utf-8', newline='') as fp:
|
||||
latest = fp.read()
|
||||
except FileNotFoundError:
|
||||
latest = None
|
||||
if latest != p['original']:
|
||||
raise ValueError('File changed immediately before apply')
|
||||
for p in prepared:
|
||||
os.replace(p['temp'], p['path'])
|
||||
committed.append(p)
|
||||
except Exception as error:
|
||||
failed = []
|
||||
for p in reversed(committed):
|
||||
try:
|
||||
if p['original'] is None:
|
||||
os.unlink(p['path'])
|
||||
else:
|
||||
with open(p['path'], 'w', encoding='utf-8', newline='') as fp:
|
||||
fp.write(p['original'])
|
||||
os.chmod(p['path'], p['mode'])
|
||||
except Exception:
|
||||
failed.append(p['path'])
|
||||
if failed:
|
||||
raise ValueError('Rollback failed: ' + ', '.join(failed)) from error
|
||||
raise
|
||||
finally:
|
||||
for p in prepared:
|
||||
if p['temp'] and os.path.exists(p['temp']):
|
||||
os.unlink(p['temp'])
|
||||
print(json.dumps({'ok': True}))
|
||||
else:
|
||||
root = os.path.realpath(args['root'])
|
||||
req = args['request']
|
||||
options = req.get('args') or {}
|
||||
name = req['name']
|
||||
if name == 'files.read':
|
||||
path = options['path']
|
||||
try:
|
||||
content = read(root, path)
|
||||
lines = content.split('\n')
|
||||
partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000
|
||||
start = options.get('startLine', 1)
|
||||
selected = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)])[:40000] if partial else content
|
||||
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': not partial, 'startLine': start, 'totalLines': len(lines)}
|
||||
except FileNotFoundError:
|
||||
checked(root, path, True)
|
||||
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
|
||||
print(json.dumps({'data': data, 'truncated': partial}, ensure_ascii=False))
|
||||
else:
|
||||
prefix = options.get('path', '')
|
||||
if prefix:
|
||||
checked(root, prefix)
|
||||
paths, matches = [], []
|
||||
visited, scanned, truncated = 0, 0, False
|
||||
def fail(error):
|
||||
raise error
|
||||
for directory, dirs, files in os.walk(root, followlinks=False, onerror=fail):
|
||||
depth = len(os.path.relpath(directory, root).split('/')) if directory != root else 0
|
||||
kept = []
|
||||
for d in sorted(dirs):
|
||||
rel = os.path.relpath(os.path.join(directory, d), root)
|
||||
if prefix and rel != prefix and not rel.startswith(prefix + '/') and not prefix.startswith(rel + '/'):
|
||||
continue
|
||||
visited += 1
|
||||
try:
|
||||
safe(rel)
|
||||
if not os.path.islink(os.path.join(directory, d)):
|
||||
kept.append(d)
|
||||
except ValueError:
|
||||
pass
|
||||
dirs[:] = kept if depth < 10 else []
|
||||
for f in sorted(files):
|
||||
path = os.path.relpath(os.path.join(directory, f), root)
|
||||
if prefix and path != prefix and not path.startswith(prefix + '/'):
|
||||
continue
|
||||
visited += 1
|
||||
if visited > 5000 or len(paths) >= 250 or len(matches) >= 80 or scanned >= 4000000:
|
||||
truncated = True
|
||||
break
|
||||
try:
|
||||
safe(path)
|
||||
if os.path.islink(os.path.join(directory, f)) or not text_pattern.search(f):
|
||||
continue
|
||||
if prefix and path != prefix and not path.startswith(prefix + '/'):
|
||||
continue
|
||||
if name == 'files.list':
|
||||
paths.append(path)
|
||||
continue
|
||||
content = read(root, path)
|
||||
except (ValueError, OSError, UnicodeError):
|
||||
continue
|
||||
scanned += len(content)
|
||||
for number, line in enumerate(content.split('\n'), 1):
|
||||
if options['query'].lower() in line.lower():
|
||||
matches.append({'path': path, 'line': number, 'text': line[:500]})
|
||||
if len(matches) >= 80:
|
||||
truncated = True
|
||||
break
|
||||
if truncated:
|
||||
break
|
||||
data = {'paths': paths} if name == 'files.list' else {'query': options['query'], 'matches': matches}
|
||||
print(json.dumps({'data': data, 'truncated': truncated}, ensure_ascii=False))
|
||||
`
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { AgentToolRequest } from '../shared/agentTools'
|
||||
|
||||
export const DEFAULT_AGENT_TOPICS = ['/raw/lidar', '/scan', '/lidar_filtered', '/cloud_map', '/odom', '/map', '/tf', '/tf_static']
|
||||
|
||||
export function agentRosProbe(topics: string[] = DEFAULT_AGENT_TOPICS): string {
|
||||
const encoded = Buffer.from(JSON.stringify(topics)).toString('base64')
|
||||
return `python3 -c ${shellQuote(agentRosProbePython)} ${shellQuote(encoded)}`
|
||||
}
|
||||
export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'` }
|
||||
|
||||
export const agentRosProbePython = String.raw`
|
||||
import base64, json, sys, time
|
||||
import rclpy
|
||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
|
||||
from rosidl_runtime_py.utilities import get_message
|
||||
rclpy.init(args=[])
|
||||
node = rclpy.create_node('lms_agent_readonly_probe_' + str(int(time.time() * 1000)))
|
||||
topics = json.loads(base64.b64decode(sys.argv[1]))
|
||||
samples, valid_samples, frames, edges, errors, subscriptions = {}, {}, {}, set(), {}, []
|
||||
try:
|
||||
deadline = time.monotonic() + 1.5
|
||||
while time.monotonic() < deadline:
|
||||
rclpy.spin_once(node, timeout_sec=0.1)
|
||||
graph = dict(node.get_topic_names_and_types())
|
||||
def callback(topic):
|
||||
def receive(message):
|
||||
samples[topic] = samples.get(topic, 0) + 1
|
||||
if hasattr(message, 'header') and message.header.frame_id:
|
||||
frame_set = frames.setdefault(topic, set())
|
||||
if len(frame_set) < 8:
|
||||
frame_set.add(message.header.frame_id)
|
||||
valid = True
|
||||
if hasattr(message, 'ranges'):
|
||||
valid = len(message.ranges) > 0
|
||||
elif hasattr(message, 'width') and hasattr(message, 'data'):
|
||||
valid = message.width * message.height > 0 and len(message.data) > 0
|
||||
elif hasattr(message, 'info') and hasattr(message, 'data'):
|
||||
valid = message.info.width * message.info.height > 0 and len(message.data) > 0
|
||||
if valid:
|
||||
valid_samples[topic] = valid_samples.get(topic, 0) + 1
|
||||
if topic in ('/tf', '/tf_static'):
|
||||
for transform in message.transforms:
|
||||
if len(edges) < 200:
|
||||
edges.add((transform.header.frame_id, transform.child_frame_id))
|
||||
return receive
|
||||
for topic in topics:
|
||||
if topic not in graph:
|
||||
continue
|
||||
try:
|
||||
retained = any(info.qos_profile.durability == DurabilityPolicy.TRANSIENT_LOCAL for info in node.get_publishers_info_by_topic(topic))
|
||||
qos = QoSProfile(depth=1, reliability=ReliabilityPolicy.BEST_EFFORT, durability=DurabilityPolicy.TRANSIENT_LOCAL if retained else DurabilityPolicy.VOLATILE)
|
||||
subscriptions.append(node.create_subscription(get_message(graph[topic][0]), topic, callback(topic), qos))
|
||||
except Exception as error:
|
||||
errors[topic] = str(error)[:500]
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline:
|
||||
rclpy.spin_once(node, timeout_sec=0.1)
|
||||
graph = dict(node.get_topic_names_and_types())
|
||||
print(json.dumps({'nodes': node.get_node_names()[:200], 'topics': [{'name': t, 'types': graph.get(t, []), 'publishers': node.count_publishers(t), 'samples': samples.get(t, 0), 'validSamples': valid_samples.get(t, 0), 'frameIds': sorted(frames.get(t, [])), 'error': errors.get(t)} for t in topics], 'tfEdges': [{'parent': p, 'child': c} for p, c in sorted(edges)], 'observationSeconds': 3.0}))
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
`
|
||||
|
||||
export async function inspectAgentRuntime(
|
||||
request: AgentToolRequest,
|
||||
host: (executable: string, args: string[]) => Promise<string>,
|
||||
container: (command: string) => Promise<string>
|
||||
): Promise<unknown> {
|
||||
if (request.name === 'network.inspect') {
|
||||
const interfaces = JSON.parse(await host('ip', ['-j', 'address', 'show']))
|
||||
const routes = JSON.parse(await host('ip', ['-j', 'route', 'show']))
|
||||
let containerNetwork: unknown
|
||||
try { containerNetwork = JSON.parse(await container('ip -j address show')) } catch (error) { containerNetwork = { error: String(error).slice(0, 500) } }
|
||||
return { host: { interfaces, routes }, container: containerNetwork, note: '호스트는 해당 모듈이 설치된 로컬 또는 SSH 대상 장치입니다. 라이다·공유기 자체 설정은 조회하지 않았습니다.' }
|
||||
}
|
||||
if (request.name === 'ros.inspect') return JSON.parse(await container(agentRosProbe(request.args?.topics)))
|
||||
throw new Error('지원하지 않는 런타임 조회입니다.')
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { BrowserWindow, clipboard, Menu } from 'electron'
|
||||
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
|
||||
import type { ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu'
|
||||
|
||||
export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null {
|
||||
if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window || window.isDestroyed()) return null
|
||||
const label = (value: unknown, fallback: string): string => typeof value === 'string' && value.trim() ? value.slice(0, 80) : fallback
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const items: MenuItemConstructorOptions[] = [{
|
||||
label: label(input.labels?.copy, 'Copy'),
|
||||
enabled: input.canCopy === true,
|
||||
click: () => {
|
||||
if (!event.sender.isDestroyed() && input.canCopy === true) event.sender.copy()
|
||||
resolve(null)
|
||||
}
|
||||
}]
|
||||
if (input.kind === 'input') {
|
||||
items.push({
|
||||
label: label(input.labels?.paste, 'Paste'),
|
||||
enabled: input.canPaste === true && clipboard.readText().length > 0,
|
||||
click: () => {
|
||||
if (!event.sender.isDestroyed() && input.canPaste === true && clipboard.readText().length > 0) event.sender.paste()
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
} else if (input.canClear !== undefined) {
|
||||
items.push({ type: 'separator' }, {
|
||||
label: label(input.labels?.clear, 'Delete conversation'),
|
||||
enabled: input.canClear === true,
|
||||
click: () => resolve(input.canClear === true ? 'clear' : null)
|
||||
})
|
||||
}
|
||||
Menu.buildFromTemplate(items).popup({ window, callback: () => resolve(null) })
|
||||
})
|
||||
}
|
||||
+116
-6
@@ -1,8 +1,13 @@
|
||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
||||
import { appLog } from './appLogging'
|
||||
import { checkDockerStatus } from './dockerStatus'
|
||||
import { showChatTextMenu } from './chatTextMenu'
|
||||
import { cameraRuntimeArtifacts } from './cameraRuntime'
|
||||
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
||||
import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
|
||||
import { agentRemoteFilesScript } from './agentRemoteFiles'
|
||||
import { inspectAgentRuntime } from './agentRuntimeTools'
|
||||
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
|
||||
import { resolveContainerTerminal } from './containerTerminal'
|
||||
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
||||
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
||||
@@ -1794,7 +1799,7 @@ function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMa
|
||||
})
|
||||
}
|
||||
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise<string> {
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined', input?: string): Promise<string> {
|
||||
const readProgress = createModuleProgressParser()
|
||||
return new Promise<string>((resolvePromise, rejectPromise) => {
|
||||
let output = ''
|
||||
@@ -1803,7 +1808,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
@@ -1826,13 +1831,17 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (data: string) => {
|
||||
if (input !== undefined) {
|
||||
child.stdin?.on('error', () => { /* Process exit is reported by close/error. */ })
|
||||
child.stdin?.end(input)
|
||||
}
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (data: string) => {
|
||||
stdout += data.toString()
|
||||
output += data.toString()
|
||||
readProgress(data.toString())
|
||||
})
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
output += data.toString()
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
@@ -2219,7 +2228,7 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
|
||||
))
|
||||
}
|
||||
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise<string> {
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number, input?: string): Promise<string> {
|
||||
const normalized = normalizeRemoteTarget(target)
|
||||
return runCommandOutput(
|
||||
'ssh',
|
||||
@@ -2249,6 +2258,7 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri
|
||||
remoteShellEnvironment(process.env),
|
||||
timeoutMs,
|
||||
'stdout',
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6016,6 +6026,86 @@ async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleN
|
||||
}
|
||||
}
|
||||
|
||||
function agentFolder(folderName?: string | null): string | undefined {
|
||||
if (folderName == null) return undefined
|
||||
if (!/^[A-Za-z0-9[\](). _-]{1,160}$/.test(folderName) || folderName === '.' || folderName === '..') throw new Error('모듈 폴더명이 올바르지 않습니다.')
|
||||
return folderName
|
||||
}
|
||||
|
||||
async function runWorkspaceAgentTool(input: AgentToolRequest, workspaceRoot?: string, folderName?: string, remote?: RemoteWorkspaceTarget): Promise<AgentToolResult> {
|
||||
const request = validateAgentTool(input)
|
||||
const target = remote ? `ssh:${normalizeRemoteTarget(remote).host}` : 'local-host'
|
||||
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
|
||||
try {
|
||||
const folder = agentFolder(folderName)
|
||||
const resolved = remote ? await resolveRemoteGitModuleRoot(remote, request.moduleName!, folder) : null
|
||||
const root = resolved?.moduleRoot ?? await findWorkspaceModuleRoot(request.moduleName!, workspaceRoot, folder)
|
||||
if (!root) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||
let payload: unknown
|
||||
if (request.name.startsWith('files.')) {
|
||||
const response = remote
|
||||
? JSON.parse(await runUnqueuedSshOutput(resolved!.target, agentRemoteFilesScript({ root, request }), 30_000))
|
||||
: await runAgentFileTool(root, request)
|
||||
payload = response.data
|
||||
result.truncated = response.truncated
|
||||
} else if (request.name === 'logs.read') {
|
||||
const response = remote
|
||||
? await readRemoteWorkspaceModuleLogs(remote, request.moduleName!, folder)
|
||||
: await readWorkspaceModuleLogs(request.moduleName!, workspaceRoot, folder)
|
||||
payload = { ...response, logs: response.logs.slice(-20_000) }
|
||||
if (!response.ok) result.status = 'error'
|
||||
result.truncated = response.logs.length > 20_000
|
||||
} else {
|
||||
const host = (executable: string, args: string[]): Promise<string> => remote
|
||||
? runUnqueuedSshOutput(resolved!.target, [executable, ...args].map(quoteShellSingle).join(' '), 12_000)
|
||||
: runCommandOutput(executable, args, undefined, undefined, 12_000, 'stdout')
|
||||
const container = async (command: string): Promise<string> => {
|
||||
const runtime = remote ? await resolveRemoteAgentContainer(resolved!.target, root, folder) : await resolveLocalAgentContainer(root, folder)
|
||||
const args = agentContainerCommandArgs(runtime, command, false)
|
||||
// Unlike free-form commands, these fixed probes always have a container-side deadline.
|
||||
args.splice(4, 0, 'timeout', '--signal=INT', '--kill-after=2s', '10s')
|
||||
return host('docker', args)
|
||||
}
|
||||
payload = await inspectAgentRuntime(request, host, container)
|
||||
if (request.name === 'network.inspect' && (payload as { container?: { error?: string } }).container?.error) result.truncated = true
|
||||
if (request.name === 'ros.inspect') result.target += ':module-container'
|
||||
}
|
||||
result.data = JSON.stringify(payload)
|
||||
if (result.data.length > 60_000) {
|
||||
result.data = JSON.stringify({ message: '조회 결과가 용량 한도를 초과했습니다. 파일 범위나 검색어를 좁혀주세요.', excerpt: result.data.slice(0, 20_000) })
|
||||
result.truncated = true
|
||||
}
|
||||
} catch (error) {
|
||||
result.status = 'error'
|
||||
result.data = JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
let agentFileApplyPending = false
|
||||
async function applyWorkspaceAgentFiles(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
||||
if (agentFileApplyPending) throw new Error('다른 파일 변경안을 적용 중입니다.')
|
||||
agentFileApplyPending = true
|
||||
try { return await applyWorkspaceAgentFilesOnce(changes, workspaceRoot, remote) }
|
||||
finally { agentFileApplyPending = false }
|
||||
}
|
||||
|
||||
async function applyWorkspaceAgentFilesOnce(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
||||
if (!Array.isArray(changes) || !changes.length || changes.length > 24) throw new Error('변경 파일 수를 확인하세요.')
|
||||
const resolved: Array<AgentCheckedChange & { root: string }> = []
|
||||
for (const change of changes) {
|
||||
if (!change || typeof change.content !== 'string' || change.content.length > 200_000 || (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash))) throw new Error('검증된 파일 변경안이 아닙니다.')
|
||||
validateAgentTool({ id: 'apply-check', name: 'files.read', moduleName: change.moduleName, args: { path: change.path } })
|
||||
const folder = agentFolder(change.folderName)
|
||||
const root = remote ? (await resolveRemoteGitModuleRoot(remote, change.moduleName, folder)).moduleRoot : await findWorkspaceModuleRoot(change.moduleName, workspaceRoot, folder)
|
||||
if (!root) throw new Error(`변경 대상 모듈이 설치되지 않았습니다: ${change.moduleName}`)
|
||||
resolved.push({ ...change, root })
|
||||
}
|
||||
if (remote) await runUnqueuedSshOutput(remote, 'bash --noprofile --norc -s', 30_000, agentRemoteFilesScript({ changes: resolved }))
|
||||
else await applyAgentFiles(resolved)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
|
||||
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
|
||||
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
|
||||
@@ -6681,6 +6771,25 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
||||
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
|
||||
const checkAgentSender = (event: Electron.IpcMainInvokeEvent): void => {
|
||||
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error('허용되지 않은 에이전트 요청입니다.')
|
||||
}
|
||||
ipcMain.handle('local-workspace:agent-tool:read', (event, request: AgentToolRequest, workspaceRoot?: string, folderName?: string) => {
|
||||
checkAgentSender(event)
|
||||
return runWorkspaceAgentTool(request, workspaceRoot, folderName)
|
||||
})
|
||||
ipcMain.handle('remote-workspace:agent-tool:read', (event, target: RemoteWorkspaceTarget, request: AgentToolRequest, folderName?: string) => {
|
||||
checkAgentSender(event)
|
||||
return runWorkspaceAgentTool(request, undefined, folderName, target)
|
||||
})
|
||||
ipcMain.handle('local-workspace:agent-files:apply', (event, changes: AgentCheckedChange[], workspaceRoot?: string) => {
|
||||
checkAgentSender(event)
|
||||
return applyWorkspaceAgentFiles(changes, workspaceRoot)
|
||||
})
|
||||
ipcMain.handle('remote-workspace:agent-files:apply', (event, target: RemoteWorkspaceTarget, changes: AgentCheckedChange[]) => {
|
||||
checkAgentSender(event)
|
||||
return applyWorkspaceAgentFiles(changes, undefined, target)
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
|
||||
ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose))
|
||||
ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
|
||||
@@ -6927,6 +7036,7 @@ app.whenReady().then(() => {
|
||||
return { opened: true }
|
||||
})
|
||||
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
|
||||
ipcMain.handle('chat-text:context-menu', showChatTextMenu)
|
||||
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
|
||||
ipcMain.on('app-quit', () => app.quit())
|
||||
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
|
||||
|
||||
Reference in New Issue
Block a user