feat: add structured agent tools and improve workspace chat UX

This commit is contained in:
2026-10-19 11:30:23 +09:00
parent 098af30fa7
commit cc589fd8cf
35 changed files with 2831 additions and 247 deletions
+133
View File
@@ -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)))
}
}
+181
View File
@@ -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))
`
+79
View File
@@ -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('지원하지 않는 런타임 조회입니다.')
}
+38
View File
@@ -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
View File
@@ -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))
+7
View File
@@ -117,6 +117,9 @@ console.info('[lms-preload] launcher auth resolved', {
})
contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('chatTextMenu', {
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options)
})
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('lmsBootSplash', {
status: (message: string) => ipcRenderer.send('lms-boot-status', message),
@@ -216,6 +219,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
},
agent: {
readTool: (request: import('../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-tool:read', request, workspaceRoot, folderName),
applyFiles: (changes: import('../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:agent-files:apply', changes, workspaceRoot),
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-command:run', moduleName, command, workspaceRoot, folderName)
},
modules: {
@@ -304,6 +309,8 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
},
agent: {
readTool: (target: RemoteWorkspaceTarget, request: import('../shared/agentTools').AgentToolRequest, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-tool:read', target, request, folderName),
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../shared/agentTools').AgentCheckedChange[]) => ipcRenderer.invoke('remote-workspace:agent-files:apply', target, changes),
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-command:run', target, moduleName, command, folderName)
},
maps: {
+192 -42
View File
@@ -12,7 +12,7 @@ import type { Socket } from 'socket.io-client'
import Sidebar from './components/Sidebar'
import DockerSetupNotice from './components/DockerSetupNotice'
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
import { ConfirmStatusModal, ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
import TitleBar from './components/TitleBar'
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
@@ -58,6 +58,8 @@ import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminal
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
import { useQaChat } from './features/workspace/useQaChat'
import { useAiAgent } from './features/workspace/useAiAgent'
import { validateAgentOperation, validateAgentTool, type AgentToolRequest, type AgentToolResult } from '../../shared/agentTools'
import { AGENT_PRACTICE_PROFILES, inspectAgentPractice, prepareAgentPractice } from './features/workspace/agentPractice'
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
import type { ChatAttachment } from './components/chatAttachments'
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
@@ -566,6 +568,7 @@ function LocalPcWorkspaceMockup({
accessToken,
courseId: courseKey,
weekNumber,
revealInView: true,
apiBaseUrl: LMS_API_BASE_URL,
socketBaseUrl: LMS_SOCKET_ORIGIN,
socketPath: LMS_SOCKET_PATH,
@@ -626,6 +629,7 @@ function LocalPcWorkspaceMockup({
enabled: agentEnabled,
statusLoading: agentStatusLoading,
pending: agentPending,
activity: agentActivity,
messages: agentMessages,
persistenceError: agentPersistenceError,
requestPlan: requestAgentPlan,
@@ -642,6 +646,10 @@ function LocalPcWorkspaceMockup({
getFreshAccessToken,
onResponse: () => onTitlebarNotice(t("AI 에이전트 답변이 도착했습니다."), 'info', true)
})
const [agentActionBusy, setAgentActionBusy] = useState(false)
const agentApplying = agentMessages.some(message => message.applyState === 'applying')
const agentActionControllerRef = useRef<AbortController | null>(null)
const [agentPracticeApproval, setAgentPracticeApproval] = useState<{ messageId: string; command: AgentCommand; starts: string[]; modules: string[]; parameters: Record<string, string[]>; runtimeSettings: string } | null>(null)
const [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
const [teamGitSyncing, setTeamGitSyncing] = useState(false)
@@ -704,6 +712,8 @@ function LocalPcWorkspaceMockup({
finishTabDrop,
resetTabDrag
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
const agentEditorFilesRef = useRef(editorFiles)
agentEditorFilesRef.current = editorFiles
const [agentDiffPreviews, setAgentDiffPreviews] = useState<AgentDiffPreview[]>([])
const [activeAgentDiffPreviewId, setActiveAgentDiffPreviewId] = useState<string | null>(null)
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
@@ -756,6 +766,11 @@ function LocalPcWorkspaceMockup({
useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return
if (agentPracticeApproval) {
event.preventDefault()
setAgentPracticeApproval(null)
return
}
if (pendingEditorCloseRequest) {
event.preventDefault()
setPendingEditorCloseRequest(null)
@@ -789,7 +804,7 @@ function LocalPcWorkspaceMockup({
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
}, [agentPracticeApproval, pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
useEffect(() => {
if (!controlEndsAt) return
setControlCountdownNow(Date.now())
@@ -1433,6 +1448,10 @@ function LocalPcWorkspaceMockup({
return () => window.removeEventListener('message', handleViewerMessage)
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, localVisualizationWorkspaceRoot, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, visualizationBridgeLocation, weekNumber])
const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
useEffect(() => {
setAgentPracticeApproval(null)
return () => { agentActionControllerRef.current?.abort() }
}, [courseKey, weekNumber, platformId, remoteWorkspaceTargetKey])
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
const hasRestorableWorkspaceUi = Boolean(
window.localStorage.getItem(workspaceUiSessionKey)
@@ -3502,6 +3521,7 @@ function LocalPcWorkspaceMockup({
folderName: module.folderName,
category: module.category,
state: module.state,
parameters: module.parameters,
files: [],
...(requestedNames.has(module.name.toLowerCase()) ? {
fileContext: {
@@ -3520,11 +3540,9 @@ function LocalPcWorkspaceMockup({
return /\.(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|zsh|launch|urdf|xacro|rviz|proto|cmake)$/i.test(name)
|| ['dockerfile', 'cmakelists.txt', 'package.xml', 'setup.py', 'setup.cfg'].includes(name)
}
let totalCharacters = 0
let totalFiles = 0
for (const module of targetModules) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break
let totalCharacters = 0
let totalFiles = 0
try {
const modulePrefix = `modules/${module.name}`
const result = remoteWorkspace
@@ -3535,9 +3553,9 @@ function LocalPcWorkspaceMockup({
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
const sourcePriority = (path: string): number => {
if (path === preferredPath) return -100
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -60
if (/(^|\/)src(\/|$)/.test(path)) return -50
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30
return 0
}
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
@@ -3616,12 +3634,43 @@ function LocalPcWorkspaceMockup({
modules: await collectAgentWorkspace(request.modules),
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
})
const readAgentModules = async (): Promise<ModuleRow[]> => {
const response = remoteWorkspace
? await window.remoteWorkspace.modules.list(getRemoteWorkspaceTarget(), runtimeModules ?? [])
: await window.localWorkspace.modules.list(clonePathPreview, runtimeModules ?? [])
return applyModuleParameterOverrides(response.modules)
}
const executeWorkspaceAgentTool = async (input: AgentToolRequest): Promise<AgentToolResult> => {
const request = validateAgentTool(input)
const target = remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().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 {
if (request.name === 'modules.list' || request.name === 'practice.inspect') {
const modules = await readAgentModules()
const payload = request.name === 'modules.list'
? modules.map(module => ({ name: module.name, state: module.state, category: module.category, parameters: module.parameters }))
: (request.args?.profile ? [request.args.profile] : AGENT_PRACTICE_PROFILES.map(profile => profile.id)).map(profile => inspectAgentPractice(profile, modules, remoteWorkspace ? 'remote' : 'local'))
return { ...result, data: JSON.stringify(payload) }
}
const matches = availableModules.filter(module => module.name === request.moduleName)
if (matches.length !== 1) throw new Error('대상 모듈이 없거나 같은 이름이 여러 개입니다.')
const module = matches[0]
return remoteWorkspace
? await window.remoteWorkspace.agent.readTool(getRemoteWorkspaceTarget(), request, module.folderName)
: await window.localWorkspace.agent.readTool(request, clonePathPreview, module.folderName)
} catch (error) {
return { ...result, status: 'error', data: JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) }) }
}
}
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) return
try {
const modules = await collectAgentWorkspace()
await requestAgentPlan(content, modules, [], {
attachments,
loadRequestedContext: loadAgentRequestedContext
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local'
})
} catch (error) {
console.error('[agent] workspace collection failed', error)
@@ -3637,32 +3686,16 @@ function LocalPcWorkspaceMockup({
}
const rendererPath = `modules/${module.name}/${change.path.replace(/^\/+/, '')}`
let originalContent = editorFiles[rendererPath]
if (originalContent === undefined) {
try {
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const normalizePath = (path: string): string => path.startsWith('modules/')
? path
: `modules/${module.name}/${path.replace(/^\/+/, '')}`
const fileContent = Object.entries(result.files).find(([path]) => normalizePath(path) === rendererPath)?.[1]
const fileExists = [...(result.filePaths ?? []), ...Object.keys(result.files)]
.some((path) => normalizePath(path) === rendererPath)
originalContent = fileContent
if (originalContent === undefined && fileExists && remoteWorkspace) {
originalContent = (await window.remoteWorkspace.moduleFiles.content(
getRemoteWorkspaceTarget(),
module.name,
rendererPath,
module.folderName
)).content
}
} catch (error) {
console.warn('[agent] failed to read original file for diff preview', error)
}
let originalContent: string | undefined
try {
const result = await executeWorkspaceAgentTool({ id: `preview-${Date.now()}`, name: 'files.read', moduleName: module.name, args: { path: change.path } })
const file = JSON.parse(result.data) as { content?: string; hash?: string | null; complete?: boolean }
if (result.status !== 'ok' || !file.complete || typeof file.content !== 'string') throw new Error('비교할 원본 파일 전체를 읽지 못했습니다. 다시 분석해주세요.')
if (change.expectedHash !== undefined && change.expectedHash !== file.hash) throw new Error('분석 이후 원본 파일이 변경되었습니다. 다시 분석해주세요.')
originalContent = file.content
} catch (error) {
onTitlebarNotice(error instanceof Error ? error.message : '비교할 원본 파일을 읽지 못했습니다.', 'error')
return
}
if (selectedModule?.name !== module.name) openModuleSource(module)
@@ -3692,7 +3725,79 @@ function LocalPcWorkspaceMockup({
return previews.filter((preview) => preview.messageId !== messageId)
})
}
const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record<string, string[]>; runtimeSettings: string }): Promise<void> => {
if (agentActionControllerRef.current || agentPending || agentApplying) return
const message = agentMessages.find(item => item.id === messageId)
if (!message?.plan || !command.operation) return
const controller = new AbortController()
agentActionControllerRef.current = controller
setAgentActionBusy(true)
const assertActive = (): void => { if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError') }
try {
const operation = validateAgentOperation(command.operation)
const runtimeSettings = JSON.stringify([runtimeSourceSettings, moduleRuntimeImages, vehicleRuntimeProfiles])
if (approval && approval.runtimeSettings !== runtimeSettings) throw new Error('승인 이후 ROS Domain 또는 실행 환경 설정이 바뀌었습니다. 다시 승인해주세요.')
const freshModules = await readAgentModules()
assertActive()
const candidates = freshModules.filter(item => item.name === command.moduleName)
if (candidates.length !== 1 || candidates[0].state === 'uninstalled') throw new Error('대상 모듈의 설치 상태를 확인해주세요.')
const module = candidates[0]
if (getDirtyModuleFiles(module.name).length) throw new Error('저장하지 않은 변경 사항이 있습니다. 저장 후 다시 승인해주세요.')
const environment = remoteWorkspace ? 'remote' as const : 'local' as const
if (operation.name === 'practice.prepare' && !approval) {
const inspection = inspectAgentPractice(operation.profile!, freshModules, environment)
if (inspection.blockers.length) throw new Error(inspection.blockers.join('\n'))
if (command.moduleName !== inspection.profile.modules.at(-1)) throw new Error('실습 구성표의 SLAM 모듈을 대상으로 지정해야 합니다.')
setAgentPracticeApproval({ messageId, command, starts: inspection.starts, modules: [...inspection.profile.modules], parameters: Object.fromEntries(inspection.required.map(item => [item.name, item.parameters])), runtimeSettings })
return
}
updateAgentCommandExecution(messageId, command.id, { status: 'running', output: '실행 전 상태 확인 중' })
let output: unknown
let ok: boolean
if (operation.name === 'practice.prepare') {
output = await prepareAgentPractice({
profileId: operation.profile!, environment, approvedStarts: approval!.starts, approvedParameters: approval!.parameters, signal: controller.signal,
list: readAgentModules,
start: async target => {
if (getDirtyModuleFiles(target.name).length) throw new Error(`${target.name}: 저장하지 않은 파일이 있습니다.`)
let failure: string | undefined
const ok = await runModuleControl(target, 'start', { background: true, freshSlam: true, onResult: result => { if (!result.ok) failure = result.message?.slice(-6000) } })
if (!ok && failure) throw new Error(`${target.name}: ${failure}`)
return ok
},
probe: (target, topics) => executeWorkspaceAgentTool({ id: `verify-${Date.now()}`, name: 'ros.inspect', moduleName: target.name, args: { topics } }),
progress: (text, steps) => updateAgentCommandExecution(messageId, command.id, { status: 'running', output: JSON.stringify({ phase: text, steps }, null, 2) })
})
ok = (output as { ready: boolean }).ready
} else {
let detail: string | undefined
ok = operation.name === 'modules.start' && module.state === 'running'
? true
: await runModuleControl(module, operation.name === 'modules.start' ? 'start' : 'build', { background: true, onResult: result => { detail = result.message?.slice(-6000) } })
output = { ok, operation: operation.name, moduleName: module.name, message: detail, note: operation.name === 'modules.start' ? '프로세스 시작 결과입니다. 센서·TF 정상 여부는 별도 검증이 필요합니다.' : '빌드 결과입니다. 전체 빌드 로그를 수집한 것은 아니며 경고 해소·기능 정상 여부는 별도 검증이 필요합니다.' }
}
assertActive()
const serialized = JSON.stringify(output, null, 2)
updateAgentCommandExecution(messageId, command.id, { status: ok ? 'completed' : 'failed', output: serialized, exitCode: ok ? 0 : 1 })
onTitlebarNotice(operation.name === 'practice.prepare' ? ok ? 'SLAM 실습 준비와 데이터 검증이 완료되었습니다.' : 'SLAM 준비 검증에 실패했습니다. 실행 결과를 확인해주세요.' : ok ? '에이전트 승인 작업이 완료되었습니다.' : '에이전트 승인 작업에 실패했습니다.', ok ? 'info' : 'error', true)
const messageIndex = agentMessages.findIndex(item => item.id === messageId)
const originalRequest = [...agentMessages.slice(0, messageIndex)].reverse().find(item => item.role === 'user')?.content ?? message.plan.summary
const currentModules = await readAgentModules()
assertActive()
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
showUserMessage: false, environment, executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
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) {
const reason = controller.signal.aborted ? '다음 단계 진행을 중단했습니다. 이미 시작된 모듈과 진행 중인 단일 작업은 자동 종료하지 않았습니다.' : error instanceof Error ? error.message : '승인 작업을 처리하지 못했습니다.'
updateAgentCommandExecution(messageId, command.id, { status: 'failed', error: reason, exitCode: null })
onTitlebarNotice(reason, 'error', true)
} finally {
if (agentActionControllerRef.current === controller) { agentActionControllerRef.current = null; setAgentActionBusy(false) }
}
}
const runWorkspaceAgentCommand = async (messageId: string, command: AgentCommand): Promise<void> => {
if (agentActionControllerRef.current || agentPending) return
const message = agentMessages.find((item) => item.id === messageId)
const currentExecution = message?.commandExecutions?.[command.id]
if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
@@ -3701,6 +3806,10 @@ function LocalPcWorkspaceMockup({
onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error')
return
}
if (command.operation) {
await runStructuredAgentCommand(messageId, command)
return
}
const module = availableModules.find((item) => item.name === command.moduleName)
?? (selectedModule?.name === command.moduleName ? selectedModule : null)
@@ -3756,7 +3865,9 @@ function LocalPcWorkspaceMockup({
].filter(Boolean).join('\n')
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], {
showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local'
})
} catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
@@ -3775,6 +3886,13 @@ function LocalPcWorkspaceMockup({
updateAgentCommandExecution(messageId, command.id, { status: 'rejected' })
}
const applyWorkspaceAgentPlan = async (plan: AgentPlan): Promise<void> => {
if (agentActionControllerRef.current || agentPending) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 적용해주세요.')
const editorSnapshot = agentEditorFilesRef.current
for (const change of plan.changes) {
if (plan.createModule && change.moduleName !== plan.createModule.name) throw new Error('새 모듈 생성과 기존 모듈 수정은 별도로 진행해주세요.')
if (getDirtyModuleFiles(change.moduleName).includes(`modules/${change.moduleName}/${change.path}`)) throw new Error(`저장하지 않은 파일입니다: ${change.moduleName}/${change.path}. 먼저 저장하거나 변경을 취소해주세요.`)
if (change.expectedHash === undefined && !plan.createModule) throw new Error('원본 파일 검증 정보가 없습니다. 다시 분석한 변경안을 적용해주세요.')
}
let createdModule: ModuleRow | null = null
if (plan.createModule) {
if (availableModules.some((module) => module.name === plan.createModule?.name)) {
@@ -3810,23 +3928,34 @@ function LocalPcWorkspaceMockup({
if (createdModule?.name === moduleName) return createdModule
return availableModules.find((module) => module.name === moduleName) ?? (selectedModule?.name === moduleName ? selectedModule : null)
}
if (!createdModule && plan.changes.length) {
const changes = plan.changes.map(change => {
const module = resolveModule(change.moduleName)
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
return { ...change, folderName: module.folderName, expectedHash: change.expectedHash! }
})
if (remoteWorkspace) await window.remoteWorkspace.agent.applyFiles(getRemoteWorkspaceTarget(), changes)
else await window.localWorkspace.agent.applyFiles(changes, clonePathPreview)
}
let firstChangedFile: { module: ModuleRow; path: string } | null = null
for (const change of plan.changes) {
const module = resolveModule(change.moduleName)
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
const rendererPath = `modules/${module.name}/${change.path}`
if (remoteWorkspace) {
if (createdModule && remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), module.name, rendererPath, 'file', module.folderName)
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), module.name, rendererPath, change.content, module.folderName)
} else {
} else if (createdModule) {
await window.localWorkspace.moduleFiles.create(module.name, rendererPath, 'file', clonePathPreview, module.folderName)
await window.localWorkspace.moduleFiles.write(module.name, rendererPath, change.content, clonePathPreview, module.folderName)
}
setEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
const editedDuringApply = agentEditorFilesRef.current[rendererPath] !== editorSnapshot[rendererPath]
setEditorFiles((files) => ({ ...files, [rendererPath]: files[rendererPath] !== editorSnapshot[rendererPath] ? files[rendererPath] : change.content }))
setSavedEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
setDirtyEditorFiles((files) => {
const next = { ...files }
delete next[rendererPath]
if (editedDuringApply) next[rendererPath] = true
else delete next[rendererPath]
return next
})
if (!firstChangedFile) firstChangedFile = { module, path: rendererPath }
@@ -4410,6 +4539,7 @@ function LocalPcWorkspaceMockup({
/>
<WorkspaceEditorPane
editingLocked={agentApplying}
fileLoading={remoteEditorFile.loading}
fileError={remoteEditorFile.error}
onRetryFile={remoteEditorFile.retry}
@@ -4430,7 +4560,7 @@ function LocalPcWorkspaceMockup({
beforeMount={configureMonacoTheme}
onMount={handleEditorMount}
onChange={(value) => {
if (!currentActiveEditorFile) return
if (!currentActiveEditorFile || agentApplying) return
updateEditorFileContent(currentActiveEditorFile, value ?? '', exampleActive)
}}
/>
@@ -4583,11 +4713,13 @@ function LocalPcWorkspaceMockup({
agentEnabled={agentEnabled}
agentStatusLoading={agentStatusLoading}
agentPending={agentPending}
agentActivity={agentActivity}
agentActionBusy={agentActionBusy || agentApplying}
agentPersistenceError={agentPersistenceError}
aiPending={aiPending}
teacherThreadStatus={teacherThreadStatus}
qaCollapseSignal={qaCollapseSignal}
inputBusy={qaMode === 'agent' ? agentPending : aiPending || Boolean(aiTypingMessageId)}
inputBusy={qaMode === 'agent' ? agentPending || agentActionBusy || agentApplying : aiPending || Boolean(aiTypingMessageId)}
inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))}
workspaceChatScrollRef={workspaceChatScrollRef}
onMinimize={() => setRightPanelMinimized(true)}
@@ -4595,7 +4727,7 @@ function LocalPcWorkspaceMockup({
setQaMode(mode)
if (mode === 'teacher') setTeacherChatFlash(false)
}}
onStop={qaMode === 'agent' ? stopAgent : stopAiResponse}
onStop={qaMode === 'agent' ? () => { agentActionControllerRef.current?.abort(); stopAgent() } : stopAiResponse}
onSubmit={(content, attachments) => {
if (qaMode === 'agent') void submitAgentRequest(content, attachments)
else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
@@ -4617,6 +4749,24 @@ function LocalPcWorkspaceMockup({
})}
/>
{agentPracticeApproval && (
<ConfirmStatusModal
dark={dark}
overlayClassName="fixed inset-0 z-[200] grid place-items-center bg-black/65 p-5"
icon={<Monitor size={18} className="text-[#2563eb]" />}
title="SLAM 실습 준비"
message={`시작할 모듈: ${agentPracticeApproval.starts.join(', ') || '없음 (이미 실행 중)'}`}
detail={`검증 대상: ${agentPracticeApproval.modules.join(', ')}. 순서대로 시작한 뒤 센서·지도·TF를 확인합니다. 차량 이동 명령은 보내지 않습니다. 실패해도 이미 시작된 모듈은 자동 종료하지 않습니다.`}
confirmLabel="승인하고 준비"
onCancel={() => setAgentPracticeApproval(null)}
onConfirm={() => {
const approval = agentPracticeApproval
setAgentPracticeApproval(null)
void runStructuredAgentCommand(approval.messageId, approval.command, approval)
}}
/>
)}
{workspaceRestoreLoading && (
<PlatformLoadingOverlay
title={t("{{v0}} 화면 불러오는 중", { v0: platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName })}
+54 -13
View File
@@ -59,23 +59,24 @@ export function ChatAvatar({
)
}
export function MarkdownMessage({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
export function MarkdownMessage({ content, dark, fadeFrom }: { content: string; dark: boolean; fadeFrom?: number }): React.JSX.Element {
useLocaleRender()
const textColor = dark ? 'text-white/76' : 'text-[#4f596b]'
const headingColor = dark ? 'text-white/92' : 'text-[#111827]'
const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]'
const codeBg = dark ? 'bg-[#171717] text-white/82' : 'bg-[#f1f5f9] text-[#334155]'
const fadeClass = (offset?: number): string => fadeFrom !== undefined && offset !== undefined && offset >= fadeFrom ? ' agent-response-fade' : ''
return (
<div className={`min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [overflow-wrap:anywhere] [word-break:break-word] ${textColor}`}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ children }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h1>,
h2: ({ children }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h2>,
h3: ({ children }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h3>,
h4: ({ children }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h4>,
p: ({ children }) => <p className="my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]">{children}</p>,
h1: ({ children, node }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h1>,
h2: ({ children, node }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h2>,
h3: ({ children, node }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h3>,
h4: ({ children, node }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h4>,
p: ({ children, node }) => <p className={`my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</p>,
strong: ({ children }) => <strong className={`font-black ${headingColor}`}>{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
a: ({ children, href }) => (
@@ -91,22 +92,22 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
),
ul: ({ children }) => <ul className="my-2 grid gap-1 pl-4">{children}</ul>,
ol: ({ children }) => <ol className="my-2 grid gap-1 pl-4">{children}</ol>,
li: ({ children }) => <li className="max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]">{children}</li>,
blockquote: ({ children }) => (
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}`}>
li: ({ children, node }) => <li className={`max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</li>,
blockquote: ({ children, node }) => (
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}${fadeClass(node?.position?.start.offset)}`}>
{children}
</blockquote>
),
hr: () => <hr className={`my-3 ${mutedBorder}`} />,
pre: ({ children }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}`}>{children}</pre>,
hr: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />,
pre: ({ children, node }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}${fadeClass(node?.position?.start.offset)}`}>{children}</pre>,
code: ({ children, className }) => {
if (className) {
return <code className="whitespace-pre-wrap break-words font-mono [overflow-wrap:anywhere]">{children}</code>
}
return <code className={`rounded px-1 py-0.5 font-mono text-[11px] ${dark ? 'bg-white/10 text-[#dbeafe]' : 'bg-[#eef2ff] text-[#1d4ed8]'}`}>{children}</code>
},
table: ({ children }) => (
<div className="my-2 max-w-full overflow-hidden">
table: ({ children, node }) => (
<div className={`my-2 max-w-full overflow-hidden${fadeClass(node?.position?.start.offset)}`}>
<table className={`w-full table-fixed border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
</div>
),
@@ -121,6 +122,33 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
)
}
export function ChatEmptyState({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<div className={['relative mx-4 min-w-0 max-w-full break-words rounded-lg border px-8 py-5', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')}>
<span>{children}</span>
<span aria-hidden="true" className={['absolute -bottom-[5px] left-7 size-2 rotate-45 border-b border-r', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')} />
</div>
</div>
)
}
export function ChatResponseSkeleton({ dark, label }: { dark: boolean; label: string }): React.JSX.Element {
return (
<div
role="status"
aria-label={label}
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
>
<div className="grid animate-pulse gap-2 motion-reduce:animate-none" aria-hidden="true">
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
</div>
</div>
)
}
export function ExpandableMarkdownMessage({
content,
dark,
@@ -167,6 +195,7 @@ export function ExpandableMarkdownMessage({
<button
type="button"
aria-label={expanded ? t("접기") : t("자세히 보기")}
aria-expanded={expanded}
title={expanded ? t("접기") : t("자세히 보기")}
onClick={() => setExpanded((value) => !value)}
className={['grid h-7 w-full cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white/80' : 'text-[#64748b] hover:bg-[#e2e8f0]/70 hover:text-[#2563eb]'].join(' ')}
@@ -296,6 +325,18 @@ export const QaInputBox = memo(function QaInputBox({
autoCorrect="off"
value={draft}
onChange={(event) => setDraft(event.target.value)}
onContextMenu={(event) => {
if (!window.chatTextMenu) return
event.preventDefault()
event.stopPropagation()
const input = event.currentTarget
void window.chatTextMenu.show({
kind: 'input',
canCopy: input.selectionEnd > input.selectionStart,
canPaste: !input.disabled && !input.readOnly,
labels: { copy: t('복사'), paste: t('붙여넣기') }
}).catch((error) => console.error('[chat] input menu failed', error))
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault()
@@ -0,0 +1,80 @@
import type React from 'react'
import { useEffect, useMemo, useState } from 'react'
import { ExpandableMarkdownMessage, MarkdownMessage } from './ChatWidgets'
import { AGENT_REVEAL_INTERVAL, agentRevealFrame, createAgentRevealDocument } from './agentResponseReveal'
type ResponseMessage = {
content: string
revealStartedAt?: number
plan?: { summary: string; explanation: string }
provider?: string
model?: string
}
export function ProgressiveChatResponse({ message, dark, children, footerAction, collapseOnSignal = false, collapseSignal = 0, defaultCollapsed = message.revealStartedAt === undefined }: {
message: ResponseMessage
dark: boolean
children?: React.ReactNode
footerAction?: React.ReactNode
collapseOnSignal?: boolean
collapseSignal?: number
defaultCollapsed?: boolean
}): React.JSX.Element {
const content = message.plan?.explanation ?? message.content
const modelLabel = [message.provider, message.model].filter(Boolean).join(' · ')
const document = useMemo(() => createAgentRevealDocument(content), [content])
const [now, setNow] = useState(Date.now)
const [reducedMotion, setReducedMotion] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
const frame = agentRevealFrame(document, message.revealStartedAt, now, reducedMotion)
useEffect(() => {
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
const update = (): void => setReducedMotion(media.matches)
media.addEventListener('change', update)
return () => media.removeEventListener('change', update)
}, [])
useEffect(() => {
if (message.revealStartedAt === undefined || reducedMotion) return
let timer: ReturnType<typeof setTimeout> | undefined
const startedAt = message.revealStartedAt
const tick = (): void => {
const current = Date.now()
setNow(current)
const remaining = frame.duration - (current - startedAt)
if (remaining > 0) timer = setTimeout(tick, Math.min(AGENT_REVEAL_INTERVAL, remaining))
}
tick()
return () => clearTimeout(timer)
}, [frame.duration, message.revealStartedAt, reducedMotion])
return (
<div className="grid gap-3" aria-busy={!frame.complete}>
<div>
{message.plan && (
<div className={['font-black', frame.fadeFrom === 0 ? 'agent-response-fade' : ''].join(' ')}>
<MarkdownMessage content={message.plan.summary} dark={dark} />
</div>
)}
<div className={message.plan ? 'mt-1' : ''}>
{frame.complete && frame.fadeFrom === undefined ? (
<ExpandableMarkdownMessage
content={content}
dark={dark}
defaultCollapsed={defaultCollapsed}
collapseOnSignal={collapseOnSignal}
collapseSignal={collapseSignal}
/>
) : <MarkdownMessage content={frame.content} dark={dark} fadeFrom={frame.fadeFrom} />}
</div>
</div>
{frame.complete ? children : null}
{frame.complete && (modelLabel || footerAction) ? (
<div className="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2">
<p title={modelLabel || undefined} className={['min-w-0 flex-1 truncate text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{modelLabel}</p>
{footerAction}
</div>
) : null}
</div>
)
}
@@ -0,0 +1,45 @@
import { unified } from 'unified'
import remarkParse from 'remark-parse'
import remarkGfm from 'remark-gfm'
const parser = unified().use(remarkParse).use(remarkGfm)
export const AGENT_REVEAL_INTERVAL = 240
export const AGENT_REVEAL_FADE_DURATION = 220
export function createAgentRevealDocument(content: string) {
const tree = parser.parse(content)
const offsets: number[] = []
const definitions: string[] = []
for (const node of tree.children) {
if (node.type === 'definition' || node.type === 'footnoteDefinition') {
definitions.push(content.slice(node.position?.start.offset, node.position?.end.offset))
continue
}
const blocks = node.type === 'list' ? node.children : [node]
for (const block of blocks) {
if (block.position?.end.offset !== undefined) offsets.push(block.position.end.offset)
}
}
// Group unusually long answers without splitting Markdown blocks or list items.
const groupSize = Math.max(1, Math.ceil(offsets.length / 20))
const boundaries = offsets.filter((_, index) => (index + 1) % groupSize === 0 || index === offsets.length - 1)
if (boundaries.length) boundaries[boundaries.length - 1] = content.length
else boundaries.push(content.length)
return { content, boundaries, definitions: definitions.join('\n\n') }
}
export function agentRevealFrame(document: ReturnType<typeof createAgentRevealDocument>, startedAt: number | undefined, now: number, reducedMotion = false) {
const elapsed = startedAt === undefined ? Infinity : Math.max(0, now - startedAt)
const visible = reducedMotion ? document.boundaries.length : Math.min(document.boundaries.length, 1 + Math.floor(elapsed / AGENT_REVEAL_INTERVAL))
const complete = visible === document.boundaries.length
const end = document.boundaries[visible - 1]
const fadeFrom = !reducedMotion && elapsed < (visible - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
? (visible > 1 ? document.boundaries[visible - 2] : 0)
: undefined
return {
content: complete ? document.content : document.content.slice(0, end) + (document.definitions ? `\n\n${document.definitions}` : ''),
fadeFrom,
complete,
duration: (document.boundaries.length - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
}
}
+7
View File
@@ -67,6 +67,9 @@ interface Window {
openDevTools: () => Promise<{ opened: boolean; message?: string }>
close: () => void
}
chatTextMenu?: {
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
}
appDiagnostics?: {
read: () => Promise<{ path: string; content: string }>
}
@@ -92,6 +95,8 @@ interface Window {
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
}
agent: {
readTool: (request: import('../../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
applyFiles: (changes: import('../../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => Promise<{ ok: boolean }>
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => Promise<{
ok: boolean
output: string
@@ -320,6 +325,8 @@ interface Window {
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
}
agent: {
readTool: (target: RemoteWorkspaceTarget, request: import('../../shared/agentTools').AgentToolRequest, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../../shared/agentTools').AgentCheckedChange[]) => Promise<{ ok: boolean }>
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ReturnType<Window['agent']['runCommand']>
}
maps: {
@@ -3,6 +3,7 @@ import Editor, { DiffEditor, type BeforeMount, type OnMount } from '@monaco-edit
import { FileText, Loader2, RotateCw } from 'lucide-react'
type Props = {
editingLocked?: boolean
fileLoading?: boolean
fileError?: string
onRetryFile?: () => void
@@ -30,6 +31,7 @@ type Props = {
}
function WorkspaceEditorPane({
editingLocked = false,
fileLoading,
fileError,
onRetryFile,
@@ -66,6 +68,10 @@ function WorkspaceEditorPane({
language={editorLanguage}
theme={dark ? 'ads-ide-dark' : 'ads-ide-light'}
beforeMount={beforeMount}
onMount={(editor) => {
editor.getOriginalEditor().getModel()?.updateOptions({ tabSize })
editor.getModifiedEditor().getModel()?.updateOptions({ tabSize })
}}
options={{
automaticLayout: true,
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
@@ -75,7 +81,6 @@ function WorkspaceEditorPane({
readOnly: true,
renderSideBySide: true,
scrollBeyondLastLine: false,
tabSize,
wordWrap: wordWrap ? 'on' : 'off'
}}
/>
@@ -136,7 +141,7 @@ function WorkspaceEditorPane({
scrollBeyondLastLine: false,
tabSize,
wordWrap: wordWrap ? 'on' : 'off',
readOnly: exampleActive || editorValue === 'Binary or large file preview is not available.'
readOnly: editingLocked || exampleActive || editorValue === 'Binary or large file preview is not available.'
}}
/>
)}
@@ -1,6 +1,7 @@
import { t, useLocaleRender } from '../../i18n'
import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react'
import type React from 'react'
import { useId } from 'react'
export type WorkspaceGuideResourceMode = 'material' | 'reference'
@@ -97,6 +98,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
onDownloadResource
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
useLocaleRender()
const panelId = useId()
const textDim = dark ? inheritedTextDim : 'text-[#475569]'
return (
<aside
@@ -147,36 +149,35 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
const canToggle = allowFutureMaterialSteps || previous || active
const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl)
const youtubeVideoId = getYoutubeVideoId(item.videoUrl)
const contentId = `${panelId}-material-${index}`
return (
<section
key={`${item.title}-${index}`}
role={canToggle ? 'button' : undefined}
tabIndex={canToggle ? 0 : undefined}
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
onKeyDown={canToggle ? (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onToggleMaterial(index)
} : undefined}
className={[
'rounded-lg border transition',
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3',
canToggle ? 'cursor-pointer' : '',
'rounded-lg border px-3 pt-3 transition',
expanded ? 'pb-4' : 'pb-3',
active
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white'
: canToggle
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#c5cfdd] bg-white hover:border-[#9cacc2] hover:bg-[#f8fafc]'
? dark ? 'border-white/12 bg-[#292929] opacity-92' : 'border-[#c5cfdd] bg-white'
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-white'
].join(' ')}
>
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
<button
type="button"
disabled={!canToggle}
aria-expanded={expanded}
aria-controls={contentId}
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
className={['flex h-6 w-full min-w-0 items-center gap-2.5 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-[#60a5fa] disabled:cursor-default', canToggle ? 'cursor-pointer' : ''].join(' ')}
>
<span className={[
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
active ? dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#ddd0fa] text-[#5b21b6]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-[#cbd5e1] text-[#334155]' : dark ? 'bg-white/8 text-white/38' : 'bg-[#d5dbe5] text-[#64748b]'
].join(' ')}><span className="relative top-px leading-none">{index + 1}</span></span>
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
<div className="flex min-w-0 items-center justify-between gap-2">
<span className="block min-w-0 flex-1">
<span className="flex min-w-0 items-center justify-between gap-2">
<strong title={item.title} className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
<span className="flex shrink-0 items-center gap-1.5">
{active && (
@@ -193,55 +194,55 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
/>
)}
</span>
</div>
{expanded && (
<div className="mt-2.5 grid gap-2.5">
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
{youtubeEmbedUrl && youtubeVideoId && (
<button
type="button"
className={['group relative aspect-video w-full cursor-pointer overflow-hidden rounded-md border bg-black text-left', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')}
onClick={(event) => {
event.stopPropagation()
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title }))
}}
>
<img
src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
alt=""
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
loading="lazy"
/>
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
<span className="absolute inset-0 grid place-items-center">
<span className="grid h-11 w-11 place-items-center rounded-full bg-white/92 text-[#2563eb] shadow-[0_14px_34px_rgba(0,0,0,0.32)] transition group-hover:scale-105">
<Play size={22} fill="currentColor" />
</span>
</span>
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span>
</button>
)}
<section>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
{item.description || topicDetails[index] || overview}
</p>
</section>
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("핵심 개념")}</span>
<div className="mt-2 grid gap-1.5">
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
<div key={concept} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#2563eb]" />
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{concept}</span>
</div>
))}
</div>
</section>
</div>
</span>
</span>
</button>
{expanded && (
<div id={contentId} className="ml-[34px] mt-2.5 grid select-text gap-2.5">
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
{youtubeEmbedUrl && youtubeVideoId && (
<button
type="button"
className={['group relative aspect-video w-full cursor-pointer overflow-hidden rounded-md border bg-black text-left', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')}
onClick={(event) => {
event.stopPropagation()
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title }))
}}
>
<img
src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
alt=""
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
loading="lazy"
/>
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
<span className="absolute inset-0 grid place-items-center">
<span className="grid h-11 w-11 place-items-center rounded-full bg-white/92 text-[#2563eb] shadow-[0_14px_34px_rgba(0,0,0,0.32)] transition group-hover:scale-105">
<Play size={22} fill="currentColor" />
</span>
</span>
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span>
</button>
)}
<section>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
{item.description || topicDetails[index] || overview}
</p>
</section>
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("핵심 개념")}</span>
<div className="mt-2 grid gap-1.5">
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
<div key={concept} className="flex items-center gap-2">
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#2563eb]" />
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{concept}</span>
</div>
))}
</div>
</section>
</div>
</div>
)}
</section>
)
}) : (
@@ -4,7 +4,8 @@ import type React from 'react'
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { ConfirmStatusModal } from '../../components/AppStatusModals'
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments'
@@ -16,6 +17,7 @@ type WorkspaceQaMessage = {
content: string
senderAvatarUrl?: string | null
isTyping?: boolean
revealStartedAt?: number
keepExpanded?: boolean
}
@@ -39,6 +41,8 @@ type WorkspaceQaPanelProps = {
agentEnabled: boolean
agentStatusLoading: boolean
agentPending: boolean
agentActivity?: string
agentActionBusy?: boolean
agentPersistenceError?: string | null
aiPending: boolean
teacherThreadStatus: string | null
@@ -74,7 +78,7 @@ function MessageCopyButton({ content, dark }: { content: string; dark: boolean }
aria-label={copied ? t("복사됨") : t("복사")}
onClick={copy}
className={[
'absolute bottom-2 right-2 z-10 grid h-6 w-6 place-items-center rounded opacity-0 transition group-hover:opacity-100 focus:opacity-100',
'grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded opacity-0 transition group-hover:opacity-100 focus-visible:opacity-100',
dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#64748b] hover:bg-[#ede9fe] hover:text-[#1e293b]'
].join(' ')}
>
@@ -108,6 +112,8 @@ export default function WorkspaceQaPanel({
agentEnabled,
agentStatusLoading,
agentPending,
agentActivity,
agentActionBusy,
agentPersistenceError,
aiPending,
teacherThreadStatus,
@@ -126,7 +132,7 @@ export default function WorkspaceQaPanel({
onRejectAgentCommand
}: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; text: string } | null>(null)
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1]
@@ -207,10 +213,25 @@ export default function WorkspaceQaPanel({
])
const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
const target = event.target as HTMLElement | null
if (target?.closest('[data-qa-message="true"]')) return
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY })
event.stopPropagation()
const selection = window.getSelection()
const text = selection?.anchorNode && selection.focusNode && event.currentTarget.contains(selection.anchorNode) && event.currentTarget.contains(selection.focusNode)
? selection.toString()
: ''
if (window.chatTextMenu) {
setContextMenu(null)
void window.chatTextMenu.show({
kind: 'message',
canCopy: text.length > 0,
canClear: !clearDisabled,
labels: { copy: t('복사'), clear: t('대화내역 삭제') }
}).then((action) => {
if (action === 'clear') requestClearConversation()
}).catch((error) => console.error('[chat] message menu failed', error))
return
}
setContextMenu({ x: event.clientX, y: event.clientY, text })
}
return (
@@ -296,6 +317,8 @@ export default function WorkspaceQaPanel({
enabled={agentEnabled}
statusLoading={agentStatusLoading}
pending={agentPending}
activity={agentActivity}
busy={agentPending || agentActionBusy}
messages={agentMessages}
onApply={onApplyAgentPlan}
onOpenChange={onOpenAgentChange}
@@ -316,8 +339,19 @@ export default function WorkspaceQaPanel({
<div
className={['fixed z-[140] min-w-[150px] overflow-hidden rounded-md border py-1 shadow-[0_18px_42px_rgba(0,0,0,0.28)]', dark ? 'border-white/10 bg-[#2b2b2b]' : 'border-[#d5dbe7] bg-white'].join(' ')}
style={{ left: contextMenu.x, top: contextMenu.y }}
onMouseDown={(event) => event.preventDefault()}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
disabled={!contextMenu.text}
className={['flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] font-semibold transition disabled:cursor-default disabled:opacity-35', dark ? 'text-white/78 enabled:hover:bg-white/10' : 'text-[#334155] enabled:hover:bg-[#f1f5f9]'].join(' ')}
onClick={() => {
const text = contextMenu.text
setContextMenu(null)
if (text) void navigator.clipboard.writeText(text).catch((error) => console.error('[chat] copy failed', error))
}}
><Copy size={13} />{t('복사')}</button>
<button
type="button"
disabled={clearDisabled}
@@ -362,6 +396,8 @@ function AgentMessages({
enabled,
statusLoading,
pending,
activity,
busy,
messages,
onApply,
onOpenChange,
@@ -372,6 +408,8 @@ function AgentMessages({
enabled: boolean
statusLoading: boolean
pending: boolean
activity?: string
busy?: boolean
messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void
onOpenChange: (messageId: string, change: AgentFileChange) => void
@@ -390,23 +428,23 @@ function AgentMessages({
if (!enabled) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<ChatEmptyState dark={dark}>
{t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")}
</div>
</ChatEmptyState>
)
}
if (!messages.length && !pending) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<ChatEmptyState dark={dark}>
{t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}<br />{t("변경 내용은 확인 후 직접 적용합니다.")}
</div>
</ChatEmptyState>
)
}
return (
<div className="grid gap-3">
{messages.map((message) => message.role === 'user' ? (
{messages.map((message, index) => message.role === 'user' ? (
<div key={message.id} data-qa-message="true" className="flex justify-end">
<div className="max-w-[82%] break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
</div>
@@ -414,18 +452,22 @@ function AgentMessages({
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div className="group relative min-w-0 max-w-[calc(100%-38px)]">
<div className={['overflow-hidden rounded-lg border px-3 pb-9 pt-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
{message.plan ? (
<div className="grid gap-3">
<div>
<div className="font-black">
<MarkdownMessage content={message.plan.summary} dark={dark} />
</div>
<div className="mt-1">
<MarkdownMessage content={message.plan.explanation} dark={dark} />
</div>
{message.provider && message.model ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{message.provider} · {message.model}</p> : null}
</div>
<div className={['overflow-hidden rounded-lg border px-3 py-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
{message.plan && (
<>
{!!message.plan.evidence?.length && (
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
{message.plan.evidence.map(result => (
<details key={result.id} className="mt-2 min-w-0 border-t border-current/15 pt-2">
<summary className="cursor-pointer break-words">{result.moduleName ? `${result.moduleName} · ` : ''}{result.name} · {result.status === 'ok' ? t("조회 완료") : t("조회 실패")}{result.truncated ? ` · ${t("일부 결과")}` : ''}</summary>
<p className="mt-1 break-all">{result.target} · {new Date(result.observedAt).toLocaleTimeString()}</p>
<pre className="thin-scrollbar mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px]">{result.data}</pre>
</details>
))}
</details>
)}
{message.plan.actionPlan.length > 0 ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{(() => {
@@ -457,10 +499,10 @@ function AgentMessages({
</div>
) : null}
{message.plan.commands.length > 0 ? (
<div className={['order-5 grid gap-3 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
<fieldset disabled={busy} className={['order-5 m-0 grid min-w-0 gap-3 border-0 border-t p-0 pt-2 disabled:cursor-wait', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{message.plan.commands.map((command) => {
const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const }
const moduleStartAction = command.command.trim() === 'lms module start'
const moduleStartAction = command.command.trim() === 'lms module start' || command.operation?.name === 'modules.start'
const finished = execution.status === 'completed' || execution.status === 'failed'
const policyRejected = execution.status === 'rejected'
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
@@ -507,7 +549,7 @@ function AgentMessages({
</div>
)
})}
</div>
</fieldset>
) : null}
{message.plan.createModule ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
@@ -541,7 +583,7 @@ function AgentMessages({
{message.plan.changes.length > 0 || message.plan.createModule ? (
<button
type="button"
disabled={message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
onClick={() => onApply(message.id, message.plan!)}
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
>
@@ -549,26 +591,19 @@ function AgentMessages({
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button>
) : null}
</div>
) : <p className="whitespace-pre-wrap">{message.content}</p>}
</>
)}
</ProgressiveChatResponse>
</div>
<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />
</div>
</div>
))}
{pending ? (
<div data-qa-message="true" className="flex gap-2.5">
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div
role="status"
aria-label={t("코드 변경안을 만드는 중입니다.")}
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
>
<div className="grid animate-pulse gap-2" aria-hidden="true">
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
</div>
<div className="min-w-0 flex-1">
{activity ? <p role="status" className={['mb-2 break-words text-[11px]', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>{activity}</p> : null}
<ChatResponseSkeleton dark={dark} label={t("코드 변경안을 만드는 중입니다.")} />
</div>
</div>
) : null}
@@ -590,9 +625,9 @@ function AiMessages({
useLocaleRender()
if (!messages.length && !pending) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<span>{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}</span>
</div>
<ChatEmptyState dark={dark}>
{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}
</ChatEmptyState>
)
}
@@ -615,16 +650,13 @@ function AiMessages({
<Bot size={15} />
</span>
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg border px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'border-transparent bg-[#303030] text-white/75' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#334155]'].join(' ')}>
<ExpandableMarkdownMessage
<ProgressiveChatResponse
collapseOnSignal={index < messages.length - 1}
collapseSignal={collapseSignal}
content={message.content}
message={message}
dark={dark}
defaultCollapsed={!message.isTyping && !message.keepExpanded}
defaultCollapsed={!message.keepExpanded}
/>
{message.isTyping && (
<span className={['mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full', dark ? 'bg-white/65' : 'bg-[#2563eb]'].join(' ')} />
)}
</div>
</div>
)
@@ -634,10 +666,7 @@ function AiMessages({
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
<Bot size={15} />
</span>
<div className={['inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-[12px] font-medium', dark ? 'border-transparent bg-[#303030] text-white/55' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#475569]'].join(' ')}>
<span>{t("답변을 작성 중입니다...")}</span>
<span className={['h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
</div>
<ChatResponseSkeleton dark={dark} label={t("답변을 작성 중입니다...")} />
</div>
)}
</div>
@@ -660,9 +689,9 @@ function TeacherMessages({
useLocaleRender()
if (!messages.length) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
<span>{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}</span>
</div>
<ChatEmptyState dark={dark}>
{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}
</ChatEmptyState>
)
}
@@ -1,6 +1,7 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[] }
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] }
export type AgentContextRequest = { modules: string[]; logs: string[] }
export type AgentPlanResponse = {
provider: string
@@ -9,52 +10,195 @@ export type AgentPlanResponse = {
plan: AgentPlan
}
export function recoverInterruptedAgentMessages(messages: AgentConversationMessage[]): AgentConversationMessage[] {
let changed = false
const recovered = messages.map(message => {
const interrupted = Object.entries(message.commandExecutions ?? {}).some(([, execution]) => execution.status === 'running')
if (!interrupted && message.applyState !== 'applying') return message
changed = true
const error = '이전 작업의 완료 결과를 확인할 수 없습니다. 현재 모듈·파일 상태를 다시 조회한 뒤 진행해주세요.'
return { ...message, ...(message.applyState === 'applying' ? { applyState: 'error' as const, applyError: error } : {}), commandExecutions: Object.fromEntries(Object.entries(message.commandExecutions ?? {}).map(([id, execution]) => [id, execution.status === 'running' ? { ...execution, status: 'failed' as const, error } : execution])) }
})
return changed ? recovered : messages
}
export function agentPlanRequestText(request: string, finalize = false): string {
if (!finalize) return request
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
return instruction + request.slice(0, 4000 - instruction.length)
}
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext {
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
const selected = new Map<string, AgentWorkspaceModule['files']>()
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
let fileCount = 0
let characters = 0
// Interleave complete files so a large module cannot consume every file slot.
while (fileCount < 24) {
let added = false
for (const candidate of candidates) {
while (candidate.index < candidate.files.length && fileCount < 24) {
const file = candidate.files[candidate.index++]
const size = file.path.length + file.content.length
if (file.path.length > 500 || file.content.length > 100_000 || characters + size > 160_000) continue
const files = selected.get(candidate.module.name) ?? []
files.push(file)
selected.set(candidate.module.name, files)
characters += size
fileCount += 1
added = true
break
}
}
if (!added) break
}
const modules = context.modules.map((module) => {
const files = selected.get(module.name) ?? []
if (files.length === module.files.length) return module
const note = `요청 용량 제한으로 전체 ${module.files.length}개 중 ${files.length}개 파일만 제공했습니다. 제외된 파일은 확인한 것으로 간주하지 말고 제공된 근거로 답하거나 필요한 자료를 구체적으로 안내하세요.`
return {
...module,
files,
fileContext: {
status: 'limited' as const,
message: `${note} ${module.fileContext?.message ?? ''}`.trim().slice(0, 500)
}
}
})
const logOrder = [...new Set([...preferredLogs, ...context.runtimeLogs.map((log) => log.moduleName).reverse()])]
const logMap = new Map(context.runtimeLogs.map((log) => [log.moduleName, log]))
const retainedLogs = logOrder.flatMap((name) => logMap.has(name) ? [logMap.get(name)!] : []).slice(0, 3)
const omitted = context.runtimeLogs.filter((log) => !retainedLogs.includes(log)).map((log) => log.moduleName)
const runtimeLogs = retainedLogs.map((log, index) => {
let notes = [
log.message,
index === 0 && omitted.length ? `로그 모듈 한도로 이전 로그 제외: ${omitted.join(', ')}. 제외된 로그를 확인했다고 주장하지 마세요.` : ''
].filter(Boolean).join(' ').slice(0, 500)
if (log.content.length + notes.length > 20_000) {
notes = `로그 용량 제한으로 최근 구간만 제공합니다. ${notes}`.trim().slice(0, 500)
}
// The server counts both log content and messages against its 60,000-character cap.
const content = log.content.slice(-(20_000 - notes.length))
return { ...log, content, ...(notes ? { message: notes } : {}) }
})
let toolSize = 0
const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => {
if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false
toolSize += result.data.length
return true
}).slice(0, 12).reverse()
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) }
}
export async function resolveAgentContext({
initialContext,
requestPlan,
loadRequestedContext,
executeTool,
onProgress,
signal
}: {
initialContext: AgentContext
requestPlan: (context: AgentContext) => Promise<AgentPlanResponse>
requestPlan: (context: AgentContext, finalize?: boolean) => Promise<AgentPlanResponse>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
onProgress?: (label: string) => void
signal: AbortSignal
}): Promise<AgentPlanResponse> {
let context = initialContext
const requestedModules = new Set<string>()
const requestedLogs = new Set<string>()
let cachedContext = initialContext
let context = fitAgentContext(cachedContext)
const requestedModules = new Set(initialContext.modules.filter((module) => module.files.length || module.fileContext).map((module) => module.name))
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
const queriedTools = new Set<string>()
let toolCount = 0
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
const usage: NonNullable<AgentConversationMessage['usage']> = {}
const checkAborted = (): void => {
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
}
// Initial request plus at most five context fetches; only return a finished response.
for (let round = 0; round <= 5; round += 1) {
checkAborted()
const response = await requestPlan(context)
checkAborted()
const addUsage = (response: AgentPlanResponse): void => {
for (const key of ['promptTokens', 'completionTokens', 'totalTokens'] as const) {
if (response.usage?.[key] !== undefined) usage[key] = (usage[key] ?? 0) + response.usage[key]
}
}
const finalize = async (): Promise<AgentPlanResponse> => {
checkAborted()
const response = await requestPlan(context, true)
checkAborted()
addUsage(response)
const stillRequesting = Boolean(response.plan.requestedModules?.length || response.plan.requestedLogs?.length || response.plan.toolRequests?.length)
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
)).join('\n').slice(0, 8000)
const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000)
const explanation = stillRequesting
? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`
: response.plan.explanation
return {
...response,
usage,
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
plan: {
...response.plan,
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
explanation,
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
}
}
}
const maxRounds = executeTool ? 8 : 5
for (let round = 0; round <= maxRounds; round += 1) {
checkAborted()
const response = await requestPlan(context)
checkAborted()
addUsage(response)
const modules = [...new Set(response.plan.requestedModules ?? [])]
const logs = [...new Set(response.plan.requestedLogs ?? [])]
if (!modules.length && !logs.length) return { ...response, usage }
if (!loadRequestedContext) throw new Error('추가 파일·로그를 읽을 수 없는 요청입니다. 워크스페이스에서 질문을 다시 보내주세요.')
if (round === 5) throw new Error('추가 자료 조회 횟수(5회)를 초과했습니다. 확인할 모듈이나 설정 파일 범위를 좁혀 다시 요청해주세요.')
const tools = response.plan.toolRequests ?? []
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
if (round === maxRounds) return finalize()
if (tools.length) {
if (!executeTool || tools.length > 6) return finalize()
const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool)))
if (!fresh.length || toolCount + fresh.length > 24) return finalize()
const results = [...(cachedContext.toolResults ?? [])]
for (const [index, tool] of fresh.entries()) {
checkAborted()
const key = fingerprint(tool)
if (queriedTools.has(key)) continue
queriedTools.add(key)
onProgress?.(`${tool.moduleName ? `${tool.moduleName} · ` : ''}${tool.name} 조회 중`)
const result = await executeTool({ ...tool, id: `query-${round}-${index}-${tool.id}`.slice(0, 80) })
checkAborted()
toolCount++
results.push(result)
}
cachedContext = { ...cachedContext, toolResults: results }
context = fitAgentContext(cachedContext)
onProgress?.('조회 결과 분석 중')
// A model may also use legacy source requests; process those on the next pass.
continue
}
if (!loadRequestedContext) return finalize()
const nextRequest = {
modules: modules.filter((name) => !requestedModules.has(name)),
logs: logs.filter((name) => !requestedLogs.has(name))
}
if (!nextRequest.modules.length && !nextRequest.logs.length) {
throw new Error(`AI가 이미 조회한 자료를 반복 요청하여 분석을 완료하지 못했습니다 (${[...modules, ...logs].join(', ')}). 해당 모듈의 설치·파일 조회 상태를 확인하고 다시 요청해주세요.`)
return finalize()
}
nextRequest.modules.forEach((name) => requestedModules.add(name))
nextRequest.logs.forEach((name) => requestedLogs.add(name))
const loaded = await loadRequestedContext(nextRequest)
checkAborted()
const loadedModules = new Map(loaded.modules.map((module) => [module.name, module]))
const mergedModules = context.modules.map((module) => {
const mergedModules = cachedContext.modules.map((module) => {
const updated = loadedModules.get(module.name)
loadedModules.delete(module.name)
// Collectors also return metadata-only entries for other modules. Keep earlier sources.
@@ -63,14 +207,13 @@ export async function resolveAgentContext({
: module
})
mergedModules.push(...loadedModules.values())
const mergedLogs = new Map(context.runtimeLogs.map((log) => [log.moduleName, log]))
loaded.runtimeLogs.forEach((log) => mergedLogs.set(log.moduleName, log))
const fileCount = mergedModules.reduce((sum, module) => sum + module.files.length, 0)
const characters = mergedModules.reduce((sum, module) => sum + module.files.reduce((count, file) => count + file.path.length + file.content.length, 0), 0)
if (fileCount > 24 || characters > 160_000 || mergedLogs.size > 3) {
throw new Error('추가 자료가 조회 한도(파일 24개·16만 자, 로그 모듈 3개)를 초과했습니다. 대상 모듈이나 파일 범위를 좁혀 다시 요청해주세요.')
}
context = { modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
const mergedLogs = new Map(cachedContext.runtimeLogs.map((log) => [log.moduleName, log]))
loaded.runtimeLogs.forEach((log) => {
mergedLogs.delete(log.moduleName)
mergedLogs.set(log.moduleName, log)
})
cachedContext = { ...cachedContext, modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
context = fitAgentContext(cachedContext, nextRequest.modules, nextRequest.logs)
}
throw new Error('AI 분석을 완료하지 못했습니다.')
}
@@ -0,0 +1,132 @@
import type { AgentToolResult } from '../../../../shared/agentTools'
import type { ModuleRow } from './types'
// The same module sequences are documented in the workspace module guide.
export const AGENT_PRACTICE_PROFILES = [
{ id: 'local-2d', environment: 'local', platform: 'turtlebot3', level: '2d', modules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
{ id: 'mkmini-2d', environment: 'remote', platform: 'mkmini', level: '2d', modules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
{ id: 'mkmini-3d', environment: 'remote', platform: 'mkmini', level: '3d', modules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'], topics: ['/lidar_filtered', '/odom', '/cloud_map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
{ id: 'yahboom-2d', environment: 'remote', platform: 'yahboom', level: '2d', modules: ['NAV2-Yahboom-Agent', 'NAV2-Yahboom', 'NAV2-Yahboom-Slam'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] }
] as const
export function inspectAgentPractice(profileId: string, modules: ModuleRow[], environment: 'local' | 'remote') {
const profile = AGENT_PRACTICE_PROFILES.find(item => item.id === profileId)
if (!profile) throw new Error('지원하지 않는 실습 구성입니다.')
const blockers: string[] = []
if (profile.environment !== environment) blockers.push(`현재 ${environment} 환경에서는 ${profile.id} 구성을 실행할 수 없습니다.`)
const required = profile.modules.map(name => {
const candidates = modules.filter(item => item.name === name)
const module = candidates[0]
if (candidates.length > 1) blockers.push(`${name}: 같은 이름의 모듈이 여러 개입니다.`)
if (!module || module.state === 'uninstalled') blockers.push(`${name}: 필수 모듈이 설치되지 않았습니다.`)
if (module) {
const params = new Map(module.parameters.map(parameter => {
const separator = parameter.indexOf(':=')
return [parameter.slice(0, separator), parameter.slice(separator + 2)]
}))
const expectedMode = environment === 'local' ? 'true' : 'false'
for (const [key, expected] of [['sim_mode', expectedMode], ['platform', profile.platform], ['robot', profile.platform], ['level', profile.level]]) {
if (params.has(key) && params.get(key) !== expected) blockers.push(`${name}: ${key}:=${params.get(key)} (필요 값: ${expected})`)
}
// Never guess hardware/simulation mode or start localization on an existing map.
if ((module.category.toLowerCase() === 'vehicle' || name === 'NAV2-MKMini' || name === 'NAV2-Yahboom') && !params.has('sim_mode')) blockers.push(`${name}: sim_mode 설정을 확인해야 합니다.`)
if (params.get('localization') === 'true') blockers.push(`${name}: 기존 지도 위치추정 모드입니다. 새 지도 SLAM 모드인지 확인해주세요.`)
}
return { name, state: module?.state ?? 'missing', parameters: module?.parameters ?? [] }
})
const conflicting = modules.filter(module => module.state === 'running' && !profile.modules.some(name => name === module.name)
&& module.name !== 'NAV2-Camera'
&& ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control'].includes(module.category.toLowerCase()))
if (conflicting.length) blockers.push(`다른 실습 모듈이 실행 중입니다: ${conflicting.map(module => module.name).join(', ')}. 자동 종료하지 않습니다.`)
return { profile, required, blockers, canPrepare: !blockers.length, starts: required.filter(module => module.state === 'stopped').map(module => module.name) }
}
export function verifyAgentPractice(profileId: string, probe: AgentToolResult): { ready: boolean; failures: string[]; evidence: unknown } {
const profile = AGENT_PRACTICE_PROFILES.find(item => item.id === profileId)
if (!profile || probe.status !== 'ok' || probe.truncated) return { ready: false, failures: ['ROS 상태를 확인하지 못했습니다.'], evidence: probe }
let data: { topics?: Array<{ name: string; publishers: number; samples: number; validSamples: number; frameIds?: string[]; error?: string }>; tfEdges?: Array<{ parent: string; child: string }> }
try { data = JSON.parse(probe.data) } catch { return { ready: false, failures: ['ROS 조회 결과 형식이 올바르지 않습니다.'], evidence: probe } }
if (!data || !Array.isArray(data.topics) || !Array.isArray(data.tfEdges)) return { ready: false, failures: ['ROS 조회 결과 형식이 올바르지 않습니다.'], evidence: probe }
const failures: string[] = []
for (const name of profile.topics) {
const topic = data.topics?.find(topic => topic.name === name)
if (!topic || ![topic.publishers, topic.samples, topic.validSamples].every(value => Number.isFinite(value) && value > 0) || topic.error) failures.push(`${name}: 관찰 구간에서 정상 수신을 확인하지 못했습니다.`)
}
const edges = data.tfEdges.filter(edge => edge && typeof edge.parent === 'string' && typeof edge.child === 'string')
const reaches = (source: string, target: string): boolean => {
const queue = [source]
const seen = new Set<string>()
while (queue.length) {
const current = queue.shift()!
if (current === target) return true
if (seen.has(current)) continue
seen.add(current)
edges.filter(edge => edge.parent.replace(/^\//, '') === current).forEach(edge => queue.push(edge.child.replace(/^\//, '')))
}
return false
}
for (const [source, target] of profile.frames) if (!reaches(source, target)) failures.push(`TF ${source} → ${target}: 연결을 확인하지 못했습니다.`)
const sensor = data.topics?.find(topic => topic.name === profile.topics[0])
if (!Array.isArray(sensor?.frameIds) || !sensor.frameIds.some(frame => typeof frame === 'string' && reaches('base_link', frame.replace(/^\//, '')))) failures.push('센서 프레임과 base_link의 TF 연결을 확인하지 못했습니다.')
return { ready: !failures.length, failures, evidence: probe }
}
export async function prepareAgentPractice(options: {
profileId: string
environment: 'local' | 'remote'
approvedStarts: string[]
approvedParameters?: Record<string, string[]>
list: () => Promise<ModuleRow[]>
start: (module: ModuleRow) => Promise<boolean>
probe: (module: ModuleRow, topics: string[]) => Promise<AgentToolResult>
progress: (text: string, steps?: Array<{ moduleName: string; status: string }>) => void
signal: AbortSignal
pause?: () => Promise<void>
}) {
const assertActive = (): void => { if (options.signal.aborted) throw new DOMException('Aborted', 'AbortError') }
assertActive()
let modules = await options.list()
assertActive()
const inspection = inspectAgentPractice(options.profileId, modules, options.environment)
if (inspection.blockers.length) throw new Error(inspection.blockers.join('\n'))
if (inspection.starts.some(name => !options.approvedStarts.includes(name))) throw new Error('승인 이후 시작 대상이 달라졌습니다. 다시 승인해주세요.')
const steps: Array<{ moduleName: string; status: string }> = []
const assertParameters = (modules: ModuleRow[]): void => {
for (const name of inspection.profile.modules) {
if (options.approvedParameters && JSON.stringify([...(modules.find(module => module.name === name)?.parameters ?? [])].sort()) !== JSON.stringify([...(options.approvedParameters[name] ?? [])].sort())) throw new Error(`${name}: 승인 이후 실행 파라미터가 바뀌었습니다. 다시 승인해주세요.`)
}
}
assertParameters(modules)
for (const name of inspection.profile.modules) {
assertActive()
modules = await options.list()
assertActive()
const latest = inspectAgentPractice(options.profileId, modules, options.environment)
assertParameters(modules)
if (latest.blockers.length) throw new Error(latest.blockers.join('\n'))
const module = modules.find(module => module.name === name)!
if (module.state === 'running') { steps.push({ moduleName: name, status: 'already-running' }); continue }
if (!options.approvedStarts.includes(name)) throw new Error(`승인받지 않은 시작 작업입니다: ${name}`)
options.progress(`${name} 시작 중`, steps)
if (!await options.start(module)) return { ready: false, steps: [...steps, { moduleName: name, status: 'failed' }], failures: [`${name} 시작에 실패했습니다. 이미 시작된 모듈은 자동 종료하지 않았습니다.`] }
steps.push({ moduleName: name, status: 'started' })
options.progress(`${name} 시작 완료`, steps)
if (module.category.toLowerCase() === 'simulator') await (options.pause?.() ?? new Promise(resolve => setTimeout(resolve, 4000)))
}
const topics = [...inspection.profile.topics, '/tf', '/tf_static']
let verification: ReturnType<typeof verifyAgentPractice> | undefined
for (let attempt = 0; attempt < 3; attempt++) {
assertActive()
options.progress(`SLAM 데이터·TF 확인 중 (${attempt + 1}/3)`, steps)
modules = await options.list()
assertActive()
const latest = inspectAgentPractice(options.profileId, modules, options.environment)
assertParameters(modules)
if (latest.blockers.length || latest.required.some(module => module.state !== 'running')) return { ready: false, steps, failures: [...latest.blockers, '필수 모듈의 실행 상태를 확인해주세요.'] }
verification = verifyAgentPractice(options.profileId, await options.probe(modules.find(module => module.name === inspection.profile.modules.at(-1))!, topics))
assertActive()
if (verification.ready) break
if (attempt < 2) await (options.pause?.() ?? new Promise(resolve => setTimeout(resolve, 2000)))
}
return { ...verification!, steps }
}
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync'
import { resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext'
import type { ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
export type AgentFileChange = {
expectedHash?: string | null
moduleName: string
path: string
content: string
@@ -20,6 +22,7 @@ export type AgentModulePlan = {
}
export type AgentCommand = {
operation?: AgentOperation
id: string
moduleName: string
command: string
@@ -34,6 +37,8 @@ export type AgentCommandExecution = {
}
export type AgentPlan = {
toolRequests?: AgentToolRequest[]
evidence?: AgentToolResult[]
summary: string
explanation: string
requestedModules: string[]
@@ -45,6 +50,7 @@ export type AgentPlan = {
}
export type AgentWorkspaceModule = {
parameters?: string[]
name: string
folderName?: string
category?: string
@@ -69,6 +75,7 @@ export type AgentConversationMessage = {
content: string
provider?: string
model?: string
revealStartedAt?: number
plan?: AgentPlan
applyState?: 'ready' | 'applying' | 'applied' | 'error'
applyError?: string
@@ -109,8 +116,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [activity, setActivity] = useState('')
const [messages, setMessages] = useState<AgentConversationMessage[]>([])
const abortControllerRef = useRef<AbortController | null>(null)
const applyingRef = useRef(false)
const tokenReaderRef = useRef(getFreshAccessToken)
tokenReaderRef.current = getFreshAccessToken
const onResponseRef = useRef(onResponse)
@@ -134,6 +143,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
}
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 구조화된 에이전트 도구를 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
}
return response.json() as Promise<T>
@@ -147,7 +157,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
body: JSON.stringify({
courseId,
week: Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : undefined,
messages: batch
messages: batch.map(({ revealStartedAt: _revealStartedAt, ...message }) => message)
})
})
let sync = sessionSyncs.get(scope)
@@ -204,7 +214,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}, [authenticated, session])
useEffect(() => {
const cached = authenticated ? session.restore([]) : []
const cached = authenticated ? recoverInterruptedAgentMessages(session.restore([])) : []
messageStateRef.current = { session, messages: cached }
setMessages(cached)
setHistoryError(null)
@@ -219,7 +229,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
requestApi<AgentSessionResponse>(`/chat/agent/session?${query.toString()}`)
.then((response) => {
if (!cancelled) {
const restored = session.restore(Array.isArray(response.messages) ? response.messages : [])
const restored = recoverInterruptedAgentMessages(session.restore(Array.isArray(response.messages) ? response.messages : []))
messageStateRef.current = { session, messages: restored }
setMessages(restored)
}
@@ -254,6 +264,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
showUserMessage?: boolean
attachments?: ChatAttachment[]
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote'
toolResults?: AgentToolResult[]
} = {}
): Promise<void> => {
const request = rawRequest.trim()
@@ -265,18 +278,21 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true)
setActivity('요청 분석 중')
const abortController = new AbortController()
abortControllerRef.current = abortController
try {
const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs },
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) },
signal: abortController.signal,
loadRequestedContext: options.loadRequestedContext,
requestPlan: (context) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
executeTool: options.executeTool,
onProgress: setActivity,
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST',
signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request, ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context })
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(options.attachments?.length ? { attachments: options.attachments } : {}), ...context })
})
})
if (abortController.signal.aborted) return
@@ -291,6 +307,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
updateMessages((current) => [...current, {
id: `agent-plan-${Date.now()}`,
role: 'assistant',
revealStartedAt: Date.now(),
content: plan.summary,
provider: response.provider,
model: response.model,
@@ -305,20 +322,24 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`,
role: 'assistant',
revealStartedAt: Date.now(),
content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.'
}])
onResponseRef.current?.()
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null
setPending(false)
setActivity('')
}
}
}
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
const message = messages.find((item) => item.id === messageId)
if (!message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return
applyingRef.current = true
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
try {
await apply(message.plan)
@@ -336,6 +357,8 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
} catch (error) {
const applyError = error instanceof Error ? error.message : '변경안을 적용하지 못했습니다.'
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item))
} finally {
applyingRef.current = false
}
}
@@ -351,7 +374,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
...message,
commandExecutions: {
...message.commandExecutions,
[commandId]: execution
[commandId]: { ...message.commandExecutions?.[commandId], ...(execution.status === 'running' ? { error: undefined } : {}), ...execution }
}
}
: message))
@@ -382,6 +405,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
...status,
statusLoading: statusLoading || historyLoading,
pending,
activity,
messages,
persistenceError: persistenceError ?? historyError,
requestPlan,
@@ -11,6 +11,7 @@ export type QaChatMessage = {
createdAt?: string
senderAvatarUrl?: string | null
isTyping?: boolean
revealStartedAt?: number
keepExpanded?: boolean
status?: 'ACTIVE' | 'CANCELED'
}
@@ -55,6 +56,7 @@ type UseQaChatOptions = {
courseId: string
weekNumber: number
globalScope?: boolean
revealInView?: boolean
apiBaseUrl: string
socketBaseUrl: string
socketPath: string
@@ -77,6 +79,7 @@ export function useQaChat({
courseId,
weekNumber,
globalScope = false,
revealInView = false,
apiBaseUrl,
socketBaseUrl,
socketPath,
@@ -369,6 +372,17 @@ export function useQaChat({
})
expandedAiMessageIdsRef.current.add(response.message.id)
setAiPending(false)
if (revealInView) {
setAiMessages((messages) => [...messages, {
id: response.message.id,
role: 'assistant',
content: fullContent,
createdAt: response.message.createdAt,
revealStartedAt: Date.now(),
keepExpanded: true
}])
return
}
setAiTypingMessageId(response.message.id)
setAiMessages((messages) => [
...messages,
@@ -11,8 +11,10 @@ type ModuleActionResult = {
ok: boolean
message?: string
}
type ModuleControlOptions = { background?: boolean; freshSlam?: boolean; onResult?: (result: ModuleActionResult) => void }
type SavedMapPathOverrides = {
freshSlam?: boolean
savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null
savedMapWorldLayoutPath?: string | null
@@ -70,7 +72,7 @@ type UseWorkspaceModuleControlResult = {
startAllModules: () => Promise<void>
stopAllModules: () => Promise<void>
restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<boolean>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options?: ModuleControlOptions) => Promise<boolean>
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
}
@@ -108,10 +110,12 @@ export function useWorkspaceModuleControl({
const moduleActionIdRef = useRef(0)
const controlGenerationRef = useRef(0)
const workspaceActiveRef = useRef(workspaceActive)
const backgroundControlsRef = useRef(0)
useEffect(() => () => { controlGenerationRef.current += 1 }, [courseKey, weekNumber, clonePathPreview, remoteWorkspace])
useEffect(() => {
workspaceActiveRef.current = workspaceActive
if (workspaceActive) return
if (workspaceActive || backgroundControlsRef.current > 0) return
// Workspace pages remain mounted while another LMS page is shown. Do not
// leave their control buttons locked behind a detached IPC request.
@@ -126,6 +130,7 @@ export function useWorkspaceModuleControl({
), [bulkExcludedModuleKeys])
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: {
freshSlam?: boolean
simulatorWorldFile?: string | null
savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null
@@ -133,7 +138,7 @@ export function useWorkspaceModuleControl({
}): string => {
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const mapDatabasePath = overrides?.freshSlam ? null : overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
@@ -175,12 +180,13 @@ export function useWorkspaceModuleControl({
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
}
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '')
const databasePath = mapDatabasePath
const databasePath = overrides?.freshSlam ? null : mapDatabasePath
?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true')
}
if (overrides?.freshSlam && /slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=false')
return parameters.join(' ')
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
@@ -473,8 +479,8 @@ export function useWorkspaceModuleControl({
}
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<boolean> => {
if (!workspaceActiveRef.current) return false
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options: ModuleControlOptions = {}): Promise<boolean> => {
if (!workspaceActiveRef.current && !options.background) return false
// The parking area only opts modules out of bulk actions; those modules
// must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = (
@@ -489,17 +495,18 @@ export function useWorkspaceModuleControl({
const actionId = ++moduleActionIdRef.current
const controlGeneration = controlGenerationRef.current
queuedModuleActionsRef.current.set(module.name, actionId)
if (options.background) backgroundControlsRef.current += 1
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null)
const execute = async (): Promise<boolean> => {
const isCurrentControl = (): boolean => (
workspaceActiveRef.current
(workspaceActiveRef.current || options.background === true)
&& controlGenerationRef.current === controlGeneration
&& queuedModuleActionsRef.current.get(module.name) === actionId
)
try {
if (!isCurrentControl()) return false
const savedMapOverrides = action === 'start' || action === 'restart'
const savedMapOverrides = options.freshSlam ? { freshSlam: true } : action === 'start' || action === 'restart'
? await prepareSavedMap?.()
: undefined
const result = action === 'stop'
@@ -517,6 +524,7 @@ export function useWorkspaceModuleControl({
: remoteWorkspace
? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
options.onResult?.(result)
if (!result.ok) throw new Error(result.message)
if (!isCurrentControl()) return false
@@ -551,6 +559,7 @@ export function useWorkspaceModuleControl({
return true
} catch (error) {
console.error(`[modules] ${action} failed`, error)
options.onResult?.({ ok: false, message: error instanceof Error ? error.message : String(error) })
if (!isCurrentControl()) return false
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
const actionLabel = action === 'stop'
@@ -564,6 +573,7 @@ export function useWorkspaceModuleControl({
await loadAvailableModules({ silent: true })
return false
} finally {
if (options.background) backgroundControlsRef.current -= 1
if (queuedModuleActionsRef.current.get(module.name) === actionId) {
queuedModuleActionsRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
+7 -1
View File
@@ -703,5 +703,11 @@
"실행 실패": "Failed",
"다시 실행": "Run again",
"명령 출력이 없습니다.": "The command produced no output.",
"코드 변경안을 만드는 중입니다.": "Preparing code changes."
"코드 변경안을 만드는 중입니다.": "Preparing code changes.",
"조회 근거": "Evidence",
"조회 완료": "Retrieved",
"조회 실패": "Retrieval failed",
"일부 결과": "Partial results",
"SLAM 실습 준비": "Prepare SLAM practice",
"승인하고 준비": "Approve and prepare"
}
+15
View File
@@ -248,6 +248,21 @@
}
@layer utilities {
@keyframes agent-response-fade {
from { opacity: 0; transform: translateY(3px); }
to { opacity: 1; transform: translateY(0); }
}
.agent-response-fade {
animation: agent-response-fade 220ms ease-out both;
}
@media (prefers-reduced-motion: reduce) {
.agent-response-fade {
animation: none;
}
}
.monaco-search-line-highlight {
background: rgba(47, 141, 255, 0.12);
}
+53
View File
@@ -0,0 +1,53 @@
export const AGENT_READ_TOOLS = ['modules.list', 'files.list', 'files.search', 'files.read', 'logs.read', 'network.inspect', 'ros.inspect', 'practice.inspect'] as const
export type AgentReadToolName = typeof AGENT_READ_TOOLS[number]
export const AGENT_RESULT_NAMES = [...AGENT_READ_TOOLS, 'action.result'] as const
export type AgentToolRequest = {
id: string
name: AgentReadToolName
moduleName?: string
args?: { path?: string; query?: string; startLine?: number; lineCount?: number; topics?: string[]; profile?: string }
}
export type AgentToolResult = {
id: string
name: typeof AGENT_RESULT_NAMES[number]
moduleName?: string
status: 'ok' | 'error'
observedAt: string
target: string
data: string
truncated: boolean
}
export type AgentOperation = { name: 'modules.start' | 'modules.build' | 'practice.prepare'; profile?: string }
export type AgentCheckedChange = { moduleName: string; folderName?: string; path: string; content: string; expectedHash: string | null }
const blockedParts = new Set(['node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'])
export function safeAgentPath(value: unknown, allowEmpty = false): string {
if (allowEmpty && (value === undefined || value === '')) return ''
if (typeof value !== 'string' || value.length > 500 || /[\\\0\r\n]/.test(value) || value.startsWith('/')) throw new Error('안전하지 않은 파일 경로입니다.')
const parts = value.split('/')
if (parts.some(part => !part || part.startsWith('.') || blockedParts.has(part.toLowerCase()) || /\.(pem|key|p12|pfx)$/i.test(part))) throw new Error('시스템·비밀 파일 또는 제외된 경로에는 접근할 수 없습니다.')
return value
}
export function validateAgentTool(input: AgentToolRequest): AgentToolRequest {
if (!input || typeof input !== 'object' || !/^[A-Za-z0-9._-]{1,80}$/.test(input.id) || !AGENT_READ_TOOLS.includes(input.name)) throw new Error('등록되지 않은 에이전트 도구입니다.')
if (input.moduleName !== undefined && (typeof input.moduleName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,119}$/.test(input.moduleName))) throw new Error('모듈명이 올바르지 않습니다.')
if (!['modules.list', 'practice.inspect'].includes(input.name) && !input.moduleName) throw new Error('대상 모듈이 필요합니다.')
const args = input.args ?? {}
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('도구 인자가 올바르지 않습니다.')
const allowed = input.name === 'files.read' ? ['path', 'startLine', 'lineCount'] : input.name === 'files.search' ? ['path', 'query'] : input.name === 'files.list' ? ['path'] : input.name === 'ros.inspect' ? ['topics'] : input.name === 'practice.inspect' ? ['profile'] : []
if (Object.keys(args).some(key => !allowed.includes(key))) throw new Error('허용되지 않은 도구 인자입니다.')
if (input.name.startsWith('files.')) safeAgentPath(args.path, input.name !== 'files.read')
if (input.name === 'files.search' && (typeof args.query !== 'string' || args.query.length < 2 || args.query.length > 200 || /[\0\r\n]/.test(args.query))) throw new Error('검색어는 2~200자의 한 줄 텍스트여야 합니다.')
for (const [key, max] of [['startLine', 100_000], ['lineCount', 400]] as const) {
if (args[key] !== undefined && (!Number.isInteger(args[key]) || args[key]! < 1 || args[key]! > max)) throw new Error('파일 조회 범위를 확인하세요.')
}
if (args.topics !== undefined && (!Array.isArray(args.topics) || args.topics.length > 12 || args.topics.some(topic => typeof topic !== 'string' || topic.length > 200 || !/^\/[A-Za-z0-9_/]+$/.test(topic)))) throw new Error('ROS 토픽 형식이 올바르지 않습니다.')
if (args.profile !== undefined && !['local-2d', 'mkmini-2d', 'mkmini-3d', 'yahboom-2d'].includes(args.profile)) throw new Error('지원하지 않는 실습 구성입니다.')
return { id: input.id, name: input.name, ...(input.moduleName ? { moduleName: input.moduleName } : {}), args }
}
export function validateAgentOperation(value: AgentOperation): AgentOperation {
if (!value || !['modules.start', 'modules.build', 'practice.prepare'].includes(value.name) || Object.keys(value).some(key => !['name', 'profile'].includes(key))) throw new Error('허용되지 않은 승인 작업입니다.')
if (value.name === 'practice.prepare' && !['local-2d', 'mkmini-2d', 'mkmini-3d', 'yahboom-2d'].includes(value.profile ?? '')) throw new Error('실습 구성을 선택해야 합니다.')
if (value.name !== 'practice.prepare' && value.profile !== undefined) throw new Error('허용되지 않은 승인 작업 인자입니다.')
return value
}
+9
View File
@@ -0,0 +1,9 @@
export type ChatTextMenuOptions = {
kind: 'message' | 'input'
canCopy: boolean
canPaste?: boolean
canClear?: boolean
labels: { copy: string; paste?: string; clear?: string }
}
export type ChatTextMenuAction = 'clear' | null