Compare commits

25 Commits

Author SHA1 Message Date
chyoo 017093077f feat: preserve compact agent context and bump to 1.2.25 2026-10-29 10:39:20 +09:00
chyoo af6dc560ea fix: show agent messages before workspace lookup and bump to 1.2.24 2026-10-20 12:57:12 +09:00
chyoo 065de83ffd fix: open agent model picker above chat attachment controls 2026-10-20 11:45:12 +09:00
chyoo a8dd23675a feat: improve agent model selection and remote source analysis; bump to 1.2.23 2026-10-20 11:36:28 +09:00
chyoo cb5872c326 chore: bump version to 1.2.22 2026-10-20 10:21:10 +09:00
chyoo af54e875df feat: support multiple camera previews and concurrent agent code navigation 2026-10-20 10:10:48 +09:00
chyoo c17763f59d chore: bump version to 1.2.21 2026-10-19 17:29:40 +09:00
chyoo 00a83ce4ac fix: simplify code navigation and support Gazebo XML files
Remove redundant code-view cancellation and align button labels. Restore module scrollbar thickness while preserving workspace colors, and support config, SDF and world text files locally and remotely.
2026-10-19 17:29:02 +09:00
chyoo b7870cffa2 chore: release 1.2.20 with rounded viewer collapse controls 2026-10-19 17:09:51 +09:00
chyoo 8b501266ff feat: improve agent responses and workspace UI
Add live agent progress, elapsed timing, verified code navigation and rich Markdown rendering. Preserve NanumSquare styling with missing glyph fallback, fix platform availability filtering and unify workspace scrollbar and visualization controls.
2026-10-19 17:06:18 +09:00
chyoo af21fa0510 fix: hide agent file attachments for A-Copilot 2026-10-19 14:50:08 +09:00
chyoo 7cfd466350 chore: bump LMS app version to 1.2.19 2026-10-19 14:47:48 +09:00
chyoo 4321d944b5 fix: refine agent image attachments and restore Korean filenames 2026-10-19 14:47:21 +09:00
chyoo 234c9ad467 fix: localize simulation description and display A-Copilot in v1.2.18 2026-10-19 14:13:39 +09:00
chyoo 6512f5fa04 feat: add agent image attachments and bump version to 1.2.17 2026-10-19 13:34:06 +09:00
chyoo 7b32668e1d feat: improve chat input, themed menus and agent proposal cancellation 2026-10-19 13:12:04 +09:00
chyoo 105342fbf3 chore: bump app version to 1.2.16 2026-10-19 11:37:32 +09:00
chyoo cc589fd8cf feat: add structured agent tools and improve workspace chat UX 2026-10-19 11:30:23 +09:00
chyoo 098af30fa7 fix(chat): hide agent tab when course agent is disabled 2026-10-16 11:18:09 +09:00
chyoo 280b000496 chore: bump app version to 1.2.15 2026-10-16 11:15:28 +09:00
chyoo 4bdc5af620 feat(chat): add agent attachments and shared conversation confirmation 2026-10-16 11:14:01 +09:00
chyoo ef260c8fb4 Merge codex/ai-agent into develop for v1.2.14 2026-10-15 14:55:08 +09:00
chyoo 1c0d861051 feat(ai): improve agent workflows and persistence for v1.2.14 2026-10-15 14:39:01 +09:00
chyoo 53518d1114 version up! 2026-10-14 15:15:28 +09:00
chyoo 654fc722f9 feat: add reviewable workspace AI agent 2026-10-14 14:25:46 +09:00
88 changed files with 10001 additions and 468 deletions
+30 -3
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.1", "version": "1.2.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.1", "version": "1.2.25",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
@@ -17,14 +17,17 @@
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
"i18next": "^26.4.2", "i18next": "^26.4.2",
"lowlight": "^3.3.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"react-i18next": "^17.0.15", "react-i18next": "^17.0.15",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "11.0.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"three": "^0.181.2" "three": "^0.181.2",
"unified": "11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
@@ -5247,6 +5250,15 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/highlight.js": {
"version": "11.11.2",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz",
"integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hosted-git-info": { "node_modules/hosted-git-info": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
@@ -6176,6 +6188,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/lowlight": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"devlop": "^1.0.0",
"highlight.js": "~11.11.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+5 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.9", "version": "1.2.25",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
@@ -58,14 +58,17 @@
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
"i18next": "^26.4.2", "i18next": "^26.4.2",
"lowlight": "^3.3.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"monaco-editor": "^0.55.1", "monaco-editor": "^0.55.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"react-i18next": "^17.0.15", "react-i18next": "^17.0.15",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"remark-parse": "11.0.0",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"three": "^0.181.2" "three": "^0.181.2",
"unified": "11.0.5"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
+53 -8
View File
@@ -6,6 +6,7 @@ import json
import logging import logging
import math import math
import os import os
import re
import shutil import shutil
import sqlite3 import sqlite3
import struct import struct
@@ -207,6 +208,8 @@ class SlamNode(Node):
self._fallback_point_cloud: Optional[PointCloud2] = None self._fallback_point_cloud: Optional[PointCloud2] = None
self._color_point_cloud: Optional[PointCloud2] = None self._color_point_cloud: Optional[PointCloud2] = None
self._camera_image: Optional[CompressedImage] = None self._camera_image: Optional[CompressedImage] = None
self._camera_frames = {}
self._camera_subscriptions = {}
self._icp_odom: Optional[Odometry] = None self._icp_odom: Optional[Odometry] = None
self._map: Optional[OccupancyGrid] = None self._map: Optional[OccupancyGrid] = None
self._plan: Optional[NavPath] = None self._plan: Optional[NavPath] = None
@@ -261,6 +264,8 @@ class SlamNode(Node):
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data) self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
if not os.environ.get("CAMERA_IMAGE_TOPIC"): if not os.environ.get("CAMERA_IMAGE_TOPIC"):
self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data) self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data)
self._camera_discovery_timer = self.create_timer(
1.0, self._discover_cameras, clock=Clock(clock_type=ClockType.STEADY_TIME))
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos) self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
@@ -625,11 +630,45 @@ class SlamNode(Node):
self._color_point_cloud_count += 1 self._color_point_cloud_count += 1
self._last_color_point_cloud_wall_time = time.time() self._last_color_point_cloud_wall_time = time.time()
def _on_camera_image(self, msg: CompressedImage): def _discover_cameras(self):
try:
topics = sorted(name for name, types in self.get_topic_names_and_types()
if re.fullmatch(r'/camera_test/cameras/video[0-9]+/image_jpeg', name)
and 'sensor_msgs/msg/CompressedImage' in types)[:8]
except Exception:
return
for topic in topics:
camera_id = topic.split('/')[-2]
if camera_id not in self._camera_subscriptions:
self._camera_subscriptions[camera_id] = self.create_subscription(
CompressedImage, topic,
lambda msg, key=camera_id, name=topic: self._on_camera_image(msg, key, name),
qos_profile_sensor_data)
active = {topic.split('/')[-2] for topic in topics}
for camera_id in list(self._camera_subscriptions):
if camera_id not in active:
self.destroy_subscription(self._camera_subscriptions.pop(camera_id))
with self._lock:
self._camera_frames.pop(camera_id, None)
def _on_camera_image(self, msg: CompressedImage, camera_id='legacy', topic=None):
with self._lock: with self._lock:
if camera_id != 'legacy' and camera_id not in self._camera_subscriptions:
return
self._camera_image = msg self._camera_image = msg
self._camera_image_count += 1 self._camera_image_count += 1
self._last_camera_image_wall_time = time.time() self._last_camera_image_wall_time = time.time()
self._camera_frames[camera_id] = (msg, self._last_camera_image_wall_time,
topic or self.camera_image_topic)
def camera_sources(self):
now = time.time()
with self._lock:
frames = list(self._camera_frames.items())
return [{"id": key, "name": f'/dev/{key}' if key != 'legacy' else 'Camera',
"topic": topic, "available": now - received <= _CAMERA_FRAME_MAX_AGE_SECONDS
and bool(image.data) and 'jpeg' in image.format.lower()}
for key, (image, received, topic) in sorted(frames)]
def _active_point_cloud(self) -> Optional[PointCloud2]: def _active_point_cloud(self) -> Optional[PointCloud2]:
with self._lock: with self._lock:
@@ -687,7 +726,6 @@ class SlamNode(Node):
point_cloud_last = self._last_point_cloud_wall_time point_cloud_last = self._last_point_cloud_wall_time
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
color_point_cloud_last = self._last_color_point_cloud_wall_time color_point_cloud_last = self._last_color_point_cloud_wall_time
camera_image_last = self._last_camera_image_wall_time
map_count = self._map_count map_count = self._map_count
plan_count = self._plan_count plan_count = self._plan_count
@@ -727,7 +765,7 @@ class SlamNode(Node):
}, },
"camera_image": { "camera_image": {
"name": self.camera_image_topic, "name": self.camera_image_topic,
"available": received_recently(camera_image_last), "available": any(camera['available'] for camera in self.camera_sources()),
}, },
"plan": {"name": self.plan_topic, "available": plan_available}, "plan": {"name": self.plan_topic, "available": plan_available},
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available}, "cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
@@ -1238,6 +1276,7 @@ class SlamNode(Node):
"sensor_point_cloud": sensor_point_cloud_payload, "sensor_point_cloud": sensor_point_cloud_payload,
"color_point_cloud": color_point_cloud_payload, "color_point_cloud": color_point_cloud_payload,
"camera_image": camera_image_payload, "camera_image": camera_image_payload,
"cameras": self.camera_sources(),
"map": map_payload, "map": map_payload,
"tf": { "tf": {
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"), "map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
@@ -1302,10 +1341,16 @@ class SlamNode(Node):
payload["received_at"] = received_at payload["received_at"] = received_at
return payload return payload
def camera_frame_jpeg(self) -> Optional[bytes]: def camera_frame_jpeg(self, camera_id=None) -> Optional[bytes]:
with self._lock: with self._lock:
image = self._camera_image if camera_id is not None:
received_at = self._last_camera_image_wall_time entry = self._camera_frames.get(camera_id)
if entry is None:
return None
image, received_at, _topic = entry
else:
image = self._camera_image
received_at = self._last_camera_image_wall_time
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS: if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
return None return None
if not image.data or "jpeg" not in image.format.lower(): if not image.data or "jpeg" not in image.format.lower():
@@ -1531,10 +1576,10 @@ def color_point_cloud():
@app.get("/camera-frame") @app.get("/camera-frame")
def camera_frame(): def camera_frame(camera_id: Optional[str] = None):
if not slam_node: if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready") raise HTTPException(status_code=503, detail="ROS node is not ready")
frame = slam_node.camera_frame_jpeg() frame = slam_node.camera_frame_jpeg(camera_id)
if frame is None: if frame is None:
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.") raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"}) return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
+67
View File
@@ -0,0 +1,67 @@
import ast
from pathlib import Path
import re
import threading
from types import SimpleNamespace
from typing import Optional
import unittest
class CameraTests(unittest.TestCase):
def setUp(self):
self.now = 10.0
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
names = ('_discover_cameras', '_on_camera_image', 'camera_sources', 'camera_frame_jpeg')
methods = [item for item in node.body if isinstance(item, ast.FunctionDef) and item.name in names]
scope = {'re': re, 'Optional': Optional, 'CompressedImage': object,
'time': SimpleNamespace(time=lambda: self.now), '_CAMERA_FRAME_MAX_AGE_SECONDS': 3,
'qos_profile_sensor_data': object()}
exec(compile(ast.Module(body=methods, type_ignores=[]), 'camera-bridge', 'exec'), scope)
self.node = type('CameraNode', (), {name: scope[name] for name in names})()
self.node._lock = threading.Lock()
self.node._camera_frames = {}
self.node._camera_subscriptions = {}
self.node._camera_image = None
self.node._last_camera_image_wall_time = None
self.node._camera_image_count = 0
self.node.camera_image_topic = '/camera_test/image_jpeg'
self.topics = []
self.callbacks = {}
self.node.get_topic_names_and_types = lambda: self.topics
def subscribe(kind, topic, callback, qos):
self.callbacks[topic] = callback
return topic
self.node.create_subscription = subscribe
self.node.destroy_subscription = lambda sub: self.callbacks.pop(sub)
def test_independent_frames_and_disconnect(self):
for key in ['video0', 'video2']:
self.topics.append((f'/camera_test/cameras/{key}/image_jpeg', ['sensor_msgs/msg/CompressedImage']))
self.topics += [('/other/image_jpeg', ['sensor_msgs/msg/CompressedImage']),
('/camera_test/cameras/video9/image_jpeg', ['sensor_msgs/msg/Image'])]
self.node._discover_cameras()
self.assertEqual(len(self.callbacks), 2)
for topic, callback in self.callbacks.items():
callback(SimpleNamespace(data=topic.encode(), format='jpeg'))
self.assertEqual([item['id'] for item in self.node.camera_sources()], ['video0', 'video2'])
self.assertIn(b'video2', self.node.camera_frame_jpeg('video2'))
self.assertIsNone(self.node.camera_frame_jpeg('../video0'))
self.now += 4
self.assertTrue(all(not item['available'] for item in self.node.camera_sources()))
self.assertIsNone(self.node.camera_frame_jpeg('video0'))
self.topics = self.topics[:1]
self.node._discover_cameras()
self.assertNotIn('video2', self.node._camera_frames)
def test_single_camera_compatibility_and_non_jpeg(self):
self.node._on_camera_image(SimpleNamespace(data=b'jpeg', format='jpeg'))
self.assertEqual(self.node.camera_frame_jpeg(), b'jpeg')
self.assertEqual(self.node.camera_frame_jpeg('legacy'), b'jpeg')
self.node._on_camera_image(SimpleNamespace(data=b'png', format='png'))
self.assertIsNone(self.node.camera_frame_jpeg('legacy'))
self.assertFalse(self.node.camera_sources()[0]['available'])
if __name__ == '__main__':
unittest.main()
+85
View File
@@ -0,0 +1,85 @@
"""Regenerate font coverage after replacing the bundled NanumSquare WOFF2 files.
Requires fonttools[woff]. Normal app builds use the checked-in CSS, not Python.
Run: python3 scripts/generate-nanumsquare-css.py [--check]
"""
import argparse
import hashlib
import unicodedata
from pathlib import Path
from fontTools.pens.boundsPen import BoundsPen
from fontTools.ttLib import TTFont
ROOT = Path(__file__).resolve().parents[1]
FONTS = ROOT / "src/renderer/public/assets/fonts"
OUTPUT = ROOT / "src/renderer/src/styles/nanumSquare.css"
FACES = [(300, "NanumSquareL"), (400, "NanumSquare"), (700, "NanumSquareB"), (800, "NanumSquareEB")]
# Hangul fillers are intentionally invisible, like whitespace and controls.
FILLERS = {0x115F, 0x1160, 0x3164, 0xFFA0}
def coverage(font):
glyphs = font.getGlyphSet()
supported = []
for code, name in sorted(font.getBestCmap().items()):
pen = BoundsPen(glyphs)
glyphs[name].draw(pen)
intentional = chr(code).isspace() or unicodedata.category(chr(code)) in {"Cc", "Cf"} or code in FILLERS
if pen.bounds is not None or intentional:
supported.append(code)
return supported
def unicode_ranges(codes):
ranges = []
start = end = codes[0]
for code in codes[1:]:
if code == end + 1:
end = code
else:
ranges.append((start, end))
start = end = code
ranges.append((start, end))
return [f"U+{start:X}" if start == end else f"U+{start:X}-{end:X}" for start, end in ranges]
def generate():
sections = [
"/* Generated by scripts/generate-nanumsquare-css.py. Do not edit manually.\n"
" * Empty glyphs are excluded so the existing system-font fallback can render them.\n"
" * Font binaries and visible glyphs are unchanged.\n */"
]
for weight, name in FACES:
file = FONTS / f"{name}.woff2"
with TTFont(file) as font:
ranges = unicode_ranges(coverage(font))
lines = [", ".join(ranges[index:index + 10]) for index in range(0, len(ranges), 10)]
sections.append(
f"/* {file.name} sha256: {hashlib.sha256(file.read_bytes()).hexdigest()} */\n"
"@font-face {\n"
" font-family: 'NanumSquare';\n"
f" src: url('/assets/fonts/{file.name}') format('woff2');\n"
f" font-weight: {weight};\n"
" font-style: normal;\n"
" font-display: swap;\n"
" unicode-range:\n " + ",\n ".join(lines) + ";\n}"
)
return "\n\n".join(sections) + "\n"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
generated = generate()
if args.check:
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated:
raise SystemExit("NanumSquare coverage CSS is stale; regenerate it")
print("NanumSquare coverage matches all four font files")
else:
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text(generated, encoding="utf-8")
print(f"Generated {OUTPUT.relative_to(ROOT)}")
+228
View File
@@ -0,0 +1,228 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { createHash, webcrypto } = require('node:crypto')
function compile(source, globals = {}, requireModule = require) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
exports, require: requireModule, Error, crypto: webcrypto, TextEncoder, window: { setTimeout: callback => setTimeout(callback, 1) }, ...globals
})
return exports
}
const tools = compile(fs.readFileSync(require.resolve('../src/shared/agentTools.ts'), 'utf8'))
const navigation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentCodeNavigation.ts'), 'utf8'), {}, name => name.endsWith('shared/agentTools') ? tools : require(name))
const content = 'def configure():\n speed = 0.4\n return speed\n'
const hash = value => createHash('sha256').update(value).digest('hex')
const reference = { id: 'location', moduleName: 'Control', path: 'src/node.py', startLine: 2, endLine: 3, reason: 'speed config', snippet: ' speed = 0.4\n return speed', expectedHash: hash(content) }
test('unchanged source reveals the verified line range, while unsaved insertions relocate it', async () => {
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content, reference))), { startLine: 2, endLine: 3 })
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange('# unsaved\n' + content, reference))), { startLine: 3, endLine: 4 })
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content.replace(/\n/g, '\r\n'), reference))), { startLine: 2, endLine: 3 })
})
test('rejects missing, ambiguous or altered code rather than highlighting a guessed line', async () => {
for (const changed of [content.replace('0.4', '0.8'), `${content}\n${content}`]) await assert.rejects(navigation.resolveCodeReferenceRange(changed, reference), /위치를 확정/)
for (const patch of [{ expectedHash: undefined }, { snippet: '' }, { snippet: '[REDACTED]' }, { snippet: 'one line' }, { path: '../etc/passwd' }, { path: '.env' }, { startLine: 0 }, { endLine: 500 }]) {
await assert.rejects(navigation.resolveCodeReferenceRange(content, { ...reference, ...patch }))
}
})
function editorHarness(text = content) {
const calls = []
const collection = { clear: () => calls.push('clear') }
const model = { uri: { path: '/modules/Control/src/node.py' }, getValue: () => text, isDisposed: () => false, getLineMaxColumn: line => text.split('\n')[line - 1].length + 1 }
const editor = { getModel: () => model, getDomNode: () => ({ clientWidth: 800, clientHeight: 600 }), layout: () => calls.push('layout'), createDecorationsCollection: value => { calls.push(['decorate', value]); return collection }, revealRangeInCenter: value => calls.push(['reveal', value]), setPosition: value => calls.push(['position', value]), focus: () => calls.push('focus') }
const options = { file: 'modules/Control/src/node.py', reference, isCurrent: () => true, getEditor: () => editor, getActiveFile: () => 'modules/Control/src/node.py', isReady: () => true, attempts: 4 }
return { model, editor, options, calls, collection }
}
test('focuses and highlights only after the correct Monaco model has loaded', async () => {
const h = editorHarness()
let reads = 0
h.options.getEditor = () => ++reads < 3 ? null : h.editor
assert.equal(await navigation.revealAgentCodeReference(h.options), h.collection)
assert.deepEqual(h.calls.map(call => Array.isArray(call) ? call[0] : call), ['layout', 'decorate', 'position', 'reveal', 'focus'])
const selection = h.calls.find(call => call[0] === 'reveal')[1]
assert.equal(selection.startLineNumber, 2)
assert.equal(selection.endLineNumber, 3)
assert.equal(h.calls[1][1][0].options.isWholeLine, true)
})
test('does not decorate another file, a hidden/diff editor, stale scope or unready model', async () => {
for (const setup of [
h => { h.model.uri.path = '/modules/Other/src/node.py' },
h => { h.options.getActiveFile = () => 'other.py' },
h => { h.options.isReady = () => false },
h => { h.options.isCurrent = () => false },
h => { h.editor.getDomNode = () => ({ clientWidth: 0, clientHeight: 0 }) },
h => { h.model.isDisposed = () => true }
]) {
const h = editorHarness()
setup(h)
await assert.rejects(navigation.revealAgentCodeReference(h.options))
assert.equal(h.calls.length, 0)
}
})
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(hookSource) === 'openCodeReference')
function consentHarness() {
const session = {}
const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
const globals = { session, messageStateRef, navigatingRef: { current: false }, applyingRef: { current: false }, abortControllerRef: { current: null }, updateMessages: update => { messageStateRef.current.messages = update(messageStateRef.current.messages) } }
return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] }
}
test('code navigation opens only when the user selects View in editor', async () => {
const h = consentHarness()
let opened = 0
const open = async () => { opened++ }
assert.equal(opened, 0)
await h.openCodeReference('answer', 'location', open)
assert.equal(opened, 1)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('legacy cancelled references can be opened with the same read-only navigation action', async () => {
const h = consentHarness()
h.message().codeReferenceStates = { location: { status: 'cancelled' } }
let opened = 0
await h.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
assert.equal(opened, 1)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('code navigation does not block or abort a pending model response', async () => {
const h = consentHarness()
const controller = new AbortController()
h.globals.abortControllerRef.current = controller
let opened = 0
await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => {
assert.equal(isCurrent(), true)
opened++
})
assert.equal(opened, 1)
assert.equal(controller.signal.aborted, false)
assert.equal(h.globals.abortControllerRef.current, controller)
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
})
test('an arriving model response does not cancel an in-flight code navigation', async () => {
const h = consentHarness()
h.globals.abortControllerRef.current = new AbortController()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise(resolve => { finish = resolve })
})
h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' })
h.globals.abortControllerRef.current = null
assert.equal(current(), true)
finish()
await pending
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened')
})
test('starting file application invalidates a navigation that is still reading', async () => {
const h = consentHarness()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) })
})
h.globals.applyingRef.current = true
assert.equal(current(), false)
finish()
await pending
assert.equal(h.message().codeReferenceStates.location.status, 'error')
})
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
const h = consentHarness()
let finish, opened = 0
const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
await h.openCodeReference('answer', 'location', async () => { opened++ })
finish()
await first
assert.equal(opened, 1)
await h.openCodeReference('answer', 'invented', async () => { opened++ })
h.globals.applyingRef.current = true
await h.openCodeReference('answer', 'location', async () => { opened++ })
assert.equal(opened, 1)
})
test('errors remain visible and late navigation cannot update another conversation', async () => {
const h = consentHarness()
await h.openCodeReference('answer', 'location', async () => { throw Error('missing file') })
assert.equal(h.message().codeReferenceStates.location.status, 'error')
assert.equal(h.message().codeReferenceStates.location.error, 'missing file')
let finish
const pending = h.openCodeReference('answer', 'location', () => new Promise(resolve => { finish = resolve }))
const other = { id: 'other' }
h.globals.messageStateRef.current = { session: {}, messages: [other] }
finish()
await pending
assert.equal(h.message(), other)
})
test('a new question cancels pending navigation without leaving its old state stuck opening', async () => {
const h = consentHarness()
let finish, current
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
current = isCurrent
return new Promise((_resolve, reject) => { finish = () => reject(Error('cancelled')) })
})
h.globals.messageStateRef.current.messages.push({ id: 'new-question', role: 'user' })
assert.equal(current(), false)
finish()
await pending
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'error')
assert.equal(h.globals.messageStateRef.current.messages.at(-1).id, 'new-question')
})
test('restored interrupted navigation becomes retryable without reopening any file', () => {
const evidence = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {}, name => name === './agentEvidence' ? evidence : require(name))
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
const restored = recoverInterruptedAgentMessages([message])[0]
assert.equal(restored.codeReferenceStates.pending.status, 'error')
assert.equal(restored.codeReferenceStates.approved.status, 'opened')
assert.equal(restored.codeReferenceStates.declined.status, 'cancelled')
assert.equal(message.codeReferenceStates.pending.status, 'opening')
})
function elements(tree, predicate) {
if (!React.isValidElement(tree)) return []
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
}
function text(tree) {
return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : ''
}
const render = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentCodeReferences.tsx'), 'utf8'), {}, name => name.endsWith('/i18n') ? { t: key => key, useLocaleRender() {} } : require(name)).AgentCodeReferences
for (const dark of [true, false]) test(`code navigation has only a vertically aligned View in editor button (${dark})`, () => {
const clicked = []
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`) }
const buttons = elements(render(props), element => element.type === 'button')
assert.equal(clicked.length, 0)
assert.deepEqual(buttons.map(text), ['코드에서 보기'])
const label = elements(buttons[0], element => element.type === 'span')[0]
assert.match(buttons[0].props.className, /items-center.*leading-none/)
assert.match(label.props.className, /top-px leading-none/)
buttons[0].props.onClick()
assert.deepEqual(clicked, ['open:location'])
for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true)
for (const status of ['cancelled', 'opened', 'error']) {
assert.deepEqual(elements(render({ ...props, states: { location: { status } } }), element => element.type === 'button').map(text), ['코드에서 보기'])
}
const opening = elements(render({ ...props, states: { location: { status: 'opening' } } }), element => element.type === 'button')[0]
assert.equal(opening.props.disabled, true)
assert.equal(text(opening), '파일 여는 중...')
assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
})
+371
View File
@@ -0,0 +1,371 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const output = {}
const evidenceApi = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: evidenceApi })
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const { resolveAgentContext } = output
const plain = value => JSON.parse(JSON.stringify(value))
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
const response = (requestedModules = [], requestedLogs = []) => ({
provider: 'QWEN', model: 'test', usage: { totalTokens: 10 },
plan: { summary: 'result', explanation: 'analysis', requestedModules, requestedLogs, actionPlan: [], commands: [], changes: [], createModule: null }
})
const options = overrides => ({
initialContext: { modules: [moduleInfo('MKMini'), moduleInfo('Control')], runtimeLogs: [] },
signal: new AbortController().signal,
...overrides
})
test('continues MKMini -> Control -> final analysis and retains earlier source files', async () => {
const inputs = []
const loads = []
const result = await resolveAgentContext(options({
requestPlan: async context => {
inputs.push(plain(context))
return [response(['MKMini']), response(['Control']), response()][inputs.length - 1]
},
loadRequestedContext: async request => {
loads.push(plain(request))
return { modules: ['MKMini', 'Control'].map(name => moduleInfo(name, request.modules.includes(name) ? [{ path: `${name}.py`, content: `${name} source` }] : [])), runtimeLogs: [] }
}
}))
assert.equal(inputs.length, 3)
assert.deepEqual(loads, [{ modules: ['MKMini'], logs: [] }, { modules: ['Control'], logs: [] }])
assert.equal(inputs[2].modules[0].files[0].content, 'MKMini source')
assert.equal(inputs[2].modules[1].files[0].content, 'Control source')
assert.deepEqual(plain(result.plan.requestedModules), [])
assert.equal(result.usage.totalTokens, 30)
})
test('keeps logs while loading a later source module', async () => {
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
calls += 1
if (calls === 3) assert.equal(context.runtimeLogs[0].content, 'error log')
return [response([], ['Control']), response(['Control']), response()][calls - 1]
},
loadRequestedContext: async request => ({
modules: [moduleInfo('Control', request.modules.length ? [{ path: 'node.py', content: 'code' }] : [])],
runtimeLogs: request.logs.length ? [{ moduleName: 'Control', state: 'running', content: 'error log' }] : []
})
}))
assert.equal(calls, 3)
})
test('makes one analysis-only completion when the model repeats a context request', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async (context, finalize) => {
calls++
if (!finalize) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').files[0].path, 'controller.py')
return { ...response(), plan: { ...response().plan, explanation: 'controller.py에서 조향 설정을 확인했습니다.' } }
},
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'steering = 1' }])], runtimeLogs: [] } }
}))
assert.equal(loads, 1)
assert.equal(calls, 3)
assert.match(result.plan.explanation, /조향 설정을 확인/)
assert.equal(result.usage.totalTokens, 30)
})
test('finishes honestly without an infinite loop if the model ignores the finalization request', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async () => { calls++; return response(['Control']) },
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
}))
assert.equal(loads, 1)
assert.equal(calls, 3)
assert.deepEqual(plain(result.plan.requestedModules), [])
assert.deepEqual(plain(result.plan.actionPlan), [])
assert.match(result.plan.explanation, /결론을 확정하지 못했습니다/)
assert.match(result.plan.explanation, /Control: 제공된 파일 0개/)
assert.equal(result.plan.createModule, null)
})
test('uses already provided source files without loading the same module again', async () => {
let loads = 0
let calls = 0
const result = await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'steering = 1' }])], runtimeLogs: [] },
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) },
loadRequestedContext: async () => { loads++; return { modules: [], runtimeLogs: [] } }
}))
assert.equal(loads, 0)
assert.equal(calls, 2)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('recovery cannot turn repeated source requests into executable commands or edits', async () => {
const result = await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'source' }])], runtimeLogs: [] },
requestPlan: async (_, finalize) => finalize ? {
...response(),
plan: {
...response().plan, actionPlan: ['execute'], commands: [{ id: 'run', command: './run.sh' }],
changes: [{ moduleName: 'Control', path: 'control.py', content: 'changed' }], createModule: { name: 'new' }
}
} : response(['Control']),
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}))
for (const field of ['commands', 'changes', 'actionPlan']) assert.deepEqual(plain(result.plan[field]), [])
assert.equal(result.plan.createModule, null)
})
test('does not publish a finalization response received after cancellation', async () => {
const controller = new AbortController()
let calls = 0
await assert.rejects(resolveAgentContext(options({
signal: controller.signal,
requestPlan: async (_, finalize) => {
calls++
if (finalize) controller.abort()
return finalize ? response() : response(['Control'])
}
})), { name: 'AbortError' })
assert.equal(calls, 2)
})
test('requests only new resources when the model also repeats an earlier module', async () => {
let count = 0
const loads = []
await resolveAgentContext(options({
requestPlan: async () => [response(['MKMini']), response(['MKMini', 'Control']), response()][count++],
loadRequestedContext: async request => { loads.push(plain(request)); return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
}))
assert.deepEqual(loads[1], { modules: ['Control'], logs: [] })
})
test('does not start another API call after cancellation during context loading', async () => {
const controller = new AbortController()
let calls = 0
await assert.rejects(resolveAgentContext(options({
signal: controller.signal,
requestPlan: async () => { calls += 1; return response(['Control']) },
loadRequestedContext: async () => { controller.abort(); return { modules: [], runtimeLogs: [] } }
})), { name: 'AbortError' })
assert.equal(calls, 1)
})
test('bounds the number of distinct automatic context requests', async () => {
let calls = 0
let loads = 0
const result = await resolveAgentContext(options({
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response([`Module${calls}`]) },
loadRequestedContext: async request => { loads++; return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
}))
assert.equal(calls, 7)
assert.equal(loads, 5)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('passes file-read failures to the model for a concrete final explanation', async () => {
let count = 0
await resolveAgentContext(options({
requestPlan: async context => {
if (count++ === 0) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').fileContext.status, 'error')
return response()
},
loadRequestedContext: async () => ({ modules: [{ ...moduleInfo('Control'), fileContext: { status: 'error', message: 'permission denied' } }], runtimeLogs: [] })
}))
})
test('excludes oversized source files with an explicit notice and continues with complete smaller files', async () => {
let calls = 0
const smallFile = { path: 'config/steering.yaml', content: 'kinematics: ackermann' }
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
const control = context.modules.find(module => module.name === 'Control')
assert.deepEqual(plain(control.files), [smallFile])
assert.equal(control.fileContext.status, 'limited')
assert.match(control.fileContext.message, /전체 2개 중 1개/)
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }, smallFile])], runtimeLogs: [] })
}))
assert.equal(calls, 2)
})
test('requests a final answer rather than leaving a context-only response when no loader is available', async () => {
let calls = 0
const result = await resolveAgentContext(options({
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) }
}))
assert.equal(calls, 2)
assert.deepEqual(plain(result.plan.requestedModules), [])
})
test('keeps a just-started module running when a stale collector supplies source files', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [{ ...moduleInfo('Control'), state: 'running' }], runtimeLogs: [] },
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.equal(context.modules[0].state, 'running')
assert.equal(context.modules[0].files[0].content, 'code')
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
}))
})
const files = (name, count, size = 10) => Array.from({ length: count }, (_, index) => ({ path: `${name}-${index}.py`, content: String(index).padEnd(size, 'x') }))
const sourceSize = modules => modules.reduce((sum, module) => sum + module.files.reduce((total, file) => total + file.path.length + file.content.length, 0), 0)
test('balances 24 old and 24 new files instead of failing the second module lookup', async () => {
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
calls++
if (calls === 1) return response(['MKMini'])
if (calls === 2) return response(['Control'])
assert.equal(context.modules.reduce((sum, module) => sum + module.files.length, 0), 24)
for (const name of ['MKMini', 'Control']) {
const module = context.modules.find(module => module.name === name)
assert.equal(module.files.length, 12)
assert.equal(module.fileContext.status, 'limited')
assert.match(module.fileContext.message, /전체 24개 중 12개/)
}
return response()
},
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name, files(name, 24))), runtimeLogs: [] })
}))
assert.equal(calls, 3)
})
test('enforces the combined character budget without slicing source file contents', async () => {
let calls = 0
const contents = new Set(['a'.repeat(60_000), 'b'.repeat(60_000)])
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.ok(sourceSize(context.modules) <= 160_000)
for (const module of context.modules) {
assert.equal(module.files.length, 1)
assert.ok(contents.has(module.files[0].content))
assert.equal(module.fileContext.status, 'limited')
}
return response()
},
loadRequestedContext: async () => ({ modules: ['MKMini', 'Control'].map((name, index) => moduleInfo(name, [
{ path: 'one.py', content: [...contents][index] },
{ path: 'two.py', content: [...contents][index] }
])), runtimeLogs: [] }),
initialContext: { modules: [moduleInfo('MKMini', [{ path: 'one.py', content: [...contents][0] }, { path: 'two.py', content: [...contents][0] }]), moduleInfo('Control')], runtimeLogs: [] }
}))
})
test('prioritizes newly requested source when earlier context nearly fills the character budget', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [moduleInfo('MKMini', files('old', 2, 70_000)), moduleInfo('Control')], runtimeLogs: [] },
requestPlan: async context => {
if (calls++ === 0) return response(['Control'])
assert.equal(context.modules.find(module => module.name === 'Control').files[0].content, 'new'.repeat(20_000))
assert.ok(context.modules.find(module => module.name === 'MKMini').files.length > 0)
assert.ok(sourceSize(context.modules) <= 160_000)
return response()
},
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'new'.repeat(20_000) }])], runtimeLogs: [] })
}))
})
test('budgets initial source context before sending the first API request', async () => {
const initialContext = { modules: [moduleInfo('Control', files('source', 30))], runtimeLogs: [] }
await resolveAgentContext(options({
initialContext,
requestPlan: async context => {
assert.equal(context.modules[0].files.length, 24)
assert.equal(context.modules[0].fileContext.status, 'limited')
return response()
}
}))
assert.equal(initialContext.modules[0].files.length, 30)
assert.equal(initialContext.modules[0].fileContext, undefined)
})
test('rotates older logs out with a notice rather than failing on a fourth module', async () => {
let calls = 0
await resolveAgentContext(options({
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${moduleName} log` })) },
requestPlan: async context => {
if (calls++ === 0) return response([], ['D'])
assert.deepEqual(plain(context.runtimeLogs.map(log => log.moduleName)), ['D', 'C', 'B'])
assert.match(context.runtimeLogs[0].message, /이전 로그 제외: A/)
return response()
},
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [{ moduleName: 'D', state: 'running', content: 'latest error' }] })
}))
})
test('counts log messages in the server budget and keeps recent log content', async () => {
await resolveAgentContext(options({
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${'x'.repeat(20_000)}LATEST`, message: 'm'.repeat(500) })) },
requestPlan: async context => {
assert.equal(context.runtimeLogs.length, 3)
assert.ok(context.runtimeLogs.reduce((sum, log) => sum + log.content.length + (log.message?.length ?? 0), 0) <= 60_000)
for (const log of context.runtimeLogs) {
assert.ok(log.content.endsWith('LATEST'))
assert.match(log.message, /최근 구간/)
assert.ok(log.message.length <= 500)
}
return response()
}
}))
})
test('collects candidates from every requested module and prioritizes configuration before large source trees', async () => {
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let collector
function visit(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'collectAgentWorkspace') collector = node.initializer.getText(source)
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(collector)
const api = {}
vm.runInNewContext(ts.transpileModule(`exports.collect = ${collector}`, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, {
exports: api,
availableModules: ['MKMini', 'Control'].map(name => ({ name, state: 'stopped' })),
remoteWorkspace: false,
clonePathPreview: '/workspace',
currentActiveEditorFile: null,
editorFiles: {},
window: { localWorkspace: { moduleFiles: { read: async name => ({ files: Object.fromEntries([
...files(name, 30).map((file, index) => [`src/node${index}.py`, file.content]),
['config/steering.yaml', 'kinematics: ackermann']
]) }) } } }
})
const modules = await api.collect(['MKMini', 'Control'])
assert.deepEqual(plain(modules.map(module => module.files.length)), [24, 24])
for (const module of modules) assert.equal(module.files[0].path, 'config/steering.yaml')
let calls = 0
await resolveAgentContext(options({
requestPlan: async context => {
if (calls++ === 0) return response(['MKMini', 'Control'])
for (const module of context.modules) {
assert.equal(module.files.length, 12)
assert.equal(module.files[0].path, 'config/steering.yaml')
}
return response()
},
loadRequestedContext: async request => ({ modules: await api.collect(request.modules), runtimeLogs: [] })
}))
})
+105
View File
@@ -0,0 +1,105 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: api })
const { agentConversationWorkspace: scope, buildAgentConversationContext: build } = api
const now = Date.now()
const local = scope('local', 'local-host', '/workspace/a')
const plain = value => JSON.parse(JSON.stringify(value))
const turn = (n, workspace = local, createdAt = now) => [
{ id: `u${n}`, role: 'user', content: `question-${n}`, conversationScope: { workspace, createdAt } },
{ id: `a${n}`, role: 'assistant', content: `summary-${n}`, conversationScope: { workspace, createdAt }, plan: {
summary: `summary-${n}`, explanation: `answer-${n}`, conversationSummary: `누적 기억-${n}`, changes: [], commands: [], requestedModules: [], requestedLogs: [], createModule: null
} }
]
test('uses the latest cumulative summary with only two recent exchanges and no duplicate current question', () => {
const messages = Array.from({ length: 20 }, (_, i) => turn(i)).flat()
messages.push({ ...turn(20)[0], content: 'pending current question' })
const context = build(messages, local, now)
assert.deepEqual(plain(context.recentMessages).map(item => item.content), ['question-18', 'summary-18\nanswer-18', 'question-19', 'summary-19\nanswer-19'])
assert.equal(context.summary, '누적 기억-19')
assert.doesNotMatch(JSON.stringify(context), /question-17|pending current question/)
})
test('hard caps payload text even with huge original answers and requests', () => {
const messages = Array.from({ length: 10 }, (_, i) => {
const pair = turn(i)
pair[0].content = 'q'.repeat(4000)
pair[1].plan.summary = 's'.repeat(500)
pair[1].plan.explanation = 'e'.repeat(12000)
pair[1].plan.conversationSummary = 'm'.repeat(2000)
return pair
}).flat()
const context = build(messages, local, now)
assert.ok(context.summary.length <= 1000)
assert.equal(context.recentMessages.length, 4)
assert.ok(context.recentMessages.every(message => message.content.length <= 700))
assert.ok(context.summary.length + context.recentMessages.reduce((sum, item) => sum + item.content.length, 0) <= 3800)
})
test('does not mix hosts, roots, SSH identities, unscoped legacy messages or expired history', () => {
for (const other of [scope('remote', 'ssh:a', '/workspace/a'), scope('local', 'local-host', '/workspace/b'), scope('remote', 'ssh:b', '/workspace/a')]) {
assert.equal(build(turn(1, other), local, now), undefined)
}
assert.equal(build(turn(1, local, now - 86400000), local, now), undefined)
assert.equal(build(turn(1, local, now + 1), local, now), undefined)
assert.equal(build(turn(1).map(({ conversationScope, ...message }) => message), local, now), undefined)
assert.equal(scope('local', 'local-host'), undefined)
assert.equal(build(turn(1), undefined, now), undefined)
assert.equal(build([], local, now), undefined)
})
test('a missing or empty generated summary preserves earlier valid memory without an extra model call', () => {
const first = turn(1), second = turn(2)
delete second[1].plan.conversationSummary
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
second[1].plan.conversationSummary = ''
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
delete first[1].plan.conversationSummary
assert.equal(build([...first, ...second], local, now).summary, '')
second[1].plan.conversationSummary = '새 누적 요약'
assert.equal(build([...first, ...second], local, now).summary, '새 누적 요약')
})
test('keeps file identities and action outcomes, but excludes payloads and tool evidence', () => {
const pair = turn(1)
pair[1].plan.explanation = '설명\n```python\nPRIVATE_SOURCE\n```\n끝'
pair[1].plan.changes = [{ moduleName: 'Control', path: 'config.yaml', content: 'PRIVATE_CHANGE' }]
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build', command: 'PRIVATE_COMMAND' }]
pair[1].plan.evidence = [{ data: 'PRIVATE_LOG' }]
pair[1].plan.codeReferences = [{ moduleName: 'Control', path: 'node.py', startLine: 20, endLine: 23, snippet: 'PRIVATE_SNIPPET' }]
pair[1].commandExecutions = { build: { status: 'completed', output: 'PRIVATE_OUTPUT' } }
pair[1].applyState = 'cancelled'
const context = JSON.stringify(build(pair, local, now))
assert.match(context, /Control\/node.py:20-23/)
assert.match(context, /file change: cancelled/)
assert.match(context, /build: completed/)
assert.doesNotMatch(context, /PRIVATE_/)
})
test('ignores errors, interrupted turns and initial lookup responses', () => {
const pair = turn(1)
const error = { ...pair[1], plan: undefined, content: 'network error' }
assert.equal(build([pair[0], error], local, now), undefined)
pair[1].plan.toolRequests = [{ name: 'files.read' }]
assert.equal(build(pair, local, now), undefined)
assert.equal(build([turn(1)[0], ...turn(2, scope('remote', 'ssh:x', '/workspace'))], local, now), undefined)
})
test('rebuilds from persisted metadata and reflects updated execution outcomes without new memory storage', () => {
const pair = turn(1)
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build' }]
assert.match(JSON.stringify(build(plain(pair), local, now)), /pending/)
pair[1].commandExecutions = { build: { status: 'failed' } }
assert.match(JSON.stringify(build(plain(pair), local, now)), /failed/)
const followUp = { ...pair[1], content: 'result', plan: { ...pair[1].plan, summary: '실행 결과 분석', explanation: '실패했습니다.' } }
assert.match(JSON.stringify(build([...pair, followUp], local, now)), /실행 결과 분석/)
assert.equal(build([], local, now), undefined)
})
+31
View File
@@ -0,0 +1,31 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceEditorPane.tsx'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText, {
exports: api,
require: id => id === '../../i18n' ? { t: value => value, useLocaleRender() {} }
: id === '@monaco-editor/react' ? { __esModule: true, default: 'Editor', DiffEditor: 'DiffEditor' }
: id === 'lucide-react' ? {} : require(id)
})
const props = { dark: true, textDim: '', editorPaneRef: { current: null }, currentActiveEditorFile: 'config/control.yaml', activeFileIsImage: false, editorLanguage: 'yaml', editorValue: 'steering: ackermann', fontSize: 12, tabSize: 4, minimapEnabled: false, exampleActive: false, beforeMount() {}, onMount() {}, onChange() {} }
for (const locked of [false, true]) {
test(`the source editor is read-only only while an agent file batch is applying (locked=${locked})`, () => {
const tree = api.default({ ...props, editingLocked: locked })
assert.equal(tree.props.children.type, 'Editor')
assert.equal(tree.props.children.props.options.readOnly, locked)
})
}
test('agent diff previews remain read-only and set tab width through text models', () => {
const tree = api.default({ ...props, diffPreview: { id: 'diff', originalContent: 'old', modifiedContent: 'new' } })
const diff = tree.props.children
assert.equal(diff.type, 'DiffEditor')
assert.equal(diff.props.options.readOnly, true)
assert.equal('tabSize' in diff.props.options, false)
const updates = []
const editor = { getModel: () => ({ updateOptions: options => updates.push(options.tabSize) }) }
diff.props.onMount({ getOriginalEditor: () => editor, getModifiedEditor: () => editor })
assert.deepEqual(updates, [4, 4])
})
+143
View File
@@ -0,0 +1,143 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ts = require('typescript')
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, filename)
const { compactAgentEvidence, missingAgentRead, readFileEvidence } = require('../src/renderer/src/features/workspace/agentEvidence.ts')
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
const file = (startLine, endLine, extra = {}, result = {}) => ({
id: `read-${startLine}-${endLine}`, name: 'files.read', moduleName: 'Control', target: 'ssh:jetson', status: 'ok', observedAt: '2026-10-20T00:00:00.000Z', truncated: true,
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), content: Array.from({ length: endLine - startLine + 1 }, (_, i) => `line${startLine + i}`).join('\n'),
startLine, endLine, totalLines: 100, complete: false, contentTruncated: false, ...extra }), ...result
})
const read = (startLine, lineCount, path = 'config/car.yaml') => ({ id: 'read', name: 'files.read', moduleName: 'Control', args: { path, ...(startLine === undefined ? {} : { startLine }), ...(lineCount === undefined ? {} : { lineCount }) } })
const answer = toolRequests => ({ provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'verified code', toolRequests, changes: [], commands: [], actionPlan: [], createModule: null } })
test('overlapping and adjacent ranges merge without promoting partial evidence to editable full files', () => {
const results = compactAgentEvidence([file(1, 10), file(8, 15), file(16, 20)])
assert.equal(results.length, 1)
const data = readFileEvidence(results[0])
assert.equal(data.startLine, 1)
assert.equal(data.endLine, 20)
assert.equal(data.content.split('\n').length, 20)
assert.equal(data.complete, false)
assert.equal(results[0].truncated, true)
assert.equal(missingAgentRead(read(3, 16), results), undefined)
assert.deepEqual(missingAgentRead(read(), results), read())
})
test('queries only the first missing range and never joins disjoint or conflicting content', () => {
const results = compactAgentEvidence([file(1, 5), file(10, 15)])
assert.equal(results.length, 2)
assert.deepEqual(missingAgentRead(read(3, 15), results).args, { path: 'config/car.yaml', startLine: 6, lineCount: 4 })
assert.equal(compactAgentEvidence([file(1, 10), file(8, 15, { content: 'different\ncontent' })]).length, 2)
})
test('host boundaries and file revisions cannot be combined or used to fill each other’s gaps', () => {
const changed = compactAgentEvidence([file(1, 10), file(11, 20, { hash: 'b'.repeat(64) })])
assert.equal(changed.length, 1)
assert.equal(missingAgentRead(read(1, 20), changed).args.startLine, 1)
const hosts = compactAgentEvidence([file(1, 10, {}, { target: 'local-host' }), file(11, 20)])
assert.equal(hosts.length, 2)
assert.equal(missingAgentRead(read(1, 20), hosts).args.startLine, 1)
})
test('full and missing observations are reused but partial character fragments are never coverage', () => {
const full = file(1, 10, { complete: true }, { truncated: false })
assert.equal(missingAgentRead(read(5, 100), [full]), undefined)
assert.equal(compactAgentEvidence([full, file(5, 8)]).length, 1)
const missing = file(1, 1, { exists: false, content: '', hash: null, complete: true }, { truncated: false })
assert.equal(missingAgentRead(read(), [missing]), undefined)
const cut = file(1, 2, { content: 'whole\ncut', endLine: 1, contentTruncated: true })
assert.equal(missingAgentRead(read(2, 1), [cut]).args.startLine, 2)
const legacy = file(1, 2, { endLine: undefined, contentTruncated: undefined })
assert.equal(missingAgentRead(read(2, 1), [legacy]).args.startLine, 2)
})
test('malformed or oversized evidence never becomes reusable complete coverage', () => {
assert.equal(readFileEvidence(file(1, 10, { hash: 'not-a-hash' })), undefined)
assert.equal(readFileEvidence(file(1, 10, { endLine: 999 })), undefined)
const large = compactAgentEvidence([file(1, 1, { content: 'a'.repeat(30_000) }), file(2, 2, { content: 'b'.repeat(30_000) })])
assert.equal(large.length, 2)
})
test('overlap requests execute only missing lines and finish once the requested range is cached', async () => {
const executions = []
let calls = 0
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
executeTool: async tool => { executions.push(tool.args); return file(tool.args.startLine, tool.args.startLine + tool.args.lineCount - 1) },
requestPlan: async (context, final) => {
calls++
if (final) {
assert.equal(context.toolResults.length, 1)
assert.equal(JSON.parse(context.toolResults[0].data).endLine, 15)
return answer([])
}
return answer([[read(1, 10)], [read(5, 11)], [read(4, 10)]][calls - 1])
}
})
assert.deepEqual(executions, [read(1, 10).args, read(11, 5).args])
assert.equal(calls, 4)
})
test('source evidence survives many later searches and omissions are reported within DTO limits', async () => {
const searchResults = Array.from({ length: 15 }, (_, i) => ({ ...file(1, 1), id: `search-${i}`, name: 'files.search', data: JSON.stringify({ query: `speed${i}`, matches: [] }) }))
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10), ...searchResults] }, signal: new AbortController().signal,
requestPlan: async context => {
assert.equal(context.toolResults.length, 12)
assert.equal(context.toolResults[0].name, 'files.read')
assert.match(context.contextNotes[0], /4개/)
assert.ok(context.contextNotes.length <= 12)
assert.ok(context.contextNotes.every(note => note.length <= 500))
return answer([])
}
})
})
test('a cached file omitted by budgets is restored when the model requests it again', async () => {
let calls = 0
const results = Array.from({ length: 13 }, (_, i) => file(1, 10, { path: `file${i}.yaml` }))
await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: results }, signal: new AbortController().signal,
executeTool: async () => { throw Error('cached file must not be queried again') },
requestPlan: async (context, final) => {
calls++
if (!final) return answer([read(1, 5, 'file0.yaml')])
assert.ok(context.toolResults.some(result => JSON.parse(result.data).path === 'file0.yaml'))
return answer([])
}
})
assert.equal(calls, 2)
})
test('finalization retains verified code locations while stripping all proposed mutations', async () => {
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
executeTool: async () => { throw Error('already cached') },
requestPlan: async (_, final) => ({ ...answer([read(1, 5)]), plan: { ...answer([read(1, 5)]).plan, ...(final ? { changes: [{ path: 'unsafe' }], commands: [{ command: 'unsafe' }] } : { codeReferences: [reference] }) } })
})
assert.equal(result.plan.codeReferences.length, 1)
assert.match(result.plan.explanation, /verified code/)
assert.deepEqual(result.plan.changes, [])
assert.deepEqual(result.plan.commands, [])
})
test('recovery never resurrects code locations after a different file revision was observed', async () => {
let calls = 0
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
executeTool: async () => file(11, 20, { hash: 'b'.repeat(64) }),
requestPlan: async (_, final) => {
calls++
return !final && calls === 1 ? { ...answer([read(11, 10)]), plan: { ...answer([read(11, 10)]).plan, codeReferences: [reference] } } : answer([read(11, 10)])
}
})
assert.deepEqual(result.plan.codeReferences, [])
assert.match(result.plan.summary, /확정하지 못했습니다/)
})
+99
View File
@@ -0,0 +1,99 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
function compile(path, overrides = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(path), 'utf8')
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText,
{ exports, document: { body: {} }, require: name => overrides[name] ?? require(name) })
return exports
}
const models = compile('../src/renderer/src/features/workspace/agentModels.ts')
const options = [{ provider: 'OPENAI', model: 'gpt-6.1-sol', imagesEnabled: false }, { provider: 'QWEN', model: 'qwen3.8-flash', imagesEnabled: true }]
const plain = value => JSON.parse(JSON.stringify(value))
test('normalizes enabled models, validates IDs and preserves old single-model servers', () => {
const status = models.normalizeAgentStatus({ enabled: true, models: [options[1], options[0], options[0], { provider: 'FORGED', model: 'bad' }, { provider: 'GEMINI', model: '' }] })
assert.deepEqual(plain(status.models), options)
assert.equal(status.selectionSupported, true)
assert.deepEqual(plain(models.normalizeAgentStatus({ enabled: true, ...options[1] }).models), [options[1]])
assert.equal(models.normalizeAgentStatus({ enabled: true, ...options[1] }).selectionSupported, false)
assert.equal(models.normalizeAgentStatus({ enabled: false, models: options }).models.length, 0)
assert.equal(models.normalizeAgentStatus({ enabled: true, models: [] }).enabled, false)
})
test('selects the saved provider only while it is available, and never invents one', () => {
assert.equal(models.selectedAgentModel(options, 'QWEN'), options[1])
assert.equal(models.selectedAgentModel(options, 'ANTHROPIC'), options[0])
assert.equal(models.selectedAgentModel([options[0]], 'QWEN'), options[0])
assert.equal(models.selectedAgentModel([], 'QWEN'), null)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function selectorHarness(props) {
const state = [], refs = []
let stateIndex = 0, refIndex = 0
const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', {
'../i18n': { t: value => value }, '../features/workspace/agentModels': models,
'react-dom': { createPortal: child => child },
react: { ...React, useId: () => 'model-menu', useEffect() {}, useLayoutEffect() {},
useState(initial) { const index = stateIndex++; if (!(index in state)) state[index] = initial; return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] },
useRef(initial) { return refs[refIndex++] ?? (refs[refIndex - 1] = { current: initial }) }
}
})
return { render(patch = {}) { stateIndex = 0; refIndex = 0; return AgentModelSelector({ ...props, ...patch }) } }
}
for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => {
let selected
const h = selectorHarness({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } })
const tree = h.render()
const trigger = elements(tree, node => node.type === 'button')[0]
assert.equal(trigger.props['aria-haspopup'], 'menu')
assert.equal(trigger.props['aria-expanded'], false)
assert.equal(trigger.props.title, 'Qwen · qwen3.8-flash')
assert.equal(elements(tree, node => node.props.role === 'menu').length, 0)
assert.equal(elements(tree, node => node.type === 'select').length, 0)
trigger.props.onClick()
const menu = elements(h.render(), node => node.props.role === 'menu')[0]
assert.match(menu.props.className, dark ? /bg-\[#252526\]/ : /bg-white/)
assert.match(menu.props.className, /fixed/)
const items = elements(menu, node => node.props.role === 'menuitemradio')
assert.deepEqual(items.map(node => node.props.title), ['OpenAI · gpt-6.1-sol', 'Qwen · qwen3.8-flash'])
assert.deepEqual(items.map(node => node.props['aria-checked']), [false, true])
assert.ok(items.every(node => elements(node, item => item.type === 'span' && item.props.className.includes('truncate')).length === 1))
items[0].props.onClick()
assert.equal(selected, 'OPENAI')
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 0)
assert.ok(elements(h.render({ disabled: true }), node => node.type === 'button')[0].props.disabled)
const empty = elements(h.render({ models: [], provider: null }), node => node.type === 'button')[0]
assert.ok(empty.props.disabled)
assert.equal(empty.props.title, '사용 가능한 모델 없음')
})
test('model menu opens from arrow keys and closes when a request blocks the selector', () => {
const h = selectorHarness({ models: options, provider: 'QWEN', dark: true, disabled: false, onChange() {} })
const trigger = elements(h.render(), node => node.type === 'button')[0]
trigger.props.onKeyDown({ key: 'ArrowUp', preventDefault() {} })
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 1)
assert.equal(elements(h.render({ disabled: true }), node => node.props.role === 'menu').length, 0)
})
test('input footer places the model action before the attachment and clear controls', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
const row = source.slice(source.indexOf('{(attachmentsEnabled || onClearConversation || footerAction)'))
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'파일 첨부\')}'))
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'대화내역 삭제\')}'))
})
test('A-Copilot has a single managed label', () => {
assert.equal(models.agentModelLabel({ provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }), 'A-Copilot')
assert.equal(models.agentModelLabel(options[0]), 'OpenAI · gpt-6.1-sol')
})
+146
View File
@@ -0,0 +1,146 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
function compile(source, globals) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require, ...globals })
return exports
}
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
const proposal = (extra = {}) => ({
id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready',
plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null,
changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }],
commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] },
commandExecutions: { build: { status: 'pending' } }, ...extra
})
function harness(message) {
const session = {}
const messageStateRef = { current: { session, messages: [message] } }
const applyingRef = { current: false }
const abortControllerRef = { current: null }
const saved = []
const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), {
session, messageStateRef, applyingRef, abortControllerRef,
updateMessages(update) {
const next = update(messageStateRef.current.messages)
messageStateRef.current.messages = next
saved.push(JSON.parse(JSON.stringify(next)))
}
})
return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] }
}
test('cancel marks the proposal and unfinished commands without applying files', async () => {
const h = harness(proposal({ applyError: 'old error' }))
assert.equal(h.cancelPlan('proposal'), true)
assert.equal(h.current().applyState, 'cancelled')
assert.equal(h.current().applyError, undefined)
assert.equal(h.current().commandExecutions.build.status, 'rejected')
assert.equal(h.saved.at(-1)[0].applyState, 'cancelled')
let applied = false
await h.applyPlan('proposal', async () => { applied = true })
assert.equal(applied, false)
assert.equal(h.cancelPlan('proposal'), false)
})
test('cancel preserves existing successful command results and rejects failed commands', () => {
const message = proposal()
message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' })
message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } }
const h = harness(message)
h.cancelPlan('proposal')
assert.equal(h.current().commandExecutions.inspect.status, 'completed')
assert.equal(h.current().commandExecutions.inspect.output, 'done')
assert.equal(h.current().commandExecutions.build.status, 'rejected')
assert.equal(h.current().commandExecutions.build.output, 'build failed')
})
for (const applyState of ['applying', 'applied', 'cancelled']) {
test(`cannot cancel a proposal in ${applyState} state`, () => {
const h = harness(proposal({ applyState }))
assert.equal(h.cancelPlan('proposal'), false)
assert.equal(h.saved.length, 0)
})
}
test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => {
for (const setup of [
h => { h.current().commandExecutions.build.status = 'running' },
h => { h.applyingRef.current = true },
h => { h.abortControllerRef.current = {} },
h => { h.messageStateRef.current.session = {} }
]) {
const h = harness(proposal())
setup(h)
assert.equal(h.cancelPlan('proposal'), false)
assert.equal(h.saved.length, 0)
}
})
test('an apply already started cannot be cancelled and completes normally', async () => {
const h = harness(proposal())
let finish
const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve }))
assert.equal(h.current().applyState, 'applying')
assert.equal(h.cancelPlan('proposal'), false)
finish()
await applying
assert.equal(h.current().applyState, 'applied')
})
test('new module proposals can be cancelled without creating anything', () => {
const message = proposal()
message.plan.changes = []
message.plan.createModule = { name: 'Example' }
const h = harness(message)
assert.equal(h.cancelPlan('proposal'), true)
assert.equal(h.current().applyState, 'cancelled')
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function text(root) {
if (typeof root === 'string') return root
return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : ''
}
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
...require('lucide-react'), t: key => key, useLocaleRender() {},
ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
AgentWorkDuration: () => null,
agentMessageCopyText: message => message.content
}).AgentMessages
for (const dark of [true, false]) {
test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => {
let cancelled
const props = { dark, enabled: true, statusLoading: false, pending: false,
messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} }
let tree = renderAgent(props)
let buttons = elements(tree, node => node.type === 'button')
const cancel = buttons.find(button => text(button) === '취소')
const apply = buttons.find(button => text(button) === '변경 적용')
assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply))
const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0]
assert.match(cancelLabel.props.className, /top-px leading-none/)
cancel.props.onClick()
assert.equal(cancelled, 'proposal')
const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0]
assert.equal(busyCancel.props.disabled, true)
tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] })
buttons = elements(tree, node => node.type === 'button')
assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false)
assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true)
})
}
+123
View File
@@ -0,0 +1,123 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const api = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentPractice.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: api, DOMException, setTimeout })
const { AGENT_PRACTICE_PROFILES: profiles, inspectAgentPractice, verifyAgentPractice, prepareAgentPractice } = api
const plain = value => JSON.parse(JSON.stringify(value))
const profile = profiles.find(item => item.id === 'mkmini-2d')
const modulesFor = p => p.modules.map((name, sortOrder) => ({ name, sortOrder, category: name.includes('MKMini') || name.includes('TurtleBot') || name === 'NAV2-Yahboom' ? 'Vehicle' : name.includes('Gazebo') ? 'Simulator' : name.includes('Perception') ? 'Sensing' : 'System', state: 'stopped', parameters: [`sim_mode:=${p.environment === 'local'}`, `platform:=${p.platform}`, `robot:=${p.platform}`, `level:=${p.level}`] }))
const probeFor = (p, overrides = {}) => ({ id: 'probe', name: 'ros.inspect', status: 'ok', target: 'test:container', observedAt: new Date().toISOString(), truncated: false, data: JSON.stringify({ topics: p.topics.map(name => ({ name, publishers: 1, samples: 3, validSamples: 3, frameIds: name === p.topics[0] ? ['laser'] : [] })), tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_footprint' }, { parent: 'base_footprint', child: 'base_link' }, { parent: 'base_link', child: 'laser' }], ...overrides }) })
function scenario(overrides = {}) {
const modules = modulesFor(profile)
const starts = []
const controller = new AbortController()
const options = {
profileId: profile.id, environment: 'remote', signal: controller.signal,
approvedStarts: [...profile.modules],
list: async () => modules.map(module => ({ ...module, parameters: [...module.parameters] })),
start: async module => { starts.push(module.name); modules.find(item => item.name === module.name).state = 'running'; return true },
probe: async () => probeFor(profile), progress: () => {}, pause: async () => {}, ...overrides
}
return { modules, starts, controller, options }
}
test('SLAM profiles follow the module guide module sequences', () => {
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
let expression
const visit = node => { if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'moduleGuidePages') expression = node.initializer.getText(source); ts.forEachChild(node, visit) }
visit(source)
const guide = {}
vm.runInNewContext(ts.transpileModule(`exports.pages = ${expression}`, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: guide })
for (const p of profiles) assert.ok(guide.pages.some(page => JSON.stringify(page.slamModules) === JSON.stringify(p.modules)))
})
test('preflight rejects wrong environments, missing modules, conflicting parameters and unrelated running modules', () => {
const modules = modulesFor(profile)
assert.equal(inspectAgentPractice(profile.id, modules, 'remote').canPrepare, true)
assert.equal(inspectAgentPractice(profile.id, modules, 'local').canPrepare, false)
assert.equal(inspectAgentPractice(profile.id, modules.slice(1), 'remote').canPrepare, false)
modules[0].parameters[0] = 'sim_mode:=true'
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
modules[0].parameters = []
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
const conflicting = [...modulesFor(profile), { name: 'NAV2-Control', category: 'Control', state: 'running', parameters: [] }]
assert.match(inspectAgentPractice(profile.id, conflicting, 'remote').blockers.join(), /NAV2-Control/)
assert.equal(inspectAgentPractice(profile.id, [...modulesFor(profile), { name: 'NAV2-Camera', category: 'Sensing', state: 'running', parameters: [] }], 'remote').canPrepare, true)
})
test('readiness requires observed sensor, odometry, map data and a connected TF chain', () => {
assert.equal(verifyAgentPractice(profile.id, probeFor(profile)).ready, true)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [] })).ready, false)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { topics: profile.topics.map(name => ({ name, publishers: 1, samples: 0 })) })).ready, false)
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_link' }] })).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), status: 'error' }).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), truncated: true }).ready, false)
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), data: 'null' }).ready, false)
})
test('prepares only missing starts, in dependency order, and verifies in the SLAM container', async () => {
const s = scenario()
s.modules[0].state = 'running'
let observed
s.options.probe = async (module, topics) => { observed = { module: module.name, topics }; return probeFor(profile) }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, true)
assert.deepEqual(s.starts, [...profile.modules].slice(1))
assert.equal(observed.module, 'NAV2-SLAM')
assert.ok(observed.topics.includes('/tf_static'))
assert.equal(result.steps[0].status, 'already-running')
})
test('a failed start stops subsequent starts and does not issue rollback stop commands', async () => {
const s = scenario()
s.options.start = async module => { s.starts.push(module.name); if (s.starts.length === 2) return false; s.modules[0].state = 'running'; return true }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, false)
assert.deepEqual(s.starts, [...profile.modules].slice(0, 2))
assert.match(result.failures[0], /자동 종료하지/)
})
test('does not start modules that became stopped after approval', async () => {
const s = scenario({ approvedStarts: ['NAV2-SLAM'] })
await assert.rejects(prepareAgentPractice(s.options), /승인 이후/)
assert.equal(s.starts.length, 0)
})
test('does not start with changed parameters after approval', async () => {
const s = scenario()
s.options.approvedParameters = Object.fromEntries(s.modules.map(module => [module.name, [...module.parameters]]))
s.modules[0].parameters.push('custom_speed:=9')
await assert.rejects(prepareAgentPractice(s.options), /실행 파라미터가 바뀌/)
assert.equal(s.starts.length, 0)
})
test('cancellation during a start prevents any subsequent module start', async () => {
const s = scenario()
s.options.start = async module => { s.starts.push(module.name); s.controller.abort(); return true }
await assert.rejects(prepareAgentPractice(s.options), { name: 'AbortError' })
assert.equal(s.starts.length, 1)
})
test('verification retries are bounded and never report ready for publisher-only data', async () => {
const s = scenario()
let probes = 0
s.options.probe = async () => { probes++; return probeFor(profile, { topics: [] }) }
const result = await prepareAgentPractice(s.options)
assert.equal(result.ready, false)
assert.equal(probes, 3)
assert.equal(result.failures.length, 4)
assert.ok(result.failures.some(message => message.includes('센서 프레임')))
})
test('the common confirmation and apply-before-build guard are connected in the workspace', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
assert.match(source, /<ConfirmStatusModal[\s\S]*?title="SLAM 실습 준비"/)
assert.match(source, /requiresAppliedChanges && message.applyState !== 'applied'/)
assert.match(source, /agent\.applyFiles\(/)
assert.match(source, /getDirtyModuleFiles\(change.moduleName\)/)
assert.match(source, /background: true, freshSlam: true/)
assert.match(source, /onResult: result =>/)
})
+325
View File
@@ -0,0 +1,325 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
function compile(source, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports, DOMException, Error, ...globals })
return exports
}
const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
require: name => { assert.equal(name, './agentEvidence'); return evidenceApi }
})
const conversation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'))
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
const plain = value => JSON.parse(JSON.stringify(value))
const response = (toolRequests = []) => ({ provider: 'QWEN', model: 'fixture', plan: { summary: '분석 완료', explanation: '최종 설명', requestedModules: [], requestedLogs: [], changes: [], commands: [], createModule: null, actionPlan: [], toolRequests } })
const deferred = () => { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
const tick = () => new Promise(resolve => setImmediate(resolve))
const result = (extra = {}) => ({ id: 'tool', name: 'files.read', status: 'ok', target: 'fixture', observedAt: new Date().toISOString(), data: '{"exists":true,"complete":true,"content":"PRIVATE_SOURCE"}', truncated: false, ...extra })
function harness(requestApi) {
const session = {}
let now = 1000
class Clock extends Date { static now() { return now } }
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
const messageStateRef = { current: { session, messages: [] } }
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
...context, ...conversation, session, messageStateRef, AbortController, FormData,
Date: Clock,
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
selectedModel,
status: { enabled: true, selectionSupported: true }, abortControllerRef: { current: null },
setPending: value => { state.pending = value },
setRequestStartedAt: value => { state.requestStartedAt = value },
setActivity: value => { state.activity = value },
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
updateMessages: update => { state.messages = update(state.messages); messageStateRef.current.messages = state.messages; state.saved.push(plain(state.messages)) },
onResponseRef: { current: () => { state.notices++ } }, requestApi
})
return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
}
test('shows and persists the question, pending state and timer before a slow module snapshot finishes', async () => {
const snapshot = deferred(), completion = deferred()
const bodies = []
let loads = 0
const h = harness((_, request) => { bodies.push(JSON.parse(request.body)); return completion.promise })
const running = h.requestPlan('원격 차량 속도 제한 위치', [], [], {
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10', executeTool: async () => result(),
loadInitialContext: signal => { loads++; assert.equal(signal.aborted, false); return snapshot.promise }
})
assert.equal(h.state.messages[0].content, '원격 차량 속도 제한 위치')
assert.equal(h.state.saved[0][0].role, 'user')
assert.equal(h.state.pending, true)
assert.equal(h.state.requestStartedAt, 1000)
assert.equal(h.state.activity, '모듈 상태 조회 중')
assert.equal(bodies.length, 0)
await h.requestPlan('중복 전송', [], [], { loadInitialContext: () => { loads++; return snapshot.promise } })
assert.equal(loads, 1)
assert.equal(h.state.messages.length, 1)
h.advance(5000)
h.selectedModel.provider = 'OPENAI'
snapshot.resolve({ modules: [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], runtimeLogs: [] })
await tick()
assert.equal(bodies.length, 1)
assert.equal(bodies[0].provider, 'QWEN')
assert.equal(bodies[0].modules[0].state, 'running')
assert.deepEqual(bodies[0].modules[0].parameters, ['sim_mode:=false'])
h.advance(2000)
completion.resolve(response())
await running
assert.equal(h.state.messages[1].durationMs, 7000)
assert.equal(h.state.pending, false)
})
for (const reject of [false, true]) test(`cancels during initial module lookup and ignores its late ${reject ? 'failure' : 'result'} without affecting a new request`, async () => {
const old = deferred(), next = deferred()
let calls = 0, signal
const h = harness(async () => { calls++; return response() })
const first = h.requestPlan('첫 질문', [], [], { loadInitialContext: current => { signal = current; return old.promise } })
h.stop()
assert.equal(signal.aborted, true)
assert.equal(h.state.pending, false)
const second = h.requestPlan('다음 질문', [], [], { loadInitialContext: () => next.promise })
if (reject) old.reject(Error('늦게 도착한 SSH 오류'))
else old.resolve({ modules: [], runtimeLogs: [] })
await first
assert.equal(calls, 0)
assert.equal(h.state.pending, true)
assert.equal(h.state.activity, '모듈 상태 조회 중')
assert.equal(h.state.messages.length, 2)
assert.equal(h.state.notices, 0)
next.resolve({ modules: [], runtimeLogs: [] })
await second
assert.equal(calls, 1)
assert.equal(h.state.messages.length, 3)
})
test('initial snapshot errors preserve the question and show a timed error without calling the model', async () => {
const snapshot = deferred()
let calls = 0
const h = harness(async () => { calls++; return response() })
const running = h.requestPlan('질문', [], [], { loadInitialContext: () => snapshot.promise })
h.advance(3000)
snapshot.reject(Error('원격 모듈 상태 조회 실패'))
await running
assert.equal(calls, 0)
assert.equal(h.state.messages[0].content, '질문')
assert.equal(h.state.messages[1].content, '원격 모듈 상태 조회 실패')
assert.equal(h.state.messages[1].durationMs, 3000)
assert.equal(h.state.pending, false)
assert.equal(h.state.requestStartedAt, null)
assert.equal(h.state.activity, '')
})
test('scope changes during initial snapshot loading suppress later API calls and errors', async () => {
const snapshot = deferred()
let calls = 0
const h = harness(async () => { calls++; return response() })
const running = h.requestPlan('이전 강좌 질문', [], [], { loadInitialContext: () => snapshot.promise })
h.messageStateRef.current.session = {}
snapshot.resolve({ modules: [], runtimeLogs: [] })
await running
assert.equal(calls, 0)
assert.equal(h.state.messages.length, 1)
assert.equal(h.state.notices, 0)
})
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
const first = deferred(), tool = deferred(), final = deferred()
let calls = 0
const h = harness(() => (++calls === 1 ? first.promise : final.promise))
const running = h.requestPlan('카메라 FPS 확인', [{ name: 'Camera', files: [] }], [], { executeTool: () => tool.promise })
assert.equal(h.state.pending, true)
assert.equal(h.state.requestStartedAt, 1000)
assert.equal(h.state.steps.length, 0)
first.resolve(response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config/camera.yaml' } }]))
await tick()
assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis'])
assert.match(h.state.activity, /files.read 조회 중/)
tool.resolve(result())
await tick()
assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis', 'tool'])
assert.equal(h.state.steps[1].path, 'config/camera.yaml')
assert.equal(JSON.stringify(h.state.steps).includes('PRIVATE_SOURCE'), false)
assert.equal(h.state.messages.length, 1)
assert.equal(h.state.saved.flat().some(message => message.progressSteps), false)
assert.equal(calls, 2)
h.advance(20_900)
final.resolve(response())
await running
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.pending, false)
assert.equal(h.state.activity, '')
assert.equal(h.state.requestStartedAt, null)
assert.equal(h.state.messages.length, 2)
assert.equal(h.state.messages[1].plan.summary, '분석 완료')
assert.equal(h.state.messages[1].durationMs, 20_900)
assert.equal(h.state.saved.at(-1)[1].durationMs, 20_900)
assert.equal(h.state.notices, 1)
})
test('image uploads and every lookup round retain the original provider without uploading the image twice', async () => {
const bodies = []
let uploads = 0
const h = harness(async (path, request) => {
if (path.endsWith('/attachments/images')) {
uploads++
assert.equal(request.body.get('provider'), 'QWEN')
assert.equal(request.body.get('courseId'), 'course')
assert.equal(request.body.get('week'), '3')
assert.equal(request.body.get('file').name, 'screen.png')
return { token: 'image-reference', name: 'screen.png', url: 'https://images.test/screen.png' }
}
bodies.push(JSON.parse(request.body))
h.selectedModel.provider = 'OPENAI'
return bodies.length === 1
? { ...response(), plan: { ...response().plan, requestedModules: ['Control'] } }
: response()
})
await h.requestPlan('이미지 확인', [{ name: 'Control', files: [] }], [], {
attachments: [{ kind: 'image', name: 'screen.png', file: new Blob(['fixture'], { type: 'image/png' }) }],
loadRequestedContext: async () => ({ modules: [{ name: 'Control', files: [{ path: 'config/control.yaml', content: 'speed: 0.3' }] }], runtimeLogs: [] })
})
assert.equal(uploads, 1)
assert.equal(bodies.length, 2)
assert.ok(bodies.every(body => body.provider === 'QWEN'))
assert.ok(bodies.every(body => body.imageTokens[0] === 'image-reference'))
assert.equal(h.state.messages.at(-1).model, 'fixture')
})
test('remote target and observed source ranges reach each provider request including finalization', async () => {
const bodies = []
const tool = { id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config/car.yaml', startLine: 20, lineCount: 1 } }
const h = harness(async (_, request) => {
bodies.push(JSON.parse(request.body))
return bodies.length < 3 ? response([tool]) : response()
})
await h.requestPlan('속도 제한 코드 위치', [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], [], {
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10',
executeTool: async input => result({ id: input.id, moduleName: 'Control', target: 'ssh:100.64.0.10', truncated: true,
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), complete: false, content: 'max_speed: 0.4', startLine: 20, endLine: 20, contentTruncated: false, totalLines: 100 }) })
})
assert.equal(bodies.length, 3)
for (const body of bodies) {
assert.equal(body.environment, 'remote')
assert.equal(body.workspaceTarget, 'ssh:100.64.0.10')
assert.deepEqual(body.modules[0].parameters, ['sim_mode:=false'])
}
assert.equal(JSON.parse(bodies[1].toolResults[0].data).content, 'max_speed: 0.4')
assert.equal(JSON.parse(bodies[2].toolResults[0].data).endLine, 20)
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
})
test('sends only prior same-workspace context and keeps it stable throughout tool lookups', async () => {
const bodies = []
const h = harness(async (_, request) => {
bodies.push(JSON.parse(request.body))
const reply = bodies.length === 2 ? response([{ id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config.yaml' } }]) : response()
return { ...reply, plan: { ...reply.plan, conversationSummary: 'Control의 config.yaml 설명을 진행 중입니다.' } }
})
const options = { environment: 'local', workspaceTarget: 'local-host', workspaceRoot: '/workspace/a', executeTool: async () => result() }
await h.requestPlan('Control 설정 위치', [], [], options)
assert.equal(bodies[0].conversationContext, undefined)
await h.requestPlan('그 파일 설명해줘', [], [], options)
assert.equal(bodies.length, 3)
assert.equal(bodies[1].conversationContext.recentMessages[0].content, 'Control 설정 위치')
assert.equal(bodies[1].conversationContext.summary, 'Control의 config.yaml 설명을 진행 중입니다.')
assert.equal(h.state.saved[1][1].plan.conversationSummary, 'Control의 config.yaml 설명을 진행 중입니다.')
assert.equal(JSON.stringify(bodies[1].conversationContext).includes('그 파일 설명해줘'), false)
assert.deepEqual(bodies[1].conversationContext, bodies[2].conversationContext)
assert.equal(bodies[1].workspaceRoot, undefined)
await h.requestPlan('다른 원격 질문', [], [], { ...options, environment: 'remote', workspaceTarget: 'ssh:device' })
assert.equal(bodies.at(-1).conversationContext, undefined)
await h.requestPlan('다른 로컬 폴더', [], [], { ...options, workspaceRoot: '/workspace/b' })
assert.equal(bodies.at(-1).conversationContext, undefined)
})
test('errors replace temporary progress with the existing error reply', async () => {
let calls = 0
const h = harness(async () => {
if (++calls === 1) return response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }])
throw Error('서버 오류')
})
await h.requestPlan('확인', [{ name: 'Camera', files: [] }], [], { executeTool: async () => result({ status: 'error' }) })
assert.equal(h.state.messages.at(-1).content, '서버 오류')
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.pending, false)
assert.equal(h.state.requestStartedAt, null)
})
test('stopping clears progress and a late response cannot overwrite a newer request', async () => {
const old = deferred(), next = deferred()
let calls = 0
const h = harness(() => (++calls === 1 ? old.promise : next.promise))
const first = h.requestPlan('첫 질문', [], [])
h.stop()
assert.equal(h.state.requestStartedAt, null)
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.activity, '')
const second = h.requestPlan('다음 질문', [], [])
old.resolve(response())
await first
assert.equal(h.state.pending, true)
assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 0)
next.resolve(response())
await second
assert.equal(h.state.pending, false)
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 1)
})
test('scope changes suppress late progress callbacks', async () => {
const tool = deferred()
const h = harness(async () => response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }]))
const running = h.requestPlan('확인', [], [], { executeTool: () => tool.promise })
await tick()
h.messageStateRef.current.session = {}
const previous = h.state.steps.length
h.stop()
tool.resolve(result())
await running
assert.equal(previous, 1)
assert.equal(h.state.steps.length, 0)
assert.equal(h.state.notices, 0)
})
test('source context stages report missing and limited modules and empty logs honestly', async () => {
const steps = []
let calls = 0
await context.resolveAgentContext({
signal: new AbortController().signal,
initialContext: { modules: [{ name: 'Missing', files: [] }, { name: 'Limited', files: [] }], runtimeLogs: [] },
onStep: step => steps.push(step),
requestPlan: async () => ++calls === 1 ? { ...response(), plan: { ...response().plan, requestedModules: ['Missing', 'Limited'], requestedLogs: ['Missing'] } } : response(),
loadRequestedContext: async () => ({ modules: [{ name: 'Missing', files: [], fileContext: { status: 'uninstalled' } }, { name: 'Limited', files: [{ path: 'x', content: 'PRIVATE_SOURCE' }], fileContext: { status: 'limited' } }], runtimeLogs: [] })
})
assert.deepEqual(plain(steps).map(step => [step.kind, step.status]), [['analysis', 'ok'], ['files', 'missing'], ['files', 'partial'], ['logs', 'empty'], ['analysis', 'ok']])
assert.equal(JSON.stringify(steps).includes('PRIVATE_SOURCE'), false)
})
test('tool stages distinguish missing files, partial reads and counts without exposing raw output', async () => {
const steps = []
const tools = [
{ id: 'missing', name: 'files.read', moduleName: 'Camera', args: { path: 'missing.yaml' } },
{ id: 'partial', name: 'files.read', moduleName: 'Camera', args: { path: 'large.py' } },
{ id: 'list', name: 'files.list', moduleName: 'Camera' },
{ id: 'search', name: 'files.search', moduleName: 'Camera', args: { query: 'fps' } }
]
let calls = 0
await context.resolveAgentContext({
signal: new AbortController().signal, initialContext: { modules: [], runtimeLogs: [] }, onStep: step => steps.push(step),
requestPlan: async () => response(++calls === 1 ? tools : []),
executeTool: async tool => result({ data: JSON.stringify(tool.args?.path === 'missing.yaml' ? { exists: false } : tool.args?.path === 'large.py' ? { complete: false } : tool.name === 'files.list' ? { paths: ['a', 'b'] } : { matches: [] }) })
})
assert.deepEqual(plain(steps.filter(step => step.kind === 'tool')).map(step => [step.status, step.count]), [['missing', undefined], ['partial', undefined], ['ok', 2], ['ok', 0]])
})
+96
View File
@@ -0,0 +1,96 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const contextApi = {}
const evidenceApi = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: evidenceApi })
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: contextApi, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression
function visit(node) {
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('imageTokens')) {
payloadExpression = node.getText(source)
}
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
test('user messages preserve only typed text while attachments remain separate metadata', () => {
let initializer
function find(node) {
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'userMessage') initializer = node.initializer.getText(source)
ts.forEachChild(node, find)
}
find(source)
assert.ok(initializer)
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
assert.equal(message.content, request)
}
})
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
return JSON.parse(vm.runInNewContext(payloadExpression, {
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
selection: provider ? { provider } : {},
agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
}))
}
test('sends image reference tokens without image bytes or public URLs on every context round', () => {
const body = serialize(undefined, true, '이미지 분석', ['signed-image-token'])
assert.deepEqual(body.imageTokens, ['signed-image-token'])
assert.equal(Object.hasOwn(body, 'attachments'), false)
assert.equal(JSON.stringify(body).includes('base64'), false)
})
for (const attachments of [undefined, []]) {
test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => {
const body = serialize(attachments)
assert.equal(Object.hasOwn(body, 'attachments'), false)
assert.equal(body.request, '설정 위치 알려줘')
assert.equal(body.modules[0].name, 'Control')
assert.equal(body.week, 3)
})
}
test('preserves actual attachment contents rather than silently dropping them', () => {
const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
assert.deepEqual(serialize(attachments).attachments, attachments)
})
test('finalization instruction reaches the API without sending unsupported DTO fields', () => {
const body = serialize(undefined, true)
assert.match(body.request, /자료 조회는 종료/)
assert.match(body.request, /설정 위치 알려줘/)
assert.match(body.request, /최종 답변/)
assert.equal(Object.hasOwn(body, 'finalize'), false)
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week'])
})
test('finalization keeps the request within the backend 4000-character limit', () => {
const body = serialize(undefined, true, 'x'.repeat(4000))
assert.equal(body.request.length, 4000)
assert.match(body.request, /원래 학생 질문/)
})
test('sends only a provider selection rather than student-supplied models or keys', () => {
for (const finalize of [false, true]) {
const body = serialize(undefined, finalize, '확인', [], 'OPENAI')
assert.equal(body.provider, 'OPENAI')
assert.equal(Object.hasOwn(body, 'model'), false)
assert.equal(Object.hasOwn(body, 'apiKey'), false)
assert.equal(Object.hasOwn(body, 'endpointUrl'), false)
}
assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false)
})
+256
View File
@@ -0,0 +1,256 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function load(file, overrides = {}, globals = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(file), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
const reveal = load('../src/renderer/src/components/agentResponseReveal.ts')
const { createAgentRevealDocument, agentRevealFrame, AGENT_REVEAL_INTERVAL } = reveal
const paragraph = 'MK-mini는 바퀴가 4개라도 전륜 조향 모델을 사용합니다.'
const content = `${paragraph}\n\n1. 물리적 바퀴 수와 운동학 모델은 다릅니다.\n - 앞바퀴로 조향합니다.\n2. 제어 설정을 확인합니다.\n\n마지막 설명입니다.`
test('reveals paragraphs and complete list items at fixed intervals without splitting Korean sentences', () => {
const document = createAgentRevealDocument(content)
const first = agentRevealFrame(document, 1000, 1000)
assert.equal(first.content, paragraph)
assert.equal(first.complete, false)
const second = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL)
assert.match(second.content, /1\. 물리적/)
assert.match(second.content, /앞바퀴로 조향합니다\./)
assert.doesNotMatch(second.content, /2\. 제어/)
const third = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL * 2)
assert.match(third.content, /2\. 제어 설정을 확인합니다\./)
assert.doesNotMatch(third.content, /마지막 설명/)
})
test('keeps fenced code and GFM tables intact', () => {
const text = '설명입니다.\n\n```python\nprint("시작")\nprint("종료")\n```\n\n| 항목 | 값 |\n| --- | --- |\n| 조향 | 전륜 |'
const document = createAgentRevealDocument(text)
assert.equal(document.boundaries.length, 3)
const code = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL).content
assert.equal((code.match(/```/g) ?? []).length, 2)
assert.match(code, /print\("종료"\)/)
assert.doesNotMatch(code, /\| 항목/)
assert.equal(agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL * 2).content, text)
})
test('reference link definitions are available before their position in the answer is revealed', () => {
const document = createAgentRevealDocument('[설명][guide]\n\n다음 문단\n\n[guide]: https://example.com/guide')
const first = agentRevealFrame(document, 0, 0)
assert.match(first.content, /\[guide\]: https:\/\/example.com\/guide/)
assert.doesNotMatch(first.content, /다음 문단/)
})
test('history, elapsed answers and reduced-motion users see the complete response immediately', () => {
const document = createAgentRevealDocument(content)
for (const frame of [agentRevealFrame(document, undefined, 0), agentRevealFrame(document, 0, 60_000), agentRevealFrame(document, 0, 0, true)]) {
assert.equal(frame.content, content)
assert.equal(frame.complete, true)
assert.equal(frame.fadeFrom, undefined)
}
})
test('long answers are grouped into at most twenty steps and finish within five seconds', () => {
const text = Array.from({ length: 100 }, (_, index) => `문단 ${index}입니다.`).join('\n\n')
const document = createAgentRevealDocument(text)
assert.ok(document.boundaries.length <= 20)
assert.ok(agentRevealFrame(document, 0, 0).duration < 5000)
assert.equal(agentRevealFrame(document, 0, 5000).content, text)
})
test('only the newly appended paragraph gets the fade class', () => {
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
})
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
const html = renderToStaticMarkup(React.createElement(MarkdownMessage, { content: frame.content, fadeFrom: frame.fadeFrom, dark: true }))
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
assert.match(html, /첫 번째 문단/)
assert.match(html, /두 번째 문단/)
assert.doesNotMatch(html, /세 번째 문단/)
})
function renderResponse(message, now, reducedMotion = false, extra = {}) {
const MarkdownMessage = ({ content }) => React.createElement('p', null, content)
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage: MarkdownMessage }, './agentResponseReveal': reveal,
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: reducedMotion }) } })
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
}
test('internal cumulative memory is not rendered in the response bubble', () => {
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
assert.match(html, /화면 설명/)
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
})
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
assert.ok(html.includes('A-Copilot'))
assert.ok(!html.includes('A_COPILOT'))
assert.ok(!html.includes('A-Copilot · A-Copilot'))
assert.equal((html.match(/>A-Copilot<\/p>/g) ?? []).length, 1)
assert.ok(renderResponse({ content: '결과', provider: 'QWEN', model: 'qwen3.8-max' }, 0).includes('QWEN · qwen3.8-max'))
})
test('approval controls wait for the explanation; restored history does not replay', () => {
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '조향 설명', explanation: content }, provider: 'QWEN', model: 'test' }
const first = renderResponse(message, 1000)
assert.match(first, /조향 설명/)
assert.match(first, /aria-busy="true"/)
assert.doesNotMatch(first, /승인|QWEN/)
for (const html of [renderResponse(message, 10_000), renderResponse({ ...message, revealStartedAt: undefined }, 1000), renderResponse(message, 1000, true)]) {
assert.match(html, /aria-busy="false"/)
assert.match(html, /마지막 설명/)
assert.match(html, /승인/)
assert.match(html, /QWEN/)
}
})
test('reveal timing is not included in conversation persistence API payloads', () => {
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let expression
function visit(node) {
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('messages: batch')) expression = node.getText(source)
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(expression)
const batch = [{ id: '1', role: 'assistant', content: '전체 답변', revealStartedAt: 1000 }]
const payload = JSON.parse(vm.runInNewContext(expression, { batch, courseId: 'course', weekNumber: 1 }))
assert.equal(Object.hasOwn(payload.messages[0], 'revealStartedAt'), false)
assert.equal(payload.messages[0].content, '전체 답변')
})
test('animation honors reduced motion in CSS', () => {
const css = fs.readFileSync(require.resolve('../src/renderer/src/index.css'), 'utf8')
assert.match(css, /@keyframes agent-response-fade/)
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*?\.agent-response-fade\s*\{\s*animation: none/)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
test('agent answers use the shared collapse widget after reveal, without hiding action controls', () => {
const MarkdownMessage = () => null
const ExpandableMarkdownMessage = () => null
let now = 1000
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage }, './agentResponseReveal': reveal,
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: false }) } })
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '요약', explanation: '상세 내용입니다.\n\n'.repeat(60) } }
const render = (extra = {}) => ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })
assert.equal(elements(render(), node => node.type === ExpandableMarkdownMessage).length, 0)
now = 10_000
const fresh = render()
assert.equal(elements(fresh, node => node.type === ExpandableMarkdownMessage)[0].props.defaultCollapsed, false)
assert.equal(elements(fresh, node => node.type === 'button').length, 1)
const history = render({ message: { ...message, revealStartedAt: undefined }, collapseOnSignal: true, collapseSignal: 4 })
const widget = elements(history, node => node.type === ExpandableMarkdownMessage)[0]
assert.equal(widget.props.defaultCollapsed, true)
assert.equal(widget.props.collapseOnSignal, true)
assert.equal(widget.props.collapseSignal, 4)
assert.equal(elements(history, node => node.type === 'button').length, 1)
})
for (const dark of [false, true]) {
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
let expanded
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
} }
})
const content = '파일과 파라미터에 대한 상세 설명입니다.\n'.repeat(50)
const render = () => ExpandableMarkdownMessage({ content, dark, defaultCollapsed: true })
let tree = render()
let button = elements(tree, node => node.type === 'button')[0]
assert.equal(button.props['aria-label'], '자세히 보기')
assert.ok(elements(tree, node => node.type === MarkdownMessage)[0].props.content.length < content.length)
button.props.onClick()
tree = render()
button = elements(tree, node => node.type === 'button')[0]
assert.equal(button.props['aria-label'], '접기')
assert.equal(elements(tree, node => node.type === MarkdownMessage)[0].props.content, content)
button.props.onClick()
assert.equal(elements(render(), node => node.type === 'button')[0].props['aria-label'], '자세히 보기')
})
}
test('short answers do not show an unnecessary collapse button', () => {
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
})
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
assert.equal(elements(tree, node => node.type === 'button').length, 0)
})
for (const dark of [false, true]) {
test(`model label and copy action share the last row after the operation controls (${dark ? 'dark' : 'light'})`, () => {
const html = renderResponse({ content: '전체 답변', provider: 'QWEN', model: 'qwen-model-name'.repeat(8) }, 1000, false, {
dark,
footerAction: React.createElement('button', { title: '복사', className: 'copy-action' }, 'Copy')
})
assert.match(html, /class="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2"><p[^>]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p><button title="복사"/)
assert.ok(html.indexOf('승인') < html.indexOf('QWEN'))
})
}
test('copy footer remains available when the message has no model metadata', () => {
const html = renderResponse({ content: '오류 안내' }, 1000, false, {
footerAction: React.createElement('button', { title: '복사' }, 'Copy')
})
assert.match(html, /min-h-6/)
assert.match(html, /title="복사"/)
})
test('reply copy is an inline fixed-size hover button and still copies the full response', async () => {
const source = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declaration = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'MessageCopyButton')
let copied = false
let clipboardText
const { MessageCopyButton } = compileCopy(declaration.getText(source))
function compileCopy(text) {
const code = ts.transpileModule(`export ${text}`, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
const exports = {}
vm.runInNewContext(code, {
exports, require,
useState: () => [copied, value => { copied = value }], t: key => key,
Check: () => null, Copy: () => null,
navigator: { clipboard: { writeText: async text => { clipboardText = text } } },
window: { setTimeout() {} }
})
return exports
}
const content = '# 요약\n\n전체 답변입니다.'
const button = MessageCopyButton({ content, dark: true })
assert.match(button.props.className, /opacity-0.*group-hover:opacity-100/)
assert.match(button.props.className, /shrink-0 cursor-pointer/)
assert.doesNotMatch(button.props.className, /absolute|bottom-|right-/)
button.props.onClick()
await Promise.resolve()
assert.equal(clipboardText, content)
assert.equal(MessageCopyButton({ content, dark: true }).props.title, '복사됨')
})
+139
View File
@@ -0,0 +1,139 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const exportsObject = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentSessionSync.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: exportsObject, console: { warn() {} } })
const { createAgentSessionSync } = exportsObject
const message = (id, content = id) => ({ id, role: 'assistant', content })
const plain = value => JSON.parse(JSON.stringify(value))
const deferred = () => {
let resolve
const promise = new Promise(done => { resolve = done })
return { promise, resolve }
}
const storage = () => {
const data = new Map()
return {
getItem: key => data.get(key) ?? null,
setItem: (key, value) => data.set(key, value),
removeItem: key => data.delete(key)
}
}
test('saves replies in order and preserves a newer result arriving during an active save', async () => {
const gate = deferred()
const calls = []
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => {
calls.push(plain(batch))
if (calls.length === 1) await gate.promise
} })
sync.enqueue([message('command', 'running')])
await Promise.resolve()
sync.enqueue([message('command', 'completed'), message('answer')])
assert.equal(calls.length, 1)
gate.resolve()
await sync.flush()
assert.equal(calls.length, 2)
assert.deepEqual(calls[1], [message('command', 'completed'), message('answer')])
})
test('keeps a reply cached before network completion and after failed persistence', async () => {
const disk = storage()
const sync = createAgentSessionSync({ storage: disk, storageKey: 'student-a', save: async () => { throw Error('offline') } })
sync.enqueue([message('answer')])
assert.deepEqual(JSON.parse(disk.getItem('student-a')), [message('answer')])
await sync.flush()
assert.ok(sync.getError())
const batches = []
const reopened = createAgentSessionSync({ storage: disk, storageKey: 'student-a', save: async batch => { batches.push(plain(batch)) } })
assert.deepEqual(plain(reopened.restore([{ id: 'question', role: 'user', content: '?' }])), [
{ id: 'question', role: 'user', content: '?' }, message('answer')
])
await reopened.flush()
assert.deepEqual(batches, [[message('answer')]])
assert.equal(disk.getItem('student-a'), null)
assert.equal(reopened.getError(), null)
})
test('does not overwrite a new reply with an older history response', async () => {
const sync = createAgentSessionSync({ storageKey: 'test', save: async () => {} })
sync.enqueue([message('answer', 'new answer')])
await sync.flush()
assert.deepEqual(plain(sync.restore([message('answer', 'old answer')])), [message('answer', 'new answer')])
})
test('saves only modified messages even when restored history exceeds 100 messages', async () => {
const batches = []
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { batches.push(plain(batch)) } })
const history = Array.from({ length: 150 }, (_, index) => message(`message-${index}`))
sync.restore(history)
sync.enqueue([...history, message('new-answer')])
await sync.flush()
assert.deepEqual(batches, [[message('new-answer')]])
})
test('chunks a large backlog below the server message-count limit', async () => {
const batches = []
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { batches.push(plain(batch)) } })
sync.enqueue(Array.from({ length: 205 }, (_, index) => message(`message-${index}`)))
await sync.flush()
assert.deepEqual(batches.map(batch => batch.length), [100, 100, 5])
assert.equal(new Set(batches.flat().map(item => item.id)).size, 205)
})
test('leaving the view does not cancel an accepted write', async () => {
const gate = deferred()
const disk = storage()
let saved = false
const sync = createAgentSessionSync({ storage: disk, storageKey: 'test', save: async () => { await gate.promise; saved = true } })
const unsubscribe = sync.subscribe(() => {})
sync.enqueue([message('answer')])
unsubscribe()
gate.resolve()
await sync.flush()
assert.equal(saved, true)
assert.equal(disk.getItem('test'), null)
})
test('archives after outstanding writes complete and clears cached replies', async () => {
const gate = deferred()
const order = []
const disk = storage()
const sync = createAgentSessionSync({ storage: disk, storageKey: 'test', save: async () => { await gate.promise; order.push('saved') } })
sync.enqueue([message('answer')])
const clearing = sync.clear(async () => { order.push('archived') })
gate.resolve()
await clearing
assert.deepEqual(order, ['saved', 'archived'])
assert.deepEqual(plain(sync.restore([])), [])
assert.equal(disk.getItem('test'), null)
})
test('pending replies stay isolated by student and course scope', async () => {
const disk = storage()
const first = createAgentSessionSync({ storage: disk, storageKey: 'student-a:course-1', save: async () => { throw Error('offline') } })
first.enqueue([message('private-answer')])
await first.flush()
const second = createAgentSessionSync({ storage: disk, storageKey: 'student-b:course-1', save: async () => {} })
assert.deepEqual(plain(second.restore([])), [])
})
test('late command updates cannot recreate a conversation while it is being cleared', async () => {
const archive = deferred()
const calls = []
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { calls.push(plain(batch)) } })
sync.enqueue([message('answer')])
await sync.flush()
const clearing = sync.clear(() => archive.promise)
sync.enqueue([message('answer', 'late command result')])
archive.resolve()
await clearing
await sync.flush()
assert.equal(calls.length, 1)
assert.deepEqual(plain(sync.restore([])), [])
})
+222
View File
@@ -0,0 +1,222 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const { spawnSync } = require('node:child_process')
const ts = require('typescript')
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(require('node:fs').readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, filename)
const { validateAgentTool, validateAgentOperation, safeAgentPath } = require('../src/shared/agentTools.ts')
const { runAgentFileTool, applyAgentFiles, agentFileHash } = require('../src/main/agentFileTools.ts')
const { agentRemoteFilesScript } = require('../src/main/agentRemoteFiles.ts')
const { inspectAgentRuntime, agentRosProbePython } = require('../src/main/agentRuntimeTools.ts')
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
const plain = value => JSON.parse(JSON.stringify(value))
const request = (name, args = {}) => ({ id: 'query-1', name, moduleName: 'NAV2-MKMini', args })
function remote(input) {
const result = spawnSync('bash', ['--noprofile', '--norc', '-s'], { input: agentRemoteFilesScript(input), encoding: 'utf8', maxBuffer: 4_000_000, timeout: 10_000 })
if (result.error || result.status !== 0) throw new Error(result.stderr || result.error?.message)
return JSON.parse(result.stdout)
}
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-agent-tools-'))
t.after(() => fs.rm(root, { recursive: true, force: true }))
await fs.mkdir(path.join(root, 'config'))
await fs.writeFile(path.join(root, 'config', 'sensor.yaml'), 'host_ip: 192.168.2.88\n조향: ackermann\n')
await fs.writeFile(path.join(root, 'CMakeLists.txt'), 'project(vehicle)\n')
return root
}
test('tool requests reject writes, extra shell arguments, unsafe paths and unbounded observations', () => {
for (const name of ['shell', 'files.write', 'network.set', 'ros.publish']) assert.throws(() => validateAgentTool(request(name)))
for (const name of ['files.list', 'files.read', 'files.search']) {
for (const p of ['../secret', '/etc/passwd', '.env', 'config/.env', 'build/secret.py', 'config/id_rsa', 'config/key.pem', 'config\\x.py', 'a//b']) assert.throws(() => validateAgentTool(request(name, { path: p, ...(name === 'files.search' ? { query: 'ip' } : {}) })))
}
assert.throws(() => validateAgentTool(request('network.inspect', { command: 'ip addr replace' })))
assert.throws(() => validateAgentTool(request('ros.inspect', { topics: ['/scan; reboot'] })))
assert.throws(() => validateAgentTool(request('files.read', { path: 'a.py', lineCount: 401 })))
assert.throws(() => validateAgentOperation({ name: 'modules.stop' }))
assert.throws(() => validateAgentOperation({ name: 'practice.prepare', profile: 'guess' }))
assert.equal(validateAgentOperation({ name: 'practice.prepare', profile: 'mkmini-3d' }).name, 'practice.prepare')
assert.equal(safeAgentPath('src/launch/vehicle.launch.py'), 'src/launch/vehicle.launch.py')
})
for (const backend of ['local', 'remote']) {
const read = async (root, tool) => backend === 'local' ? runAgentFileTool(root, validateAgentTool(tool)) : remote({ root, request: validateAgentTool(tool) })
const apply = changes => backend === 'local' ? applyAgentFiles(changes) : Promise.resolve().then(() => remote({ changes }))
test(`${backend}: lists and searches sources without hidden, generated or symlinked data`, async t => {
const root = await fixture(t)
await fs.writeFile(path.join(root, '.env'), 'host_ip: PRIVATE')
await fs.mkdir(path.join(root, 'build'))
await fs.writeFile(path.join(root, 'build', 'generated.yaml'), 'host_ip: PRIVATE')
await fs.symlink('/etc/passwd', path.join(root, 'config', 'outside.txt'))
const listing = await read(root, request('files.list'))
assert.deepEqual([...listing.data.paths].sort(), ['CMakeLists.txt', 'config/sensor.yaml'])
const search = await read(root, request('files.search', { query: 'HOST_IP' }))
assert.equal(search.data.matches.length, 1)
assert.equal(search.data.matches[0].path, 'config/sensor.yaml')
assert.equal(search.data.matches[0].line, 1)
await assert.rejects(read(root, request('files.read', { path: 'config/outside.txt' })))
})
test(`${backend}: full file reads include a reproducible hash, partial and missing reads are explicit`, async t => {
const root = await fixture(t)
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
assert.equal(full.data.complete, true)
const rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 }))
assert.equal(rangedFull.data.complete, true)
assert.equal(rangedFull.truncated, false)
assert.equal(rangedFull.data.endLine, 3)
assert.equal(rangedFull.data.contentTruncated, false)
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
assert.equal(partial.data.content, '조향: ackermann')
assert.equal(partial.data.complete, false)
assert.equal(partial.truncated, true)
assert.equal(partial.data.endLine, 2)
assert.equal(partial.data.contentTruncated, false)
const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
assert.equal(missing.data.exists, false)
assert.equal(missing.data.hash, null)
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
})
test(`${backend}: character truncation never advertises a cut line as fully observed`, async t => {
const root = await fixture(t)
await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`)
const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 }))
assert.equal(readout.data.complete, false)
assert.equal(readout.data.contentTruncated, true)
assert.equal(readout.data.endLine, 1)
})
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
const root = await fixture(t)
await fs.mkdir(path.join(root, 'code'))
for (let i = 0; i < 5; i++) await fs.writeFile(path.join(root, 'code', `large${i}.py`), 'x'.repeat(900_000))
const result = await read(root, request('files.search', { query: 'host_ip', path: 'config' }))
assert.equal(result.truncated, false)
assert.equal(result.data.matches[0].path, 'config/sensor.yaml')
})
test(`${backend}: validates every original before changing any file and preserves permissions`, async t => {
const root = await fixture(t)
const original = await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')
const first = { root, moduleName: 'NAV2-MKMini', path: 'config/sensor.yaml', content: 'host_ip: 192.168.1.88\n', expectedHash: agentFileHash(original) }
const stale = { root, moduleName: 'NAV2-MKMini', path: 'CMakeLists.txt', content: 'changed', expectedHash: '0'.repeat(64) }
await assert.rejects(apply([first, stale]))
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), original)
await fs.chmod(path.join(root, first.path), 0o664)
await apply([first, { root, moduleName: 'NAV2-MKMini', path: 'config/new.yaml', content: 'created: true', expectedHash: null }])
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), first.content)
assert.equal((await fs.stat(path.join(root, first.path))).mode & 0o777, 0o664)
assert.equal(await fs.readFile(path.join(root, 'config/new.yaml'), 'utf8'), 'created: true')
await assert.rejects(apply([first]))
})
}
test('remote batch streams large content rather than putting it into command arguments', async t => {
const root = await fixture(t)
const content = 'large YAML value '.repeat(11_000)
remote({ changes: [{ root, moduleName: 'NAV2-MKMini', path: 'config/large.yaml', expectedHash: null, content }] })
assert.equal(await fs.readFile(path.join(root, 'config/large.yaml'), 'utf8'), content)
})
test('local batch rolls back earlier replacements if a later replacement fails', async t => {
const root = await fixture(t)
const rename = fs.rename
let calls = 0
fs.rename = async (...args) => { if (++calls === 2) throw new Error('injected rename error'); return rename(...args) }
try {
await assert.rejects(applyAgentFiles([
{ root, moduleName: 'M', path: 'config/new.yaml', expectedHash: null, content: 'new' },
{ root, moduleName: 'M', path: 'CMakeLists.txt', expectedHash: agentFileHash('project(vehicle)\n'), content: 'changed' }
]), /injected/)
await assert.rejects(fs.stat(path.join(root, 'config/new.yaml')), { code: 'ENOENT' })
assert.equal(await fs.readFile(path.join(root, 'CMakeLists.txt'), 'utf8'), 'project(vehicle)\n')
} finally { fs.rename = rename }
})
test('network inspection keeps real host and container observations separate', async () => {
const calls = []
const result = await inspectAgentRuntime(request('network.inspect'), async (executable, args) => { calls.push([executable, args]); return '[{"ifname":"eth0"}]' }, async command => { calls.push(command); throw new Error('container is stopped') })
assert.equal(result.host.interfaces[0].ifname, 'eth0')
assert.match(result.container.error, /stopped/)
assert.equal(calls[0][0], 'ip')
assert.equal(calls[1][0], 'ip')
assert.equal(calls[2], 'ip -j address show')
})
test('ROS probe samples retained maps with matching QoS and distinguishes empty sensor payloads', () => {
const preamble = String.raw`
import sys, types, time
NS = types.SimpleNamespace
tick = [0]
def monotonic():
tick[0] += 1
return tick[0]
time.monotonic = monotonic
class Node:
def get_topic_names_and_types(self):
return [(topic, ['test/Message']) for topic in ['/map', '/scan', '/tf_static']]
def get_publishers_info_by_topic(self, topic):
return [NS(qos_profile=NS(durability='retained' if topic in ['/map', '/tf_static'] else 'volatile'))]
def create_subscription(self, message_type, topic, callback, qos):
if topic in ['/map', '/tf_static'] and qos.durability != 'retained':
raise ValueError('wrong durability')
if topic == '/map':
callback(NS(info=NS(width=10, height=10), data=[]))
elif topic == '/scan':
callback(NS(ranges=[1, 2, 3]))
else:
callback(NS(transforms=[NS(header=NS(frame_id='map'), child_frame_id='odom')]))
return object()
def count_publishers(self, topic):
return 1
def get_node_names(self):
return ['probe']
def destroy_node(self):
pass
sys.modules['rclpy'] = NS(init=lambda **kw: None, create_node=lambda name: Node(), spin_once=lambda *a, **kw: None, shutdown=lambda: None)
sys.modules['rclpy.qos'] = NS(QoSProfile=lambda **kw: NS(**kw), ReliabilityPolicy=NS(BEST_EFFORT='best-effort'), DurabilityPolicy=NS(TRANSIENT_LOCAL='retained', VOLATILE='volatile'))
sys.modules['rosidl_runtime_py'] = types.ModuleType('rosidl_runtime_py')
sys.modules['rosidl_runtime_py.utilities'] = NS(get_message=lambda name: object)
`
const run = spawnSync('python3', ['-c', `${preamble}\nexec(${JSON.stringify(agentRosProbePython)})`, Buffer.from(JSON.stringify(['/map', '/scan', '/tf_static'])).toString('base64')], { encoding: 'utf8', timeout: 5000 })
assert.equal(run.status, 0, run.stderr)
const data = JSON.parse(run.stdout)
assert.equal(data.topics.find(topic => topic.name === '/map').samples, 1)
assert.equal(data.topics.find(topic => topic.name === '/map').validSamples, 0)
assert.equal(data.topics.find(topic => topic.name === '/scan').validSamples, 1)
assert.deepEqual(data.tfEdges, [{ parent: 'map', child: 'odom' }])
})
test('restoring an interrupted action does not leave history permanently running', () => {
const { recoverInterruptedAgentMessages } = require('../src/renderer/src/features/workspace/agentContext.ts')
const messages = [{ id: 'reply', role: 'assistant', content: 'result', applyState: 'applying', commandExecutions: { start: { status: 'running', output: 'started one module' }, build: { status: 'completed' } } }]
const restored = recoverInterruptedAgentMessages(messages)
assert.equal(restored[0].applyState, 'error')
assert.equal(restored[0].commandExecutions.start.status, 'failed')
assert.equal(restored[0].commandExecutions.start.output, 'started one module')
assert.equal(restored[0].commandExecutions.build.status, 'completed')
assert.equal(messages[0].commandExecutions.start.status, 'running')
})
test('tool loop sends actual evidence, deduplicates requests despite new IDs and never runs actions', async () => {
let calls = 0
const executed = []
const result = await resolveAgentContext({
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
executeTool: async tool => { executed.push(tool); return { id: tool.id, name: tool.name, status: 'ok', target: 'test', observedAt: new Date().toISOString(), data: '{"paths":["config/a.yaml"]}', truncated: false } },
requestPlan: async (context, finalize) => {
calls++
if (calls === 2) assert.equal(context.toolResults.length, 1)
return { provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'explanation', requestedModules: [], requestedLogs: [], actionPlan: [], commands: finalize ? [{ operation: { name: 'modules.start' } }] : [], changes: [], createModule: null, toolRequests: [request('files.list', {})] } }
}
})
assert.equal(executed.length, 1)
assert.equal(calls, 3)
assert.deepEqual(plain(result.plan.commands), [])
assert.deepEqual(plain(result.plan.toolRequests), [])
assert.match(result.plan.explanation, /files.list: ok/)
})
test('backend and desktop share exactly the same tool validation contract', async () => {
assert.equal(await fs.readFile(path.resolve(__dirname, '../../lms-be/src/ai/agent-tools.ts'), 'utf8'), await fs.readFile(path.resolve(__dirname, '../src/shared/agentTools.ts'), 'utf8'))
})
+87
View File
@@ -0,0 +1,87 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function harness(initialNow = 1000) {
let now = initialNow, state, effect, interval, cleared = 0
const windowEvents = new Map(), documentEvents = new Map()
class Clock extends Date { static now() { return now } }
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports, Date: Clock,
window: {
setInterval(callback, delay) { assert.equal(delay, 1000); interval = callback; return 7 },
clearInterval(id) { assert.equal(id, 7); interval = undefined; cleared++ },
addEventListener: (name, callback) => windowEvents.set(name, callback),
removeEventListener: name => windowEvents.delete(name)
},
document: {
addEventListener: (name, callback) => documentEvents.set(name, callback),
removeEventListener: name => documentEvents.delete(name)
},
require(name) {
if (name === '../i18n') return { t: (key, values) => key.replace(/\{\{seconds\}\}/g, values.seconds), useLocaleRender() {} }
if (name === 'react') return { ...React,
useState(initial) { state ??= typeof initial === 'function' ? initial() : initial; return [state, value => { state = value }] },
useEffect(callback) { effect = callback }
}
return require(name)
}
})
return {
render: props => renderToStaticMarkup(exports.AgentWorkDuration({ dark: true, ...props })),
mount: () => effect(), advance: ms => { now += ms }, tick: () => interval?.(),
visible: () => documentEvents.get('visibilitychange')?.(), focus: () => windowEvents.get('focus')?.(),
interval: () => interval, cleared: () => cleared,
listeners: () => windowEvents.size + documentEvents.size
}
}
test('running duration is based on start time, not the number of interval ticks', () => {
const h = harness()
assert.match(h.render({ startedAt: 1000 }), /0초 동안 작업 중입니다/)
const cleanup = h.mount()
h.advance(12_900)
h.tick()
assert.match(h.render({ startedAt: 1000 }), /12초 동안 작업 중입니다/)
h.advance(60_000)
h.visible()
assert.match(h.render({ startedAt: 1000 }), /72초 동안 작업 중입니다/)
h.advance(5_000)
h.focus()
assert.match(h.render({ startedAt: 1000 }), /77초 동안 작업 중입니다/)
assert.equal(h.listeners(), 2)
cleanup()
assert.equal(h.interval(), undefined)
assert.equal(h.listeners(), 0)
assert.equal(h.cleared(), 1)
})
test('returning to a remounted chat shows the full elapsed time immediately', () => {
const h = harness(74_000)
assert.match(h.render({ startedAt: 1000 }), /73초 동안 작업 중입니다/)
})
test('completed duration stays fixed and never starts a timer', () => {
const h = harness()
assert.match(h.render({ startedAt: 1000, durationMs: 20_900 }), /20초 동안 작업함/)
assert.equal(h.mount(), undefined)
h.advance(60_000)
h.tick()
assert.match(h.render({ durationMs: 20_900 }), /20초 동안 작업함/)
assert.equal(h.interval(), undefined)
assert.equal(h.listeners(), 0)
assert.match(h.render({ durationMs: 0 }), /0초 동안 작업함/)
})
test('legacy replies and invalid timing do not invent an elapsed duration', () => {
const h = harness()
for (const durationMs of [undefined, -1, NaN, Infinity]) assert.equal(h.render({ durationMs }), '')
assert.match(h.render({ startedAt: 2000 }), /0초 동안 작업 중입니다/)
})
+77
View File
@@ -0,0 +1,77 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const ts = require('typescript')
const vm = require('node:vm')
const app = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = new Map()
function visit(node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) declarations.set(node.name.text, node.initializer?.getText(app))
ts.forEachChild(node, visit)
}
visit(app)
const plain = value => JSON.parse(JSON.stringify(value))
function harness(remoteWorkspace) {
const executions = []
const calls = []
const contexts = []
const events = []
const errors = []
const target = { host: '100.64.0.10', user: 'fixture', workspaceRoot: '/fixture/workspace' }
const live = [{ name: 'NAV2-MKMini', folderName: 'vehicle', state: 'running', parameters: ['sim_mode:=false', 'robot:=mkmini'] }]
const readTool = backend => async (...args) => { executions.push({ backend, args: plain(args) }); return { status: 'ok', data: '{"content":"remote source"}' } }
const exports = {}
const globals = {
exports, remoteWorkspace, availableModules: [{ ...live[0], state: 'stopped', parameters: ['sim_mode:=true'] }],
clonePathPreview: '/local/workspace', runtimeModules: [], agentActionControllerRef: { current: null }, agentApplying: false,
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
window: {
remoteWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('remote') } },
localWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('local') } }
},
requestAgentPlan: async (...args) => {
calls.push(args)
events.push('plan-start')
contexts.push(await args[3].loadInitialContext(new AbortController().signal))
},
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
}
const names = ['collectAgentWorkspace', 'readAgentModules', 'executeWorkspaceAgentTool', 'submitAgentRequest']
vm.runInNewContext(ts.transpileModule(names.map(name => `const ${name} = ${declarations.get(name)}; exports.${name} = ${name}`).join('\n'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, globals)
return { ...exports, executions, calls, contexts, events, errors }
}
for (const remote of [false, true]) {
test(`${remote ? 'remote' : 'local'}: enters the chat lifecycle immediately and refreshes state before the provider request`, async () => {
const h = harness(remote)
await h.submitAgentRequest('최대 속도 제한 코드 위치')
assert.deepEqual(h.errors, [])
assert.equal(h.calls.length, 1)
const [, initialModules, , options] = h.calls[0]
assert.deepEqual(plain(initialModules), [])
assert.deepEqual(h.events, ['plan-start', 'snapshot-read'])
const { modules } = h.contexts[0]
assert.equal(modules[0].state, 'running')
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
assert.deepEqual(plain(modules[0].files), [])
assert.equal(options.environment, remote ? 'remote' : 'local')
assert.equal(options.workspaceTarget, remote ? 'ssh:100.64.0.10' : 'local-host')
})
test(`${remote ? 'remote' : 'local'}: source reads use only the connected workspace transport and module folder`, async () => {
const h = harness(remote)
const tool = { id: 'read-1', name: 'files.read', moduleName: 'NAV2-MKMini', args: { path: 'src/profiles.py', startLine: 10, lineCount: 20 } }
await h.executeWorkspaceAgentTool(tool)
assert.equal(h.executions.length, 1)
assert.equal(h.executions[0].backend, remote ? 'remote' : 'local')
const args = h.executions[0].args
assert.deepEqual(args[remote ? 1 : 0], tool)
assert.equal(args[2], 'vehicle')
assert.equal(remote ? args[0].host : args[1], remote ? '100.64.0.10' : '/local/workspace')
})
}
+208
View File
@@ -0,0 +1,208 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(source, overrides = {}, globals = {}) {
const exports = {}
const code = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
} }).outputText
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
})
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
const AgentCodeReferences = () => null
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
})
const { AgentProgressBubble } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentProgressBubble.tsx'), 'utf8'), {
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} },
react: { ...React, useEffect() {}, useRef: () => ({ current: null }) },
'./AgentWorkDuration': { AgentWorkDuration }
})
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
t: key => key, useLocaleRender() {},
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
ChatEmptyState: widgets.ChatEmptyState,
ProgressiveChatResponse,
ChatImageAttachments,
AgentCodeReferences,
AgentProgressBubble,
AgentWorkDuration,
MessageCopyButton: () => null,
agentMessageCopyText: message => message.content,
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
})
test('existing code locations stay usable during model and command work but not file application', () => {
for (const applying of [false, true]) {
const onOpen = () => {}
const tree = messages.AgentMessages({
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
onOpenCodeReference: onOpen
})
let references
function visit(element) {
if (!React.isValidElement(element)) return
if (element.type === AgentCodeReferences) references = element
React.Children.toArray(element.props.children).forEach(visit)
}
visit(tree)
assert.ok(references)
assert.equal(references.props.busy, applying)
assert.equal(typeof references.props.onOpen, 'function')
}
})
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
const tree = messages.AgentMessages({
dark: true, enabled: true, statusLoading: false, pending: false,
messages: [{ id: 'user', role: 'user', content: '이 화면을 확인해줘\n두 번째 줄', imageAttachments: [{ name: '스크린샷.png', url: 'https://files.test/image.png' }] }]
})
let row
function visit(element) {
if (!React.isValidElement(element)) return
const children = React.Children.toArray(element.props.children)
if (children.some(child => React.isValidElement(child) && child.props['data-agent-user-bubble'])) row = children
children.forEach(visit)
}
visit(tree)
assert.ok(row)
assert.equal(row[0].props.children, '이 화면을 확인해줘\n두 번째 줄')
assert.match(row[0].props.className, /whitespace-pre-wrap/)
assert.equal(row[1].type, ChatImageAttachments)
assert.equal(row[1].props.variant, 'sent')
assert.equal(row[1].props.images.length, 1)
})
for (const dark of [false, true]) {
for (const mode of ['AiMessages', 'AgentMessages']) {
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, statusLoading: false, pending: true, messages: [] }))
assert.match(html, /role="status"/)
assert.equal((html.match(/<span class="h-2 /g) ?? []).length, 3)
for (const width of ['w-full', 'w-[76%]', 'w-[52%]']) assert.ok(html.includes(width))
assert.doesNotMatch(html, /animate-spin/)
assert.match(html, /motion-reduce:animate-none/)
})
}
}
test('AI answer forwards reveal timing and collapse settings to the same presentation component', () => {
const html = renderToStaticMarkup(messages.AiMessages({
dark: true, pending: false, collapseSignal: 2,
messages: [{ id: 'answer', role: 'assistant', content: '전체 답변입니다.', revealStartedAt: 1234, keepExpanded: true }]
}))
assert.match(html, /data-reveal-start="1234"/)
assert.match(html, /data-collapsed="false"/)
assert.match(html, /전체 답변/)
})
for (const dark of [true, false]) {
test(`agent progress accumulates only during the request and is replaced by the final reply (${dark ? 'dark' : 'light'})`, () => {
const progressSteps = [
{ id: 'analysis-0', kind: 'analysis', status: 'ok', round: 1, requests: 1 },
{ id: 'tool-0-0', kind: 'tool', name: 'files.list', moduleName: 'Camera', status: 'ok', count: 3 },
{ id: 'tool-1-0', kind: 'tool', name: 'files.read', moduleName: 'Camera', path: 'config/camera.yaml', status: 'missing' },
{ id: 'tool-1-1', kind: 'tool', name: 'logs.read', moduleName: 'Camera', status: 'error' },
{ id: 'tool-1-2', kind: 'tool', name: 'files.search', moduleName: 'Camera', status: 'partial', count: 2 }
]
const props = { dark, enabled: true, statusLoading: false, pending: true, activity: '조회 결과 분석 중', progressSteps, messages: [] }
const html = renderToStaticMarkup(messages.AgentMessages(props))
assert.equal((html.match(/data-agent-progress="true"/g) ?? []).length, 1)
assert.match(html, /1차 분석 완료 · 추가 조회 1건/)
assert.match(html, /Camera · 파일 목록 · 조회 완료 · 3건/)
assert.match(html, /Camera · 파일 읽기 · config\/camera.yaml · 대상 없음/)
assert.match(html, /Camera · 로그 조회 · 조회 실패/)
assert.match(html, /Camera · 파일 검색 · 일부 결과 · 2건/)
assert.match(html, /조회 결과 분석 중/)
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 5)
const final = renderToStaticMarkup(messages.AgentMessages({ ...props, pending: false, messages: [{ id: 'answer', role: 'assistant', content: '최종 답변입니다.', durationMs: 20_900 }] }))
assert.doesNotMatch(final, /data-agent-progress|config\/camera.yaml|진행 상황/)
assert.match(final, /최종 답변입니다/)
assert.match(final, /20초 동안 작업함/)
assert.ok(final.indexOf('20초 동안 작업함') < final.indexOf('최종 답변입니다'))
})
}
for (const dark of [false, true]) {
for (const [mode, expected] of [
['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/],
['AgentMessages', /현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요\.<br\/>변경 내용은 확인 후 직접 적용합니다\./],
['TeacherMessages', /강사에게 문의할<br\/>내용을 입력하세요/]
]) {
test(`empty ${mode} view uses the shared speech bubble (${dark ? 'dark' : 'light'})`, () => {
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, pending: false, messages: [] }))
assert.match(html, expected)
assert.match(html, /rounded-lg border px-8 py-5/)
assert.match(html, /aria-hidden="true"[^>]*rotate-45 border-b border-r/)
assert.doesNotMatch(html, /<button|role="status"/)
})
}
}
function chatHarness(revealInView) {
const states = []
const refs = []
let stateIndex = 0
let refIndex = 0
let waits = 0
const content = '첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n마지막 문단입니다.'
const { useQaChat } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useQaChat.ts'), 'utf8'), {
react: {
useEffect() {},
useState(initial) {
const index = stateIndex++
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
return [states[index], value => { states[index] = typeof value === 'function' ? value(states[index]) : value }]
},
useRef(initial) { const index = refIndex++; return refs[index] ??= { current: initial } }
},
'socket.io-client': {}
}, {
URLSearchParams, AbortController, DOMException,
fetch: async () => ({ ok: true, json: async () => ({ threadId: 'thread', content, message: { id: 'answer', content, createdAt: '2026-10-19T03:00:00Z' } }) }),
window: { setTimeout(fn) { waits++; fn() } }
})
return {
render() {
stateIndex = 0; refIndex = 0
return useQaChat({ accessToken: 'test', courseId: 'course', weekNumber: 1, revealInView, getFreshAccessToken: async () => 'test', onTitlebarNotice() {} })
},
content, waits: () => waits
}
}
test('workspace AI stores the complete answer immediately and leaves animation to the view', async () => {
const harness = chatHarness(true)
await harness.render().sendQaMessage('조향 설정을 알려줘')
const state = harness.render()
const answer = state.visibleAiMessages.find(message => message.id === 'answer')
assert.equal(answer.content, harness.content)
assert.equal(typeof answer.revealStartedAt, 'number')
assert.equal(answer.keepExpanded, true)
assert.equal(state.aiPending, false)
assert.equal(state.aiTypingMessageId, null)
assert.equal(harness.waits(), 0)
})
test('the separate AI guide keeps its existing reveal mode unless explicitly opted in', async () => {
const harness = chatHarness(false)
await harness.render().sendQaMessage('질문')
const state = harness.render()
assert.equal(state.visibleAiMessages.find(message => message.id === 'answer').content, harness.content)
assert.ok(harness.waits() > 0)
assert.equal(state.aiTypingMessageId, null)
})
+35
View File
@@ -0,0 +1,35 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const output = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, TextDecoder })
const { readChatAttachments } = output
const file = (name, content) => new File([content], name)
test('reads source/config/log files and preserves Korean UTF-8 text', async () => {
const files = await readChatAttachments([file('control.py', '# 조향 설정'), file('config.yaml', 'robot: mkmini'), file('runtime.log', 'started')], [])
assert.equal(files[0].content, '# 조향 설정')
assert.equal(files[2].name, 'runtime.log')
})
test('rejects unsupported image/PDF files', async () => {
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /현재 모델/)
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /PDF/)
})
test('rejects more than five attachments including existing ones', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a')], Array(5).fill({ name: 'b.txt', content: '' })), /최대 5개/)
})
test('rejects binary and non-UTF8 content even under a text extension', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a\0b')], []), /바이너리/)
await assert.rejects(readChatAttachments([file('a.txt', new Uint8Array([255, 254]))], []), /UTF-8/)
})
test('rejects oversized files instead of silently truncating', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(100_001))], []), /100KB/)
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_001))], []), /5만 자/)
})
test('rejects aggregate attachment context above 100000 characters', async () => {
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_000)), file('b.txt', 'a'.repeat(50_000))], []), /10만 자/)
})
+207
View File
@@ -0,0 +1,207 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
const source = fs.readFileSync(require.resolve(file), 'utf8')
const code = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
const i18n = { t: key => key, useLocaleRender() {} }
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
const QaInputBox = () => null
const ChatTextContextMenu = () => null
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function setup(overrides = {}, browser = {}) {
const state = []
const effects = []
let index = 0
let cleared = 0
const modeChanges = []
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
'../../i18n': i18n,
'../../components/AppStatusModals': { ConfirmStatusModal },
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
'../../components/ChatWidgets': { QaInputBox },
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
'../../components/ChatImageAttachments': {},
'../../components/AgentProgressBubble': {},
'../../components/AgentWorkDuration': {},
'../../components/AgentCodeReferences': {},
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
'react-dom': { createPortal: child => child },
react: {
...React,
useEffect(effect) { effects.push(effect) },
useState(initial) {
const slot = index++
if (!(slot in state)) state[slot] = initial
return [state[slot], value => { state[slot] = value }]
}
}
}, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default
const props = {
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
onModeChange(mode) { modeChanges.push(mode) },
onClearConversation() { cleared++ }, ...overrides
}
return {
render() { index = 0; effects.length = 0; return panel(props) },
cleared: () => cleared,
runEffects() { effects.forEach(effect => effect()) },
modeChanges
}
}
for (const agentProvider of ['A_COPILOT', 'QWEN', 'OPENAI', 'ANTHROPIC', 'GEMINI']) {
for (const qaMode of ['ai', 'agent', 'teacher']) {
test(`attachment controls follow the provider and chat mode (${agentProvider}, ${qaMode})`, () => {
const harness = setup({ qaMode, agentProvider })
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
assert.equal(input.props.attachmentsEnabled, qaMode === 'agent' && agentProvider !== 'A_COPILOT')
assert.equal(typeof input.props.onClearConversation, 'function')
})
}
}
test('the workspace passes the current provider from agent status to the chat panel', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
assert.match(source, /provider: agentProvider,[\s\S]*?\} = useAiAgent\(/)
assert.match(source, /<WorkspaceQaPanel[\s\S]*?agentProvider=\{agentProvider\}/)
})
for (const qaMode of ['ai', 'agent', 'teacher']) {
test(`${qaMode} history is deleted only after shared modal confirmation`, () => {
const harness = setup({ qaMode, agentMessages: [{ id: 'a', content: 'answer' }], visibleTeacherMessages: [{ id: 't', content: 'answer' }] })
const input = tree => elements(tree, node => node.type === QaInputBox)[0]
const modal = tree => elements(tree, node => node.type === ConfirmStatusModal)[0]
assert.equal(modal(harness.render()), undefined)
input(harness.render()).props.onClearConversation()
assert.equal(harness.cleared(), 0)
modal(harness.render()).props.onCancel()
assert.equal(modal(harness.render()), undefined)
assert.equal(harness.cleared(), 0)
input(harness.render()).props.onClearConversation()
modal(harness.render()).props.onConfirm()
assert.equal(harness.cleared(), 1)
assert.equal(modal(harness.render()), undefined)
})
}
test('context menu deletion also requests confirmation', () => {
const harness = setup()
const root = harness.render()
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
menu.props.onClear()
assert.equal(harness.cleared(), 0)
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
})
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
const node = {}
const harness = setup({}, {
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') }
})
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
area.props.onContextMenu({
currentTarget: { contains: () => inside }, target: { closest: () => ({}) },
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
})
await Promise.resolve()
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
assert.equal(menu.props.menu.kind, 'message')
assert.equal(Boolean(menu.props.menu.text), selected && inside)
assert.equal(menu.props.canClear, true)
assert.equal(harness.cleared(), 0)
})
}
test('themed menu deletion still goes through the shared confirmation dialog', async () => {
const harness = setup()
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
await Promise.resolve()
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
menu.props.onClear()
assert.equal(harness.cleared(), 0)
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
assert.ok(modal)
modal.props.onConfirm()
assert.equal(harness.cleared(), 1)
})
for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
const harness = setup(props)
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
assert.equal(input.props.clearDisabled, true)
input.props.onClearConversation()
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 0)
assert.equal(harness.cleared(), 0)
})
}
for (const dark of [false, true]) {
test(`shared confirmation renders cancel left and delete right (${dark ? 'dark' : 'light'})`, () => {
const harness = setup({ dark })
elements(harness.render(), node => node.type === QaInputBox)[0].props.onClearConversation()
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
assert.equal(modal.props.dark, dark)
assert.match(modal.props.overlayClassName, /fixed.*z-\[180\]/)
const html = renderToStaticMarkup(modal)
assert.match(html, /취소<\/button><button[^>]*>삭제<\/button>/)
assert.match(html, /bg-\[#d14b4b\]/)
})
}
test('input no longer opens a native confirmation dialog', () => {
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
assert.doesNotMatch(source, /window\.confirm/)
assert.match(source, /onClick=\{onClearConversation\}/)
})
for (const dark of [false, true]) {
for (const agentEnabled of [false, true]) {
test(`agent availability controls tab count and layout (${dark ? 'dark' : 'light'}, enabled=${agentEnabled})`, () => {
const harness = setup({ dark, agentEnabled })
const grids = elements(harness.render(), node => node.props.className?.includes('mt-3 grid'))
assert.equal(grids.length, 1)
const tabs = elements(grids[0], node => node.type === 'button')
assert.deepEqual(tabs.map(tab => tab.props.children[1].props.children), agentEnabled ? ['AI', '에이전트', '강사'] : ['AI', '강사'])
assert.ok(grids[0].props.className.includes(agentEnabled ? 'grid-cols-3' : 'grid-cols-2'))
})
}
}
test('returns to AI when the selected agent is disabled after status loading', () => {
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: false, minimized: true })
harness.render()
harness.runEffects()
assert.deepEqual(harness.modeChanges, ['ai'])
})
test('does not reset the selected agent while status is still loading', () => {
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: true, minimized: true })
harness.render()
harness.runEffects()
assert.deepEqual(harness.modeChanges, [])
})
+81
View File
@@ -0,0 +1,81 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
test('custom menu clipboard IPC validates the main frame and rechecks paste availability', () => {
let clipboard = ''
let pastes = 0
const frame = {}
const sender = { mainFrame: frame, isDestroyed: () => false, paste() { pastes++ } }
const event = { sender, senderFrame: frame }
const native = compile('../src/main/chatTextMenu.ts', {
electron: { BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, clipboard: { readText: () => clipboard, writeText: text => { clipboard = text } } }
})
assert.equal(native.readChatClipboardState(event).hasText, false)
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'selected text' }), true)
assert.equal(clipboard, 'selected text')
assert.equal(native.readChatClipboardState(event).hasText, true)
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), true)
assert.equal(pastes, 1)
clipboard = ''
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
assert.equal(pastes, 1)
event.senderFrame = {}
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'blocked' }), false)
assert.equal(native.readChatClipboardState(event).hasText, false)
event.senderFrame = frame
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: '' }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'x'.repeat(5_000_001) }), false)
assert.equal(native.performChatClipboardAction(event, { action: 'unknown' }), false)
})
function renderMenu({ dark, kind = 'message', text = '', hasText = false, readOnly = false }) {
const menu = { kind, x: 20, y: 30, text, input: { readOnly, disabled: false } }
let state = 0
const { ChatTextContextMenu } = compile('../src/renderer/src/components/ChatTextContextMenu.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} },
'react-dom': { createPortal: child => child },
react: { ...React, useEffect() {}, useLayoutEffect() {}, useRef: initial => ({ current: initial }), useState() { return [state++ === 0 ? { menu, hasText } : { x: 20, y: 30 }, () => {}] } }
}, { document: { body: {} } })
return ChatTextContextMenu({ dark, menu, onClose() {}, onClear() {}, canClear: true })
}
for (const dark of [true, false]) {
test(`chat menu uses the terminal menu surface and item metrics (${dark ? 'dark' : 'light'})`, () => {
const tree = renderMenu({ dark, text: 'selected' })
const html = renderToStaticMarkup(tree)
assert.match(html, dark ? /bg-\[#252526\]/ : /bg-white/)
assert.match(html, dark ? /enabled:hover:bg-\[#37373d\]/ : /enabled:hover:bg-\[#eef1f7\]/)
assert.match(html, /rounded-md border py-1 text-\[12px\] font-medium/)
assert.match(html, /flex h-8 w-full cursor-pointer/)
assert.match(html, /role="menu"/)
assert.match(html, /role="menuitem"/)
assert.match(html, /대화내역 삭제/)
assert.doesNotMatch(html, /붙여넣기/)
})
}
for (const hasText of [true, false]) {
for (const readOnly of [true, false]) {
test(`paste disabled state follows clipboard and input editability (clipboard=${hasText}, readOnly=${readOnly})`, () => {
const tree = renderMenu({ dark: true, kind: 'input', hasText, readOnly })
const buttons = React.Children.toArray(tree.props.children).filter(child => child.type === 'button')
assert.equal(buttons[0].props.disabled, true)
assert.equal(buttons[1].props.disabled, !hasText || readOnly)
assert.doesNotMatch(renderToStaticMarkup(tree), /대화내역 삭제/)
})
}
}
+118
View File
@@ -0,0 +1,118 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function setup() {
const api = {}
const revoked = []
let count = 0
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, {
exports: api, TextDecoder,
createImageBitmap: async file => {
if (file.invalid) throw Error('invalid')
return { width: file.width ?? 10, height: file.height ?? 10, close() {} }
},
URL: { createObjectURL: () => `blob:fixture-${++count}`, revokeObjectURL: url => revoked.push(url) }
})
return { api, read: (files, existing = []) => api.readChatAttachments(files, existing, { imagesEnabled: true }), revoked }
}
const image = (extra = {}) => ({ name: 'screen.png', size: 100, ...extra })
test('image and text attachments coexist without counting image bytes as text context', async () => {
const h = setup()
const files = await h.read([image({ size: 5 * 1024 * 1024 }), { name: 'config.yaml', size: 10, arrayBuffer: async () => new TextEncoder().encode('robot: mini').buffer }])
assert.equal(files[0].kind, 'image')
assert.equal(files[0].previewUrl, 'blob:fixture-1')
assert.equal(files[1].content, 'robot: mini')
})
test('bounds image counts, byte sizes, decoded pixels and rejects malformed images', async () => {
const h = setup()
await assert.rejects(h.read([image({ size: 5 * 1024 * 1024 + 1 })]), /5MB/)
await assert.rejects(h.read([image({ width: 4001, height: 4001 })]), /1600만/)
await assert.rejects(h.read([image({ invalid: true })]), /읽지 못/)
await assert.rejects(h.read(Array.from({ length: 4 }, () => image())), /3장/)
assert.equal(h.revoked.length, 3)
})
test('a mixed batch failure revokes only newly created image previews', async () => {
const h = setup()
const existing = await h.read([image()])
await assert.rejects(h.read([image(), { name: 'file.pdf', size: 20 }], existing), /PDF/)
assert.deepEqual(h.revoked, ['blob:fixture-2'])
assert.equal(existing[0].previewUrl, 'blob:fixture-1')
})
test('restores cached multipart filenames while preserving valid Unicode and Latin-1 names', () => {
const { api } = setup()
const name = '스크린샷 2026-10-19 14-30-45.png'
assert.equal(api.displayAttachmentName(Buffer.from(name).toString('latin1')), name)
for (const value of [name, 'screen.png', 'caf\u00e9.png', '\uD83D\uDCF7.png']) assert.equal(api.displayAttachmentName(value), value)
})
function previewHarness() {
const { api } = setup()
const exports = {}
let preview = null
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatImageAttachments.tsx'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports,
document: { body: {} },
require(name) {
if (name === 'react') return { ...React, useEffect() {}, useRef: () => ({ current: null }), useState: () => [preview, value => { preview = value }] }
if (name === 'react-dom') return { createPortal: child => child }
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
if (name === './chatAttachments') return api
return require(name)
}
})
const find = (node, predicate) => {
if (!React.isValidElement(node)) return null
if (predicate(node)) return node
return React.Children.toArray(node.props.children).map(child => find(child, predicate)).find(Boolean)
}
return { render: props => exports.ChatImageAttachments(props), find }
}
for (const dark of [true, false]) {
test(`draft combines thumbnail, truncated filename and removal in one bounded item (${dark ? 'dark' : 'light'})`, () => {
const h = previewHarness()
const name = '스크린샷-긴파일이름-'.repeat(12) + '.png'
let removed
const props = { images: [{ name, url: 'blob:fixture' }], dark, onRemove: image => { removed = image.url } }
const tree = h.render(props)
const item = h.find(tree, node => node.props['data-chat-image-draft'])
assert.ok(item)
assert.match(item.props.className, /w-56.*max-w-full/)
assert.ok(h.find(item, node => node.type === 'img'))
const label = h.find(item, node => node.type === 'span')
assert.equal(label.props.children, name)
assert.match(label.props.className, /truncate/)
const zoom = h.find(item, node => node.type === 'button' && node.props.title === name)
assert.equal(zoom.props.title, name)
h.find(item, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.onClick()
assert.equal(removed, 'blob:fixture')
const busyItem = h.find(h.render({ ...props, removeDisabled: true }), node => node.props['data-chat-image-draft'])
assert.equal(h.find(busyItem, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.disabled, true)
})
}
test('cached garbled names are restored in the thumbnail label and enlarged dialog', () => {
const h = previewHarness()
const name = '스크린샷 2026-10-19 14-30-45.png'
const props = { images: [{ name: Buffer.from(name).toString('latin1'), url: 'https://files.test/image.png' }], variant: 'sent' }
const tree = h.render(props)
assert.equal(h.find(tree, node => node.type === 'button').props.title, name)
assert.equal(h.find(tree, node => node.props['data-chat-image-draft']), undefined)
h.find(tree, node => node.type === 'button').props.onClick()
const html = renderToStaticMarkup(h.render(props))
assert.match(html, /<dialog[^>]*aria-label="스크린샷 2026-10-19 14-30-45.png"/)
assert.ok(html.includes(`>${name}</span>`))
})
+137
View File
@@ -0,0 +1,137 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
function harness() {
let layoutEffects = []
let effects = []
let resizeCallback
let disconnected = false
const states = []
const refs = []
let stateIndex = 0
let refIndex = 0
const submissions = []
const input = {
style: {}, scrollHeight: 56,
getBoundingClientRect: () => ({ width: 300 })
}
const exports = {}
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports,
window: { getComputedStyle: () => ({ lineHeight: '20px', paddingTop: '10px', paddingBottom: '10px', borderTopWidth: '0px', borderBottomWidth: '0px', minHeight: '56px' }) },
ResizeObserver: class {
constructor(callback) { resizeCallback = callback }
observe(element) { assert.equal(element, input) }
disconnect() { disconnected = true }
},
require(name) {
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
if (name === './chatAttachments') return {}
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
if (name === './ChatCodeBlock') return {}
if (name === 'react') return {
...React,
useState(initial) {
const index = stateIndex++
if (!(index in states)) states[index] = initial
return [states[index], value => { states[index] = value }]
},
useRef(initial) { return refs[refIndex++] ??= { current: initial } },
useEffect(callback) { effects.push(callback) },
useLayoutEffect(callback) { layoutEffects.push(callback) }
}
return require(name)
}
})
function find(node, type) {
if (!React.isValidElement(node)) return null
if (node.type === type) return node
return React.Children.toArray(node.props.children).map(child => find(child, type)).find(Boolean)
}
function render() {
stateIndex = 0
refIndex = 0
layoutEffects = []
effects = []
const tree = exports.QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit: content => submissions.push(content) })
const textarea = find(tree, 'textarea')
textarea.props.ref.current = input
layoutEffects.forEach(callback => callback())
return textarea
}
return {
input, render, submissions,
mountEffects: () => effects.map(callback => callback()),
resize: width => resizeCallback([{ contentRect: { width } }]),
disconnected: () => disconnected
}
}
test('input grows to eight visible lines and scrolls only above the cap', () => {
const h = harness()
h.render()
assert.equal(h.input.style.height, '56px')
assert.equal(h.input.style.overflowY, 'hidden')
h.input.scrollHeight = 100
h.render()
assert.equal(h.input.style.height, '100px')
h.input.scrollHeight = 180
h.render()
assert.equal(h.input.style.height, '180px')
assert.equal(h.input.style.overflowY, 'hidden')
h.input.scrollHeight = 300
h.render()
assert.equal(h.input.style.height, '180px')
assert.equal(h.input.style.overflowY, 'auto')
})
test('deleting text and submitting restore the initial height', () => {
const h = harness()
let textarea = h.render()
textarea.props.onChange({ target: { value: 'question\nsecond line\nthird line' } })
h.input.scrollHeight = 80
textarea = h.render()
assert.equal(h.input.style.height, '80px')
let prevented = false
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: {}, preventDefault() { prevented = true } })
assert.equal(prevented, true)
assert.equal(h.submissions.length, 1)
h.input.scrollHeight = 40
textarea = h.render()
assert.equal(textarea.props.value, '')
assert.equal(h.input.style.height, '56px')
assert.equal(h.input.style.overflowY, 'hidden')
})
test('Shift+Enter and IME composition do not submit', () => {
const h = harness()
let textarea = h.render()
textarea.props.onChange({ target: { value: 'question' } })
textarea = h.render()
const preventDefault = () => assert.fail('newline or composing Enter must not be intercepted')
textarea.props.onKeyDown({ key: 'Enter', shiftKey: true, nativeEvent: {}, preventDefault })
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: true }, preventDefault })
assert.equal(h.submissions.length, 0)
})
test('panel width changes recalculate wrapped lines and observer disconnects on unmount', () => {
const h = harness()
h.render()
const cleanups = h.mountEffects()
h.input.scrollHeight = 120
h.resize(180)
assert.equal(h.input.style.height, '120px')
h.input.scrollHeight = 50
h.resize(400)
assert.equal(h.input.style.height, '56px')
cleanups.forEach(cleanup => cleanup?.())
assert.equal(h.disconnected(), true)
})
+111
View File
@@ -0,0 +1,111 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
exports, require: name => overrides[name] ?? require(name), clearTimeout, setTimeout, ...globals
})
return exports
}
const i18n = { t: key => key, useLocaleRender() {} }
const block = compile('../src/renderer/src/components/ChatCodeBlock.tsx', { '../i18n': i18n })
const { MarkdownMessage } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': i18n, './ChatCodeBlock': block, './chatAttachments': {}, './ChatImageAttachments': {}, './ChatTextContextMenu': {}
})
const render = (content, dark = true, extra = {}) => renderToStaticMarkup(React.createElement(MarkdownMessage, { content, dark, ...extra }))
for (const dark of [true, false]) {
test(`renders numbered/nested/task lists without losing the starting number (${dark})`, () => {
const html = render('3. 설정 확인\n - 속도 제한\n - 조향 제한\n4. 결과 확인\n\n- [x] 완료\n- [ ] 대기', dark)
assert.match(html, /<ol start="3" class="[^"]*list-decimal/)
assert.match(html, /<ul class="[^"]*list-disc/)
assert.match(html, /contains-task-list list-none/)
assert.match(html, /task-list-item list-none/)
assert.equal((html.match(/type="checkbox"/g) ?? []).length, 2)
})
test(`renders GFM tables with keyboard-scrollable framing and column alignment (${dark})`, () => {
const html = render('| 모듈 | 속도 |\n| --- | ---: |\n| Control | `0.3 m/s` |', dark)
assert.match(html, /data-chat-table/)
assert.match(html, /tabindex="0" role="region" aria-label="표"/)
assert.match(html, /overflow-x-auto/)
assert.match(html, /<table class="[^"]*table-auto/)
assert.match(html, /text-align:right/)
assert.match(html, /chat-inline-code/)
})
test(`renders fenced Python with language, token colors, copy control and preserved indentation (${dark})`, () => {
const html = render('```python\ndef speed():\n return 0.3\n```', dark)
assert.match(html, /data-chat-code-block/)
assert.ok(html.includes(`data-code-theme="${dark ? 'dark' : 'light'}"`))
assert.match(html, />Python<\/span>/)
assert.match(html, /aria-label="코드 복사"/)
assert.match(html, /hljs-keyword/)
assert.match(html, /hljs-number/)
assert.match(html, /\n <span/)
assert.match(html, /max-h-80 max-w-full overflow-auto/)
assert.match(html, /whitespace-pre font-mono/)
assert.doesNotMatch(html, /chat-inline-code/)
})
}
test('unlabelled and unknown languages remain code blocks rather than inline code', () => {
for (const language of ['', 'unknown-language']) {
const html = render('```' + language + '\n unformatted <value>\n```')
assert.match(html, /data-chat-code-block/)
assert.ok(html.includes(language || '일반 텍스트'))
assert.ok(html.includes(' unformatted &lt;value&gt;'))
assert.doesNotMatch(html, /hljs-/)
}
})
test('HTML-looking code is rendered as escaped text, never live elements', () => {
const html = render('```html\n<script>window.injected = true</script>\n<img src=x onerror="window.injected=true">\n```')
assert.doesNotMatch(html, /<script\b|<img\b/)
assert.match(html, /&lt;/)
assert.match(html, /window.injected/)
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatCodeBlock.tsx'), 'utf8')
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
})
test('only newly revealed code and tables receive the fade class', () => {
const markdown = '설명\n\n```yaml\nspeed: 0.3\n```\n\n| 키 | 값 |\n| --- | --- |\n| speed | 0.3 |'
const html = render(markdown, true, { fadeFrom: markdown.indexOf('| 키') })
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
assert.match(html, /overflow-x-auto rounded-md agent-response-fade/)
})
test('oversized code skips tokenization but still preserves all content', () => {
const source = '# comment\n'.repeat(6000)
const html = renderToStaticMarkup(React.createElement(block.ChatCodeBlock, { source, language: 'python', dark: true }))
assert.doesNotMatch(html, /hljs-comment/)
assert.equal((html.match(/# comment/g) ?? []).length, 6000)
})
function elements(tree, predicate) {
if (!React.isValidElement(tree)) return []
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
}
for (const fails of [false, true]) test(`code copy preserves the full source and reports clipboard ${fails ? 'failure' : 'success'}`, async () => {
let state = 'idle', copied, timer
const api = compile('../src/renderer/src/components/ChatCodeBlock.tsx', {
'../i18n': i18n,
react: { ...React, useState: () => [state, value => { state = value }], useMemo: fn => fn(), useRef: () => ({ current: null }), useEffect() {} }
}, { navigator: { clipboard: { writeText: async text => { if (fails) throw Error('denied'); copied = text } } }, setTimeout: callback => { timer = callback; return 1 }, clearTimeout() {} })
const source = '\t# 한글 주석\n speed = 0.3\n\n'
const renderBlock = () => api.ChatCodeBlock({ source, language: 'python', dark: true })
const button = elements(renderBlock(), element => element.type === 'button')[0]
let stopped = false
button.props.onClick({ stopPropagation() { stopped = true } })
await new Promise(resolve => setImmediate(resolve))
assert.equal(stopped, true)
assert.equal(state, fails ? 'error' : 'copied')
if (!fails) assert.equal(copied, source)
assert.equal(elements(renderBlock(), element => element.type === 'button')[0].props['aria-label'], fails ? '코드를 복사하지 못했습니다.' : '복사됨')
timer()
assert.equal(state, 'idle')
})
+145
View File
@@ -0,0 +1,145 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
function compile(file, overrides = {}, globals = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
return exports
}
function nativeMenu() {
let template
let close
let clipboard = ''
let copies = 0
let pastes = 0
const frame = {}
const sender = { mainFrame: frame, isDestroyed: () => false, copy() { copies++ }, paste() { pastes++ } }
const event = { sender, senderFrame: frame }
const { showChatTextMenu } = compile('../src/main/chatTextMenu.ts', {
electron: {
BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) },
clipboard: { readText: () => clipboard },
Menu: { buildFromTemplate(items) {
template = items
return { popup(options) { close = options.callback } }
} }
}
})
return {
show: options => showChatTextMenu(event, options),
items: () => template,
close: () => close(),
setClipboard: text => { clipboard = text },
copies: () => copies, pastes: () => pastes, event
}
}
const labels = { copy: '복사', paste: '붙여넣기', clear: '대화내역 삭제' }
for (const canCopy of [false, true]) {
test(`message copy is enabled only with a selection (selected=${canCopy})`, async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy, canClear: true, labels })
assert.equal(menu.items()[0].enabled, canCopy)
assert.equal(menu.items()[0].label, '복사')
assert.equal(menu.items().some(item => item.label === '붙여넣기'), false)
menu.items()[0].click()
assert.equal(await result, null)
assert.equal(menu.copies(), canCopy ? 1 : 0)
})
}
for (const clipboard of ['', '복사해 둔 내용']) {
test(`input paste availability is read from the OS clipboard (empty=${!clipboard})`, async () => {
const menu = nativeMenu()
menu.setClipboard(clipboard)
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
assert.equal(menu.items().length, 2)
assert.equal(menu.items()[0].enabled, false)
assert.equal(menu.items()[1].label, '붙여넣기')
assert.equal(menu.items()[1].enabled, Boolean(clipboard))
menu.items()[1].click()
await result
assert.equal(menu.pastes(), clipboard ? 1 : 0)
})
}
test('read-only input never allows paste, even with clipboard text', async () => {
const menu = nativeMenu()
menu.setClipboard('text')
const result = menu.show({ kind: 'input', canCopy: true, canPaste: false, labels })
assert.equal(menu.items()[1].enabled, false)
menu.items()[1].click()
await result
assert.equal(menu.pastes(), 0)
})
test('paste rechecks an emptied clipboard before editing the input', async () => {
const menu = nativeMenu()
menu.setClipboard('text')
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
assert.equal(menu.items()[1].enabled, true)
menu.setClipboard('')
menu.items()[1].click()
await result
assert.equal(menu.pastes(), 0)
})
test('delete selection returns a confirmation request rather than deleting data', async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy: false, canClear: true, labels })
menu.items().find(item => item.label === '대화내역 삭제').click()
assert.equal(await result, 'clear')
})
test('closing a menu without choosing an action does nothing', async () => {
const menu = nativeMenu()
const result = menu.show({ kind: 'message', canCopy: true, labels })
menu.close()
assert.equal(await result, null)
assert.equal(menu.copies(), 0)
})
test('untrusted frames and invalid menu targets cannot open a native menu', () => {
const menu = nativeMenu()
assert.equal(menu.show({ kind: 'unknown', canCopy: true, labels }), null)
menu.event.senderFrame = {}
assert.equal(menu.show({ kind: 'input', canCopy: true, labels }), null)
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
for (const selected of [false, true]) {
test(`textarea opens the themed menu with a selection snapshot (selected=${selected})`, async () => {
let options
let prevented = false
let stopped = false
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, './ChatCodeBlock': {},
react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }) }
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
const textarea = elements(tree, node => node.type === 'textarea')[0]
textarea.props.onContextMenu({
currentTarget: { value: 'abcdef', selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
preventDefault() { prevented = true }, stopPropagation() { stopped = true }
})
await Promise.resolve()
assert.equal(options.kind, 'input')
assert.equal(options.text, selected ? 'cde' : '')
assert.equal(options.input.disabled, false)
assert.equal(options.selectionStart, 2)
assert.equal(prevented, true)
assert.equal(stopped, true)
})
}
+67
View File
@@ -0,0 +1,67 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/AppStatusModals.tsx'), 'utf8')
const compiled = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText
function loadModals() {
const exports = {}
vm.runInNewContext(compiled, {
exports,
require: name => name === '../i18n' ? { t: key => key, useLocaleRender() {} } : require(name)
})
return exports
}
function buttons(element) {
if (!React.isValidElement(element)) return []
if (element.type === 'button') return [element]
return React.Children.toArray(element.props.children).flatMap(buttons)
}
for (const dark of [false, true]) {
test(`extension action stays on the right in both dialogs (${dark ? 'dark' : 'light'})`, () => {
const { StatusNoticeModal, ControlExtensionConfirmModal } = loadModals()
let confirmed = 0
let extended = 0
const notice = StatusNoticeModal({
overlayClassName: 'overlay', dark, tone: 'clock', title: '제어 시간 안내', message: '제어 시간이 곧 종료됩니다.',
actionLabel: '연장', onAction: () => { extended++ }, onConfirm: () => { confirmed++ }
})
const noticeButtons = buttons(notice)
assert.deepEqual(noticeButtons.map(button => button.props.children), ['확인', '연장'])
assert.match(noticeButtons[1].props.className, /bg-\[#6f4df6\]/)
assert.doesNotMatch(noticeButtons[0].props.className, /bg-\[#6f4df6\]/)
noticeButtons[0].props.onClick()
noticeButtons[1].props.onClick()
assert.equal(confirmed, 1)
assert.equal(extended, 1)
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark, platformName: 'MK-mini', onCancel() {}, onConfirm() {} })
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장'])
assert.match(buttons(confirmation)[1].props.className, /bg-\[#6f4df6\]/)
assert.match(renderToStaticMarkup(confirmation), /취소<\/button><button[^>]*>연장<\/button>/)
})
}
test('notices without an extension action retain their single primary confirmation button', () => {
const { StatusNoticeModal } = loadModals()
const notice = StatusNoticeModal({ overlayClassName: 'overlay', dark: true, tone: 'warning', title: '안내', message: '메시지', onConfirm() {} })
assert.equal(buttons(notice).length, 1)
assert.equal(buttons(notice)[0].props.children, '확인')
assert.match(buttons(notice)[0].props.className, /bg-\[#6f4df6\]/)
})
test('the extension confirmation keeps the right button disabled while submitting', () => {
const { ControlExtensionConfirmModal } = loadModals()
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark: true, platformName: 'MK-mini', submitting: true, onCancel() {}, onConfirm() {} })
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장 중'])
assert.ok(buttons(confirmation).every(button => button.props.disabled))
})
+61
View File
@@ -0,0 +1,61 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const crypto = require('node:crypto')
const postcss = require('postcss')
const root = path.resolve(__dirname, '..')
const source = fs.readFileSync(path.join(root, 'src/renderer/src/styles/nanumSquare.css'), 'utf8')
const css = postcss.parse(source)
const faces = []
css.walkAtRules('font-face', rule => {
faces.push(Object.fromEntries(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node.value])))
})
function codepoints(range) {
const result = new Set()
let previous = -1
for (const entry of range.split(',')) {
const match = entry.trim().match(/^U\+([0-9A-F]+)(?:-([0-9A-F]+))?$/)
assert.ok(match, `Invalid unicode-range: ${entry}`)
const start = parseInt(match[1], 16)
const end = parseInt(match[2] ?? match[1], 16)
assert.ok(start > previous && end >= start && end <= 0x10ffff)
for (let code = start; code <= end; code++) result.add(code)
previous = end
}
return result
}
test('coverage is tied to the original bundled font bytes for all four weights', () => {
assert.equal(faces.length, 4)
assert.deepEqual(faces.map(face => face['font-weight']), ['300', '400', '700', '800'])
for (const face of faces) {
assert.equal(face['font-family'], "'NanumSquare'")
assert.equal(face['font-display'], 'swap')
const filename = face.src.match(/\/([^/]+\.woff2)'/)[1]
const hash = crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'src/renderer/public/assets/fonts', filename))).digest('hex')
assert.ok(source.includes(`${filename} sha256: ${hash}`), 'Regenerate coverage if a font changes')
}
})
for (const face of faces) {
test(`weight ${face['font-weight']} preserves visible Hangul and lets blank glyphs fall back`, () => {
const included = codepoints(face['unicode-range'])
assert.equal([...included].filter(code => code >= 0xac00 && code <= 0xd7a3).length, 2479)
for (const char of '가모한글학생SLAM0123 →') assert.ok(included.has(char.codePointAt(0)), `Keep ${char}`)
for (const char of ['몯', '힣', '\u2022']) assert.equal(included.has(char.codePointAt(0)), false, `Let ${char} use fallback`)
for (const code of [0x20, 0x3000, 0x3164]) assert.ok(included.has(code), 'Preserve intentional spacing')
})
}
test('UI keeps its original default font and native fallback without Neo', () => {
const entry = fs.readFileSync(path.join(root, 'src/renderer/src/index.css'), 'utf8')
assert.match(entry, /@import "\.\/styles\/nanumSquare\.css"/)
assert.match(entry, /font-family: 'NanumSquare', Inter, system-ui, sans-serif/)
assert.doesNotMatch(entry + source, /NanumSquareNeo/)
const directFaces = []
postcss.parse(entry).walkAtRules('font-face', rule => directFaces.push(rule))
assert.equal(directFaces.length, 0, 'Unrestricted duplicate faces would suppress fallback')
})
+111
View File
@@ -0,0 +1,111 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const React = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
const source = fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx'), 'utf8')
const output = {}
let videos = []
vm.runInNewContext(ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
}).outputText, {
exports: output,
require: name => name === '../../i18n' ? { t: key => key, useLocaleRender() {} }
: name === 'react' ? { ...React, useId: () => 'guide' } : require(name),
URL,
window: { learningVideo: { open: async url => { videos.push(url) } } }
})
function elements(root, predicate) {
if (!React.isValidElement(root)) return []
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
}
function setup(overrides = {}) {
const toggled = []
const props = {
dark: true, workspaceActive: true, minimized: false, resourceMode: 'material',
materialItems: [{ title: 'SLAM', description: '센서 토픽과 TF 상태를 점검하고 지도를 생성합니다.', concepts: ['TF 점검', '지도 저장'], videoUrl: '' }],
activeMaterialIndex: 0, expandedMaterialIndexes: [0], topics: [], topicDetails: [], overview: '',
resources: [], downloadingResourceIds: [], onToggleMaterial: index => toggled.push(index), ...overrides
}
const tree = output.default(props)
const headers = elements(tree, node => node.type === 'button' && node.props['aria-controls'])
return { tree, headers, toggled }
}
for (const dark of [false, true]) {
test(`only the material header toggles the card (${dark ? 'dark' : 'light'})`, () => {
const { tree, headers, toggled } = setup({ dark })
assert.equal(headers.length, 1)
const header = headers[0]
assert.equal(header.props.type, 'button')
assert.equal(header.props['aria-expanded'], true)
assert.equal(header.props.disabled, false)
assert.doesNotMatch(header.props.className, /hover:bg/)
assert.match(header.props.className, /focus-visible:ring-2/)
const card = elements(tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
assert.ok(card)
assert.equal(card.props.onClick, undefined)
assert.equal(card.props.onKeyDown, undefined)
assert.equal(card.props.role, undefined)
assert.equal(card.props.tabIndex, undefined)
header.props.onClick()
assert.deepEqual(toggled, [0])
})
test(`description is selectable outside the interactive header (${dark ? 'dark' : 'light'})`, () => {
const { tree, headers, toggled } = setup({ dark })
const contentId = headers[0].props['aria-controls']
const body = elements(tree, node => node.props.id === contentId)[0]
assert.match(body.props.className, /select-text/)
assert.equal(body.props.onClick, undefined)
assert.equal(elements(headers[0], node => node.props.id === contentId).length, 0)
assert.ok(renderToStaticMarkup(body).includes('센서 토픽과 TF 상태'))
assert.equal(elements(body, node => Boolean(node.props.onClick || node.props.onKeyDown)).length, 0)
assert.deepEqual(toggled, [])
})
test(`header padding and dimensions stay identical when toggled (${dark ? 'dark' : 'light'})`, () => {
const expanded = setup({ dark, expandedMaterialIndexes: [0] })
const collapsed = setup({ dark, expandedMaterialIndexes: [] })
for (const state of [expanded, collapsed]) {
const header = state.headers[0]
const card = elements(state.tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
assert.match(card.props.className, /\bpx-3 pt-3\b/)
assert.doesNotMatch(card.props.className, /px-2\.5|pt-2\.5/)
assert.match(header.props.className, /\bflex h-6 w-full\b/)
}
assert.equal(expanded.headers[0].props.className, collapsed.headers[0].props.className)
})
}
test('future material remains disabled, while previous/current steps keep native keyboard buttons', () => {
const items = ['이전', '현재', '다음'].map(title => ({ title, description: '', videoUrl: '' }))
const { headers } = setup({ materialItems: items, activeMaterialIndex: 1, expandedMaterialIndexes: [1] })
assert.deepEqual(headers.map(header => header.props.disabled), [false, false, true])
assert.equal(headers[2].props.onClick, undefined)
for (const header of headers) {
assert.equal(header.props.type, 'button')
assert.equal(header.props.onKeyDown, undefined)
}
assert.ok(setup({ materialItems: items, allowFutureMaterialSteps: true }).headers.every(header => !header.props.disabled))
})
test('video playback is not nested in the collapse button and does not toggle the card', async () => {
videos = []
const { tree, headers, toggled } = setup({ materialItems: [{ title: 'SLAM', description: '설명', videoUrl: 'https://www.youtube.com/watch?v=test123' }] })
const video = elements(tree, node => node.type === 'button' && node.props.className?.includes('aspect-video'))[0]
assert.ok(video)
assert.equal(elements(headers[0], node => node === video).length, 0)
video.props.onClick({ stopPropagation() {} })
await Promise.resolve()
assert.deepEqual(videos, ['https://www.youtube.com/embed/test123'])
assert.deepEqual(toggled, [])
for (const button of elements(tree, node => node.type === 'button')) {
assert.equal(React.Children.toArray(button.props.children).flatMap(child => elements(child, node => node.type === 'button')).length, 0)
}
})
+33
View File
@@ -9,6 +9,39 @@ const { createInstance } = require('i18next')
const root = path.resolve(__dirname, '../src/renderer/src') const root = path.resolve(__dirname, '../src/renderer/src')
const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8')) const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8'))
test('only the default simulation description follows language; vehicle and custom text stay unchanged', () => {
const { api } = setup('ko')
const exports = {}
const source = fs.readFileSync(path.join(root, 'features/platforms/PlatformCards.tsx'), 'utf8')
const code = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
} }).outputText
vm.runInNewContext(code, { exports, require: name => name === '../../i18n'
? { ...api, useLocaleRender() {} } : require(name) })
const { createElement } = require('react')
const { renderToStaticMarkup } = require('react-dom/server')
const description = '내 PC에서 직접 개발 환경을 실행합니다.'
const platform = { id: 'local', platformKey: 'futuredrive-local-pc', title: '로컬 PC', description, state: 'ready' }
const render = value => renderToStaticMarkup(createElement(exports.PlatformOptionCard, {
platform: value, Icon: () => null, dotClassName: '', unavailable: false,
statusPending: false, displayStatus: '사용 가능', primaryAction: '연결',
actionDisabled: false, running: false, onAction() {}, onSettings() {}
}))
assert.ok(render(platform).includes(description))
api.setLanguage('en')
assert.ok(render(platform).includes('Run the development environment directly on this PC.'))
assert.ok(!render(platform).includes(description))
const vehicleDescription = '기초 SLAM 및 자율주행 실습 장비입니다.'
assert.ok(render({ ...platform, platformKey: 'mk-mini', description: vehicleDescription }).includes(vehicleDescription))
assert.ok(render({ ...platform, platformKey: 'mk-mini' }).includes(description))
assert.ok(render({ ...platform, description: '강사가 작성한 시뮬레이션 안내' }).includes('강사가 작성한 시뮬레이션 안내'))
assert.ok(render({ ...platform, description: '설정' }).includes('>설정</p>'))
api.setLanguage('ko')
assert.ok(render(platform).includes(description))
assert.equal(platform.description, description)
})
test('close studio modal translates the built-in Local PC title but preserves vehicle names', () => { test('close studio modal translates the built-in Local PC title but preserves vehicle names', () => {
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8') const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
const expression = source.match(/platformTitle=\{(runningPlatformCloseTarget\.platformKey[^\n]+)\}/)?.[1] const expression = source.match(/platformTitle=\{(runningPlatformCloseTarget\.platformKey[^\n]+)\}/)?.[1]
+86
View File
@@ -0,0 +1,86 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const names = ['isRemotePlatformStatusPending', 'filteredPlatformOptions', 'platformFilterCounts']
const declarations = new Map()
function visit(node) {
if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(source))) {
declarations.set(node.name.getText(source), node.getText(source))
}
ts.forEachChild(node, visit)
}
visit(source)
for (const name of names) assert.ok(declarations.has(name), `missing ${name}`)
const filterScript = ts.transpileModule(`${names.map(name => `const ${declarations.get(name)}`).join('\n')}
result = { counts: platformFilterCounts, ids: filteredPlatformOptions.map(platform => platform.id) }`, {
compilerOptions: { target: ts.ScriptTarget.ES2022 }
}).outputText
const controlSource = fs.readFileSync(require.resolve('../src/renderer/src/features/platforms/platformControl.ts'), 'utf8')
const control = {}
vm.runInNewContext(ts.transpileModule(controlSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, {
exports: control,
require: name => {
assert.equal(name, '../../config/appConfig')
return { LMS_API_BASE_URL: 'http://unused.invalid' }
}
})
function classify(platformOptions, checkedIds = ['mini'], platformFilter = 'all') {
const context = {
platformOptions, platformFilter,
checkedRemotePlatformStatusIds: new Set(checkedIds),
isPlatformUnavailable: control.isPlatformUnavailable
}
vm.runInNewContext(filterScript, context)
return JSON.parse(JSON.stringify(context.result))
}
const simulation = { id: 'sim', platformKey: 'futuredrive-local-pc', state: 'ready', status: '사용 가능', action: '시작' }
const mini = { id: 'mini', platformKey: 'mkmini', state: 'ready', status: '사용 가능', action: '제어권 요청', connection: { headscaleOnline: true, agentHealthy: true } }
test('disconnected ready platform counts as unavailable and appears only in that tab', () => {
const disconnected = { ...mini, status: '연결 끊김', connection: { headscaleOnline: false, agentHealthy: false } }
const platforms = [simulation, disconnected]
assert.deepEqual(classify(platforms), { counts: { all: 2, available: 1, unavailable: 1 }, ids: ['sim', 'mini'] })
assert.deepEqual(classify(platforms, ['mini'], 'available').ids, ['sim'])
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
})
test('offline network, unhealthy agent and explicit unavailability all exclude ready platforms', () => {
for (const patch of [
{ connection: { headscaleOnline: false, agentHealthy: true } },
{ connection: { headscaleOnline: true, agentHealthy: false } },
{ connection: undefined, status: '연결 끊김' },
{ connection: undefined, status: '연결 불가' },
{ connection: undefined, action: '연결 불가' }
]) {
const platforms = [simulation, { ...mini, ...patch }]
assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 })
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
}
})
test('pending remote status remains in All without prematurely entering either availability tab', () => {
const platforms = [simulation, mini]
assert.deepEqual(classify(platforms, []).counts, { all: 2, available: 1, unavailable: 0 })
assert.deepEqual(classify(platforms, [], 'available').ids, ['sim'])
assert.deepEqual(classify(platforms, [], 'unavailable').ids, [])
})
test('reconnecting moves a platform back to available without changing All', () => {
assert.deepEqual(classify([simulation, mini]).counts, { all: 2, available: 2, unavailable: 0 })
assert.deepEqual(classify([simulation, mini], ['mini'], 'available').ids, ['sim', 'mini'])
assert.deepEqual(classify([simulation, mini], ['mini'], 'unavailable').ids, [])
})
test('locked, requested and reserved platform classifications are preserved', () => {
for (const state of ['locked', 'request', 'reserved']) {
const platforms = [simulation, { ...mini, state }]
assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 })
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
}
})
+18
View File
@@ -46,3 +46,21 @@ test('theme synchronizes across windows without writing runtime settings', () =>
events.get('storage')({ key: null }) events.get('storage')({ key: null })
assert.equal(api.useStudioTheme(), 'dark') assert.equal(api.useStudioTheme(), 'dark')
}) })
test('module content scrollbar uses workspace colors without thinner scrollbar dimensions', () => {
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(path.join(root, 'ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
const scrollClasses = []
function visit(node) {
if (ts.isJsxAttribute(node) && node.name.getText(source) === 'className' && node.initializer && ts.isStringLiteral(node.initializer)) {
const classes = node.initializer.text.split(/\s+/)
if (classes.includes('overflow-x-auto')) scrollClasses.push(classes)
}
ts.forEachChild(node, visit)
}
visit(source)
assert.ok(scrollClasses.length > 0)
for (const classes of scrollClasses) {
assert.ok(classes.includes('module-content-scrollbar'))
assert.ok(!classes.includes('thin-x-scrollbar'))
}
})
+78
View File
@@ -0,0 +1,78 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const vm = require('node:vm')
const ts = require('typescript')
const { execFile } = require('node:child_process')
const { promisify } = require('node:util')
const exec = promisify(execFile)
const root = path.resolve(__dirname, '..')
const placeholder = 'Binary or large file preview is not available.'
const xml = '<?xml version="1.0"?>\n<model><name>mkmini_gazebo</name><description>주행 모델</description></model>\n'
function compile(source, context = {}) {
const exports = {}
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports, require, Buffer, ...context })
return exports
}
async function fixture(run) {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-xml-editor-'))
try {
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) await fs.writeFile(path.join(dir, name), xml)
await fs.writeFile(path.join(dir, 'large.config'), 'x'.repeat(1024 * 1024 + 1))
await fs.writeFile(path.join(dir, 'model.glb'), Buffer.from([0, 1, 2, 3]))
await run(dir)
} finally { await fs.rm(dir, { recursive: true, force: true }) }
}
test('local module and root collectors read small Gazebo XML files without changing binary and size exclusions', async () => {
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const functions = source.statements.filter(node => ts.isFunctionDeclaration(node) && ['collectModuleFiles', 'collectRootFiles'].includes(node.name?.text))
const pattern = source.statements.find(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(source) === 'textFilePattern')
assert.equal(functions.length, 2)
assert.ok(pattern)
await fixture(async dir => {
const api = compile([pattern.getText(source), ...functions.map(node => `export ${node.getText(source)}`)].join('\n'), {
readdir: fs.readdir, stat: fs.stat, readFile: fs.readFile,
resolve: path.resolve, basename: path.basename,
skippedCacheDirs: new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']),
findModuleRoot: async () => dir,
toRendererModulePath: (name, file, base) => `modules/${name}/${path.relative(base, file)}`,
toRendererRootPath: (name, file, base) => `${name}/${path.relative(base, file)}`
})
for (const result of [await api.collectModuleFiles('NAV2-MKMini'), await api.collectRootFiles(dir, 'examples')]) {
const files = Object.fromEntries(Object.entries(result.files).map(([file, content]) => [path.basename(file), content]))
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal(files[name], xml)
for (const name of ['large.config', 'model.glb']) assert.equal(files[name], placeholder)
}
})
})
test('remote listing, content and search include Gazebo XML while preserving size, NUL and path protections', async () => {
const api = compile(await fs.readFile(path.join(root, 'src/main/remoteModuleFiles.ts'), 'utf8'))
await fixture(async dir => {
const request = async input => JSON.parse((await exec('bash', ['-c', api.remoteModuleFilesScript(dir, input)], { timeout: 10000 })).stdout)
const listing = await request({ action: 'list' })
assert.ok(listing.filePaths.includes('model.config'))
for (const file of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal((await request({ action: 'content', path: file })).content, xml)
for (const file of ['large.config', 'model.glb']) assert.equal((await request({ action: 'content', path: file })).content, placeholder)
await fs.writeFile(path.join(dir, 'binary.config'), Buffer.from('text\0binary'))
assert.equal((await request({ action: 'content', path: 'binary.config' })).content, placeholder)
const matches = await request({ action: 'search', query: 'mkmini_gazebo' })
assert.deepEqual(matches.map(match => match.file).sort(), ['MODEL.CONFIG', 'model.config', 'model.sdf', 'simulation.world'].sort())
await assert.rejects(request({ action: 'content', path: '../outside.config' }), /Invalid module path/)
await fs.symlink(path.join(dir, '..'), path.join(dir, 'escape'))
await assert.rejects(request({ action: 'content', path: 'escape/outside.config' }), /Invalid module path/)
})
})
test('Gazebo model.config, SDF and world files use XML highlighting without treating arbitrary config formats as XML', async () => {
const api = compile(await fs.readFile(path.join(root, 'src/renderer/src/features/workspace/workspaceFiles.ts'), 'utf8'))
for (const file of ['model.config', 'models/mkmini/model.config', 'models/MODEL.CONFIG', 'model.sdf', 'simulation.world', 'WORLD.SDF', 'package.xml', 'robot.urdf', 'robot.xacro']) assert.equal(api.getEditorLanguage(file), 'xml', file)
assert.equal(api.getEditorLanguage('app.config'), 'plaintext')
assert.equal(api.getEditorLanguage('control.yaml'), 'yaml')
assert.equal(api.getEditorLanguage('model.glb'), 'plaintext')
})
+137
View File
@@ -0,0 +1,137 @@
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 startLine = args.startLine ?? 1
const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content
const selected = selection.slice(0, 40_000)
const contentTruncated = selected.length < selection.length
const complete = startLine === 1 && selected === content
const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated))
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete }
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// A missing leaf in an existing directory is evidence for a new file.
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)))
}
}
+186
View File
@@ -0,0 +1,186 @@
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')
ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000
start = options.get('startLine', 1)
selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
selected = selection[:40000]
content_truncated = len(selected) < len(selection)
complete = start == 1 and selected == content
partial = not complete
end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated))
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)}
except FileNotFoundError:
checked(root, path, True)
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
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('지원하지 않는 런타임 조회입니다.')
}
+61
View File
@@ -0,0 +1,61 @@
import { BrowserWindow, clipboard, Menu } from 'electron'
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
import type { ChatClipboardAction, ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu'
function trustedChatFrame(event: IpcMainInvokeEvent): boolean {
if (event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return false
const window = BrowserWindow.fromWebContents(event.sender)
return Boolean(window && !window.isDestroyed())
}
export function readChatClipboardState(event: IpcMainInvokeEvent): { hasText: boolean } {
return { hasText: trustedChatFrame(event) && clipboard.readText().length > 0 }
}
export function performChatClipboardAction(event: IpcMainInvokeEvent, input: ChatClipboardAction): boolean {
if (!trustedChatFrame(event) || !input) return false
if (input.action === 'copy' && typeof input.text === 'string' && input.text.length > 0 && input.text.length <= 5_000_000) {
clipboard.writeText(input.text)
return true
}
if (input.action === 'paste' && clipboard.readText().length > 0) {
event.sender.paste()
return true
}
return false
}
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) })
})
}
+355 -9
View File
@@ -1,8 +1,13 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron' import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { appLog } from './appLogging' import { appLog } from './appLogging'
import { checkDockerStatus } from './dockerStatus' import { checkDockerStatus } from './dockerStatus'
import { performChatClipboardAction, readChatClipboardState, showChatTextMenu } from './chatTextMenu'
import { cameraRuntimeArtifacts } from './cameraRuntime' import { cameraRuntimeArtifacts } from './cameraRuntime'
import { remoteModuleFilesScript } from './remoteModuleFiles' 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 { resolveContainerTerminal } from './containerTerminal'
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell' import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport' import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
@@ -73,7 +78,7 @@ async function readAppDiagnostics(): Promise<{ path: string; content: string }>
} }
} }
const skippedCacheDirs = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']) const skippedCacheDirs = new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker'])
const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i const textFilePattern = /\.(c|cc|cpp|cxx|h|hpp|hh|py|xml|urdf|xacro|sdf|world|config|yaml|yml|json|txt|md|cmake|launch|sh|bash|toml|ini|cfg|rviz)$/i
const imageFilePattern = /\.(png|jpe?g|webp|gif|svg)$/i const imageFilePattern = /\.(png|jpe?g|webp|gif|svg)$/i
const maxImagePreviewBytes = 10 * 1024 * 1024 const maxImagePreviewBytes = 10 * 1024 * 1024
const explicitModuleFolders: Record<string, string> = { const explicitModuleFolders: Record<string, string> = {
@@ -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() const readProgress = createModuleProgressParser()
return new Promise<string>((resolvePromise, rejectPromise) => { return new Promise<string>((resolvePromise, rejectPromise) => {
let output = '' let output = ''
@@ -1803,7 +1808,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
const child = spawn(command, args, { const child = spawn(command, args, {
cwd, cwd,
detached: process.platform !== 'win32', detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'], stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
env: { env: {
...process.env, ...process.env,
...env, ...env,
@@ -1826,13 +1831,17 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`)) rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
}, timeoutMs) }, timeoutMs)
child.stdout.setEncoding('utf8') if (input !== undefined) {
child.stdout.on('data', (data: string) => { 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() stdout += data.toString()
output += data.toString() output += data.toString()
readProgress(data.toString()) readProgress(data.toString())
}) })
child.stderr.on('data', (data: Buffer) => { child.stderr?.on('data', (data: Buffer) => {
output += data.toString() output += data.toString()
}) })
child.on('error', (error) => { 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) const normalized = normalizeRemoteTarget(target)
return runCommandOutput( return runCommandOutput(
'ssh', 'ssh',
@@ -2249,6 +2258,7 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri
remoteShellEnvironment(process.env), remoteShellEnvironment(process.env),
timeoutMs, timeoutMs,
'stdout', 'stdout',
input,
) )
} }
@@ -2553,6 +2563,23 @@ async function startRemoteVisualizationBridge(
].join('\n'), 900_000) ].join('\n'), 900_000)
} }
const command = bridgeContainerCommand(stack) const command = bridgeContainerCommand(stack)
let bridgeContextRevision: string | null = null
if (stack !== 'autoware') {
const context = resolveResourceContextPath('resources/slam-ros-bridge')
if (!context) throw new Error('Bundled SLAM bridge source is missing')
const files = { 'bridge.py': await readFile(join(context, 'bridge.py'), 'utf8'), 'requirements.txt': await readFile(join(context, 'requirements.txt'), 'utf8') }
bridgeContextRevision = createHash('sha256').update(JSON.stringify(files)).digest('hex')
await runUnqueuedSshOutput(target, `python3 -c ${quoteShellSingle([
'import json, os, pathlib, sys',
'data = json.load(sys.stdin)',
'root = pathlib.Path.home() / ".autonomy-studio/runtime/bridges" / data["revision"]',
'root.mkdir(parents=True, exist_ok=True)',
'for name in ("bridge.py", "requirements.txt"):',
' temporary = root / ("." + name + "." + str(os.getpid()) + ".tmp")',
' temporary.write_text(data["files"][name], encoding="utf-8")',
' temporary.replace(root / name)',
].join('\n'))}`, 30_000, JSON.stringify({ revision: bridgeContextRevision, files }))
}
reportVisualizationProgress('시각화 시작 중...') reportVisualizationProgress('시각화 시작 중...')
@@ -2570,6 +2597,7 @@ async function startRemoteVisualizationBridge(
'docker run -d --name "$CONTAINER" --net=host', 'docker run -d --name "$CONTAINER" --net=host',
'-v /dev/shm:/dev/shm', '-v /dev/shm:/dev/shm',
'-v "$ROOT:/workspace"', '-v "$ROOT:/workspace"',
...(bridgeContextRevision ? [`-v "$HOME/.autonomy-studio/runtime/bridges/${bridgeContextRevision}:/app:ro"`] : []),
...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []), ...(fastDdsConfigPath ? ['-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro"', '-e FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml', '-e FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml'] : []),
'-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"', '-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"',
'-e ROS_LOCALHOST_ONLY=0', '-e ROS_LOCALHOST_ONLY=0',
@@ -4767,10 +4795,19 @@ function buildNav2DockerRunScript(): string {
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }', 'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
...yahboomDevicePreflight(), ...yahboomDevicePreflight(),
'CAMERA_DEVICE=""', 'CAMERA_DEVICE=""',
'CAMERA_DEVICES=()',
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then', 'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
' CAMERA_DEVICE=/dev/video0', ' CAMERA_DEVICE=/dev/video0',
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done', ' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }', ' if [ "$CAMERA_DEVICE" = /dev ]; then',
' CAMERA_DISCOVERY="$MODULE_ROOT/$MODULE_FOLDER/src/camera_test_pkg/camera_test_pkg/devices.py"',
' test -f "$CAMERA_DISCOVERY" || { echo "Update and rebuild the camera module for automatic discovery" >&2; exit 1; }',
' camera_devices=$(python3 "$CAMERA_DISCOVERY" /dev) || exit 1',
' mapfile -t CAMERA_DEVICES <<< "$camera_devices"',
' else CAMERA_DEVICES=("$CAMERA_DEVICE"); fi',
' for camera_device in "${CAMERA_DEVICES[@]}"; do',
' [[ "$camera_device" =~ ^/dev/video[0-9]+$ ]] && [ -c "$camera_device" ] || { echo "Camera device not found: $camera_device" >&2; exit 1; }',
' done',
'fi', 'fi',
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
'', '',
@@ -4790,7 +4827,7 @@ function buildNav2DockerRunScript(): string {
')', ')',
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi', 'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
'if [ -n "$CAMERA_DEVICE" ]; then', 'if [ -n "$CAMERA_DEVICE" ]; then',
' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")', ' for camera_device in "${CAMERA_DEVICES[@]}"; do args+=(--device "$camera_device:$camera_device"); done',
' for capture_device in /dev/capture-vi-channel*; do', ' for capture_device in /dev/capture-vi-channel*; do',
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi', ' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
' done', ' done',
@@ -5811,6 +5848,291 @@ async function readRemoteWorkspaceModuleLogs(
} }
} }
type AgentCommandResult = {
ok: boolean
output: string
exitCode: number | null
message?: string
}
const allowedAgentExecutables = new Set([
'bash', 'cat', 'cmake', 'colcon', 'find', 'git', 'grep', 'head', 'ls', 'make', 'ninja', 'node', 'npm', 'npx',
'pnpm', 'printenv', 'pwd', 'python', 'python3', 'pytest', 'rg', 'ros2', 'sed', 'sh', 'tail', 'which', 'yarn'
])
function validateAgentCommand(input: string): string {
const command = input.trim()
if (!command || command.length > 2000) throw new Error('명령 길이가 허용 범위를 벗어났습니다.')
if (/[\0\r\n`;{}<>]|\$\(|\.\.\//.test(command)) throw new Error('명령 치환, 경로 이탈 또는 리다이렉션은 허용되지 않습니다.')
if (/\b(?:sudo|su|shutdown|reboot|poweroff|halt|mkfs|fdisk|parted|mount|umount|passwd|useradd|userdel|chown)\b/i.test(command)) {
throw new Error('시스템 권한 또는 설정을 변경하는 명령은 허용되지 않습니다.')
}
if (/\brm\b|\b(?:curl|wget)\b[^|]*\|/i.test(command)) throw new Error('삭제 또는 원격 스크립트 실행 명령은 허용되지 않습니다.')
if (/(^|\s)(?:~\/(?:\.ssh|\.aws|\.config)|\/[^\s]*\/\.(?:ssh|aws)\/|\/etc\/|\/boot\/|\/proc\/|\/sys\/|\/dev\/|\.env(?:\s|$))/i.test(command)) {
throw new Error('시스템 또는 자격증명 경로에는 접근할 수 없습니다.')
}
const withoutAnd = command.replace(/&&|\|\|/g, '')
if (/[&]/.test(withoutAnd)) throw new Error('백그라운드 명령은 허용되지 않습니다.')
const segments = command.split(/&&|\|\||\|/).map((segment) => segment.trim()).filter(Boolean)
for (const segment of segments) {
const tokens = segment.split(/\s+/).filter(Boolean)
while (tokens[0] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens.shift()
const executable = tokens[0]
if (!executable) throw new Error('실행할 명령을 확인할 수 없습니다.')
if (executable === 'source' || executable === '.') {
const sourcePath = tokens[1] ?? ''
if (!/^(?:\/opt\/ros\/[A-Za-z0-9._-]+\/setup\.bash|\.?\/?(?:install|build)\/setup\.bash)$/.test(sourcePath)) {
throw new Error('허용되지 않은 환경 설정 파일입니다.')
}
continue
}
if (executable.startsWith('./')) {
if (!/^\.\/[A-Za-z0-9_./-]+\.sh$/.test(executable)) throw new Error('모듈 내부의 셸 스크립트만 실행할 수 있습니다.')
continue
}
if (!allowedAgentExecutables.has(executable)) throw new Error(`허용되지 않은 실행 파일입니다: ${executable}`)
if (executable === 'bash' || executable === 'sh') {
const scriptPath = tokens[1] ?? ''
if (!/^(?:\.\/)?[A-Za-z0-9_./-]+\.sh$/.test(scriptPath)) {
throw new Error('bash와 sh는 모듈 내부의 셸 스크립트만 실행할 수 있습니다.')
}
}
if ((executable === 'python' || executable === 'python3') && tokens.some((token) => token === '-c')) {
throw new Error('인라인 Python 코드는 실행할 수 없습니다.')
}
if (executable === 'node' && tokens.some((token) => token === '-e' || token === '--eval')) {
throw new Error('인라인 Node.js 코드는 실행할 수 없습니다.')
}
if (executable === 'find' && tokens.some((token) => token === '-exec' || token === '-execdir' || token === '-delete')) {
throw new Error('find의 실행 및 삭제 옵션은 허용되지 않습니다.')
}
if (executable === 'sed' && tokens.some((token) => token === '-i' || token.startsWith('-i'))) {
throw new Error('명령을 통한 파일 직접 수정은 허용되지 않습니다.')
}
if (executable === 'git' && !/^(?:status|diff|log|show|rev-parse|branch)$/.test(tokens[1] ?? '')) {
throw new Error('Agent는 Git 조회 명령만 실행할 수 있습니다.')
}
if (executable === 'ros2' && !/^(?:topic|node|interface|doctor|pkg|run|launch)$/.test(tokens[1] ?? '')) {
throw new Error('허용되지 않은 ROS2 명령입니다.')
}
if (executable === 'ros2' && /\b(?:pub|call|send_goal)\b/.test(segment)) {
throw new Error('ROS2 발행 및 제어 명령은 Agent에서 실행할 수 없습니다.')
}
}
return command
}
function appendAgentCommandOutput(current: string, chunk: string): string {
const next = current + chunk
return next.length > 80_000 ? next.slice(-80_000) : next
}
function isAgentRos2Command(command: string): boolean {
return /(?:^|&&|\|\|?|;)\s*ros2\s+/.test(command)
}
function isAgentRos2RuntimeCommand(command: string): boolean {
return /(?:^|&&|\|\|?|;)\s*ros2\s+(?:run|launch)\b/.test(command)
}
function agentContainerCommandArgs(container: { id: string; cwd: string }, command: string, observeRuntime: boolean): string[] {
const init = [
'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi',
'if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi',
command
].join('; ')
return [
'exec', '-w', container.cwd, container.id,
...(observeRuntime ? ['timeout', '--signal=INT', '--kill-after=5s', '20s'] : []),
'bash', '--noprofile', '--norc', '-lc', init
]
}
async function resolveLocalAgentContainer(moduleRoot: string, folderName?: string | null): Promise<{ id: string; cwd: string }> {
const info = parseModuleDockerInfo(folderName?.trim() || basename(moduleRoot))
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('모듈 컨테이너 정보를 확인할 수 없습니다.')
const inspect = await runCommandOutput('docker', ['inspect', info.containerKey], undefined, undefined, 10_000)
return resolveContainerTerminal(inspect, moduleRoot)
}
async function resolveRemoteAgentContainer(
target: ReturnType<typeof normalizeRemoteTarget>,
moduleRoot: string,
folderName?: string | null
): Promise<{ id: string; cwd: string }> {
const info = parseModuleDockerInfo(folderName?.trim() || basename(moduleRoot))
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('모듈 컨테이너 정보를 확인할 수 없습니다.')
const inspect = await runSshOutput(target, `docker inspect ${quoteShellSingle(info.containerKey)}`, 10_000)
return resolveContainerTerminal(inspect, moduleRoot)
}
async function runLocalAgentCommand(moduleName: string, commandInput: string, workspaceRoot?: string | null, folderName?: string | null): Promise<AgentCommandResult> {
const command = validateAgentCommand(commandInput)
const moduleRoot = await findWorkspaceModuleRoot(moduleName, workspaceRoot, folderName)
if (!moduleRoot) throw new Error('설치된 모듈을 찾을 수 없습니다.')
const observeRuntime = isAgentRos2RuntimeCommand(command)
const container = isAgentRos2Command(command) ? await resolveLocalAgentContainer(moduleRoot, folderName) : null
const executable = container ? 'docker' : '/bin/bash'
const args = container
? agentContainerCommandArgs(container, command, observeRuntime)
: ['--noprofile', '--norc', '-lc', command]
return new Promise<AgentCommandResult>((resolvePromise) => {
let output = ''
let timedOut = false
const child = spawn(executable, args, {
cwd: container ? undefined : moduleRoot,
detached: process.platform !== 'win32',
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
})
const timeout = setTimeout(() => {
timedOut = true
if (process.platform !== 'win32' && child.pid) {
try { process.kill(-child.pid, 'SIGTERM') } catch { /* already exited */ }
} else child.kill('SIGTERM')
}, observeRuntime ? 30_000 : 120_000)
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', (data: string) => { output = appendAgentCommandOutput(output, data) })
child.stderr.on('data', (data: string) => { output = appendAgentCommandOutput(output, data) })
child.on('error', (error) => {
clearTimeout(timeout)
resolvePromise({ ok: false, output, exitCode: null, message: error.message })
})
child.on('close', (code) => {
clearTimeout(timeout)
const exitCode = typeof code === 'number' ? code : null
const observationCompleted = observeRuntime && exitCode === 124
if (observationCompleted) {
output = appendAgentCommandOutput(output, '\n[20초 동안 실행을 확인한 뒤 프로세스를 종료했습니다.]\n')
}
resolvePromise({
ok: !timedOut && (exitCode === 0 || observationCompleted),
output,
exitCode: observationCompleted ? 0 : exitCode,
message: timedOut ? `명령 실행 시간이 ${observeRuntime ? 30 : 120}초를 초과했습니다.` : undefined
})
})
})
}
async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleName: string, commandInput: string, folderName?: string | null): Promise<AgentCommandResult> {
const command = validateAgentCommand(commandInput)
const resolved = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName)
const marker = '__AUTONOMY_AGENT_EXIT_CODE__:'
try {
const observeRuntime = isAgentRos2RuntimeCommand(command)
const container = isAgentRos2Command(command)
? await resolveRemoteAgentContainer(resolved.target, resolved.moduleRoot, folderName)
: null
const invocation = container
? ['docker', ...agentContainerCommandArgs(container, command, observeRuntime)].map(quoteShellSingle).join(' ')
: `bash --noprofile --norc -lc ${quoteShellSingle(command)}`
const rawOutput = await runSshOutput(resolved.target, [
`cd ${quoteShellSingle(resolved.moduleRoot)}`,
`${invocation} 2>&1`,
'CODE=$?',
`printf '\\n${marker}%s\\n' "$CODE"`,
].join('; '), observeRuntime ? 35_000 : 130_000)
const markerIndex = rawOutput.lastIndexOf(marker)
const exitCode = markerIndex >= 0 ? Number.parseInt(rawOutput.slice(markerIndex + marker.length).trim(), 10) : null
const observationCompleted = observeRuntime && exitCode === 124
let output = appendAgentCommandOutput('', markerIndex >= 0 ? rawOutput.slice(0, markerIndex).trimEnd() : rawOutput)
if (observationCompleted) output = appendAgentCommandOutput(output, '\n[20초 동안 실행을 확인한 뒤 프로세스를 종료했습니다.]')
return {
ok: exitCode === 0 || observationCompleted,
output,
exitCode: observationCompleted ? 0 : exitCode,
message: markerIndex < 0 ? '원격 명령의 종료 코드를 확인하지 못했습니다.' : undefined
}
} catch (error) {
return { ok: false, output: '', exitCode: null, message: error instanceof Error ? error.message : String(error) }
}
}
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> { async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName) const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] } if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
@@ -6475,6 +6797,26 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers()) ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers())
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: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: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: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: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)) ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
@@ -6539,6 +6881,7 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('remote-workspace:ros:reset-session', (_event, target: RemoteWorkspaceTarget) => resetRemoteRosSession(target)) ipcMain.handle('remote-workspace:ros:reset-session', (_event, target: RemoteWorkspaceTarget) => resetRemoteRosSession(target))
ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName)) ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
ipcMain.handle('remote-workspace:agent-command:run', (_event, target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string | null) => runRemoteAgentCommand(target, moduleName, command, folderName))
ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target)) ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target))
ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork))) ipcMain.handle('remote-workspace:visualization:start', (_event, target: RemoteWorkspaceTarget, stack?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, requestId?: string) => visualizationProgress.run((message) => { if (!_event.sender.isDestroyed()) _event.sender.send('visualization:progress', requestId, message) }, () => startRemoteVisualizationBridge(target, stack, rosDomainId, environmentProfile, rosNetwork)))
ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target)) ipcMain.handle('remote-workspace:visualization:stop', (_event, target: RemoteWorkspaceTarget) => stopRemoteVisualizationBridge(target))
@@ -6720,6 +7063,9 @@ app.whenReady().then(() => {
return { opened: true } return { opened: true }
}) })
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics()) ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
ipcMain.handle('chat-text:context-menu', showChatTextMenu)
ipcMain.handle('chat-text:clipboard-state', readChatClipboardState)
ipcMain.handle('chat-text:clipboard-action', performChatClipboardAction)
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close()) ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
ipcMain.on('app-quit', () => app.quit()) ipcMain.on('app-quit', () => app.quit())
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
+1 -1
View File
@@ -7,7 +7,7 @@ import base64, json, os, stat, sys
args = json.loads(base64.b64decode(sys.argv[1])) args = json.loads(base64.b64decode(sys.argv[1]))
root = os.path.realpath(args['root']) root = os.path.realpath(args['root'])
skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'} skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'}
extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'} extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.sdf', '.world', '.config', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'}
placeholder = 'Binary or large file preview is not available.' placeholder = 'Binary or large file preview is not available.'
def content(relative): def content(relative):
if os.path.isabs(relative) or '..' in relative.split('/'): if os.path.isabs(relative) or '..' in relative.split('/'):
+15
View File
@@ -117,6 +117,11 @@ console.info('[lms-preload] launcher auth resolved', {
}) })
contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('electron', electronAPI)
contextBridge.exposeInMainWorld('chatTextMenu', {
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options),
readClipboardState: () => ipcRenderer.invoke('chat-text:clipboard-state'),
perform: (action: import('../shared/chatTextMenu').ChatClipboardAction) => ipcRenderer.invoke('chat-text:clipboard-action', action)
})
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth) contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('lmsBootSplash', { contextBridge.exposeInMainWorld('lmsBootSplash', {
status: (message: string) => ipcRenderer.send('lms-boot-status', message), status: (message: string) => ipcRenderer.send('lms-boot-status', message),
@@ -215,6 +220,11 @@ contextBridge.exposeInMainWorld('localWorkspace', {
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:rename', moduleName, fromPath, toPath, workspaceRoot, folderName), rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:rename', moduleName, fromPath, toPath, workspaceRoot, folderName),
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName) 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: { modules: {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules), list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'), listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'),
@@ -300,6 +310,11 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName), rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName),
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName) 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: { maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts), readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath), copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
@@ -0,0 +1,113 @@
export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
const cameras = new Map();
let enabled = false;
let timer = null;
function sources() {
const { state, receivedAt } = getState();
if (!enabled || Date.now() - receivedAt >= 3000) return [];
const list = Array.isArray(state?.cameras) ? state.cameras
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
const seen = new Set();
return list.filter(item => {
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
seen.add(item.id);
return true;
}).slice(0, 8);
}
function clearFrame(camera) {
if (camera.url) URL.revokeObjectURL(camera.url);
camera.url = null;
camera.image.removeAttribute('src');
}
function remove(id, camera) {
cameras.delete(id);
camera.controller?.abort();
clearFrame(camera);
camera.element.remove();
}
async function frame(id, camera) {
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
const controller = new AbortController();
camera.controller = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error('Camera frame unavailable');
const blob = await response.blob();
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
const previous = camera.url;
camera.url = URL.createObjectURL(blob);
camera.image.src = camera.url;
camera.retryAt = 0;
if (previous) URL.revokeObjectURL(previous);
} catch (_) {
if (cameras.get(id) === camera) {
camera.retryAt = Date.now() + 5000;
clearFrame(camera);
}
} finally {
window.clearTimeout(timeout);
if (camera.controller === controller) camera.controller = null;
}
}
function refresh() {
const live = sources();
const ids = new Set(live.map(item => item.id));
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
for (const item of live) {
let camera = cameras.get(item.id);
if (!camera) {
const element = document.createElement('div');
element.className = 'camera-preview-item';
element.dataset.cameraId = item.id;
const label = document.createElement('div');
label.className = 'camera-preview-label';
const image = document.createElement('img');
image.className = 'camera-preview-image';
element.append(label, image);
root.append(element);
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
image.addEventListener('error', () => {
if (cameras.get(item.id) === camera) {
clearFrame(camera);
camera.retryAt = Date.now() + 5000;
}
});
cameras.set(item.id, camera);
}
camera.label.textContent = item.name || item.id;
camera.image.alt = item.name || item.id;
void frame(item.id, camera);
}
onAvailable(enabled && cameras.size > 0);
}
function visibilityChanged() {
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
else refresh();
}
document.addEventListener('visibilitychange', visibilityChanged);
function setEnabled(value) {
enabled = value;
if (timer) window.clearInterval(timer);
timer = null;
refresh();
if (enabled) timer = window.setInterval(refresh, 200);
}
return {
setEnabled,
refresh,
dispose() {
setEnabled(false);
document.removeEventListener('visibilitychange', visibilityChanged);
},
};
}
+80 -110
View File
@@ -24,11 +24,12 @@
#miniMapToggle { #miniMapToggle {
position:absolute; position:absolute;
right:16px; right:16px;
bottom:240px; bottom:calc(16px + 220px + 2px);
z-index:11; z-index:11;
min-height:28px; min-height:28px;
padding:0 10px; padding:0 10px;
border:1px solid rgba(255,255,255,.22); border:1px solid rgba(255,255,255,.22);
border-radius:6px 6px 0 0;
background:rgba(17,24,39,.42); background:rgba(17,24,39,.42);
color:rgba(255,255,255,.88); color:rgba(255,255,255,.88);
font:12px sans-serif; font:12px sans-serif;
@@ -60,14 +61,29 @@
#cameraPreviewWrap.is-visible { display:block; } #cameraPreviewWrap.is-visible { display:block; }
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; } #cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; } #cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
#cameraPreview { #cameraPreviewList {
max-height:min(60vh, 600px);
overflow-y:auto;
scrollbar-width:thin;
scrollbar-color:rgba(148,163,184,.65) transparent;
}
.camera-preview-item + .camera-preview-item { border-top:1px solid rgba(255,255,255,.18); }
.camera-preview-label {
padding:5px 8px;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color:rgba(255,255,255,.85);
font:11px sans-serif;
}
.camera-preview-image {
display:block; display:block;
width:100%; width:100%;
aspect-ratio:16 / 9; aspect-ratio:16 / 9;
object-fit:contain; object-fit:contain;
background:#111520; background:#111520;
} }
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; } #cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; }
#cameraPreviewToggle { #cameraPreviewToggle {
position:absolute; position:absolute;
left:0; left:0;
@@ -78,6 +94,7 @@
place-items:center; place-items:center;
padding:0; padding:0;
border:1px solid rgba(255,255,255,.22); border:1px solid rgba(255,255,255,.22);
border-radius:0 0 6px 6px;
background:rgba(17,24,39,.42); background:rgba(17,24,39,.42);
color:rgba(255,255,255,.9); color:rgba(255,255,255,.9);
font:11px sans-serif; font:11px sans-serif;
@@ -109,6 +126,7 @@
display:none; display:none;
} }
#keyGuide { #keyGuide {
display:none;
position:absolute; position:absolute;
left:16px; left:16px;
bottom:16px; bottom:16px;
@@ -127,7 +145,7 @@
#rosTopicStatusWrap { #rosTopicStatusWrap {
position:absolute; position:absolute;
left:16px; left:16px;
bottom:54px; bottom:16px;
z-index:10; z-index:10;
width:178px; width:178px;
} }
@@ -150,6 +168,7 @@
place-items:center; place-items:center;
padding:0; padding:0;
border:1px solid rgba(255,255,255,.22); border:1px solid rgba(255,255,255,.22);
border-radius:6px 6px 0 0;
background:rgba(17,24,39,.42); background:rgba(17,24,39,.42);
color:rgba(255,255,255,.9); color:rgba(255,255,255,.9);
font:11px sans-serif; font:11px sans-serif;
@@ -252,6 +271,7 @@
#cameraPresetSelect { #cameraPresetSelect {
min-height:32px; min-height:32px;
border:1px solid rgba(255,255,255,.22); border:1px solid rgba(255,255,255,.22);
border-radius:6px;
background:rgba(17,24,39,.42); background:rgba(17,24,39,.42);
color:rgba(255,255,255,.88); color:rgba(255,255,255,.88);
font:12px sans-serif; font:12px sans-serif;
@@ -261,29 +281,43 @@
box-shadow:0 10px 26px rgba(0,0,0,.16); box-shadow:0 10px 26px rgba(0,0,0,.16);
transition:background .15s ease, border-color .15s ease, color .15s ease; transition:background .15s ease, border-color .15s ease, color .15s ease;
} }
#downloadMap, .viewer-icon-button {
#pathToggle, display:grid;
#sensorToggle, place-items:center;
#poseToggle, width:34px;
#goalToggle, height:32px;
#obstacleToggle { flex:0 0 34px;
padding:0 11px; padding:0;
} }
#downloadMap { .camera-preset-wrap {
display:inline-flex; position:relative;
align-items:center; width:34px;
gap:6px; height:32px;
} flex:0 0 34px;
}
.camera-preset-wrap > svg {
position:absolute;
inset:0;
margin:auto;
color:rgba(255,255,255,.88);
pointer-events:none;
}
#cameraPresetSelect { #cameraPresetSelect {
min-width:86px; width:100%;
padding:0 24px 0 9px; height:100%;
outline:none; padding:0;
color:transparent;
appearance:none; appearance:none;
background-image:linear-gradient(45deg, transparent 50%, rgba(255,255,255,.7) 50%), linear-gradient(135deg, rgba(255,255,255,.7) 50%, transparent 50%);
background-position:calc(100% - 13px) 14px, calc(100% - 8px) 14px;
background-size:5px 5px, 5px 5px;
background-repeat:no-repeat;
} }
#cameraPresetSelect option {
color:rgba(255,255,255,.88);
background:#202020;
}
.viewer-icon-button:focus-visible,
#cameraPresetSelect:focus-visible {
outline:2px solid #93c5fd;
outline-offset:2px;
}
#downloadMap:hover, #downloadMap:hover,
#pathToggle:hover, #pathToggle:hover,
#sensorToggle:hover, #sensorToggle:hover,
@@ -296,6 +330,7 @@
border-color:rgba(255,255,255,.34); border-color:rgba(255,255,255,.34);
color:#fff; color:#fff;
} }
#cameraPresetSelect:hover { color:transparent; }
#downloadMap:disabled { #downloadMap:disabled {
opacity:.45; opacity:.45;
cursor:not-allowed; cursor:not-allowed;
@@ -374,41 +409,39 @@
.nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; } .nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; }
.nav2-navigation #downloadWrap { display:none; } .nav2-navigation #downloadWrap { display:none; }
.nav2-navigation #topLeftControls { display:block; } .nav2-navigation #topLeftControls { display:block; }
.nav2-navigation #keyGuide { display:none; }
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.real-runtime #topLeftControls { display:none; } .real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; }
.real-teleop:not(.editor-mode) #keyGuide { display:block; }
.real-teleop #rosTopicStatusWrap { bottom:50px; }
</style> </style>
</head> </head>
<body> <body>
<div id="topLeftControls"> <div id="topLeftControls">
<button id="obstacleToggle" title="벽 배치" data-i18n-title="벽 배치" aria-label="벽 배치" data-i18n-aria-label="벽 배치" aria-pressed="false">Wall</button> <button id="obstacleToggle" class="viewer-icon-button" title="벽 배치" data-i18n-title="벽 배치" aria-label="벽 배치" data-i18n-aria-label="벽 배치" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-brick-wall" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 9v6M16 15v6M16 3v6M3 15h18M3 9h18M8 15v6M8 3v6"/></svg></button>
</div> </div>
<div id="topRightControls"> <div id="topRightControls">
<div id="downloadWrap"> <div id="downloadWrap">
<button id="downloadMap" title="맵 저장" data-i18n-title="맵 저장" aria-label="맵 저장" data-i18n-aria-label="맵 저장" disabled><span>SAVE</span></button> <button id="downloadMap" class="viewer-icon-button" title="맵 저장" data-i18n-title="맵 저장" aria-label="맵 저장" data-i18n-aria-label="맵 저장" disabled><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save" aria-hidden="true"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7M7 3v4a1 1 0 0 0 1 1h7"/></svg></button>
</div> </div>
<button id="pathToggle" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시">Path</button> <button id="pathToggle" class="viewer-icon-button" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-route" aria-hidden="true"><circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></svg></button>
<button id="sensorToggle" title="센서 표시" data-i18n-title="센서 표시" aria-label="센서 표시" data-i18n-aria-label="센서 표시" aria-pressed="false">Sensor</button> <button id="sensorToggle" class="viewer-icon-button" title="센서 표시" data-i18n-title="센서 표시" aria-label="센서 표시" data-i18n-aria-label="센서 표시" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-radar" aria-hidden="true"><path d="M19.07 4.93A10 10 0 0 0 6.99 3.34M4 6h.01M2.29 9.62A10 10 0 1 0 21.31 8.35M16.24 7.76A6 6 0 1 0 8.23 16.67M12 18h.01M17.99 11.66A6 6 0 0 1 15.77 16.67"/><circle cx="12" cy="12" r="2"/><path d="m13.41 10.59 5.66-5.66"/></svg></button>
<button id="cameraToggle" type="button" title="카메라 영상 표시" data-i18n-title="카메라 영상 표시" aria-label="카메라 영상 표시" data-i18n-aria-label="카메라 영상 표시" aria-pressed="true" aria-controls="cameraPreviewWrap"> <button id="cameraToggle" class="viewer-icon-button" type="button" title="카메라 영상 표시" data-i18n-title="카메라 영상 표시" aria-label="카메라 영상 표시" data-i18n-aria-label="카메라 영상 표시" aria-pressed="true" aria-controls="cameraPreviewWrap">
<!-- Lucide Camera icon. --> <!-- Lucide Camera icon. -->
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/></svg>
</button> </button>
<button id="poseToggle" title="초기 위치 지정" data-i18n-title="초기 위치 지정" aria-label="초기 위치 지정" data-i18n-aria-label="초기 위치 지정">Pose</button> <button id="poseToggle" class="viewer-icon-button" title="초기 위치 지정" data-i18n-title="초기 위치 지정" aria-label="초기 위치 지정" data-i18n-aria-label="초기 위치 지정"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-locate-fixed" aria-hidden="true"><path d="M2 12h3M19 12h3M12 2v3M12 19v3"/><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="3"/></svg></button>
<button id="goalToggle" title="목표 위치 지정" data-i18n-title="목표 위치 지정" aria-label="목표 위치 지정" data-i18n-aria-label="목표 위치 지정">Goal</button> <button id="goalToggle" class="viewer-icon-button" title="목표 위치 지정" data-i18n-title="목표 위치 지정" aria-label="목표 위치 지정" data-i18n-aria-label="목표 위치 지정"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-flag" aria-hidden="true"><path d="M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528"/></svg></button>
<div class="camera-preset-wrap">
<select id="cameraPresetSelect" title="카메라 시점" data-i18n-title="카메라 시점" aria-label="카메라 시점" data-i18n-aria-label="카메라 시점"> <select id="cameraPresetSelect" title="카메라 시점" data-i18n-title="카메라 시점" aria-label="카메라 시점" data-i18n-aria-label="카메라 시점">
<option value="default">Default</option> <option value="default">Default</option>
<option value="top">Top</option> <option value="top">Top</option>
<option value="follow">Follow</option> <option value="follow">Follow</option>
</select> </select>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panels-top-left" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
</div>
</div> </div>
<canvas id="miniMap" width="260" height="220"></canvas> <canvas id="miniMap" width="260" height="220"></canvas>
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button> <button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
<div id="cameraPreviewWrap"> <div id="cameraPreviewWrap">
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button> <button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" /> <div id="cameraPreviewList"></div>
</div> </div>
<div id="keyGuide"> <div id="keyGuide">
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div> <div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
@@ -445,6 +478,7 @@
<script type="module" src="./locale.js"></script> <script type="module" src="./locale.js"></script>
<script type="module"> <script type="module">
import { t, setText } from './locale.js'; import { t, setText } from './locale.js';
import { createCameraPreview } from './camera-preview.js';
import * as THREE from 'three'; import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
@@ -484,7 +518,7 @@ const miniMap = document.getElementById('miniMap');
const miniCtx = miniMap.getContext('2d'); const miniCtx = miniMap.getContext('2d');
const miniMapToggle = document.getElementById('miniMapToggle'); const miniMapToggle = document.getElementById('miniMapToggle');
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap'); const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
const cameraPreview = document.getElementById('cameraPreview'); const cameraPreviewList = document.getElementById('cameraPreviewList');
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle'); const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
const cameraToggleButton = document.getElementById('cameraToggle'); const cameraToggleButton = document.getElementById('cameraToggle');
const topRightControls = document.getElementById('topRightControls'); const topRightControls = document.getElementById('topRightControls');
@@ -919,12 +953,7 @@ let pointCloudRefreshTimer = null;
let savedMapPointCloudLoaded = false; let savedMapPointCloudLoaded = false;
let sensorPointCloudRequestInFlight = false; let sensorPointCloudRequestInFlight = false;
let sensorPointCloudRefreshTimer = null; let sensorPointCloudRefreshTimer = null;
let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null;
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW; let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
let cameraFrameAbortController = null;
let pathVisible = false; let pathVisible = false;
// SLAM always renders its live scan, including the forward beam. Navigation // SLAM always renders its live scan, including the forward beam. Navigation
// keeps the sensor opt-in so a saved-map view starts cleanly. // keeps the sensor opt-in so a saved-map view starts cleanly.
@@ -2069,84 +2098,27 @@ async function refreshSensorPointCloud() {
} }
} }
function setCameraPreviewAvailable(available) { const cameraPreviewController = createCameraPreview({
available = available && cameraPreviewEnabled; root: cameraPreviewList,
cameraPreviewWrap?.classList.toggle('is-visible', available); baseUrl: BACKEND_URL,
if (available) return; getState: () => ({ state: latestSlamState, receivedAt: latestSlamStateReceivedAt }),
if (cameraFrameObjectUrl) { onAvailable: available => cameraPreviewWrap.classList.toggle('is-visible', available),
URL.revokeObjectURL(cameraFrameObjectUrl); });
cameraFrameObjectUrl = null;
}
if (cameraPreview) cameraPreview.removeAttribute('src');
}
function hasLiveCameraState() {
return latestSlamState?.topic_status?.camera_image?.available === true
&& Date.now() - latestSlamStateReceivedAt < 3000;
}
async function refreshCameraFrame() {
if (!cameraPreviewEnabled || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false);
return;
}
if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true;
const controller = new AbortController();
cameraFrameAbortController = controller;
const timeout = window.setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
return;
}
const frame = await response.blob();
if (controller.signal.aborted || !cameraPreviewEnabled || !hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
const nextUrl = URL.createObjectURL(frame);
const previousUrl = cameraFrameObjectUrl;
cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) {
if (!controller.signal.aborted) cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
} finally {
window.clearTimeout(timeout);
cameraFrameAbortController = null;
cameraFrameRequestInFlight = false;
}
}
function setCameraPreviewEnabled(enabled) { function setCameraPreviewEnabled(enabled) {
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled; cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled); cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled)); cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer); cameraPreviewController.setEnabled(cameraPreviewEnabled);
cameraFrameRefreshTimer = null;
if (!cameraPreviewEnabled) { if (!cameraPreviewEnabled) {
cameraFrameAbortController?.abort();
setCameraPreviewAvailable(false);
return; return;
} }
cameraFrameRetryAt = 0;
cameraPreviewWrap.classList.remove('is-collapsed'); cameraPreviewWrap.classList.remove('is-collapsed');
cameraPreviewToggle.setAttribute('aria-expanded', 'true'); cameraPreviewToggle.setAttribute('aria-expanded', 'true');
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기')); cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
} }
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled)); cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) { function drawMiniMap(state) {
pendingMiniMapState = state; pendingMiniMapState = state;
@@ -3281,9 +3253,7 @@ window.addEventListener('beforeunload', () => {
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer); if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer); if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer); if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer); cameraPreviewController.dispose();
cameraFrameAbortController?.abort();
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
}); });
function connectControlSocket() { function connectControlSocket() {
+682 -25
View File
@@ -12,7 +12,7 @@ import type { Socket } from 'socket.io-client'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import DockerSetupNotice from './components/DockerSetupNotice' import DockerSetupNotice from './components/DockerSetupNotice'
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay' 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 TitleBar from './components/TitleBar'
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig' 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' import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
@@ -57,6 +57,12 @@ import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceCont
import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminalContextMenu' import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminalContextMenu'
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel' import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
import { useQaChat } from './features/workspace/useQaChat' 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 { AgentCodeReference, AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
import { revealAgentCodeReference, validateCodeReference } from './features/workspace/agentCodeNavigation'
import type { ChatAttachment } from './components/chatAttachments'
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime' import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
import { useSourceControl } from './features/workspace/useSourceControl' import { useSourceControl } from './features/workspace/useSourceControl'
import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus' import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus'
@@ -398,6 +404,18 @@ type ModuleWorkspaceSnapshot = {
selectedTreePath: string | null selectedTreePath: string | null
} }
type AgentDiffPreview = {
id: string
messageId: string
moduleName: string
path: string
rendererPath: string
originalContent: string
modifiedContent: string
}
const AGENT_START_MODULE_COMMAND = 'lms module start'
type WorkspaceVisualizationStateSetter<Value> = (value: Value | ((current: Value) => Value)) => void type WorkspaceVisualizationStateSetter<Value> = (value: Value | ((current: Value) => Value)) => void
type VisualizationResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw' type VisualizationResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
@@ -551,6 +569,7 @@ function LocalPcWorkspaceMockup({
accessToken, accessToken,
courseId: courseKey, courseId: courseKey,
weekNumber, weekNumber,
revealInView: true,
apiBaseUrl: LMS_API_BASE_URL, apiBaseUrl: LMS_API_BASE_URL,
socketBaseUrl: LMS_SOCKET_ORIGIN, socketBaseUrl: LMS_SOCKET_ORIGIN,
socketPath: LMS_SOCKET_PATH, socketPath: LMS_SOCKET_PATH,
@@ -607,6 +626,39 @@ function LocalPcWorkspaceMockup({
) )
const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState<StudioSourceSettings | null>(null) const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState<StudioSourceSettings | null>(null)
const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(null) const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(null)
const {
enabled: agentEnabled,
provider: agentProvider,
imagesEnabled: agentImagesEnabled,
models: agentModels,
selectProvider: selectAgentProvider,
statusLoading: agentStatusLoading,
pending: agentPending,
activity: agentActivity,
progressSteps: agentProgressSteps,
requestStartedAt: agentRequestStartedAt,
messages: agentMessages,
persistenceError: agentPersistenceError,
requestPlan: requestAgentPlan,
applyPlan: applyAgentPlan,
cancelPlan: cancelAgentPlan,
updateCommandExecution: updateAgentCommandExecution,
openCodeReference: openAgentCodeReference,
stop: stopAgent,
clear: clearAgentConversation
} = useAiAgent({
accessToken,
studentEmail,
courseId: courseKey,
weekNumber,
apiBaseUrl: LMS_API_BASE_URL,
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 [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false) const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
const [teamGitSyncing, setTeamGitSyncing] = useState(false) const [teamGitSyncing, setTeamGitSyncing] = useState(false)
@@ -669,6 +721,10 @@ function LocalPcWorkspaceMockup({
finishTabDrop, finishTabDrop,
resetTabDrag resetTabDrag
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive) } = 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) const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
const [imagePreviewSources, setImagePreviewSources] = useState<Partial<Record<string, string>>>(initialImagePreviewSources) const [imagePreviewSources, setImagePreviewSources] = useState<Partial<Record<string, string>>>(initialImagePreviewSources)
const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null) const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null)
@@ -712,11 +768,18 @@ function LocalPcWorkspaceMockup({
setActiveExampleEditorFile(null) setActiveExampleEditorFile(null)
setOpenExampleEditorTabs([]) setOpenExampleEditorTabs([])
setSelectedExampleTreePath(null) setSelectedExampleTreePath(null)
setAgentDiffPreviews([])
setActiveAgentDiffPreviewId(null)
}, [curriculumWeek.week]) }, [curriculumWeek.week])
useEffect(() => { useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => { const handleEscape = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') return if (event.key !== 'Escape') return
if (agentPracticeApproval) {
event.preventDefault()
setAgentPracticeApproval(null)
return
}
if (pendingEditorCloseRequest) { if (pendingEditorCloseRequest) {
event.preventDefault() event.preventDefault()
setPendingEditorCloseRequest(null) setPendingEditorCloseRequest(null)
@@ -750,7 +813,7 @@ function LocalPcWorkspaceMockup({
window.addEventListener('keydown', handleEscape) window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape)
}, [pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog]) }, [agentPracticeApproval, pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
useEffect(() => { useEffect(() => {
if (!controlEndsAt) return if (!controlEndsAt) return
setControlCountdownNow(Date.now()) setControlCountdownNow(Date.now())
@@ -798,6 +861,7 @@ function LocalPcWorkspaceMockup({
const editorPaneRef = useRef<HTMLDivElement>(null) const editorPaneRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<Parameters<OnMount>[0] | null>(null) const editorRef = useRef<Parameters<OnMount>[0] | null>(null)
const decorationIdsRef = useRef<string[]>([]) const decorationIdsRef = useRef<string[]>([])
const agentCodeDecorationsRef = useRef<ReturnType<Parameters<OnMount>[0]['createDecorationsCollection']> | null>(null)
const [leftPanelWidth, setLeftPanelWidth] = useState(310) const [leftPanelWidth, setLeftPanelWidth] = useState(310)
const [rightPanelWidth, setRightPanelWidth] = useState(310) const [rightPanelWidth, setRightPanelWidth] = useState(310)
const [projectTreeWidth, setProjectTreeWidth] = useState(280) const [projectTreeWidth, setProjectTreeWidth] = useState(280)
@@ -1394,6 +1458,10 @@ function LocalPcWorkspaceMockup({
return () => window.removeEventListener('message', handleViewerMessage) 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]) }, [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()) const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
useEffect(() => {
setAgentPracticeApproval(null)
return () => { agentActionControllerRef.current?.abort() }
}, [courseKey, weekNumber, platformId, remoteWorkspaceTargetKey])
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId) const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
const hasRestorableWorkspaceUi = Boolean( const hasRestorableWorkspaceUi = Boolean(
window.localStorage.getItem(workspaceUiSessionKey) window.localStorage.getItem(workspaceUiSessionKey)
@@ -2319,7 +2387,7 @@ function LocalPcWorkspaceMockup({
visualizationHostInstanceId, visualizationHostInstanceId,
]) ])
const topToggleButtonClass = (active: boolean): string => [ const topToggleButtonClass = (active: boolean): string => [
'flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 transition', 'inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border px-2 leading-none transition',
active active
? dark ? dark
? 'border-[#2563eb]/45 bg-[#2563eb]/24 text-[#9dbdff]' ? 'border-[#2563eb]/45 bg-[#2563eb]/24 text-[#9dbdff]'
@@ -2328,8 +2396,15 @@ function LocalPcWorkspaceMockup({
? 'border-white/15 text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white' ? 'border-white/15 text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
: 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]' : 'border-[#cfd6e4] bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ') ].join(' ')
const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext' const visibleAgentDiffPreviews = exampleActive || !selectedModule
const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false ? []
: agentDiffPreviews.filter((preview) => preview.moduleName === selectedModule.name)
const activeAgentDiffPreview = visibleAgentDiffPreviews.find((preview) => preview.id === activeAgentDiffPreviewId) ?? null
const visibleEditorTabs = [...currentOpenEditorTabs, ...visibleAgentDiffPreviews.map((preview) => preview.id)]
const activeEditorTab = activeAgentDiffPreview?.id ?? currentActiveEditorFile
const activeEditorDisplayPath = activeAgentDiffPreview?.rendererPath ?? currentActiveEditorFile
const editorLanguage = activeEditorDisplayPath ? getEditorLanguage(activeEditorDisplayPath) : 'plaintext'
const activeFileIsImage = !activeAgentDiffPreview && currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false
const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined
const remoteEditorFile = useRemoteEditorFile({ const remoteEditorFile = useRemoteEditorFile({
enabled: remoteWorkspace, enabled: remoteWorkspace,
@@ -2361,6 +2436,7 @@ function LocalPcWorkspaceMockup({
return () => { disposed = true } return () => { disposed = true }
}, [activeFileIsImage, clonePathPreview, currentActiveEditorFile, imagePreviewSources, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule]) }, [activeFileIsImage, clonePathPreview, currentActiveEditorFile, imagePreviewSources, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule])
const openEditorFile = (file: EditorFilePath): void => { const openEditorFile = (file: EditorFilePath): void => {
setActiveAgentDiffPreviewId(null)
if (file.startsWith('examples/example-code/')) { if (file.startsWith('examples/example-code/')) {
setSelectedExampleTreePath(file) setSelectedExampleTreePath(file)
setActiveExampleEditorFile(file) setActiveExampleEditorFile(file)
@@ -2372,10 +2448,15 @@ function LocalPcWorkspaceMockup({
setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file])) setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file]))
} }
const openEditorFileAtLine = (file: EditorFilePath): void => { const openEditorFileAtLine = (file: EditorFilePath): void => {
setActiveAgentDiffPreviewId(null)
setSelectedTreePath(file) setSelectedTreePath(file)
setActiveEditorFile(file) setActiveEditorFile(file)
setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file])) setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file]))
} }
const agentNavigationScope = JSON.stringify([studentEmail, courseKey, weekNumber, platformId, clonePathPreview, remoteWorkspace, remoteWorkspaceTargetKey])
const agentNavigationRef = useRef({ scope: agentNavigationScope, file: currentActiveEditorFile, ready: false, visible: workspaceVisible })
agentNavigationRef.current = { scope: agentNavigationScope, file: currentActiveEditorFile, ready: activeIdeActivity === 'workspace' && !activeAgentDiffPreview && !remoteEditorFile.loading && !remoteEditorFile.error, visible: workspaceVisible }
useEffect(() => () => { agentCodeDecorationsRef.current?.clear(); agentCodeDecorationsRef.current = null }, [agentNavigationScope, currentActiveEditorFile, activeIdeActivity, activeAgentDiffPreview?.id, workspaceVisible])
const loadExampleCodeFiles = async (): Promise<void> => { const loadExampleCodeFiles = async (): Promise<void> => {
try { try {
const token = accessToken ? await getFreshAccessToken() : '' const token = accessToken ? await getFreshAccessToken() : ''
@@ -3425,7 +3506,7 @@ function LocalPcWorkspaceMockup({
{ ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length }, { ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length },
]) ])
} }
const createCustomModule = async (input: CustomModuleInput): Promise<void> => { const createCustomModuleRecord = async (input: CustomModuleInput): Promise<NonNullable<CurriculumWeek['modules']>[number]> => {
const customModule = await registerCustomModule(input) const customModule = await registerCustomModule(input)
const workspaceModule = { const workspaceModule = {
name: customModule.name, name: customModule.name,
@@ -3442,6 +3523,511 @@ function LocalPcWorkspaceMockup({
} }
addCustomModuleToList(customModule) addCustomModuleToList(customModule)
onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info') onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
return customModule
}
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
await createCustomModuleRecord(input)
}
const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise<AgentWorkspaceModule[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({
name: module.name,
folderName: module.folderName,
category: module.category,
state: module.state,
parameters: module.parameters,
files: [],
...(requestedNames.has(module.name.toLowerCase()) ? {
fileContext: {
status: module.state === 'uninstalled' ? 'uninstalled' as const : 'limited' as const,
message: module.state === 'uninstalled' ? '설치되지 않아 소스를 읽을 수 없습니다.' : '파일 수·크기 제한으로 읽지 못했습니다.'
}
} : {})
}))
const targetModules = moduleSnapshot.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
))
if (!targetModules.length) return workspaceModules
const isTextSource = (path: string): boolean => {
const name = path.split('/').pop()?.toLowerCase() ?? ''
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)
}
for (const module of targetModules) {
let totalCharacters = 0
let totalFiles = 0
try {
const modulePrefix = `modules/${module.name}`
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null
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
return 0
}
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
.filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path))
.sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right))
const files: AgentWorkspaceModule['files'] = []
for (const rendererPath of paths) {
if (totalFiles >= 24 || totalCharacters >= 140_000) break
let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath)
if (content === undefined && remoteWorkspace) {
content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content
}
if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue
if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue
totalCharacters += content.length
totalFiles += 1
files.push({ path: rendererPath.slice(modulePrefix.length + 1), content })
}
const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name)
const workspaceModule = {
name: module.name,
folderName: module.folderName,
category: module.category,
state: module.state,
files,
fileContext: {
status: files.length ? 'loaded' as const : 'empty' as const,
message: files.length ? '텍스트 소스를 조회했습니다. 파일 수·크기·형식 제한으로 일부 파일이 제외될 수 있습니다.' : '읽을 수 있는 텍스트 파일이 없습니다. 파일 수·크기·형식 제한 또는 빈 소스일 수 있습니다.'
}
}
if (moduleIndex < 0) workspaceModules.push(workspaceModule)
else workspaceModules[moduleIndex] = workspaceModule
} catch (error) {
const workspaceModule = workspaceModules.find((item) => item.name === module.name)
if (workspaceModule) workspaceModule.fileContext = {
status: 'error',
message: (error instanceof Error ? error.message : '모듈 소스를 읽지 못했습니다.').slice(0, 500)
}
}
}
return workspaceModules
}
const collectAgentRuntimeLogs = async (requestedModuleNames: string[]): Promise<AgentRuntimeLog[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const targetModules = availableModules.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
)).slice(0, 3)
return Promise.all(targetModules.map(async (module): Promise<AgentRuntimeLog> => {
try {
const result = remoteWorkspace
? await window.remoteWorkspace.modules.logs(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.modules.logs(module.name, clonePathPreview, module.folderName)
return {
moduleName: module.name,
state: module.state,
content: result.logs.split(/\r?\n/).slice(-200).join('\n').slice(-20_000),
message: result.message
}
} catch (error) {
return {
moduleName: module.name,
state: module.state,
content: '',
message: error instanceof Error ? error.message : '로그를 불러오지 못했습니다.'
}
}
}))
}
const loadAgentRequestedContext = async (request: { modules: string[]; logs: string[] }): Promise<{
modules: AgentWorkspaceModule[]
runtimeLogs: AgentRuntimeLog[]
}> => ({
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 {
await requestAgentPlan(content, [], [], {
attachments,
loadInitialContext: async (signal) => {
const modules = await readAgentModules()
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
return { modules: await collectAgentWorkspace([], modules), runtimeLogs: [] }
},
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
})
} catch (error) {
console.error('[agent] workspace collection failed', error)
onTitlebarNotice(error instanceof Error ? error.message : '모듈 파일을 읽지 못했습니다.', 'error')
}
}
const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => {
if (agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
validateCodeReference(reference)
const scope = agentNavigationScope
const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible
const module = availableModules.find(item => item.name === reference.moduleName)
if (!module) throw new Error('코드 위치의 모듈을 찾을 수 없습니다. 다시 분석을 요청해주세요.')
const file = `modules/${module.name}/${reference.path}`
if (!isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.')
const snapshot = agentEditorFilesRef.current[file]
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, file, module.folderName)
: { content: (await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)).files[file] }
if (!isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.')
if (typeof result.content !== 'string') throw new Error('코드 파일을 읽지 못했습니다. 다시 분석을 요청해주세요.')
setEditorFiles(files => {
// Preserve any editor buffer already open, including edits made during the read.
if (files[file] !== undefined || files[file] !== snapshot) return files
return { ...files, [file]: result.content }
})
setSavedEditorFiles(files => ({ ...files, [file]: result.content }))
if (selectedModule?.name !== module.name) openModuleSource(module)
else setActiveIdeActivity('workspace')
openEditorFile(file)
agentCodeDecorationsRef.current?.clear()
agentCodeDecorationsRef.current = await revealAgentCodeReference({ file, reference, isCurrent, getEditor: () => editorRef.current, getActiveFile: () => agentNavigationRef.current.file, isReady: () => agentNavigationRef.current.ready })
}
const openAgentChangePreview = async (messageId: string, change: AgentFileChange): Promise<void> => {
const module = availableModules.find((item) => item.name === change.moduleName)
?? (selectedModule?.name === change.moduleName ? selectedModule : null)
if (!module) {
onTitlebarNotice(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`, 'error')
return
}
const rendererPath = `modules/${module.name}/${change.path.replace(/^\/+/, '')}`
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)
else setActiveIdeActivity('workspace')
const id = `agent-diff:${messageId}:${module.name}:${change.path}`
const preview: AgentDiffPreview = {
id,
messageId,
moduleName: module.name,
path: change.path,
rendererPath,
originalContent: originalContent ?? '',
modifiedContent: change.content
}
setAgentDiffPreviews((previews) => [...previews.filter((item) => item.id !== id), preview])
setActiveAgentDiffPreviewId(id)
}
const closeAgentDiffPreview = (previewId: string): void => {
setAgentDiffPreviews((previews) => previews.filter((preview) => preview.id !== previewId))
setActiveAgentDiffPreviewId((activeId) => activeId === previewId ? null : activeId)
}
const closeAgentDiffPreviewsForMessage = (messageId: string): void => {
setAgentDiffPreviews((previews) => {
const closingIds = new Set(previews.filter((preview) => preview.messageId === messageId).map((preview) => preview.id))
setActiveAgentDiffPreviewId((activeId) => activeId && closingIds.has(activeId) ? null : activeId)
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 || message.applyState === 'cancelled' || !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, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview,
executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
})
} 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 || message.applyState === 'cancelled' || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
const requiresAppliedChanges = message.plan.changes.length > 0 || message.plan.createModule !== null
if (requiresAppliedChanges && message.applyState !== 'applied') {
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)
if (!module) {
updateAgentCommandExecution(messageId, command.id, { status: 'failed', error: `명령 대상 모듈을 찾을 수 없습니다: ${command.moduleName}` })
return
}
updateAgentCommandExecution(messageId, command.id, { status: 'running' })
try {
const startModuleAction = command.command.trim() === AGENT_START_MODULE_COMMAND
const result = startModuleAction
? module.state === 'uninstalled'
? { ok: false, output: '', exitCode: null, message: '설치되지 않은 모듈은 시작할 수 없습니다.' }
: module.state === 'running'
? { ok: true, output: `${module.name} 모듈이 이미 실행 중입니다.`, exitCode: 0 }
: await runModuleControl(module, 'start').then((ok) => ({
ok,
output: ok ? `${module.name} 모듈을 시작했습니다.` : '',
exitCode: ok ? 0 : null,
message: ok ? undefined : `${module.name} 모듈을 시작하지 못했습니다.`
}))
: remoteWorkspace
? await window.remoteWorkspace.agent.runCommand(getRemoteWorkspaceTarget(), module.name, command.command, module.folderName)
: await window.localWorkspace.agent.runCommand(module.name, command.command, clonePathPreview, module.folderName)
updateAgentCommandExecution(messageId, command.id, {
status: result.ok ? 'completed' : 'failed',
output: result.output,
exitCode: result.exitCode,
error: result.message
})
await loadModuleFiles(module, true)
if (installedGitModules.length) await refreshGitStatuses(null)
const messageIndex = agentMessages.findIndex((item) => item.id === messageId)
const originalRequest = messageIndex > 0
? [...agentMessages.slice(0, messageIndex)].reverse().find((item) => item.role === 'user')?.content
: undefined
const nextWorkspace = (await collectAgentWorkspace()).map((workspaceModule) => (
startModuleAction && result.ok && workspaceModule.name === module.name
? { ...workspaceModule, state: 'running' }
: workspaceModule
))
const followUpRequest = [
'이전 작업의 승인된 명령 실행 결과를 분석하고 다음 단계만 제안하세요.',
originalRequest ? `학생의 원래 요청: ${originalRequest}` : '',
`이전 계획 요약: ${message.plan.summary}`,
`실행 모듈: ${command.moduleName}`,
`실행 명령: ${command.command}`,
`종료 코드: ${result.exitCode ?? '확인 불가'}`,
result.message ? `실행 메시지: ${result.message}` : '',
`명령 출력:\n${result.output.slice(-2400) || '(출력 없음)'}`,
'이미 실행한 명령을 그대로 반복하지 말고, 결과상 추가 작업이 필요할 때만 다음 승인 명령 또는 파일 변경안을 제안하세요.'
].filter(Boolean).join('\n')
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], {
showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
})
} catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
.replace(/^Error invoking remote method '[^']+': Error:\s*/, '')
const policyRejected = /허용되지|실행할 수 없습니다/.test(errorMessage)
updateAgentCommandExecution(messageId, command.id, {
status: policyRejected ? 'rejected' : 'failed',
exitCode: null,
error: errorMessage
})
}
}
const rejectWorkspaceAgentCommand = (messageId: string, command: AgentCommand): void => {
const execution = agentMessages.find((item) => item.id === messageId)?.commandExecutions?.[command.id]
if (execution && execution.status !== 'pending') return
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)) {
throw new Error(`이미 존재하는 모듈입니다: ${plan.createModule.name}`)
}
const customModule = await createCustomModuleRecord({
name: plan.createModule.name,
category: plan.createModule.category,
dockerImage: plan.createModule.dockerImage ?? '',
packageName: plan.createModule.packageName,
launchFile: plan.createModule.launchFile
})
createdModule = {
id: customModule.id,
state: 'stopped',
sortOrder: customModule.sortOrder ?? 10_000,
category: customModule.category,
name: customModule.name,
folderName: customModule.folderName,
sourceType: customModule.sourceType,
dockerImage: customModule.dockerImage,
packageName: customModule.packageName,
launchFile: customModule.launchFile,
runtimeTarget: customModule.runtimeTarget,
parameters: [],
cpu: null,
memory: null,
runtime: '-'
}
}
const resolveModule = (moduleName: string): ModuleRow | null => {
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 (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 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)
}
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 }
if (editedDuringApply) next[rendererPath] = true
else delete next[rendererPath]
return next
})
if (!firstChangedFile) firstChangedFile = { module, path: rendererPath }
}
if (firstChangedFile) {
setSelectedModule(firstChangedFile.module)
setModuleTabs((tabs) => tabs.some((tab) => tab.name === firstChangedFile!.module.name) ? tabs : [...tabs, firstChangedFile!.module])
setActiveIdeActivity('workspace')
await loadModuleFiles(firstChangedFile.module, true)
openEditorFile(firstChangedFile.path)
} else if (createdModule) {
setSelectedModule(createdModule)
setModuleTabs((tabs) => tabs.some((tab) => tab.name === createdModule!.name) ? tabs : [...tabs, createdModule!])
setActiveIdeActivity('workspace')
await loadModuleFiles(createdModule, true)
}
if (plan.changes.length && installedGitModules.length) {
await refreshGitStatuses(null)
}
onTitlebarNotice(`${plan.changes.length}개 파일에 AI 변경안을 적용했습니다.`, 'info')
} }
const importGitModule = async (input: GitModuleImportInput): Promise<void> => { const importGitModule = async (input: GitModuleImportInput): Promise<void> => {
if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.') if (!remoteWorkspace) throw new Error('원격 워크스페이스에서만 사용할 수 있습니다.')
@@ -3914,19 +4500,36 @@ function LocalPcWorkspaceMockup({
gridColumn: 6, gridColumn: 6,
gridRow: 2, gridRow: 2,
gridTemplateRows: exampleActive gridTemplateRows: exampleActive
? currentOpenEditorTabs.length ? '44px 28px minmax(0, 1fr)' : 'minmax(0, 1fr)' ? visibleEditorTabs.length ? '44px 28px minmax(0, 1fr)' : 'minmax(0, 1fr)'
: currentOpenEditorTabs.length ? `44px 28px minmax(0, 1fr) ${terminalPanelHeight}px` : `minmax(0, 1fr) ${terminalPanelHeight}px` : visibleEditorTabs.length ? `44px 28px minmax(0, 1fr) ${terminalPanelHeight}px` : `minmax(0, 1fr) ${terminalPanelHeight}px`
}} }}
> >
<WorkspaceEditorTabs <WorkspaceEditorTabs
dark={dark} dark={dark}
files={currentOpenEditorTabs} files={visibleEditorTabs}
activeFile={currentActiveEditorFile} activeFile={activeEditorTab}
draggingFile={draggingEditorTab} draggingFile={draggingEditorTab}
dropIndex={tabDropIndex} dropIndex={tabDropIndex}
isDirtyFile={isDirtyEditorFile} isDirtyFile={isDirtyEditorFile}
onOpenFile={openEditorFile} isPreviewFile={(file) => file.startsWith('agent-diff:')}
onCloseFile={closeEditorTab} getFileLabel={(file) => {
const preview = visibleAgentDiffPreviews.find((item) => item.id === file)
return preview ? `${preview.path.split('/').pop()} (AI)` : file.split('/').pop() ?? file
}}
onOpenFile={(file) => {
if (file.startsWith('agent-diff:')) {
setActiveAgentDiffPreviewId(file)
return
}
openEditorFile(file)
}}
onCloseFile={(file) => {
if (file.startsWith('agent-diff:')) {
closeAgentDiffPreview(file)
return
}
closeEditorTab(file)
}}
onContextMenu={(file, x, y) => { onContextMenu={(file, x, y) => {
setTabContextMenu({ file, x, y }) setTabContextMenu({ file, x, y })
}} }}
@@ -3979,20 +4582,23 @@ function LocalPcWorkspaceMockup({
<WorkspaceEditorPathBar <WorkspaceEditorPathBar
dark={dark} dark={dark}
currentActiveEditorFile={currentActiveEditorFile} currentActiveEditorFile={activeEditorDisplayPath}
exampleActive={exampleActive} exampleActive={exampleActive}
readOnlyLabel={activeAgentDiffPreview ? t("AI 변경안") : undefined}
isDirtyFile={isDirtyEditorFile} isDirtyFile={isDirtyEditorFile}
onSave={saveCurrentEditorFile} onSave={saveCurrentEditorFile}
/> />
<WorkspaceEditorPane <WorkspaceEditorPane
editingLocked={agentApplying}
fileLoading={remoteEditorFile.loading} fileLoading={remoteEditorFile.loading}
fileError={remoteEditorFile.error} fileError={remoteEditorFile.error}
onRetryFile={remoteEditorFile.retry} onRetryFile={remoteEditorFile.retry}
dark={dark} dark={dark}
textDim={textDim} textDim={textDim}
editorPaneRef={editorPaneRef} editorPaneRef={editorPaneRef}
currentActiveEditorFile={currentActiveEditorFile} currentActiveEditorFile={activeEditorDisplayPath}
diffPreview={activeAgentDiffPreview}
activeFileIsImage={activeFileIsImage} activeFileIsImage={activeFileIsImage}
activeImagePreviewSource={activeImagePreviewSource} activeImagePreviewSource={activeImagePreviewSource}
editorLanguage={editorLanguage} editorLanguage={editorLanguage}
@@ -4005,7 +4611,7 @@ function LocalPcWorkspaceMockup({
beforeMount={configureMonacoTheme} beforeMount={configureMonacoTheme}
onMount={handleEditorMount} onMount={handleEditorMount}
onChange={(value) => { onChange={(value) => {
if (!currentActiveEditorFile) return if (!currentActiveEditorFile || agentApplying) return
updateEditorFileContent(currentActiveEditorFile, value ?? '', exampleActive) updateEditorFileContent(currentActiveEditorFile, value ?? '', exampleActive)
}} }}
/> />
@@ -4154,24 +4760,75 @@ function LocalPcWorkspaceMockup({
qaMessagesLoading={qaMessagesLoading} qaMessagesLoading={qaMessagesLoading}
visibleAiMessages={visibleAiMessages} visibleAiMessages={visibleAiMessages}
visibleTeacherMessages={visibleTeacherMessages} visibleTeacherMessages={visibleTeacherMessages}
agentMessages={agentMessages}
agentEnabled={agentEnabled}
agentProvider={agentProvider}
agentImagesEnabled={Boolean(agentImagesEnabled)}
agentModels={agentModels}
onAgentProviderChange={selectAgentProvider}
agentStatusLoading={agentStatusLoading}
agentPending={agentPending}
agentActivity={agentActivity}
agentProgressSteps={agentProgressSteps}
agentRequestStartedAt={agentRequestStartedAt}
agentActionBusy={agentActionBusy || agentApplying}
agentPersistenceError={agentPersistenceError}
aiPending={aiPending} aiPending={aiPending}
teacherThreadStatus={teacherThreadStatus} teacherThreadStatus={teacherThreadStatus}
qaCollapseSignal={qaCollapseSignal} qaCollapseSignal={qaCollapseSignal}
inputBusy={aiPending || Boolean(aiTypingMessageId)} inputBusy={qaMode === 'agent' ? agentPending || agentActionBusy || agentApplying : aiPending || Boolean(aiTypingMessageId)}
inputDisabled={!validChatWeek} inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))}
workspaceChatScrollRef={workspaceChatScrollRef} workspaceChatScrollRef={workspaceChatScrollRef}
onMinimize={() => setRightPanelMinimized(true)} onMinimize={() => setRightPanelMinimized(true)}
onModeChange={(mode) => { onModeChange={(mode) => {
setQaMode(mode) setQaMode(mode)
if (mode === 'teacher') setTeacherChatFlash(false) if (mode === 'teacher') setTeacherChatFlash(false)
}} }}
onStop={stopAiResponse} onStop={qaMode === 'agent' ? () => { agentActionControllerRef.current?.abort(); stopAgent() } : stopAiResponse}
onSubmit={(content) => void sendQaMessage(content, { contextWeek: curriculumWeek.week })} onSubmit={(content, attachments) => {
onClearConversation={() => { if (qaMode === 'agent') void submitAgentRequest(content, attachments)
void clearQaConversation().catch((error) => console.error('[chat] clear failed', error)) else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
}} }}
onClearConversation={() => {
if (qaMode === 'agent') {
clearAgentConversation()
setAgentDiffPreviews([])
setActiveAgentDiffPreviewId(null)
}
else void clearQaConversation().catch((error) => console.error('[chat] clear failed', error))
}}
onOpenAgentChange={(messageId, change) => void openAgentChangePreview(messageId, change)}
onRunAgentCommand={(messageId, command) => void runWorkspaceAgentCommand(messageId, command)}
onRejectAgentCommand={rejectWorkspaceAgentCommand}
onCancelAgentPlan={messageId => {
if (agentActionControllerRef.current || agentPending) return
if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId)
}}
onOpenAgentCodeReference={(messageId, referenceId) => void openAgentCodeReference(messageId, referenceId, openWorkspaceAgentCodeReference)}
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => {
await applyWorkspaceAgentPlan(plan)
closeAgentDiffPreviewsForMessage(messageId)
})}
/> />
{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 && ( {workspaceRestoreLoading && (
<PlatformLoadingOverlay <PlatformLoadingOverlay
title={t("{{v0}} 화면 불러오는 중", { v0: platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName })} title={t("{{v0}} 화면 불러오는 중", { v0: platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName })}
@@ -4941,14 +5598,14 @@ export default function App(): React.JSX.Element {
) )
const filteredPlatformOptions = platformOptions.filter((platform) => { const filteredPlatformOptions = platformOptions.filter((platform) => {
const pending = isRemotePlatformStatusPending(platform) const pending = isRemotePlatformStatusPending(platform)
if (platformFilter === 'available') return platform.state === 'ready' && !pending if (platformFilter === 'available') return platform.state === 'ready' && !isPlatformUnavailable(platform) && !pending
if (platformFilter === 'unavailable') return platform.state !== 'ready' && !pending if (platformFilter === 'unavailable') return (platform.state !== 'ready' || isPlatformUnavailable(platform)) && !pending
return true return true
}) })
const platformFilterCounts = { const platformFilterCounts = {
all: platformOptions.length, all: platformOptions.length,
available: platformOptions.filter((platform) => platform.state === 'ready' && !isRemotePlatformStatusPending(platform)).length, available: platformOptions.filter((platform) => platform.state === 'ready' && !isPlatformUnavailable(platform) && !isRemotePlatformStatusPending(platform)).length,
unavailable: platformOptions.filter((platform) => platform.state !== 'ready' && !isRemotePlatformStatusPending(platform)).length unavailable: platformOptions.filter((platform) => (platform.state !== 'ready' || isPlatformUnavailable(platform)) && !isRemotePlatformStatusPending(platform)).length
} }
const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc') const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc')
const openPlatformSettings = (platform: PlatformOption): void => { const openPlatformSettings = (platform: PlatformOption): void => {
@@ -0,0 +1,33 @@
import { FileCode2, Loader2 } from 'lucide-react'
import { t, useLocaleRender } from '../i18n'
import type { AgentCodeReference, AgentCodeReferenceState } from '../features/workspace/useAiAgent'
export function AgentCodeReferences({ references, states, dark, busy, onOpen }: {
references: AgentCodeReference[]
states?: Record<string, AgentCodeReferenceState>
dark: boolean
busy?: boolean
onOpen?: (id: string) => void
}): React.JSX.Element {
useLocaleRender()
return <div className="grid min-w-0 gap-3">
{references.map(reference => {
const state = states?.[reference.id]
const opening = state?.status === 'opening'
return <div key={reference.id} data-agent-code-reference={reference.id} className={['min-w-0 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
<div className="flex min-w-0 items-start gap-2">
<FileCode2 size={14} className="mt-0.5 shrink-0 text-[#8b5cf6]" />
<div className="min-w-0">
<p className="break-all font-bold">{reference.moduleName}/{reference.path}</p>
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{t('{{start}}~{{end}}줄', { start: reference.startLine, end: reference.endLine })}</p>
<p className="mt-1 break-words">{reference.reason}</p>
</div>
</div>
{state?.error && <p role="alert" className="mt-2 break-words text-[11px] text-[#ef4444]">{t(state.error)}</p>}
<div className="mt-2 flex min-w-0 items-center justify-end gap-2">
<button type="button" disabled={busy || opening || !onOpen} onClick={() => onOpen?.(reference.id)} className="inline-flex h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-bold leading-none text-white hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:opacity-40">{opening ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <FileCode2 size={13} className="shrink-0" />}<span className="relative top-px leading-none">{opening ? t('파일 여는 중...') : t('코드에서 보기')}</span></button>
</div>
</div>
})}
</div>
}
@@ -0,0 +1,119 @@
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Bot, Check } from 'lucide-react'
import { t } from '../i18n'
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
models: AgentModelOption[]
provider: string | null
disabled: boolean
dark: boolean
onChange: (provider: string) => void
}): React.JSX.Element {
const selected = models.find(model => model.provider === provider)
const blocked = disabled || models.length === 0
const [open, setOpen] = useState(false)
const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 })
const buttonRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
const menuId = useId()
const visible = open && !blocked
const close = (restoreFocus = false): void => {
setOpen(false)
if (restoreFocus) buttonRef.current?.focus({ preventScroll: true })
}
useEffect(() => {
if (blocked) setOpen(false)
}, [blocked])
useLayoutEffect(() => {
if (!visible || !buttonRef.current) return
const bounds = buttonRef.current.getBoundingClientRect()
const width = Math.min(320, window.innerWidth - 16)
setPosition({
left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)),
bottom: window.innerHeight - bounds.top + 6,
width,
maxHeight: Math.max(0, Math.min(320, bounds.top - 14))
})
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
const index = Math.max(0, models.findIndex(model => model.provider === provider))
items?.[index]?.focus({ preventScroll: true })
}, [visible, models, provider])
useEffect(() => {
if (!visible) return
const outside = (event: Event): void => {
const target = event.target as Node
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
}
const dismiss = (): void => setOpen(false)
window.addEventListener('pointerdown', outside, true)
window.addEventListener('contextmenu', outside)
window.addEventListener('scroll', outside, true)
window.addEventListener('resize', dismiss)
window.addEventListener('blur', dismiss)
return () => {
window.removeEventListener('pointerdown', outside, true)
window.removeEventListener('contextmenu', outside)
window.removeEventListener('scroll', outside, true)
window.removeEventListener('resize', dismiss)
window.removeEventListener('blur', dismiss)
}
}, [visible])
return (
<>
<button
ref={buttonRef}
type="button"
aria-label={t('에이전트 모델 선택')}
aria-haspopup="menu"
aria-expanded={visible}
aria-controls={visible ? menuId : undefined}
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
disabled={blocked}
onClick={() => setOpen(current => !current)}
onKeyDown={event => {
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) }
else if (event.key === 'Escape') close()
}}
className={['grid size-7 shrink-0 cursor-pointer place-items-center rounded outline-none transition focus-visible:ring-1 focus-visible:ring-[#8b5cf6] disabled:cursor-default disabled:opacity-35',
dark ? 'text-white/60 hover:bg-white/10 hover:text-white/90' : 'text-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]',
visible ? dark ? 'bg-white/10 text-white/90' : 'bg-[#eef1f7] text-[#111520]' : ''].join(' ')}
>
<Bot size={15} aria-hidden="true" />
</button>
{visible && createPortal(
<div ref={menuRef} id={menuId} role="menu" aria-label={t('에이전트 모델 선택')}
className={['fixed z-[160] overflow-y-auto overscroll-contain rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'].join(' ')}
style={position}
onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}
onKeyDown={event => {
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); return }
if (event.key === 'Tab') { close(true); return }
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
event.preventDefault()
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
const current = items.indexOf(document.activeElement as HTMLButtonElement)
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
items[next]?.focus()
}}
>
{models.map(model => <button key={model.provider} type="button" role="menuitemradio" aria-checked={model.provider === provider}
title={agentModelLabel(model)} tabIndex={-1}
onClick={() => { if (!blocked) { onChange(model.provider); close(true) } }}
className={['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left outline-none transition',
dark ? 'hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520] focus:bg-[#eef1f7] focus:text-[#111520]'].join(' ')}>
<Check size={13} className={['shrink-0', model.provider === provider ? '' : 'opacity-0'].join(' ')} aria-hidden="true" />
<span className="min-w-0 truncate">{agentModelLabel(model)}</span>
</button>)}
</div>, document.body
)}
</>
)
}
@@ -0,0 +1,58 @@
import { CheckCircle2, CircleAlert, XCircle } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { t, useLocaleRender } from '../i18n'
import type { AgentProgressStep } from '../features/workspace/agentContext'
import { AgentWorkDuration } from './AgentWorkDuration'
function stepLabel(step: AgentProgressStep): string {
if (step.kind === 'analysis') return [
t('{{round}}차 분석 완료', { round: step.round ?? 1 }),
step.requests ? t('추가 조회 {{count}}건', { count: step.requests }) : ''
].filter(Boolean).join(' · ')
if (step.kind === 'image') return `${t('이미지 업로드 완료')} · ${step.name}`
const tools: Record<string, string> = {
'modules.list': t('모듈 목록'),
'files.list': t('파일 목록'),
'files.search': t('파일 검색'),
'files.read': t('파일 읽기'),
'logs.read': t('로그 조회'),
'network.inspect': t('네트워크 조회'),
'ros.inspect': t('ROS 상태 조회'),
'practice.inspect': t('실습 구성 조회')
}
const status = step.status === 'error' ? t('조회 실패') : step.status === 'partial' ? t('일부 결과') : step.status === 'empty' ? t('조회 결과 없음') : step.status === 'missing' ? t('대상 없음') : t('조회 완료')
return [
step.moduleName,
step.kind === 'files' ? t('소스 파일') : step.kind === 'logs' ? t('로그 조회') : tools[step.name ?? ''] ?? step.name,
step.path,
status,
step.count !== undefined ? t('{{count}}건', { count: step.count }) : ''
].filter(Boolean).join(' · ')
}
export function AgentProgressBubble({ dark, steps, activity, startedAt }: { dark: boolean; steps: AgentProgressStep[]; activity?: string; startedAt?: number | null }): React.JSX.Element {
useLocaleRender()
const listRef = useRef<HTMLOListElement>(null)
useEffect(() => {
const list = listRef.current
if (list) list.scrollTop = list.scrollHeight
}, [steps.length])
return (
<div data-agent-progress="true" className={['min-w-0 flex-1 overflow-hidden rounded-lg border px-3 py-3 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
<AgentWorkDuration dark={dark} startedAt={startedAt} />
<p className="font-bold">{t('진행 상황')}</p>
{steps.length > 0 && <ol ref={listRef} role="log" aria-label={t('진행 상황')} aria-live="polite" aria-relevant="additions" className="thin-scrollbar mt-2 grid max-h-64 gap-2 overflow-y-auto">
{steps.map(step => <li key={step.id} data-agent-progress-step={step.kind} className="agent-response-fade flex min-w-0 items-start gap-2">
{step.status === 'error' ? <XCircle size={13} className="mt-0.5 shrink-0 text-[#ef4444]" /> : step.status !== 'ok' ? <CircleAlert size={13} className="mt-0.5 shrink-0 text-[#d97706]" /> : <CheckCircle2 size={13} className="mt-0.5 shrink-0 text-[#22c55e]" />}
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{stepLabel(step)}</span>
</li>)}
</ol>}
<p role="status" className={['mt-2 break-words text-[11px]', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>{activity ? t(activity) : t('요청 분석 중')}</p>
<div className="mt-3 grid w-[220px] max-w-full 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>
)
}
@@ -0,0 +1,27 @@
import { useEffect, useState } from 'react'
import { t, useLocaleRender } from '../i18n'
export function AgentWorkDuration({ startedAt, durationMs, dark }: { startedAt?: number | null; durationMs?: number; dark: boolean }): React.JSX.Element | null {
useLocaleRender()
const [now, setNow] = useState(Date.now)
const completed = typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0
const running = !completed && typeof startedAt === 'number' && Number.isFinite(startedAt)
useEffect(() => {
if (!running) return
const update = (): void => setNow(Date.now())
update()
const timer = window.setInterval(update, 1000)
document.addEventListener('visibilitychange', update)
window.addEventListener('focus', update)
return () => {
window.clearInterval(timer)
document.removeEventListener('visibilitychange', update)
window.removeEventListener('focus', update)
}
}, [running, startedAt])
if (!running && !completed) return null
const seconds = Math.floor(Math.max(0, completed ? durationMs : now - startedAt!) / 1000)
return <p data-agent-work-duration={completed ? 'completed' : 'running'} aria-live="off" className={['mb-2 text-left text-[11px] font-normal leading-relaxed tabular-nums', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>
{completed ? t('{{seconds}}초 동안 작업함', { seconds }) : t('{{seconds}}초 동안 작업 중입니다', { seconds })}
</p>
}
@@ -75,6 +75,7 @@ export function StatusNoticeModal({
onConfirm: () => void onConfirm: () => void
}): React.JSX.Element { }): React.JSX.Element {
useLocaleRender() useLocaleRender()
const hasAction = Boolean(actionLabel && onAction)
return ( return (
<div className={overlayClassName}> <div className={overlayClassName}>
<section className={modalClass(dark)}> <section className={modalClass(dark)}>
@@ -91,12 +92,12 @@ export function StatusNoticeModal({
</p> </p>
)} )}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button type="button" onClick={onConfirm} className={hasAction ? cancelButtonClass(dark) : confirmButtonClass('primary')}>{t("확인")}</button>
{actionLabel && onAction && ( {actionLabel && onAction && (
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}> <button type="button" onClick={onAction} className={confirmButtonClass('primary')}>
{t(actionLabel)} {t(actionLabel)}
</button> </button>
)} )}
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>{t("확인")}</button>
</div> </div>
</div> </div>
</section> </section>
@@ -276,7 +277,7 @@ export function ControlExtensionConfirmModal({
) )
} }
function ConfirmStatusModal({ export function ConfirmStatusModal({
overlayClassName, overlayClassName,
dark, dark,
icon, icon,
@@ -0,0 +1,63 @@
import { Check, Copy, FileCode2, X } from 'lucide-react'
import { common, createLowlight } from 'lowlight'
import type React from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { t, useLocaleRender } from '../i18n'
const highlighter = createLowlight(common)
const languageLabels: Record<string, string> = {
python: 'Python', py: 'Python', yaml: 'YAML', yml: 'YAML', json: 'JSON',
cpp: 'C++', 'c++': 'C++', c: 'C', javascript: 'JavaScript', js: 'JavaScript',
typescript: 'TypeScript', ts: 'TypeScript', bash: 'Bash', sh: 'Bash', shell: 'Shell',
xml: 'XML', html: 'HTML', css: 'CSS', sql: 'SQL', markdown: 'Markdown', md: 'Markdown',
}
type HighlightNode = ReturnType<typeof highlighter.highlight>['children'][number]
function renderToken(node: HighlightNode, key: string): React.ReactNode {
if (node.type === 'text') return node.value
if (node.type !== 'element') return null
const classes = node.properties.className
return <span key={key} className={Array.isArray(classes) ? classes.join(' ') : undefined}>
{node.children.map((child, index) => renderToken(child, `${key}-${index}`))}
</span>
}
export function ChatCodeBlock({ source, language = '', dark, fadeClass = '' }: {
source: string
language?: string
dark: boolean
fadeClass?: string
}): React.JSX.Element {
useLocaleRender()
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle')
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => () => clearTimeout(timer.current), [])
useEffect(() => { setCopyState('idle'); clearTimeout(timer.current) }, [source])
const normalizedLanguage = language.toLowerCase()
const tokens = useMemo(() => {
if (source.length > 50_000 || !highlighter.registered(normalizedLanguage)) return source
try { return highlighter.highlight(normalizedLanguage, source).children.map((node, index) => renderToken(node, `${index}`)) }
catch { return source }
}, [source, normalizedLanguage])
const copy = async (): Promise<void> => {
try {
await navigator.clipboard.writeText(source)
setCopyState('copied')
} catch {
setCopyState('error')
}
clearTimeout(timer.current)
timer.current = setTimeout(() => setCopyState('idle'), 1800)
}
const copyLabel = copyState === 'copied' ? t('복사됨') : copyState === 'error' ? t('코드를 복사하지 못했습니다.') : t('코드 복사')
return <div data-chat-code-block data-code-theme={dark ? 'dark' : 'light'} className={['chat-code-block my-2 min-w-0 max-w-full overflow-hidden rounded-md border', dark ? 'border-white/12 bg-[#191919] text-[#dedede]' : 'border-[#d7dfeb] bg-[#f8fafc] text-[#253247]', fadeClass].join(' ')}>
<div className={['flex h-9 min-w-0 items-center justify-between gap-2 border-b px-2.5', dark ? 'border-white/10 bg-[#262626]' : 'border-[#e2e8f0] bg-[#eef2f7]'].join(' ')}>
<span title={language || undefined} className="flex min-w-0 items-center gap-1.5 text-[11px] font-bold"><FileCode2 size={13} className="shrink-0" /><span className="truncate">{languageLabels[normalizedLanguage] ?? (language ? language.slice(0, 40) : t('일반 텍스트'))}</span></span>
<button type="button" title={copyLabel} aria-label={copyLabel} onClick={event => { event.stopPropagation(); void copy() }} className={['grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded transition focus-visible:outline-2 focus-visible:outline-[#60a5fa]', copyState === 'error' ? 'text-[#ef4444]' : dark ? 'text-white/65 hover:bg-white/10 hover:text-white' : 'text-[#526079] hover:bg-[#dbe3ee] hover:text-[#182337]'].join(' ')}>{copyState === 'copied' ? <Check size={14} /> : copyState === 'error' ? <X size={14} /> : <Copy size={14} />}</button>
<span role="status" className="sr-only">{copyState === 'idle' ? '' : copyLabel}</span>
</div>
<pre tabIndex={0} aria-label={t('코드')} className="thin-scrollbar m-0 max-h-80 max-w-full overflow-auto p-3 text-[11px] leading-relaxed outline-offset-[-2px] [overflow-wrap:normal] [tab-size:4] [word-break:normal]">
<code className="block w-max min-w-full whitespace-pre font-mono font-normal [overflow-wrap:normal] [word-break:normal]">{tokens}</code>
</pre>
</div>
}
@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { t, useLocaleRender } from '../i18n'
import { displayAttachmentName } from './chatAttachments'
type ImageAttachment = { name: string; url: string }
export function ChatImageAttachments({ images, variant = 'draft', dark = true, onRemove, removeDisabled = false }: {
images: ImageAttachment[]
variant?: 'draft' | 'sent'
dark?: boolean
onRemove?: (image: ImageAttachment) => void
removeDisabled?: boolean
}): React.JSX.Element | null {
useLocaleRender()
const [preview, setPreview] = useState<ImageAttachment | null>(null)
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
if (preview) dialogRef.current?.showModal()
else dialogRef.current?.close()
}, [preview])
if (!images.length) return null
return <>
<div data-chat-image-attachments={variant} className={['flex min-w-0 flex-wrap gap-2', variant === 'sent' ? 'justify-end' : 'mt-2'].join(' ')}>
{images.map((source, index) => {
const image = { ...source, name: displayAttachmentName(source.name) }
return variant === 'draft' ? <div key={`${image.url}-${index}`} data-chat-image-draft="true" className={['flex h-16 w-56 min-w-0 max-w-full items-center gap-1 overflow-hidden rounded-md border p-1', dark ? 'border-white/10 bg-white/[0.03] text-white/70' : 'border-[#d8dee9] bg-[#f8f9fc] text-[#475569]'].join(' ')}>
<button type="button" title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="flex h-full min-w-0 flex-1 cursor-zoom-in items-center gap-2 overflow-hidden text-left">
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-14 shrink-0 rounded bg-black/10 object-contain" />
<span className="min-w-0 truncate text-[11px]">{image.name}</span>
</button>
{onRemove && <button type="button" disabled={removeDisabled} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${image.name}`} onClick={() => onRemove(image)} className={['grid size-6 shrink-0 cursor-pointer place-items-center rounded disabled:cursor-default disabled:opacity-40', dark ? 'hover:bg-white/10' : 'hover:bg-black/5'].join(' ')}><X size={12} /></button>}
</div> : <button type="button" key={`${image.url}-${index}`} title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="h-16 w-20 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-current/20 bg-black/10">
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-contain" />
</button>
})}
</div>
{preview && createPortal(<dialog ref={dialogRef} aria-label={preview.name} onCancel={() => setPreview(null)} onClick={event => { if (event.target === event.currentTarget) setPreview(null) }} className="fixed inset-0 m-auto max-h-[90vh] max-w-[94vw] overflow-auto rounded-lg border border-white/15 bg-[#202020] p-3 text-white shadow-xl backdrop:bg-black/75">
<div className="mb-2 flex min-w-0 items-center justify-between gap-3"><span className="min-w-0 break-all text-[12px]">{preview.name}</span><button type="button" title={t('닫기')} aria-label={t('닫기')} onClick={() => setPreview(null)} className="grid size-8 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10"><X size={16} /></button></div>
<img src={preview.url} alt={preview.name} referrerPolicy="no-referrer" className="max-h-[78vh] max-w-full object-contain" />
</dialog>, document.body)}
</>
}
@@ -0,0 +1,106 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ClipboardPaste, Copy, Trash2 } from 'lucide-react'
import { t, useLocaleRender } from '../i18n'
export type ChatTextContextMenuState = {
kind: 'message' | 'input'
x: number
y: number
text: string
input?: HTMLTextAreaElement
selectionStart?: number
selectionEnd?: number
}
export function ChatTextContextMenu({ dark, menu, canClear = false, onClose, onClear, onPasteText }: {
dark: boolean
menu: ChatTextContextMenuState | null
canClear?: boolean
onClose: () => void
onClear?: () => void
onPasteText?: (text: string, menu: ChatTextContextMenuState) => void
}): React.JSX.Element | null {
useLocaleRender()
const menuRef = useRef<HTMLDivElement>(null)
const [clipboardState, setClipboardState] = useState<{ menu: ChatTextContextMenuState; hasText: boolean } | null>(null)
const [position, setPosition] = useState({ x: 0, y: 0 })
const closeRef = useRef(onClose)
closeRef.current = onClose
useLayoutEffect(() => {
if (!menu || !menuRef.current) return
const bounds = menuRef.current.getBoundingClientRect()
setPosition({ x: Math.max(8, Math.min(menu.x, window.innerWidth - bounds.width - 8)), y: Math.max(8, Math.min(menu.y, window.innerHeight - bounds.height - 8)) })
}, [menu])
useEffect(() => {
if (!menu) return
let disposed = false
if (menu.kind === 'input') {
const read = Promise.resolve().then(() => window.chatTextMenu?.readClipboardState
? window.chatTextMenu.readClipboardState()
: navigator.clipboard.readText().then(text => ({ hasText: text.length > 0 })))
void read.then(state => { if (!disposed) setClipboardState({ menu, hasText: state.hasText }) })
.catch(() => { if (!disposed) setClipboardState({ menu, hasText: false }) })
}
const outside = (event: Event): void => {
if (!menuRef.current?.contains(event.target as Node)) closeRef.current()
}
const keydown = (event: KeyboardEvent): void => {
if (event.key === 'Escape') { event.preventDefault(); closeRef.current() }
}
const close = (): void => closeRef.current()
window.addEventListener('pointerdown', outside, true)
window.addEventListener('contextmenu', outside)
window.addEventListener('scroll', outside, true)
window.addEventListener('resize', close)
window.addEventListener('blur', close)
window.addEventListener('keydown', keydown)
return () => {
disposed = true
window.removeEventListener('pointerdown', outside, true)
window.removeEventListener('contextmenu', outside)
window.removeEventListener('scroll', outside, true)
window.removeEventListener('resize', close)
window.removeEventListener('blur', close)
window.removeEventListener('keydown', keydown)
}
}, [menu])
if (!menu) return null
const pasteEnabled = menu.kind === 'input' && !menu.input?.disabled && !menu.input?.readOnly && clipboardState?.menu === menu && clipboardState.hasText
const itemClass = ['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition disabled:cursor-default disabled:opacity-35', dark ? 'enabled:hover:bg-[#37373d] enabled:hover:text-white' : 'enabled:hover:bg-[#eef1f7] enabled:hover:text-[#111520]'].join(' ')
const copy = async (): Promise<void> => {
if (!menu.text) return
onClose()
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'copy', text: menu.text })
else await navigator.clipboard.writeText(menu.text)
}
const paste = async (): Promise<void> => {
if (!pasteEnabled || !menu.input?.isConnected || menu.input.disabled || menu.input.readOnly) return
const input = menu.input
input.focus({ preventScroll: true })
input.setSelectionRange(menu.selectionStart ?? input.selectionStart, menu.selectionEnd ?? input.selectionEnd)
onClose()
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'paste' })
else {
const text = await navigator.clipboard.readText()
if (text) onPasteText?.(text, menu)
}
}
return createPortal(
<div ref={menuRef} role="menu" aria-label={t('질의응답')} className={[
'fixed z-[160] w-[160px] overflow-hidden rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'
].join(' ')} style={{ left: position.x, top: position.y }} onMouseDown={event => event.preventDefault()} onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}>
<button type="button" role="menuitem" disabled={!menu.text} onClick={() => void copy().catch(error => console.error('[chat] copy failed', error))} className={itemClass}><Copy size={13} />{t('복사')}</button>
{menu.kind === 'input' ? (
<button type="button" role="menuitem" disabled={!pasteEnabled} onClick={() => void paste().catch(error => console.error('[chat] paste failed', error))} className={itemClass}><ClipboardPaste size={13} />{t('붙여넣기')}</button>
) : onClear ? <>
<div role="separator" className={['my-1 border-t', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')} />
<button type="button" role="menuitem" disabled={!canClear} onClick={() => { onClose(); onClear() }} className={itemClass}><Trash2 size={13} />{t('대화내역 삭제')}</button>
</> : null}
</div>, document.body
)
}
+210 -30
View File
@@ -1,9 +1,13 @@
import { t, useLocaleRender } from '../i18n' import { t, useLocaleRender } from '../i18n'
import { ChevronDown, Send, Square } from 'lucide-react' import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { memo, useEffect, useState } from 'react' import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { CHAT_ATTACHMENT_ACCEPT, CHAT_IMAGE_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments'
import { ChatImageAttachments } from './ChatImageAttachments'
import { ChatCodeBlock } from './ChatCodeBlock'
import ReactMarkdown from 'react-markdown' import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm' import remarkGfm from 'remark-gfm'
import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu'
export function ChatAvatar({ export function ChatAvatar({
accessToken, accessToken,
@@ -58,23 +62,23 @@ 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() useLocaleRender()
const textColor = dark ? 'text-white/76' : 'text-[#4f596b]' const textColor = dark ? 'text-white/76' : 'text-[#4f596b]'
const headingColor = dark ? 'text-white/92' : 'text-[#111827]' const headingColor = dark ? 'text-white/92' : 'text-[#111827]'
const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]' 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 ( 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}`}> <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 <ReactMarkdown
remarkPlugins={[remarkGfm]} remarkPlugins={[remarkGfm]}
components={{ components={{
h1: ({ children }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h1>, 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 }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h2>, 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 }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h3>, 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 }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h4>, 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 }) => <p className="my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]">{children}</p>, 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>, strong: ({ children }) => <strong className={`font-black ${headingColor}`}>{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>, em: ({ children }) => <em className="italic">{children}</em>,
a: ({ children, href }) => ( a: ({ children, href }) => (
@@ -88,30 +92,37 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
{children} {children}
</a> </a>
), ),
ul: ({ children }) => <ul className="my-2 grid gap-1 pl-4">{children}</ul>, ul: ({ children, className }) => <ul className={`my-2 grid gap-1 pl-5 ${className?.includes('contains-task-list') ? 'contains-task-list list-none' : 'list-disc [&_ul]:list-[circle]'}`}>{children}</ul>,
ol: ({ children }) => <ol className="my-2 grid gap-1 pl-4">{children}</ol>, ol: ({ children, start }) => <ol start={start} className="my-2 grid list-decimal gap-1 pl-5">{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>, li: ({ children, node, className }) => <li className={`max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word] ${className?.includes('task-list-item') ? 'task-list-item list-none' : ''}${fadeClass(node?.position?.start.offset)}`}>{children}</li>,
blockquote: ({ children }) => ( blockquote: ({ children, node }) => (
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}`}> <blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}${fadeClass(node?.position?.start.offset)}`}>
{children} {children}
</blockquote> </blockquote>
), ),
hr: () => <hr className={`my-3 ${mutedBorder}`} />, hr: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />,
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>, pre: ({ children, node }) => {
const code = node?.children.find(child => child.type === 'element' && child.tagName === 'code')
if (code?.type !== 'element') return <pre className="my-2 max-w-full overflow-auto whitespace-pre font-mono text-[11px]">{children}</pre>
const source = code.children.map(child => child.type === 'text' ? child.value : '').join('')
const classes = code.properties.className
const language = Array.isArray(classes) ? String(classes.find(value => String(value).startsWith('language-')) ?? '').slice(9) : ''
return <ChatCodeBlock source={source} language={language} dark={dark} fadeClass={fadeClass(node?.position?.start.offset)} />
},
code: ({ children, className }) => { code: ({ children, className }) => {
if (className) { if (className) {
return <code className="whitespace-pre-wrap break-words font-mono [overflow-wrap:anywhere]">{children}</code> 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> return <code className={`chat-inline-code rounded px-1 py-0.5 font-mono text-[11px] ${dark ? 'bg-white/10 text-[#dbeafe]' : 'bg-[#eef2ff] text-[#1d4ed8]'}`}>{children}</code>
}, },
table: ({ children }) => ( table: ({ children, node }) => (
<div className="my-2 max-w-full overflow-hidden"> <div data-chat-table tabIndex={0} role="region" aria-label={t('표')} className={`thin-x-scrollbar my-2 max-w-full overflow-x-auto rounded-md${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> <table className={`w-full table-auto border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
</div> </div>
), ),
thead: ({ children }) => <thead className={dark ? 'bg-white/8 text-white/86' : 'bg-[#eef2f7] text-[#1e293b]'}>{children}</thead>, thead: ({ children }) => <thead className={dark ? 'bg-white/8 text-white/86' : 'bg-[#eef2f7] text-[#1e293b]'}>{children}</thead>,
th: ({ children }) => <th className={`break-words border px-2 py-1.5 font-black [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</th>, th: ({ children, style }) => <th style={style} className={`min-w-[96px] max-w-[320px] break-words border px-2 py-1.5 font-black [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</th>,
td: ({ children }) => <td className={`break-words border px-2 py-1.5 align-top [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</td> td: ({ children, style }) => <td style={style} className={`min-w-[96px] max-w-[320px] break-words border px-2 py-1.5 align-top [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</td>
}} }}
> >
{content} {content}
@@ -120,6 +131,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({ export function ExpandableMarkdownMessage({
content, content,
dark, dark,
@@ -166,6 +204,7 @@ export function ExpandableMarkdownMessage({
<button <button
type="button" type="button"
aria-label={expanded ? t("접기") : t("자세히 보기")} aria-label={expanded ? t("접기") : t("자세히 보기")}
aria-expanded={expanded}
title={expanded ? t("접기") : t("자세히 보기")} title={expanded ? t("접기") : t("자세히 보기")}
onClick={() => setExpanded((value) => !value)} 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(' ')} 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(' ')}
@@ -234,11 +273,32 @@ export function ExpandablePlainMessage({
) )
} }
function resizeQaInput(input: HTMLTextAreaElement): void {
const styles = window.getComputedStyle(input)
const lineHeight = Number.parseFloat(styles.lineHeight)
const padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom)
const border = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth)
const minHeight = Number.parseFloat(styles.minHeight)
const maxHeight = Math.ceil(lineHeight * 8 + padding + border)
input.style.height = 'auto'
input.style.overflowY = 'hidden'
const contentHeight = input.scrollHeight + border
input.style.maxHeight = `${maxHeight}px`
input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px`
input.style.overflowY = contentHeight > maxHeight ? 'auto' : 'hidden'
}
export const QaInputBox = memo(function QaInputBox({ export const QaInputBox = memo(function QaInputBox({
dark, dark,
busy, busy,
disabled, disabled,
accent = 'blue', accent = 'blue',
attachmentsEnabled = false,
imagesEnabled = false,
onClearConversation,
clearDisabled = false,
footerAction,
onStop, onStop,
onSubmit onSubmit
}: { }: {
@@ -246,44 +306,127 @@ export const QaInputBox = memo(function QaInputBox({
busy: boolean busy: boolean
disabled: boolean disabled: boolean
accent?: 'blue' | 'cyan' accent?: 'blue' | 'cyan'
attachmentsEnabled?: boolean
imagesEnabled?: boolean
onClearConversation?: () => void
clearDisabled?: boolean
footerAction?: React.ReactNode
onStop: () => void onStop: () => void
onSubmit: (content: string) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
}): React.JSX.Element { }): React.JSX.Element {
const [draft, setDraft] = useState('') const [draft, setDraft] = useState('')
const canSubmit = busy || (Boolean(draft.trim()) && !disabled) const [attachments, setAttachments] = useState<ChatAttachment[]>([])
const [attachmentError, setAttachmentError] = useState<string | null>(null)
const [readingAttachments, setReadingAttachments] = useState(false)
const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const attachmentReadRef = useRef(0)
const attachmentUrlsRef = useRef<string[]>([])
const canSubmit = busy || ((Boolean(draft.trim()) || attachments.some(file => file.kind === 'image')) && !disabled && !readingAttachments)
useEffect(() => {
const urls = attachments.flatMap(file => file.kind === 'image' ? [file.previewUrl] : [])
attachmentUrlsRef.current.filter(url => !urls.includes(url)).forEach(url => URL.revokeObjectURL(url))
attachmentUrlsRef.current = urls
}, [attachments])
useEffect(() => () => attachmentUrlsRef.current.forEach(url => URL.revokeObjectURL(url)), [])
const attachFiles = (files: File[]): void => {
if (!attachmentsEnabled || disabled || busy || readingAttachments || !files.length) return
const readId = ++attachmentReadRef.current
setReadingAttachments(true)
setAttachmentError(null)
void readChatAttachments(files, attachments, { imagesEnabled }).then(files => {
if (attachmentReadRef.current === readId) setAttachments(files)
else files.forEach(file => { if (file.kind === 'image' && !attachments.includes(file)) URL.revokeObjectURL(file.previewUrl) })
}).catch(error => {
if (attachmentReadRef.current === readId) setAttachmentError(error instanceof Error ? error.message : '파일을 읽지 못했습니다.')
}).finally(() => { if (attachmentReadRef.current === readId) setReadingAttachments(false) })
}
useLayoutEffect(() => {
if (textareaRef.current) resizeQaInput(textareaRef.current)
}, [draft])
useEffect(() => {
const input = textareaRef.current
if (!input) return
let width = input.getBoundingClientRect().width
const observer = new ResizeObserver(([entry]) => {
if (!entry || entry.contentRect.width === width) return
width = entry.contentRect.width
resizeQaInput(input)
})
observer.observe(input)
return () => observer.disconnect()
}, [])
useEffect(() => {
attachmentReadRef.current += 1
setReadingAttachments(false)
if (!attachmentsEnabled) {
setAttachments([])
setAttachmentError(null)
setReadingAttachments(false)
}
else if (!imagesEnabled) setAttachments(current => current.filter(file => file.kind !== 'image'))
return () => { attachmentReadRef.current += 1 }
}, [attachmentsEnabled, imagesEnabled])
const submit = (): void => { const submit = (): void => {
const content = draft.trim() const content = draft.trim() || (attachments.some(file => file.kind === 'image') ? t('첨부 이미지를 분석해주세요.') : '')
if (busy) { if (busy) {
onStop() onStop()
return return
} }
if (!content || disabled) return if (!content || disabled || readingAttachments) return
setDraft('') setDraft('')
onSubmit(content) onSubmit(content, attachmentsEnabled ? attachments : undefined)
setAttachments([])
setAttachmentError(null)
} }
return ( return (
<div className={['m-3 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')}> <div className={['m-3 shrink-0 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')} onDragOver={event => { if (attachmentsEnabled && event.dataTransfer.types.includes('Files')) event.preventDefault() }} onDrop={event => { if (!attachmentsEnabled) return; event.preventDefault(); attachFiles(Array.from(event.dataTransfer.files)) }}>
<div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}> <div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}>
<textarea <textarea
ref={textareaRef}
rows={2} rows={2}
spellCheck={false}
autoCorrect="off"
value={draft} value={draft}
onChange={(event) => setDraft(event.target.value)} onChange={(event) => setDraft(event.target.value)}
onPaste={event => { if (attachmentsEnabled && event.clipboardData.files.length) { event.preventDefault(); attachFiles(Array.from(event.clipboardData.files)) } }}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
const input = event.currentTarget
setContextMenu({
kind: 'input',
x: event.clientX,
y: event.clientY,
text: input.value.slice(input.selectionStart, input.selectionEnd),
input,
selectionStart: input.selectionStart,
selectionEnd: input.selectionEnd
})
}}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === 'Enter' && !event.shiftKey) { if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault() event.preventDefault()
submit() submit()
} }
}} }}
placeholder={t("질문하세요...")} placeholder={t("질문하세요...")}
className={[ className={[
'min-h-[56px] min-w-0 flex-1 resize-none bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium', 'box-border min-h-[56px] min-w-0 flex-1 self-start resize-none overflow-y-hidden bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium',
dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]' dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]'
].join(' ')} ].join(' ')}
/> />
<button <button
type="button" type="button"
aria-label={busy ? t('중지') : t('전송')}
disabled={!canSubmit} disabled={!canSubmit}
onClick={busy ? onStop : submit} onClick={busy ? onStop : submit}
className={[ className={[
@@ -296,6 +439,43 @@ export const QaInputBox = memo(function QaInputBox({
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />} {busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
</button> </button>
</div> </div>
<ChatTextContextMenu dark={dark} menu={contextMenu} onClose={() => setContextMenu(null)} onPasteText={(text, menu) => {
const start = menu.selectionStart ?? draft.length
const end = menu.selectionEnd ?? start
setDraft(current => current.slice(0, start) + text + current.slice(end))
window.requestAnimationFrame(() => textareaRef.current?.setSelectionRange(start + text.length, start + text.length))
}} />
{attachmentsEnabled && attachments.some(file => file.kind !== 'image') && (
<div className="flex flex-wrap gap-1.5 pt-2">
{attachments.map((file, index) => file.kind === 'image' ? null : (
<span key={`${file.name}-${index}`} className={['inline-flex min-w-0 max-w-full items-center gap-1 rounded border px-2 py-1 text-[11px]', dark ? 'border-white/10 text-white/70' : 'border-[#d8dee9] text-[#475569]'].join(' ')}>
<Paperclip size={12} className="shrink-0" />
<span className="min-w-0 truncate" title={file.name}>{file.name}</span>
<button type="button" disabled={busy || readingAttachments} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${file.name}`} onClick={() => setAttachments((current) => current.filter((_, item) => item !== index))} className="grid size-5 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10 disabled:cursor-default disabled:opacity-40"><X size={12} /></button>
</span>
))}
</div>
)}
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
{(attachmentsEnabled || onClearConversation || footerAction) && (
<div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
{footerAction}
{attachmentsEnabled && (
<>
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
const files = Array.from(event.currentTarget.files ?? [])
event.currentTarget.value = ''
attachFiles(files)
}} />
<button type="button" title={t('파일 첨부')} aria-label={t('파일 첨부')} disabled={disabled || busy || readingAttachments} onClick={() => fileInputRef.current?.click()} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 disabled:cursor-default disabled:opacity-35"><Paperclip size={15} className={readingAttachments ? 'animate-pulse' : ''} /></button>
</>
)}
{onClearConversation && (
<button type="button" title={t('대화내역 삭제')} aria-label={t('대화내역 삭제')} disabled={clearDisabled || busy || readingAttachments} onClick={onClearConversation} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 hover:text-[#ef4444] disabled:cursor-default disabled:opacity-35"><Trash2 size={15} /></button>
)}
</div>
)}
</div> </div>
) )
}) })
@@ -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 === 'A_COPILOT' ? 'A-Copilot' : [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 min-w-0 max-w-full gap-3" aria-busy={!frame.complete}>
<div className="min-w-0">
{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 min-w-0' : 'min-w-0'}>
{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
}
}
@@ -0,0 +1,63 @@
export type ChatTextAttachment = { name: string; content: string; kind?: 'text' }
export type ChatImageDraft = { kind: 'image'; name: string; file: File; previewUrl: string }
export type ChatAttachment = ChatTextAttachment | ChatImageDraft
export type AgentImageReference = { token: string; name: string; mimeType: string; size: number; url: string }
export const CHAT_IMAGE_ACCEPT = '.png,.jpg,.jpeg,.webp'
export function displayAttachmentName(name: string): string {
// Older multipart uploads can leave Latin-1-decoded UTF-8 in cached image references.
if (!/^[\u0000-\u00ff]*$/.test(name)) return name
try {
return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(name, character => character.charCodeAt(0)))
} catch {
return name
}
}
export const CHAT_ATTACHMENT_ACCEPT = '.txt,.log,.md,.json,.yaml,.yml,.xml,.py,.js,.jsx,.ts,.tsx,.c,.cc,.cpp,.h,.hpp,.sh,.bash,.cfg,.ini,.toml,.csv,.urdf,.xacro,.launch,.rviz,.cmake'
export async function readChatAttachments(files: File[], existing: ChatAttachment[], options: { imagesEnabled?: boolean } = {}): Promise<ChatAttachment[]> {
if (files.length + existing.length > 5) throw new Error('파일은 최대 5개까지 첨부할 수 있습니다.')
const result = [...existing]
const createdUrls: string[] = []
try { for (const file of files) {
const name = file.name
const extension = `.${name.split('.').pop()?.toLowerCase()}`
if (CHAT_IMAGE_ACCEPT.split(',').includes(extension)) {
if (!options.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다. Qwen 3.7 Plus·Flash 또는 3.8 Max·Flash를 선택해주세요.')
if (result.filter(item => item.kind === 'image').length >= 3) throw new Error('이미지는 최대 3장까지 첨부할 수 있습니다.')
if (name.length > 255 || !file.size || file.size > 5 * 1024 * 1024) throw new Error('이미지는 장당 5MB 이하여야 합니다.')
let bitmap: ImageBitmap
try { bitmap = await createImageBitmap(file) } catch { throw new Error('이미지 파일을 읽지 못했습니다.') }
const pixels = bitmap.width * bitmap.height
bitmap.close()
if (!pixels || pixels > 16_000_000) throw new Error('이미지는 1600만 픽셀 이하여야 합니다.')
const previewUrl = URL.createObjectURL(file)
createdUrls.push(previewUrl)
result.push({ kind: 'image', name, file, previewUrl })
continue
}
if (!CHAT_ATTACHMENT_ACCEPT.split(',').includes(extension) && !['dockerfile', 'cmakelists.txt'].includes(name.toLowerCase())) {
throw new Error('코드·설정·로그 또는 PNG·JPEG·WebP 이미지를 첨부해주세요. PDF는 지원하지 않습니다.')
}
if (name.length > 255 || file.size > 100_000) throw new Error('파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.')
let content: string
try {
content = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer())
} catch {
throw new Error('UTF-8 텍스트 파일만 첨부할 수 있습니다.')
}
if (content.includes('\0')) throw new Error('바이너리 파일은 첨부할 수 없습니다.')
if (content.length > 50_000) throw new Error('파일 하나의 내용은 5만 자 이하여야 합니다.')
result.push({ name, content })
}
if (result.reduce((sum, file) => sum + (file.kind === 'image' ? 0 : file.name.length + file.content.length), 0) > 100_000) {
throw new Error('첨부 파일의 전체 내용은 10만 자 이하여야 합니다.')
}
return result
} catch (error) {
createdUrls.forEach(url => URL.revokeObjectURL(url))
throw error
}
}
+21
View File
@@ -67,6 +67,11 @@ interface Window {
openDevTools: () => Promise<{ opened: boolean; message?: string }> openDevTools: () => Promise<{ opened: boolean; message?: string }>
close: () => void close: () => void
} }
chatTextMenu?: {
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
readClipboardState: () => Promise<{ hasText: boolean }>
perform: (action: import('../../shared/chatTextMenu').ChatClipboardAction) => Promise<boolean>
}
appDiagnostics?: { appDiagnostics?: {
read: () => Promise<{ path: string; content: string }> read: () => Promise<{ path: string; content: string }>
} }
@@ -91,6 +96,16 @@ interface Window {
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> 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
exitCode: number | null
message?: string
}>
}
modules: { modules: {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{ list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{
cacheRoot: string cacheRoot: string
@@ -267,6 +282,7 @@ interface Window {
openGuide: () => Promise<void> openGuide: () => Promise<void>
} }
moduleFiles: Window['moduleFiles'] moduleFiles: Window['moduleFiles']
agent: Window['agent']
modules: Window['modules'] modules: Window['modules']
rosGraph: { rosGraph: {
snapshot: () => Promise<{ snapshot: () => Promise<{
@@ -310,6 +326,11 @@ interface Window {
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']> rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']>
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']> 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: { maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>> readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }> copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
@@ -105,7 +105,11 @@ export function PlatformOptionCard({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('시뮬레이션') : platform.title}</h3> <h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('시뮬레이션') : platform.title}</h3>
</div> </div>
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p> <p className="mt-1 text-[12px] font-medium text-[#5f6878]">
{platform.platformKey === 'futuredrive-local-pc' && platform.description === '내 PC에서 직접 개발 환경을 실행합니다.'
? t('내 PC에서 직접 개발 환경을 실행합니다.')
: platform.description}
</p>
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]"> <div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]">
{statusPending {statusPending
? <LoaderCircle size={13} className="animate-spin text-[#7a879a]" /> ? <LoaderCircle size={13} className="animate-spin text-[#7a879a]" />
@@ -112,15 +112,17 @@ export default function ModuleListToolbar({
onClick() onClick()
}} }}
className={[ className={[
'flex h-7 whitespace-nowrap items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold transition', 'inline-flex h-7 whitespace-nowrap items-center gap-1.5 rounded-md border px-3 text-[12px] font-bold leading-none transition',
disabled ? (dark ? 'cursor-default opacity-45' : 'cursor-default') : 'cursor-pointer', disabled ? (dark ? 'cursor-default opacity-45' : 'cursor-default') : 'cursor-pointer',
dark dark
? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white' ? 'border-white/15 bg-white/[0.04] text-white/70 hover:border-white/25 hover:bg-white/10 hover:text-white'
: disabled ? 'border-[#d2ccdc] bg-[#e9e6ee] text-[#8b8498]' : lightClass : disabled ? 'border-[#d2ccdc] bg-[#e9e6ee] text-[#8b8498]' : lightClass
].join(' ')} ].join(' ')}
> >
{busy ? <RefreshCw size={14} className="animate-spin" /> : <Icon size={14} />} {busy
{t(label)} ? <RefreshCw size={14} className="block shrink-0 animate-spin" />
: <Icon size={14} className="block shrink-0" />}
<span className="translate-y-px leading-none">{t(label)}</span>
</button> </button>
))} ))}
</div> </div>
@@ -275,7 +275,7 @@ export default function ModuleListView({
)} )}
</div> </div>
<div className="-mt-2 flex min-h-0 flex-1 overflow-x-auto overflow-y-hidden"> <div className="module-content-scrollbar -mt-2 flex min-h-0 flex-1 overflow-x-auto overflow-y-hidden">
<div className={['flex h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}> <div className={['flex h-full min-w-[760px] flex-1 flex-col overflow-hidden border-b', dark ? 'border-white/10 bg-[#242424]' : 'border-[#d8dee9] bg-white'].join(' ')}>
<ModuleListToolbar <ModuleListToolbar
dark={dark} dark={dark}
@@ -1,8 +1,9 @@
import { t, useLocaleRender } from '../../i18n' import { t, useLocaleRender } from '../../i18n'
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react' import Editor, { DiffEditor, type BeforeMount, type OnMount } from '@monaco-editor/react'
import { FileText, Loader2, RotateCw } from 'lucide-react' import { FileText, Loader2, RotateCw } from 'lucide-react'
type Props = { type Props = {
editingLocked?: boolean
fileLoading?: boolean fileLoading?: boolean
fileError?: string fileError?: string
onRetryFile?: () => void onRetryFile?: () => void
@@ -10,6 +11,11 @@ type Props = {
textDim: string textDim: string
editorPaneRef: React.RefObject<HTMLDivElement | null> editorPaneRef: React.RefObject<HTMLDivElement | null>
currentActiveEditorFile: string | null currentActiveEditorFile: string | null
diffPreview?: {
id: string
originalContent: string
modifiedContent: string
} | null
activeFileIsImage: boolean activeFileIsImage: boolean
activeImagePreviewSource?: string activeImagePreviewSource?: string
editorLanguage: string editorLanguage: string
@@ -25,6 +31,7 @@ type Props = {
} }
function WorkspaceEditorPane({ function WorkspaceEditorPane({
editingLocked = false,
fileLoading, fileLoading,
fileError, fileError,
onRetryFile, onRetryFile,
@@ -32,6 +39,7 @@ function WorkspaceEditorPane({
textDim, textDim,
editorPaneRef, editorPaneRef,
currentActiveEditorFile, currentActiveEditorFile,
diffPreview,
activeFileIsImage, activeFileIsImage,
activeImagePreviewSource, activeImagePreviewSource,
editorLanguage, editorLanguage,
@@ -48,7 +56,35 @@ function WorkspaceEditorPane({
useLocaleRender() useLocaleRender()
return ( return (
<div ref={editorPaneRef} className="min-h-0 min-w-0 overflow-hidden"> <div ref={editorPaneRef} className="min-h-0 min-w-0 overflow-hidden">
{!currentActiveEditorFile ? ( {diffPreview && currentActiveEditorFile ? (
<DiffEditor
key={diffPreview.id}
height="100%"
width="100%"
original={diffPreview.originalContent}
modified={diffPreview.modifiedContent}
originalModelPath={`${currentActiveEditorFile}.agent-original`}
modifiedModelPath={`${currentActiveEditorFile}.agent-proposed`}
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',
fontSize,
minimap: { enabled: minimapEnabled },
originalEditable: false,
readOnly: true,
renderSideBySide: true,
scrollBeyondLastLine: false,
wordWrap: wordWrap ? 'on' : 'off'
}}
/>
) : !currentActiveEditorFile ? (
<div className={['grid h-full place-items-center', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}> <div className={['grid h-full place-items-center', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
<div className="px-6 py-5 text-center"> <div className="px-6 py-5 text-center">
<FileText size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} /> <FileText size={24} className={['mx-auto mb-3', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')} />
@@ -105,7 +141,7 @@ function WorkspaceEditorPane({
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
tabSize, tabSize,
wordWrap: wordWrap ? 'on' : 'off', 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.'
}} }}
/> />
)} )}
@@ -6,6 +6,7 @@ type Props = {
dark: boolean dark: boolean
currentActiveEditorFile: string | null currentActiveEditorFile: string | null
exampleActive: boolean exampleActive: boolean
readOnlyLabel?: string
isDirtyFile: (file: string) => boolean isDirtyFile: (file: string) => boolean
onSave: () => void onSave: () => void
} }
@@ -14,6 +15,7 @@ function WorkspaceEditorPathBar({
dark, dark,
currentActiveEditorFile, currentActiveEditorFile,
exampleActive, exampleActive,
readOnlyLabel,
isDirtyFile, isDirtyFile,
onSave onSave
}: Props): React.JSX.Element | null { }: Props): React.JSX.Element | null {
@@ -31,8 +33,13 @@ function WorkspaceEditorPathBar({
</span> </span>
</span> </span>
))} ))}
{readOnlyLabel ? (
<span className={['ml-2 shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold', dark ? 'bg-[#7c3aed]/25 text-[#c4b5fd]' : 'bg-[#ede9fe] text-[#6d28d9]'].join(' ')}>
{readOnlyLabel}
</span>
) : null}
</div> </div>
{!exampleActive && ( {!exampleActive && !readOnlyLabel && (
<Tooltip label={t("저장")} side="bottom"> <Tooltip label={t("저장")} side="bottom">
<button <button
type="button" type="button"
@@ -1,5 +1,5 @@
import { t, useLocaleRender } from '../../i18n' import { t, useLocaleRender } from '../../i18n'
import { FileText, X } from 'lucide-react' import { FileDiff, FileText, X } from 'lucide-react'
type EditorFilePath = string type EditorFilePath = string
@@ -10,6 +10,8 @@ type Props = {
draggingFile: EditorFilePath | null draggingFile: EditorFilePath | null
dropIndex: number | null dropIndex: number | null
isDirtyFile: (file: EditorFilePath) => boolean isDirtyFile: (file: EditorFilePath) => boolean
isPreviewFile?: (file: EditorFilePath) => boolean
getFileLabel?: (file: EditorFilePath) => string
onOpenFile: (file: EditorFilePath) => void onOpenFile: (file: EditorFilePath) => void
onCloseFile: (file: EditorFilePath) => void onCloseFile: (file: EditorFilePath) => void
onContextMenu: (file: EditorFilePath, x: number, y: number) => void onContextMenu: (file: EditorFilePath, x: number, y: number) => void
@@ -26,6 +28,8 @@ function WorkspaceEditorTabs({
draggingFile, draggingFile,
dropIndex, dropIndex,
isDirtyFile, isDirtyFile,
isPreviewFile = () => false,
getFileLabel = (file) => file.split('/').pop() ?? file,
onOpenFile, onOpenFile,
onCloseFile, onCloseFile,
onContextMenu, onContextMenu,
@@ -40,11 +44,14 @@ function WorkspaceEditorTabs({
return ( return (
<div className={['min-w-0 overflow-hidden border-b pl-1 pr-2', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d4c9e4] bg-white'].join(' ')}> <div className={['min-w-0 overflow-hidden border-b pl-1 pr-2', dark ? 'border-white/10 bg-[#252525]' : 'border-[#d4c9e4] bg-white'].join(' ')}>
<div className="thin-x-scrollbar flex h-full min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden [scrollbar-gutter:stable]"> <div className="thin-x-scrollbar flex h-full min-w-0 items-end gap-0.5 overflow-x-auto overflow-y-hidden [scrollbar-gutter:stable]">
{files.map((file, index) => ( {files.map((file, index) => {
const previewFile = isPreviewFile(file)
const FileIcon = previewFile ? FileDiff : FileText
return (
<button <button
key={file} key={file}
type="button" type="button"
draggable draggable={!previewFile}
onClick={() => onOpenFile(file)} onClick={() => onOpenFile(file)}
onAuxClick={(event) => { onAuxClick={(event) => {
if (event.button !== 1) return if (event.button !== 1) return
@@ -53,12 +60,21 @@ function WorkspaceEditorTabs({
}} }}
onContextMenu={(event) => { onContextMenu={(event) => {
event.preventDefault() event.preventDefault()
if (previewFile) return
onContextMenu(file, event.clientX, event.clientY) onContextMenu(file, event.clientX, event.clientY)
}} }}
onDragStart={(event) => onDragStart(file, index, event)} onDragStart={(event) => {
onDragOver={(event) => onDragOver(index, event)} if (!previewFile) onDragStart(file, index, event)
onDrop={onDrop} }}
onDragEnd={onDragEnd} onDragOver={(event) => {
if (!previewFile) onDragOver(index, event)
}}
onDrop={(event) => {
if (!previewFile) onDrop(event)
}}
onDragEnd={() => {
if (!previewFile) onDragEnd()
}}
className={[ className={[
'group relative flex h-8 min-w-[118px] max-w-[180px] shrink-0 cursor-pointer items-center gap-1.5 rounded-t-md border-x border-t px-3 text-[12px] font-bold transition', 'group relative flex h-8 min-w-[118px] max-w-[180px] shrink-0 cursor-pointer items-center gap-1.5 rounded-t-md border-x border-t px-3 text-[12px] font-bold transition',
draggingFile === file ? 'opacity-60' : '', draggingFile === file ? 'opacity-60' : '',
@@ -77,8 +93,8 @@ function WorkspaceEditorTabs({
{draggingFile && dropIndex === index + 1 && index === files.length - 1 && ( {draggingFile && dropIndex === index + 1 && index === files.length - 1 && (
<span className="absolute -right-[3px] bottom-0 top-0 w-[2px] rounded-full bg-[#d1d5db]" /> <span className="absolute -right-[3px] bottom-0 top-0 w-[2px] rounded-full bg-[#d1d5db]" />
)} )}
<FileText size={13} /> <FileIcon size={13} className={previewFile ? 'text-[#a78bfa]' : undefined} />
<span className="truncate">{file.split('/').pop()}</span> <span className="truncate">{getFileLabel(file)}</span>
{isDirtyFile(file) && ( {isDirtyFile(file) && (
<span className={['ml-auto h-2 w-2 shrink-0 rounded-full', dark ? 'bg-[#f1f5f9]' : 'bg-[#2563eb]'].join(' ')} /> <span className={['ml-auto h-2 w-2 shrink-0 rounded-full', dark ? 'bg-[#f1f5f9]' : 'bg-[#2563eb]'].join(' ')} />
)} )}
@@ -107,7 +123,8 @@ function WorkspaceEditorTabs({
<X size={12} /> <X size={12} />
</span> </span>
</button> </button>
))} )
})}
</div> </div>
</div> </div>
) )
@@ -1,6 +1,7 @@
import { t, useLocaleRender } from '../../i18n' import { t, useLocaleRender } from '../../i18n'
import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react' import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { useId } from 'react'
export type WorkspaceGuideResourceMode = 'material' | 'reference' export type WorkspaceGuideResourceMode = 'material' | 'reference'
@@ -97,6 +98,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
onDownloadResource onDownloadResource
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element { }: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
useLocaleRender() useLocaleRender()
const panelId = useId()
const textDim = dark ? inheritedTextDim : 'text-[#475569]' const textDim = dark ? inheritedTextDim : 'text-[#475569]'
return ( return (
<aside <aside
@@ -147,36 +149,35 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
const canToggle = allowFutureMaterialSteps || previous || active const canToggle = allowFutureMaterialSteps || previous || active
const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl) const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl)
const youtubeVideoId = getYoutubeVideoId(item.videoUrl) const youtubeVideoId = getYoutubeVideoId(item.videoUrl)
const contentId = `${panelId}-material-${index}`
return ( return (
<section <section
key={`${item.title}-${index}`} 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={[ className={[
'rounded-lg border transition', 'rounded-lg border px-3 pt-3 transition',
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3', expanded ? 'pb-4' : 'pb-3',
canToggle ? 'cursor-pointer' : '',
active active
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white' ? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white'
: canToggle : 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' : dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-white'
].join(' ')} ].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={[ <span className={[
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold', '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]' 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> ].join(' ')}><span className="relative top-px leading-none">{index + 1}</span></span>
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}> <span className="block min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-2"> <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> <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"> <span className="flex shrink-0 items-center gap-1.5">
{active && ( {active && (
@@ -193,55 +194,55 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
/> />
)} )}
</span> </span>
</div> </span>
{expanded && ( </span>
<div className="mt-2.5 grid gap-2.5"> </button>
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} /> {expanded && (
{youtubeEmbedUrl && youtubeVideoId && ( <div id={contentId} className="ml-[34px] mt-2.5 grid select-text gap-2.5">
<button <div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
type="button" {youtubeEmbedUrl && youtubeVideoId && (
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(' ')} <button
onClick={(event) => { type="button"
event.stopPropagation() 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(' ')}
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title })) 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="" <img
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100" src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
loading="lazy" alt=""
/> className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" /> loading="lazy"
<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"> <span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
<Play size={22} fill="currentColor" /> <span className="absolute inset-0 grid place-items-center">
</span> <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">
</span> <Play size={22} fill="currentColor" />
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span> </span>
</button> </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>
<section> </button>
<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>
)} )}
<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>
</div> )}
</section> </section>
) )
}) : ( }) : (
@@ -1,10 +1,23 @@
import { t, useLocaleRender } from '../../i18n' import { t, useLocaleRender } from '../../i18n'
import { Bot, MessageCircle, Minus, User } from 'lucide-react' import { Bot, Check, CheckCircle2, Circle, Copy, FileCode2, ListChecks, Loader2, MessageCircle, Minus, Play, TerminalSquare, Trash2, User, WandSparkles, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets' import { createPortal } from 'react-dom'
import { ConfirmStatusModal } from '../../components/AppStatusModals'
import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
import { ChatTextContextMenu, type ChatTextContextMenuState } from '../../components/ChatTextContextMenu'
import { ChatImageAttachments } from '../../components/ChatImageAttachments'
import { AgentProgressBubble } from '../../components/AgentProgressBubble'
import { AgentWorkDuration } from '../../components/AgentWorkDuration'
import { AgentCodeReferences } from '../../components/AgentCodeReferences'
import { AgentModelSelector } from '../../components/AgentModelSelector'
import type { AgentModelOption } from './agentModels'
import type { AgentProgressStep } from './agentContext'
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments'
type QaMode = 'ai' | 'teacher' type QaMode = 'ai' | 'agent' | 'teacher'
type WorkspaceQaMessage = { type WorkspaceQaMessage = {
id: string id: string
@@ -12,6 +25,7 @@ type WorkspaceQaMessage = {
content: string content: string
senderAvatarUrl?: string | null senderAvatarUrl?: string | null
isTyping?: boolean isTyping?: boolean
revealStartedAt?: number
keepExpanded?: boolean keepExpanded?: boolean
} }
@@ -31,6 +45,19 @@ type WorkspaceQaPanelProps = {
qaMessagesLoading: boolean qaMessagesLoading: boolean
visibleAiMessages: WorkspaceQaMessage[] visibleAiMessages: WorkspaceQaMessage[]
visibleTeacherMessages: WorkspaceQaMessage[] visibleTeacherMessages: WorkspaceQaMessage[]
agentMessages: AgentConversationMessage[]
agentEnabled: boolean
agentProvider?: string | null
agentImagesEnabled?: boolean
agentModels?: AgentModelOption[]
onAgentProviderChange?: (provider: string) => void
agentStatusLoading: boolean
agentPending: boolean
agentActivity?: string
agentProgressSteps?: AgentProgressStep[]
agentRequestStartedAt?: number | null
agentActionBusy?: boolean
agentPersistenceError?: string | null
aiPending: boolean aiPending: boolean
teacherThreadStatus: string | null teacherThreadStatus: string | null
qaCollapseSignal: number qaCollapseSignal: number
@@ -40,8 +67,45 @@ type WorkspaceQaPanelProps = {
onMinimize: () => void onMinimize: () => void
onModeChange: (mode: QaMode) => void onModeChange: (mode: QaMode) => void
onStop: () => void onStop: () => void
onSubmit: (content: string) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
onClearConversation: () => void onClearConversation: () => void
onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void
onCancelAgentPlan: (messageId: string) => void
onOpenAgentChange: (messageId: string, change: AgentFileChange) => void
onRunAgentCommand: (messageId: string, command: AgentCommand) => void
onRejectAgentCommand: (messageId: string, command: AgentCommand) => void
onOpenAgentCodeReference?: (messageId: string, referenceId: string) => void
}
function MessageCopyButton({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
const [copied, setCopied] = useState(false)
const copy = (): void => {
void navigator.clipboard.writeText(content).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1400)
})
}
return (
<button
type="button"
title={copied ? t("복사됨") : t("복사")}
aria-label={copied ? t("복사됨") : t("복사")}
onClick={copy}
className={[
'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(' ')}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</button>
)
}
function agentMessageCopyText(message: AgentConversationMessage): string {
if (!message.plan) return message.content
return [message.plan.summary, message.plan.explanation].filter(Boolean).join('\n\n')
} }
export default function WorkspaceQaPanel({ export default function WorkspaceQaPanel({
@@ -60,6 +124,19 @@ export default function WorkspaceQaPanel({
qaMessagesLoading, qaMessagesLoading,
visibleAiMessages, visibleAiMessages,
visibleTeacherMessages, visibleTeacherMessages,
agentMessages,
agentEnabled,
agentProvider,
agentImagesEnabled = false,
agentModels = [],
onAgentProviderChange,
agentStatusLoading,
agentPending,
agentActivity,
agentProgressSteps = [],
agentRequestStartedAt,
agentActionBusy,
agentPersistenceError,
aiPending, aiPending,
teacherThreadStatus, teacherThreadStatus,
qaCollapseSignal, qaCollapseSignal,
@@ -70,27 +147,36 @@ export default function WorkspaceQaPanel({
onModeChange, onModeChange,
onStop, onStop,
onSubmit, onSubmit,
onClearConversation onClearConversation,
onApplyAgentPlan,
onCancelAgentPlan,
onOpenAgentCodeReference,
onOpenAgentChange,
onRunAgentCommand,
onRejectAgentCommand
}: WorkspaceQaPanelProps): React.JSX.Element { }: WorkspaceQaPanelProps): React.JSX.Element {
useLocaleRender() useLocaleRender()
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1] const lastMessage = activeMessages[activeMessages.length - 1]
const clearDisabled = inputBusy || qaMessagesLoading || (agentStatusLoading && qaMode === 'agent') || activeMessages.length === 0
useEffect(() => { useEffect(() => {
if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai') setClearConfirmationOpen(false)
}, [onModeChange, qaMode, teacherQaDisabled]) setContextMenu(null)
}, [qaMode, workspaceActive, minimized])
const requestClearConversation = (): void => {
setContextMenu(null)
if (!clearDisabled) setClearConfirmationOpen(true)
}
useEffect(() => { useEffect(() => {
if (!contextMenu) return if ((teacherQaDisabled && qaMode === 'teacher') || (!agentStatusLoading && !agentEnabled && qaMode === 'agent')) {
const closeContextMenu = (): void => setContextMenu(null) onModeChange('ai')
window.addEventListener('click', closeContextMenu)
window.addEventListener('contextmenu', closeContextMenu)
return () => {
window.removeEventListener('click', closeContextMenu)
window.removeEventListener('contextmenu', closeContextMenu)
} }
}, [contextMenu]) }, [agentEnabled, agentStatusLoading, onModeChange, qaMode, teacherQaDisabled])
useEffect(() => { useEffect(() => {
if (minimized || qaMessagesLoading) return if (minimized || qaMessagesLoading) return
@@ -126,6 +212,10 @@ export default function WorkspaceQaPanel({
observer?.disconnect() observer?.disconnect()
} }
}, [ }, [
agentMessages.length,
agentProgressSteps.length,
agentActivity,
agentPending,
aiPending, aiPending,
lastMessage?.content, lastMessage?.content,
lastMessage?.id, lastMessage?.id,
@@ -139,10 +229,13 @@ export default function WorkspaceQaPanel({
]) ])
const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => { const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
const target = event.target as HTMLElement | null
if (target?.closest('[data-qa-message="true"]')) return
event.preventDefault() 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()
: ''
setContextMenu({ kind: 'message', x: event.clientX, y: event.clientY, text })
} }
return ( return (
@@ -153,18 +246,21 @@ export default function WorkspaceQaPanel({
> >
<div className={['border-b p-3', borderSoft, dark ? '' : 'bg-white'].join(' ')}> <div className={['border-b p-3', borderSoft, dark ? '' : 'bg-white'].join(' ')}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h3 className={['flex items-center gap-2 text-[13px] font-bold', textStrong].join(' ')}> <h3 className={['flex items-center gap-2 text-[13px] font-bold leading-none', textStrong].join(' ')}>
<MessageCircle size={14} className={textDim} />{t("질의응답")}</h3> <MessageCircle size={14} className={['block shrink-0', textDim].join(' ')} />
<span className="translate-y-px leading-none">{t("질의응답")}</span>
</h3>
<button type="button" aria-label={t("질의응답 최소화")} onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}> <button type="button" aria-label={t("질의응답 최소화")} onClick={onMinimize} className={[iconButtonClass, 'group relative'].join(' ')}>
<Minus size={15} /> <Minus size={15} />
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span> <span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span>
</button> </button>
</div> </div>
<div className={['mt-3 grid grid-cols-2 overflow-hidden rounded-md border', borderSoft].join(' ')}> <div className={['mt-3 grid overflow-hidden rounded-md border', agentEnabled ? 'grid-cols-3' : 'grid-cols-2', borderSoft].join(' ')}>
{[ {[
{ id: 'ai' as const, label: 'AI', Icon: Bot }, { id: 'ai' as const, label: 'AI', Icon: Bot },
{ id: 'agent' as const, label: t("에이전트"), Icon: WandSparkles },
{ id: 'teacher' as const, label: t("강사"), Icon: User } { id: 'teacher' as const, label: t("강사"), Icon: User }
].map(({ id, label, Icon }) => { ].filter(({ id }) => id !== 'agent' || agentEnabled).map(({ id, label, Icon }) => {
const disabled = id === 'teacher' && teacherQaDisabled const disabled = id === 'teacher' && teacherQaDisabled
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai' const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
@@ -177,7 +273,7 @@ export default function WorkspaceQaPanel({
if (!disabled) onModeChange(id) if (!disabled) onModeChange(id)
}} }}
className={[ className={[
'relative flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold transition', 'relative inline-flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold leading-none transition',
disabled ? 'cursor-not-allowed' : 'cursor-pointer', disabled ? 'cursor-not-allowed' : 'cursor-pointer',
disabled disabled
? dark ? dark
@@ -194,14 +290,17 @@ export default function WorkspaceQaPanel({
: 'bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]' : 'bg-white text-[#4f596b] hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')} ].join(' ')}
> >
<Icon size={14} className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')} /> <Icon size={14} className={['relative z-10 block shrink-0', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')} />
<span className={['relative z-10', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{t(label)}</span> <span className={['relative z-10 translate-y-px leading-none', teacherTabFlash ? 'animate-pulse text-[#7db3ff]' : ''].join(' ')}>{t(label)}</span>
</button> </button>
) )
})} })}
</div> </div>
</div> </div>
<div ref={workspaceChatScrollRef} className="thin-y-scrollbar min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}> <div ref={workspaceChatScrollRef} className="thin-y-scrollbar min-h-0 flex-1 overflow-auto p-3" onContextMenu={openContextMenu}>
{qaMode === 'agent' && agentPersistenceError ? (
<p role="status" className="mb-3 text-[11px] leading-relaxed text-[#d97706]">{t(agentPersistenceError)}</p>
) : null}
{qaMessagesLoading ? ( {qaMessagesLoading ? (
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}> <div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
<div> <div>
@@ -216,6 +315,24 @@ export default function WorkspaceQaPanel({
pending={aiPending} pending={aiPending}
collapseSignal={qaCollapseSignal} collapseSignal={qaCollapseSignal}
/> />
) : qaMode === 'agent' ? (
<AgentMessages
dark={dark}
enabled={agentEnabled}
statusLoading={agentStatusLoading}
pending={agentPending}
activity={agentActivity}
progressSteps={agentProgressSteps}
requestStartedAt={agentRequestStartedAt}
busy={agentPending || agentActionBusy}
messages={agentMessages}
onApply={onApplyAgentPlan}
onCancel={onCancelAgentPlan}
onOpenCodeReference={onOpenAgentCodeReference}
onOpenChange={onOpenAgentChange}
onRunCommand={onRunAgentCommand}
onRejectCommand={onRejectAgentCommand}
/>
) : ( ) : (
<TeacherMessages <TeacherMessages
dark={dark} dark={dark}
@@ -226,33 +343,286 @@ export default function WorkspaceQaPanel({
/> />
)} )}
</div> </div>
{contextMenu && ( <ChatTextContextMenu dark={dark} menu={contextMenu} canClear={!clearDisabled} onClose={() => setContextMenu(null)} onClear={requestClearConversation} />
<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 }}
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
className={['block w-full px-3 py-2 text-left text-[12px] font-semibold transition', dark ? 'text-white/78 hover:bg-white/10' : 'text-[#334155] hover:bg-[#f1f5f9]'].join(' ')}
onClick={() => {
setContextMenu(null)
onClearConversation()
}}
>{t("대화내역 삭제")}</button>
</div>
)}
<QaInputBox <QaInputBox
dark={dark} dark={dark}
busy={inputBusy} busy={inputBusy}
disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')} disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
onStop={onStop} onStop={onStop}
onSubmit={onSubmit} onSubmit={onSubmit}
attachmentsEnabled={qaMode === 'agent' && agentProvider !== 'A_COPILOT'}
imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
onClearConversation={requestClearConversation}
clearDisabled={clearDisabled}
footerAction={qaMode === 'agent' && onAgentProviderChange ? <AgentModelSelector models={agentModels} provider={agentProvider ?? null} dark={dark} disabled={inputBusy || agentStatusLoading || inputDisabled} onChange={onAgentProviderChange} /> : undefined}
/> />
{clearConfirmationOpen && createPortal(
<ConfirmStatusModal
overlayClassName="fixed bottom-0 left-0 right-0 top-[38px] z-[180] grid place-items-center bg-black/36"
dark={dark}
icon={<Trash2 size={15} className="text-[#e87979]" />}
title={t('대화내역 삭제')}
message={t('대화내역을 삭제할까요?')}
detail={t('현재 탭의 대화내역을 삭제합니다.')}
confirmLabel={t('삭제')}
onCancel={() => setClearConfirmationOpen(false)}
onConfirm={() => {
setClearConfirmationOpen(false)
if (!clearDisabled) onClearConversation()
}}
/>,
document.body
)}
</aside> </aside>
) )
} }
function AgentMessages({
dark,
enabled,
statusLoading,
pending,
activity,
progressSteps = [],
requestStartedAt,
busy,
messages,
onApply,
onCancel,
onOpenChange,
onRunCommand,
onRejectCommand,
onOpenCodeReference
}: {
dark: boolean
enabled: boolean
statusLoading: boolean
pending: boolean
activity?: string
progressSteps?: AgentProgressStep[]
requestStartedAt?: number | null
busy?: boolean
messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void
onCancel: (messageId: string) => void
onOpenChange: (messageId: string, change: AgentFileChange) => void
onRunCommand: (messageId: string, command: AgentCommand) => void
onRejectCommand: (messageId: string, command: AgentCommand) => void
onOpenCodeReference?: (messageId: string, referenceId: string) => void
}): React.JSX.Element {
useLocaleRender()
if (statusLoading) {
return (
<div className={['flex h-full min-h-[160px] items-center justify-center gap-2 text-[12px] font-semibold', dark ? 'text-white/48' : 'text-[#687284]'].join(' ')}>
<Loader2 size={15} className="animate-spin" />
<span>{t("에이전트 설정을 확인하는 중입니다.")}</span>
</div>
)
}
if (!enabled) {
return (
<ChatEmptyState dark={dark}>
{t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")}
</ChatEmptyState>
)
}
if (!messages.length && !pending) {
return (
<ChatEmptyState dark={dark}>
{t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}<br />{t("변경 내용은 확인 후 직접 적용합니다.")}
</ChatEmptyState>
)
}
const codeNavigationBusy = messages.some(message => message.applyState === 'applying')
return (
<div className="grid gap-3">
{messages.map((message, index) => message.role === 'user' ? (
<div key={message.id} data-qa-message="true" className="flex justify-end">
<div className="flex min-w-0 max-w-[82%] flex-col items-end gap-2">
<div data-agent-user-bubble="true" className="min-w-0 max-w-full whitespace-pre-wrap break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
<ChatImageAttachments images={message.imageAttachments ?? []} variant="sent" />
</div>
</div>
) : (
<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 py-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
<AgentWorkDuration dark={dark} durationMs={message.durationMs} />
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
{message.plan && (
<>
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={codeNavigationBusy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
{!!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(' ')}>
{(() => {
const executions = message.commandExecutions ?? {}
const completedCommands = message.plan?.commands.filter((command) => executions[command.id]?.status === 'completed').length ?? 0
const completedChanges = message.applyState === 'applied' && (message.plan?.changes.length || message.plan?.createModule) ? 1 : 0
const completedSteps = Math.min(message.plan?.actionPlan.length ?? 0, completedCommands + completedChanges)
return (
<>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="flex items-center gap-1.5 font-black"><ListChecks size={13} className="text-[#8b5cf6]" />{t("작업 계획")}</p>
<span className={['text-[10px] font-bold', dark ? 'text-white/38' : 'text-[#94a3b8]'].join(' ')}>{completedSteps}/{message.plan.actionPlan.length}</span>
</div>
<div className="grid gap-1.5">
{message.plan.actionPlan.map((step, index) => {
const finished = index < completedSteps
const active = index === completedSteps
return (
<div key={`${index}-${step}`} className={['flex min-w-0 items-start gap-2', finished ? (dark ? 'text-white/45' : 'text-[#64748b]') : ''].join(' ')}>
{finished ? <CheckCircle2 size={12} className="mt-0.5 shrink-0 text-[#22c55e]" /> : <Circle size={12} className={['mt-0.5 shrink-0', active ? 'text-[#a78bfa]' : dark ? 'text-white/25' : 'text-[#94a3b8]'].join(' ')} />}
<span className={active ? 'font-bold' : ''}>{step}</span>
</div>
)
})}
</div>
</>
)
})()}
</div>
) : null}
{message.plan.commands.length > 0 ? (
<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' || command.operation?.name === 'modules.start'
const finished = execution.status === 'completed' || execution.status === 'failed'
const policyRejected = message.applyState === 'cancelled' || execution.status === 'rejected'
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
const waitingForChanges = (message.plan!.changes.length > 0 || message.plan!.createModule !== null) && message.applyState !== 'applied'
return (
<div key={command.id} className={['min-w-0 border-y py-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
<div className="mb-2 flex items-center justify-between gap-2">
<p className="flex min-w-0 items-center gap-1.5 font-black"><TerminalSquare size={13} /><span>{moduleStartAction ? t("모듈 시작") : t("명령어")}</span></p>
<button
type="button"
title={t("복사")}
aria-label={t("복사")}
onClick={() => void navigator.clipboard.writeText(moduleStartAction ? `${command.moduleName} · ${t("모듈 시작")}` : command.command)}
className={['grid h-6 w-6 shrink-0 place-items-center rounded transition', dark ? 'hover:bg-white/10' : 'hover:bg-[#ede9fe]'].join(' ')}
><Copy size={12} /></button>
</div>
<p className={['mb-2 text-[11px]', dark ? 'text-white/48' : 'text-[#64748b]'].join(' ')}>{command.moduleName} · {command.purpose}</p>
<pre className={['thin-x-scrollbar max-h-44 overflow-auto whitespace-pre-wrap break-all px-2 py-2 font-mono text-[11px] leading-relaxed', dark ? 'bg-[#202020] text-[#d8dee9]' : 'bg-[#f1f5f9] text-[#1e293b]'].join(' ')}><code>{moduleStartAction ? `${command.moduleName} · ${t("모듈 시작")}` : command.command}</code></pre>
{execution.output ? (
<pre className={['thin-scrollbar mt-2 max-h-52 overflow-auto whitespace-pre-wrap break-all px-2 py-2 font-mono text-[11px] leading-relaxed', dark ? 'bg-[#171717] text-white/65' : 'bg-[#e8edf5] text-[#334155]'].join(' ')}><code>{execution.output}</code></pre>
) : null}
{execution.error ? <p className="mt-2 text-[11px] font-bold text-[#ef4444]">{execution.error}</p> : null}
<div className="mt-2 flex items-center justify-end gap-2">
{message.applyState === 'cancelled' ? (
<span className={['text-[11px] font-bold', dark ? 'text-white/40' : 'text-[#64748b]'].join(' ')}>{execution.status === 'completed' ? t("실행 완료") : t("취소됨")}</span>
) : execution.status === 'pending' ? <>
<button type="button" onClick={() => onRejectCommand(message.id, command)} className={['inline-flex h-7 items-center gap-1 rounded px-2 text-[11px] font-bold transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white' : 'text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}><X size={12} />{t("거부")}</button>
<button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("승인")}</button>
</> : execution.status === 'running' ? (
<span className="inline-flex items-center gap-1.5 text-[11px] font-bold text-[#a78bfa]"><Loader2 size={12} className="animate-spin" />{t("실행 중")}</span>
) : policyRejected ? (
<span className={['text-[11px] font-bold', dark ? 'text-white/40' : 'text-[#64748b]'].join(' ')}>{t("거부됨")}</span>
) : execution.status === 'failed' ? (
<>
<span className="inline-flex items-center gap-1 text-[11px] font-bold text-[#ef4444]"><X size={12} />{t("실행 실패")} · exit {execution.exitCode ?? '-'}</span>
<button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("다시 실행")}</button>
</>
) : (
<span className={['inline-flex items-center gap-1 text-[11px] font-bold', execution.status === 'completed' ? 'text-[#22c55e]' : 'text-[#ef4444]'].join(' ')}>
<Check size={12} />
{t("실행 완료")} · exit {execution.exitCode ?? '-'}
</span>
)}
</div>
{finished && !execution.output && !execution.error ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{t("명령 출력이 없습니다.")}</p> : null}
</div>
)
})}
</fieldset>
) : null}
{message.plan.createModule ? (
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
<p className="font-black">{t("새 모듈")}: {message.plan.createModule.name}</p>
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{message.plan.createModule.packageName} · {message.plan.createModule.launchFile}</p>
</div>
) : null}
{message.plan.changes.length > 0 ? (
<div className={['grid gap-1.5 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
{message.plan.changes.map((change) => (
<button
key={`${change.moduleName}:${change.path}`}
type="button"
title={t("변경사항 비교")}
onClick={() => onOpenChange(message.id, change)}
className={[
'flex min-w-0 cursor-pointer gap-2 rounded-md px-1.5 py-1 text-left transition',
dark ? 'hover:bg-white/8' : 'hover:bg-[#ede9fe]'
].join(' ')}
>
<FileCode2 size={13} className="mt-0.5 shrink-0 text-[#7c3aed]" />
<div className="min-w-0">
<p className="break-all font-bold">{change.moduleName}/{change.path}</p>
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{change.reason}</p>
</div>
</button>
))}
</div>
) : null}
{message.applyError ? <p className="text-[11px] font-bold text-[#ef4444]">{message.applyError}</p> : null}
{message.plan.changes.length > 0 || message.plan.createModule ? (
<div className="flex min-w-0 items-center gap-2">
{message.applyState !== 'applied' && message.applyState !== 'cancelled' && (
<button
type="button"
disabled={busy || message.applyState === 'applying' || Object.values(message.commandExecutions ?? {}).some(execution => execution.status === 'running')}
onClick={() => onCancel(message.id)}
className={['inline-flex h-8 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md border px-3 text-[11px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40', dark ? 'border-white/15 text-white/65 hover:bg-white/8' : 'border-[#d8dee9] text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}
>
<X size={13} className="shrink-0" /><span className="relative top-px leading-none">{t("취소")}</span>
</button>
)}
<button
type="button"
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
onClick={() => onApply(message.id, message.plan!)}
className="inline-flex h-8 min-w-0 flex-1 cursor-pointer 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]"
>
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'cancelled' ? <X size={13} /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />}
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'cancelled' ? t("취소됨") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
</button>
</div>
) : null}
</>
)}
</ProgressiveChatResponse>
</div>
</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>
<AgentProgressBubble dark={dark} steps={progressSteps} activity={activity} startedAt={requestStartedAt} />
</div>
) : null}
</div>
)
}
function AiMessages({ function AiMessages({
dark, dark,
messages, messages,
@@ -267,9 +637,9 @@ function AiMessages({
useLocaleRender() useLocaleRender()
if (!messages.length && !pending) { if (!messages.length && !pending) {
return ( 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(' ')}> <ChatEmptyState dark={dark}>
<span>{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}</span> {t("AI에게 질문할")}<br />{t("내용을 입력하세요")}
</div> </ChatEmptyState>
) )
} }
@@ -292,16 +662,13 @@ function AiMessages({
<Bot size={15} /> <Bot size={15} />
</span> </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(' ')}> <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} collapseOnSignal={index < messages.length - 1}
collapseSignal={collapseSignal} collapseSignal={collapseSignal}
content={message.content} message={message}
dark={dark} 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>
</div> </div>
) )
@@ -311,10 +678,7 @@ function AiMessages({
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white"> <span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
<Bot size={15} /> <Bot size={15} />
</span> </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(' ')}> <ChatResponseSkeleton dark={dark} label={t("답변을 작성 중입니다...")} />
<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>
</div> </div>
)} )}
</div> </div>
@@ -337,9 +701,9 @@ function TeacherMessages({
useLocaleRender() useLocaleRender()
if (!messages.length) { if (!messages.length) {
return ( 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(' ')}> <ChatEmptyState dark={dark}>
<span>{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}</span> {t("강사에게 문의할")}<br />{t("내용을 입력하세요")}
</div> </ChatEmptyState>
) )
} }
@@ -272,11 +272,13 @@ export default function WorkspaceRosConnectionsView({
> >
<div className={['flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#a6b4c9] bg-white'].join(' ')}> <div className={['flex flex-wrap items-center justify-between gap-3 border-b px-4 py-3', dark ? 'border-white/10 bg-[#242424]' : 'border-[#a6b4c9] bg-white'].join(' ')}>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-[#31c48d]/25 bg-[#31c48d]/10 text-[#8fe8bf]' : 'border-[#79b99a] bg-[#effcf5] text-[#168254]'].join(' ')}> <span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold leading-none', dark ? 'border-[#31c48d]/25 bg-[#31c48d]/10 text-[#8fe8bf]' : 'border-[#79b99a] bg-[#effcf5] text-[#168254]'].join(' ')}>
<Activity size={13} />{t("실행 모듈")}{runningModules} <Activity size={13} className="block shrink-0" />
<span className="inline-flex translate-y-px items-center gap-1 leading-none"><span>{t("실행 모듈")}</span><span>{runningModules}</span></span>
</span> </span>
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075]'].join(' ')}> <span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold leading-none', dark ? 'border-white/10 bg-white/5 text-white/70' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075]'].join(' ')}>
<Waypoints size={13} />{t("토픽")}{connections.length} <Waypoints size={13} className="block shrink-0" />
<span className="inline-flex translate-y-px items-center gap-1 leading-none"><span>{t("토픽")}</span><span>{connections.length}</span></span>
</span> </span>
<button <button
type="button" type="button"
@@ -284,30 +286,35 @@ export default function WorkspaceRosConnectionsView({
title={t("활성 연결만 보기")} title={t("활성 연결만 보기")}
onClick={() => setActiveOnly((value) => !value)} onClick={() => setActiveOnly((value) => !value)}
className={[ className={[
'inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8b5cf6]', 'inline-flex cursor-pointer items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold leading-none transition focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8b5cf6]',
activeOnly activeOnly
? dark ? 'border-[#a78bfa] bg-[#6d28d9] text-white hover:bg-[#7c3aed]' : 'border-[#b59bd8] bg-[#e7daf5] text-[#5b21a3] hover:bg-[#dac5ee]' ? dark ? 'border-[#a78bfa] bg-[#6d28d9] text-white hover:bg-[#7c3aed]' : 'border-[#b59bd8] bg-[#e7daf5] text-[#5b21a3] hover:bg-[#dac5ee]'
: dark ? 'border-white/10 bg-white/5 text-white/70 hover:bg-white/10' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075] hover:bg-[#ece6f4]' : dark ? 'border-white/10 bg-white/5 text-white/70 hover:bg-white/10' : 'border-[#aebace] bg-[#f6f8fb] text-[#536075] hover:bg-[#ece6f4]'
].join(' ')} ].join(' ')}
> >
{activeOnly ? <Check size={13} /> : <CircleDot size={13} />}{t("활성 연결")} {activeConnections} {activeOnly
? <Check size={13} className="block shrink-0" />
: <CircleDot size={13} className="block shrink-0" />}
<span className="inline-flex translate-y-px items-center gap-1 leading-none"><span>{t("활성 연결")}</span><span>{activeConnections}</span></span>
</button> </button>
{rosGraph.message && ( {rosGraph.message && (
<span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}> <span className={['max-w-[260px] truncate text-[10px] font-medium', dark ? 'text-[#fbbf24]' : 'text-[#a16207]'].join(' ')} title={rosGraph.message}>
{rosGraph.message} {rosGraph.message}
</span> </span>
)} )}
<span className={['inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[11px] font-semibold', dark ? 'border-white/10 bg-white/5 text-white/62' : 'border-[#aebace] bg-[#f6f8fb] text-[#657187]'].join(' ')}> <span className={['inline-flex items-center gap-1 rounded-md border px-2 py-1 text-[11px] font-semibold leading-none', dark ? 'border-white/10 bg-white/5 text-white/62' : 'border-[#aebace] bg-[#f6f8fb] text-[#657187]'].join(' ')}>
ROS Domain {rosDomainId} <span className="translate-y-px">ROS Domain</span><span className="translate-y-px">{rosDomainId}</span>
</span> </span>
</div> </div>
<button <button
type="button" type="button"
onClick={() => void refreshRosGraph(true)} onClick={() => void refreshRosGraph(true)}
disabled={rosGraphLoading} disabled={rosGraphLoading}
className={['inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-semibold transition', rosGraphLoading ? 'cursor-wait opacity-60' : 'cursor-pointer', dark ? 'border-white/12 text-white/72 hover:bg-white/10 hover:text-white' : 'border-[#a6b4c9] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')} className={['inline-flex h-7 items-center gap-1.5 rounded-md border px-2.5 text-[11px] font-semibold leading-none transition', rosGraphLoading ? 'cursor-wait opacity-60' : 'cursor-pointer', dark ? 'border-white/12 text-white/72 hover:bg-white/10 hover:text-white' : 'border-[#a6b4c9] text-[#526078] hover:bg-[#eef3f9] hover:text-[#1e293b]'].join(' ')}
> >
<RefreshCw size={13} className={rosGraphLoading ? 'animate-spin' : ''} />{t("새로고침")}</button> <RefreshCw size={13} className={['block shrink-0', rosGraphLoading ? 'animate-spin' : ''].join(' ')} />
<span className="translate-y-px leading-none">{t("새로고침")}</span>
</button>
</div> </div>
<div <div
@@ -80,7 +80,9 @@ export default function WorkspaceTopBar({
</div> </div>
<div className={['flex items-center gap-2 text-[12px] font-bold', textMid].join(' ')}> <div className={['flex items-center gap-2 text-[12px] font-bold', textMid].join(' ')}>
<button type="button" onClick={onToggleLeftPanel} className={topToggleButtonClass(!leftPanelMinimized)}> <button type="button" onClick={onToggleLeftPanel} className={topToggleButtonClass(!leftPanelMinimized)}>
<BookOpen size={14} />{t("학습가이드")}</button> <BookOpen size={14} className="block shrink-0" />
<span className="translate-y-px leading-none">{t("학습가이드")}</span>
</button>
<button <button
type="button" type="button"
onClick={onToggleRightPanel} onClick={onToggleRightPanel}
@@ -92,14 +94,18 @@ export default function WorkspaceTopBar({
<span className="pointer-events-none absolute inset-0 rounded-md bg-[#60a5fa]/18 shadow-[0_0_18px_rgba(96,165,250,0.38)] animate-pulse" /> <span className="pointer-events-none absolute inset-0 rounded-md bg-[#60a5fa]/18 shadow-[0_0_18px_rgba(96,165,250,0.38)] animate-pulse" />
</> </>
)} )}
<MessageCircle size={14} className="relative z-10" /> <MessageCircle size={14} className="relative z-10 block shrink-0" />
<span className="relative z-10">{t("질의응답")}</span> <span className="relative z-10 translate-y-px leading-none">{t("질의응답")}</span>
</button> </button>
<button type="button" onClick={onTogglePreview} className={topToggleButtonClass(previewOpen)}> <button type="button" onClick={onTogglePreview} className={topToggleButtonClass(previewOpen)}>
<PanelLeft size={14} />{t("시각화")}</button> <PanelLeft size={14} className="block shrink-0" />
<span className="translate-y-px leading-none">{t("시각화")}</span>
</button>
{cameraAvailable && ( {cameraAvailable && (
<button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}> <button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}>
<Camera size={14} />{t("오버뷰")}</button> <Camera size={14} className="block shrink-0" />
<span className="translate-y-px leading-none">{t("오버뷰")}</span>
</button>
)} )}
{showControlCountdown && ( {showControlCountdown && (
<> <>
@@ -0,0 +1,63 @@
import type { editor } from 'monaco-editor'
import { safeAgentPath } from '../../../../shared/agentTools'
import type { AgentCodeReference } from './useAiAgent'
export function validateCodeReference(reference: AgentCodeReference): void {
safeAgentPath(reference.path)
if (!Number.isSafeInteger(reference.startLine) || !Number.isSafeInteger(reference.endLine) || reference.startLine < 1 || reference.endLine < reference.startLine || reference.endLine > 100_000 || reference.endLine - reference.startLine >= 200
|| !reference.expectedHash || !/^[a-f0-9]{64}$/.test(reference.expectedHash) || !reference.snippet?.trim() || reference.snippet.length > 12_000 || reference.snippet.includes('[REDACTED]')) {
throw new Error('코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.')
}
}
export async function resolveCodeReferenceRange(content: string, reference: AgentCodeReference): Promise<{ startLine: number; endLine: number }> {
validateCodeReference(reference)
const lines = content.replace(/\r\n/g, '\n').split('\n')
const snippet = reference.snippet!.replace(/\r\n/g, '\n')
const expectedLines = reference.endLine - reference.startLine + 1
if (snippet.split('\n').length !== expectedLines) throw new Error('코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.')
const bytes = globalThis.crypto?.subtle ? await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(content)) : null
const hash = bytes ? Array.from(new Uint8Array(bytes), value => value.toString(16).padStart(2, '0')).join('') : null
if (hash === reference.expectedHash && lines.slice(reference.startLine - 1, reference.endLine).join('\n') === snippet) return { startLine: reference.startLine, endLine: reference.endLine }
// Unsaved edits can shift lines. Only a unique, exact match is safe to reveal.
const matches: number[] = []
for (let index = 0; index <= lines.length - expectedLines; index++) {
if (lines.slice(index, index + expectedLines).join('\n') === snippet) matches.push(index + 1)
if (matches.length > 1) break
}
if (matches.length !== 1) throw new Error('코드가 변경되어 위치를 확정할 수 없습니다. 다시 분석을 요청해주세요.')
return { startLine: matches[0], endLine: matches[0] + expectedLines - 1 }
}
export async function revealAgentCodeReference(options: {
file: string
reference: AgentCodeReference
isCurrent: () => boolean
getEditor: () => editor.IStandaloneCodeEditor | null
getActiveFile: () => string | null
isReady: () => boolean
attempts?: number
}): Promise<editor.IEditorDecorationsCollection> {
for (let attempt = 0; attempt < (options.attempts ?? 100); attempt++) {
if (!options.isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.')
const instance = options.getEditor()
const model = instance?.getModel()
const element = instance?.getDomNode()
if (options.isReady() && options.getActiveFile() === options.file && instance && element && element.clientWidth > 0 && element.clientHeight > 0 && model && !model.isDisposed() && model.uri.path === `/${options.file}`) {
const content = model.getValue()
const range = await resolveCodeReferenceRange(content, options.reference)
if (!options.isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.')
if (instance !== options.getEditor() || model !== instance.getModel() || model.getValue() !== content || !options.isReady() || options.getActiveFile() !== options.file) continue
instance.layout()
const selection = { startLineNumber: range.startLine, startColumn: 1, endLineNumber: range.endLine, endColumn: model.getLineMaxColumn(range.endLine) }
const decorations = instance.createDecorationsCollection([{ range: selection, options: { isWholeLine: true, className: 'monaco-agent-code-highlight' } }])
instance.setPosition({ lineNumber: range.startLine, column: 1 })
instance.revealRangeInCenter(selection)
instance.focus()
return decorations
}
await new Promise(resolve => window.setTimeout(resolve, 100))
}
throw new Error('코드 편집기를 준비하지 못했습니다. 다시 시도해주세요.')
}
@@ -0,0 +1,300 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { compactAgentEvidence, describeAgentEvidence, missingAgentRead, readFileEvidence } from './agentEvidence'
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[]; contextNotes?: string[] }
export type AgentContextRequest = { modules: string[]; logs: string[] }
export type AgentProgressStep = {
id: string
kind: 'analysis' | 'tool' | 'files' | 'logs' | 'image'
status: 'ok' | 'error' | 'partial' | 'empty' | 'missing'
moduleName?: string
name?: string
path?: string
count?: number
round?: number
requests?: number
}
function toolProgressStep(tool: AgentToolRequest, result: AgentToolResult, id: string): AgentProgressStep {
const step: AgentProgressStep = { id, kind: 'tool', name: tool.name, moduleName: tool.moduleName, path: tool.args?.path, status: result.status === 'error' ? 'error' : result.truncated ? 'partial' : 'ok' }
if (result.status !== 'ok') return step
try {
const data = JSON.parse(result.data)
if (tool.name === 'files.read' && data?.exists === false) step.status = 'missing'
else if (tool.name === 'files.read' && data?.complete === false) step.status = 'partial'
const items = tool.name === 'files.list' ? data?.paths : tool.name === 'files.search' ? data?.matches : undefined
if (Array.isArray(items)) step.count = items.length
} catch { /* Progress never exposes raw tool output or treats it as instructions. */ }
return step
}
export type AgentPlanResponse = {
provider: string
model: string
usage?: AgentConversationMessage['usage']
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')
const interruptedNavigation = Object.values(message.codeReferenceStates ?? {}).some(state => state.status === 'opening')
if (!interrupted && !interruptedNavigation && message.applyState !== 'applying') return message
changed = true
const error = '이전 작업의 완료 결과를 확인할 수 없습니다. 현재 모듈·파일 상태를 다시 조회한 뒤 진행해주세요.'
return { ...message, ...(message.applyState === 'applying' ? { applyState: 'error' as const, applyError: error } : {}), ...(interruptedNavigation ? { codeReferenceStates: Object.fromEntries(Object.entries(message.codeReferenceStates ?? {}).map(([id, state]) => [id, state.status === 'opening' ? { status: 'error' as const, error: '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' } : state])) } : {}), 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로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 코드 위치와 역할 설명은 전체 파일이 아닌 확인된 줄 범위만으로 답할 수 있습니다. 확인된 내용은 codeReferences와 함께 답하고 미확인 항목만 별도로 구분하세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
return instruction + request.slice(0, 4000 - instruction.length)
}
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = [], preferredReads: AgentToolRequest[] = []): AgentContext {
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
const selected = new Map<string, AgentWorkspaceModule['files']>()
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
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 evidence = compactAgentEvidence(context.toolResults ?? [])
const priority = (result: AgentToolResult): number => {
const file = readFileEvidence(result)
if (file && preferredReads.some(tool => tool.name === 'files.read' && tool.moduleName === result.moduleName && tool.args?.path === file.path)) return 6
return result.name === 'action.result' ? 5 : !result.name.startsWith('files.') ? 4
: file || result.status === 'error' ? 3 : 1
}
const candidatesByPriority = evidence.map((result, index) => ({ result, index }))
.sort((a, b) => priority(b.result) - priority(a.result) || b.index - a.index)
const retained = new Set<AgentToolResult>()
for (const { result } of candidatesByPriority) {
if (retained.size >= 12 || result.data.length > 60_000 || toolSize + result.data.length > 180_000) continue
retained.add(result)
toolSize += result.data.length
}
const toolResults = evidence.filter(result => retained.has(result))
const omittedTools = evidence.filter(result => !retained.has(result))
const contextNotes = omittedTools.length
? [`용량·개수 한도로 조회 근거 ${omittedTools.length}개를 제외했습니다. 제외된 내용을 확인했다고 주장하지 마세요.`,
...omittedTools.slice(-10).map(result => describeAgentEvidence(result).slice(0, 500))]
: []
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults, contextNotes } : {}) }
}
export async function resolveAgentContext({
initialContext,
requestPlan,
loadRequestedContext,
executeTool,
onProgress,
onStep,
signal
}: {
initialContext: AgentContext
requestPlan: (context: AgentContext, finalize?: boolean) => Promise<AgentPlanResponse>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
onProgress?: (label: string) => void
onStep?: (step: AgentProgressStep) => void
signal: AbortSignal
}): Promise<AgentPlanResponse> {
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
let confirmedAnswer: AgentPlanResponse | undefined
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
const usage: NonNullable<AgentConversationMessage['usage']> = {}
const checkAborted = (): void => {
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
}
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()
onProgress?.('최종 답변 정리 중')
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 => `- ${describeAgentEvidence(result)}`).join('\n').slice(0, 8000)
const candidate = response.plan.codeReferences?.length ? response : confirmedAnswer
const references = candidate?.plan.codeReferences?.filter(reference => {
const latest = (cachedContext.toolResults ?? []).filter(result => result.moduleName === reference.moduleName)
.map(readFileEvidence).filter(file => file?.path === reference.path).at(-1)
return !latest || latest.exists && latest.hash === reference.expectedHash
})
const confirmed = references?.length && candidate ? { ...candidate, plan: { ...candidate.plan, codeReferences: references } } : undefined
const confirmedReferences = Boolean(confirmed?.plan.codeReferences?.length)
const explanation = stillRequesting
? `${confirmedReferences ? `${confirmed!.plan.explanation}\n\n추가 분석은 완료하지 못했지만 확인된 코드 위치는 유지했습니다.` : 'AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.'}\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`.slice(0, 12_000)
: response.plan.explanation
return {
...response,
usage,
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
plan: {
...response.plan,
summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}),
explanation,
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
}
}
}
const maxRounds = executeTool ? 8 : 5
for (let round = 0; round <= maxRounds; round += 1) {
checkAborted()
onProgress?.(round === 0 ? '요청 분석 중' : '조회 결과 분석 중')
const response = await requestPlan(context)
checkAborted()
addUsage(response)
if (response.plan.codeReferences?.length) confirmedAnswer = response
const modules = [...new Set(response.plan.requestedModules ?? [])]
const logs = [...new Set(response.plan.requestedLogs ?? [])]
const tools = response.plan.toolRequests ?? []
onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length })
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
if (round === maxRounds) {
context = fitAgentContext(cachedContext, [], [], tools)
return finalize()
}
if (tools.length) {
if (!executeTool || tools.length > 6) return finalize()
const fresh = tools.filter(tool => {
const missing = missingAgentRead(tool, cachedContext.toolResults ?? [])
return missing && !queriedTools.has(fingerprint(missing))
})
if (!fresh.length || toolCount + fresh.length > 24) {
context = fitAgentContext(cachedContext, [], [], tools)
return finalize()
}
const results = [...(cachedContext.toolResults ?? [])]
for (const [index, proposedTool] of fresh.entries()) {
checkAborted()
const tool = missingAgentRead(proposedTool, results)
if (!tool) continue
const key = fingerprint(tool)
if (queriedTools.has(key)) continue
queriedTools.add(key)
onProgress?.(`${tool.moduleName ? `${tool.moduleName} · ` : ''}${tool.name} 조회 중`)
const result = await executeTool({ ...tool, id: `query-${round}-${index}-${tool.id}`.slice(0, 80) })
checkAborted()
onStep?.(toolProgressStep(tool, result, `tool-${round}-${index}`))
toolCount++
results.push(result)
}
cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) }
context = fitAgentContext(cachedContext, [], [], tools)
onProgress?.('조회 결과 분석 중')
// A model may also use legacy source requests; process those on the next pass.
continue
}
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) {
return finalize()
}
nextRequest.modules.forEach((name) => requestedModules.add(name))
nextRequest.logs.forEach((name) => requestedLogs.add(name))
onProgress?.('추가 자료 조회 중')
const loaded = await loadRequestedContext(nextRequest)
checkAborted()
for (const name of nextRequest.modules) {
const module = loaded.modules.find(module => module.name === name)
const state = module?.fileContext?.status
const status = state === 'error' ? 'error' : state === 'uninstalled' ? 'missing' : state === 'limited' ? 'partial' : module?.files.length ? 'ok' : 'empty'
onStep?.({ id: `files-${round}-${name}`, kind: 'files', moduleName: name, status, count: module?.files.length ?? 0 })
}
for (const name of nextRequest.logs) {
const log = loaded.runtimeLogs.find(log => log.moduleName === name)
onStep?.({ id: `logs-${round}-${name}`, kind: 'logs', moduleName: name, status: log?.content ? 'ok' : 'empty' })
}
const loadedModules = new Map(loaded.modules.map((module) => [module.name, 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.
return updated && nextRequest.modules.includes(module.name)
? { ...module, files: updated.files, fileContext: updated.fileContext }
: module
})
mergedModules.push(...loadedModules.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,74 @@
import type { AgentConversationMessage } from './useAiAgent'
export type AgentConversationScope = { workspace: string; createdAt: number }
export type AgentConversationContext = {
environment: 'local' | 'remote'
workspaceTarget: string
summary: string
recentMessages: Array<{ role: 'user' | 'assistant'; content: string }>
}
const MAX_AGE_MS = 24 * 60 * 60 * 1000
const RECENT_TURNS = 2
const MESSAGE_LIMIT = 700
const SUMMARY_LIMIT = 1000
const clip = (text: string, limit: number): string => text.length > limit ? `${text.slice(0, limit - 3)}...` : text
export function agentConversationWorkspace(environment?: 'local' | 'remote', target?: string, root?: string): string | undefined {
if (!environment || !target || !root) return undefined
const workspace = JSON.stringify([environment, target, root])
return workspace.length <= 1000 ? workspace : undefined
}
function responseContext(message: AgentConversationMessage, limit: number): string {
const plan = message.plan!
// Keep identities and outcomes, never replay source contents, logs, or executable payloads.
const details = [
...(plan.codeReferences ?? []).slice(0, 3).map(item => `${item.moduleName}/${item.path}:${item.startLine}-${item.endLine}`),
...plan.changes.slice(0, 2).map(item => `${item.moduleName}/${item.path} (file change: ${message.applyState ?? 'ready'})`),
...plan.commands.slice(0, 2).map(item => `${item.moduleName} (${clip(item.purpose, 60)}: ${message.commandExecutions?.[item.id]?.status ?? 'pending'})`)
].join('; ')
const targets = details ? `\nHistorical references/outcomes: ${clip(details, Math.min(240, Math.floor(limit / 2)))}` : ''
const answer = (limit > 300 ? `${plan.summary}\n${plan.explanation}` : plan.summary).replace(/```[^\n]*\n[\s\S]*?(?:```|$)/g, '[code block omitted]')
return `${clip(answer.trim(), limit - targets.length)}${targets}`
}
export function buildAgentConversationContext(
messages: AgentConversationMessage[],
workspace: string | undefined,
now = Date.now()
): AgentConversationContext | undefined {
if (!workspace) return undefined
const [environment, workspaceTarget] = JSON.parse(workspace) as ['local' | 'remote', string, string]
const turns: Array<{ user: AgentConversationMessage; assistant: AgentConversationMessage }> = []
let summary = ''
let user: AgentConversationMessage | undefined
const inScope = (message: AgentConversationMessage): boolean => message.conversationScope?.workspace === workspace
&& Number.isFinite(message.conversationScope.createdAt)
&& message.conversationScope.createdAt <= now
&& now - message.conversationScope.createdAt < MAX_AGE_MS
for (const message of messages) {
if (message.role === 'user') {
user = inScope(message) ? message : undefined
continue
}
if (!user || !inScope(message) || !message.plan || message.plan.toolRequests?.length
|| message.plan.requestedModules.length || message.plan.requestedLogs.length) continue
const previous = turns.at(-1)
if (previous?.user === user) previous.assistant = message
else turns.push({ user, assistant: message })
if (turns.length > RECENT_TURNS) turns.shift()
if (message.plan.conversationSummary?.trim()) summary = clip(message.plan.conversationSummary.trim(), SUMMARY_LIMIT)
}
if (!turns.length) return undefined
const recent = turns.slice(-RECENT_TURNS)
return {
environment, workspaceTarget, summary,
recentMessages: recent.flatMap(turn => [
{ role: 'user' as const, content: clip(turn.user.content, MESSAGE_LIMIT) },
{ role: 'assistant' as const, content: responseContext(turn.assistant, MESSAGE_LIMIT) }
])
}
}
@@ -0,0 +1,107 @@
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
type FileEvidence = {
path: string; exists: boolean; content: string; hash: string | null
complete: boolean; startLine: number; endLine: number; totalLines: number
contentTruncated?: boolean
}
export function readFileEvidence(result: AgentToolResult): FileEvidence | undefined {
if (result.name !== 'files.read' || result.status !== 'ok') return
try {
const data = JSON.parse(result.data)
if (typeof data.path !== 'string' || typeof data.content !== 'string' || typeof data.complete !== 'boolean'
|| data.contentTruncated !== undefined && typeof data.contentTruncated !== 'boolean') return
if (data.exists === false && data.complete === true && data.hash === null && data.content === '') {
return { ...data, startLine: 1, endLine: 0, totalLines: 0 }
}
if (data.exists !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) return
const startLine = data.startLine ?? 1
const last = startLine + data.content.split('\n').length - 1
// Older clients cannot distinguish a ranged read from a cut final line.
const endLine = data.endLine ?? last - Number(result.truncated)
if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(endLine)
|| endLine < startLine - 1 || endLine > last || !Number.isSafeInteger(data.totalLines) || data.totalLines < 1 || endLine > data.totalLines
|| data.contentTruncated === true && endLine >= last) return
return { ...data, startLine, endLine }
} catch { return }
}
const fileKey = (result: AgentToolResult, file: FileEvidence): string => JSON.stringify([result.target, result.moduleName, file.path])
// Only combine observations of the same file revision on the same host.
export function compactAgentEvidence(results: AgentToolResult[]): AgentToolResult[] {
const compacted: AgentToolResult[] = []
for (const result of results) {
const file = readFileEvidence(result)
if (!file) { compacted.push(result); continue }
const key = fileKey(result, file)
let current = result
let currentFile = file
for (let index = compacted.length - 1; index >= 0; index--) {
const previous = compacted[index]
const prior = readFileEvidence(previous)
if (!prior || fileKey(previous, prior) !== key) continue
if (prior.hash !== file.hash || prior.exists !== file.exists) {
compacted.splice(index, 1)
continue
}
if (currentFile.complete) { compacted.splice(index, 1); continue }
if (prior.complete) { current = previous; currentFile = prior; compacted.splice(index, 1); continue }
if (prior.contentTruncated !== false || currentFile.contentTruncated !== false
|| prior.totalLines !== currentFile.totalLines
|| prior.endLine + 1 < currentFile.startLine || currentFile.endLine + 1 < prior.startLine) continue
const lines = new Map<number, string>()
let conflict = false
for (const part of [prior, currentFile]) {
for (const [offset, line] of part.content.split('\n').entries()) {
const number = part.startLine + offset
if (number > part.endLine) break
if (lines.has(number) && lines.get(number) !== line) conflict = true
lines.set(number, line)
}
}
if (conflict) continue
const startLine = Math.min(prior.startLine, currentFile.startLine)
const endLine = Math.max(prior.endLine, currentFile.endLine)
const content = Array.from({ length: endLine - startLine + 1 }, (_, offset) => lines.get(startLine + offset)!).join('\n')
// Merged ranges remain read-only evidence: no unverified full-file hash promotion.
const merged = { ...currentFile, startLine, endLine, content, complete: false, contentTruncated: false }
const data = JSON.stringify(merged)
if (content.length > 40_000 || data.length > 60_000) continue
currentFile = merged
current = { ...result, data, truncated: true }
compacted.splice(index, 1)
}
compacted.push(current)
}
return compacted
}
export function missingAgentRead(tool: AgentToolRequest, results: AgentToolResult[]): AgentToolRequest | undefined {
if (tool.name !== 'files.read') return tool
const observations = results.filter(result => result.moduleName === tool.moduleName)
.flatMap(result => { const file = readFileEvidence(result); return file && file.path === tool.args?.path ? [{ file, target: result.target }] : [] })
const latest = observations.at(-1)
const files = observations.filter(item => item.target === latest?.target && item.file.hash === latest?.file.hash).map(item => item.file)
if (files.some(file => file.complete)) return
// An unbounded read may be needed for an approved edit; partial ranges do not replace it.
if (tool.args?.startLine === undefined && tool.args?.lineCount === undefined) return tool
const start = tool.args?.startLine ?? 1
const total = files[0]?.totalLines
const end = Math.min(start + (tool.args?.lineCount ?? 200) - 1, total ?? Infinity)
let next = start
const ranges = files.map(file => [file.startLine, file.endLine]).sort((a, b) => a[0] - b[0])
for (const [from, to] of ranges) {
if (from > next) return { ...tool, args: { ...tool.args, startLine: next, lineCount: Math.min(end, from - 1) - next + 1 } }
if (to >= next) next = to + 1
if (next > end) return
}
if (next > end) return
return { ...tool, args: { ...tool.args, startLine: next, lineCount: end - next + 1 } }
}
export function describeAgentEvidence(result: AgentToolResult): string {
const file = readFileEvidence(result)
return `${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${file ? ` · ${file.path}${file.exists ? ` · ${file.startLine}–${file.endLine}/${file.totalLines}줄` : ' · 파일 없음'}` : ''}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`
}
@@ -0,0 +1,34 @@
export const AGENT_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const
export type AgentProvider = typeof AGENT_PROVIDERS[number]
export type AgentModelOption = { provider: AgentProvider; model: string; imagesEnabled: boolean }
export type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
imagesEnabled?: boolean
models: AgentModelOption[]
selectionSupported: boolean
}
export const AGENT_PROVIDER_LABELS: Record<AgentProvider, string> = {
A_COPILOT: 'A-Copilot', OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen'
}
export function normalizeAgentStatus(value: Partial<AgentStatus>): AgentStatus {
const selectionSupported = Array.isArray(value.models)
const candidates = value.enabled ? selectionSupported ? value.models! : [value] : []
const models = AGENT_PROVIDERS.flatMap(provider => {
const item = candidates.find(item => item.provider === provider && typeof item.model === 'string' && item.model.trim() && item.model.length <= 120)
return item ? [{ provider, model: item.model!.trim(), imagesEnabled: provider === 'QWEN' && item.imagesEnabled === true }] : []
})
return { enabled: models.length > 0, provider: models[0]?.provider ?? null, model: models[0]?.model ?? null,
imagesEnabled: models[0]?.imagesEnabled ?? false, models, selectionSupported }
}
export function selectedAgentModel(models: AgentModelOption[], preference: string | null): AgentModelOption | null {
return models.find(model => model.provider === preference) ?? models[0] ?? null
}
export function agentModelLabel(model: AgentModelOption): string {
return model.provider === 'A_COPILOT' ? 'A-Copilot' : `${AGENT_PROVIDER_LABELS[model.provider]} · ${model.model}`
}
@@ -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 }
}
@@ -0,0 +1,129 @@
import type { AgentConversationMessage } from './useAiAgent'
type PendingMessage = { message: AgentConversationMessage; serialized: string }
type SessionSyncOptions = {
save: (messages: AgentConversationMessage[]) => Promise<unknown>
storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>
storageKey: string
}
export function createAgentSessionSync({ save, storage, storageKey }: SessionSyncOptions) {
const pending = new Map<string, PendingMessage>()
const localMessages = new Map<string, AgentConversationMessage>()
const signatures = new Map<string, string>()
const listeners = new Set<() => void>()
let running: Promise<void> | null = null
let clearing: Promise<void> | null = null
let error: string | null = null
try {
const cached: unknown = JSON.parse(storage?.getItem(storageKey) ?? '[]')
if (Array.isArray(cached)) {
for (const message of cached) {
if (message && typeof message.id === 'string' && typeof message.content === 'string'
&& (message.role === 'user' || message.role === 'assistant')) {
const serialized = JSON.stringify(message)
pending.set(message.id, { message, serialized })
localMessages.set(message.id, message)
signatures.set(message.id, serialized)
}
}
}
} catch (cause) {
console.warn('[agent] failed to read pending conversation', cause)
}
const notify = (): void => { listeners.forEach((listener) => listener()) }
const persistPending = (): void => {
try {
if (pending.size) storage?.setItem(storageKey, JSON.stringify([...pending.values()].map((entry) => entry.message)))
else storage?.removeItem(storageKey)
} catch (cause) {
console.warn('[agent] failed to cache pending conversation', cause)
}
}
const flush = (): Promise<void> => {
if (clearing) return clearing.catch(() => undefined)
if (running) return running
if (!pending.size) return Promise.resolve()
// Keep writes ordered even when a component leaves the workspace mid-save.
running = Promise.resolve().then(async () => {
try {
while (pending.size) {
const batch: PendingMessage[] = []
let characters = 0
for (const entry of pending.values()) {
if (batch.length >= 100 || (batch.length && characters + entry.serialized.length > 4_500_000)) break
batch.push(entry)
characters += entry.serialized.length
}
await save(batch.map((entry) => entry.message))
for (const entry of batch) {
if (pending.get(entry.message.id) === entry) pending.delete(entry.message.id)
}
persistPending()
}
error = null
} catch (cause) {
console.warn('[agent] failed to persist conversation', cause)
error = '대화를 서버에 저장하지 못했습니다. 자동으로 다시 시도합니다.'
} finally {
running = null
notify()
}
})
return running
}
return {
enqueue(messages: AgentConversationMessage[]): void {
if (clearing) return
let changed = false
for (const message of messages) {
const serialized = JSON.stringify(message)
if (signatures.get(message.id) === serialized) continue
const snapshot = JSON.parse(serialized) as AgentConversationMessage
signatures.set(message.id, serialized)
localMessages.set(message.id, snapshot)
pending.set(message.id, { message: snapshot, serialized })
changed = true
}
if (changed) {
persistPending()
void flush()
}
},
restore(serverMessages: AgentConversationMessage[]): AgentConversationMessage[] {
const merged = new Map(serverMessages.map((message) => [message.id, message]))
for (const message of serverMessages) {
if (!localMessages.has(message.id)) signatures.set(message.id, JSON.stringify(message))
}
for (const message of localMessages.values()) merged.set(message.id, message)
return [...merged.values()]
},
flush,
setSave(nextSave: SessionSyncOptions['save']): void { save = nextSave },
clear(archive: () => Promise<unknown>): Promise<void> {
if (clearing) return clearing
clearing = Promise.resolve().then(async () => {
if (running) await running
await archive()
pending.clear()
localMessages.clear()
signatures.clear()
error = null
persistPending()
}).finally(() => {
clearing = null
notify()
})
return clearing
},
getError: (): string | null => error,
subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
}
}
@@ -0,0 +1,573 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync'
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
import { agentConversationWorkspace, buildAgentConversationContext, type AgentConversationScope } from './agentConversationContext'
export type AgentFileChange = {
expectedHash?: string | null
moduleName: string
path: string
content: string
reason: string
}
export type AgentCodeReference = {
id: string
moduleName: string
path: string
startLine: number
endLine: number
reason: string
expectedHash?: string
snippet?: string
}
export type AgentCodeReferenceState = {
status: 'pending' | 'opening' | 'opened' | 'cancelled' | 'error'
error?: string
}
export type AgentModulePlan = {
name: string
category: string
folderName: string
dockerImage: string | null
packageName: string
launchFile: string
}
export type AgentCommand = {
operation?: AgentOperation
id: string
moduleName: string
command: string
purpose: string
}
export type AgentCommandExecution = {
status: 'pending' | 'running' | 'completed' | 'failed' | 'rejected'
output?: string
exitCode?: number | null
error?: string
}
export type AgentPlan = {
conversationSummary?: string
codeReferences?: AgentCodeReference[]
toolRequests?: AgentToolRequest[]
evidence?: AgentToolResult[]
summary: string
explanation: string
requestedModules: string[]
requestedLogs: string[]
actionPlan: string[]
commands: AgentCommand[]
changes: AgentFileChange[]
createModule: AgentModulePlan | null
}
export type AgentWorkspaceModule = {
parameters?: string[]
name: string
folderName?: string
category?: string
state?: string
fileContext?: {
status: 'loaded' | 'empty' | 'error' | 'uninstalled' | 'limited'
message?: string
}
files: Array<{ path: string; content: string }>
}
export type AgentRuntimeLog = {
moduleName: string
state: string
content: string
message?: string
}
export type AgentConversationMessage = {
conversationScope?: AgentConversationScope
id: string
role: 'user' | 'assistant'
content: string
provider?: string
model?: string
revealStartedAt?: number
durationMs?: number
imageAttachments?: AgentImageReference[]
plan?: AgentPlan
applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled'
applyError?: string
commandExecutions?: Record<string, AgentCommandExecution>
codeReferenceStates?: Record<string, AgentCodeReferenceState>
usage?: {
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
}
type UseAiAgentOptions = {
accessToken: string
studentEmail: string
courseId: string
weekNumber: number
apiBaseUrl: string
getFreshAccessToken: () => Promise<string>
onResponse?: () => void
}
type AgentSessionResponse = {
sessionId: string | null
messages: AgentConversationMessage[]
}
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
const modelPreferenceKey = `autonomy-studio.lms.agent-model:${JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId])}`
const [statusSnapshot, setStatusSnapshot] = useState<{ scope: string; status: AgentStatus }>({ scope: '', status: normalizeAgentStatus({}) })
const status = statusSnapshot.scope === modelPreferenceKey ? statusSnapshot.status : normalizeAgentStatus({})
const [modelPreference, setModelPreference] = useState<{ scope: string; provider: string | null }>({ scope: '', provider: null })
const selectedModel = selectedAgentModel(status.models, modelPreference.scope === modelPreferenceKey ? modelPreference.provider : null)
const [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null)
const [pending, setPending] = useState(false)
const [activity, setActivity] = useState('')
const [progressSteps, setProgressSteps] = useState<AgentProgressStep[]>([])
const [requestStartedAt, setRequestStartedAt] = useState<number | null>(null)
const [messages, setMessages] = useState<AgentConversationMessage[]>([])
const abortControllerRef = useRef<AbortController | null>(null)
const applyingRef = useRef(false)
const navigatingRef = useRef(false)
const tokenReaderRef = useRef(getFreshAccessToken)
tokenReaderRef.current = getFreshAccessToken
const onResponseRef = useRef(onResponse)
onResponseRef.current = onResponse
const authenticated = Boolean(accessToken && courseId && studentEmail)
const selectProvider = (provider: string): void => {
if (pending || applyingRef.current || abortControllerRef.current || !status.models.some(model => model.provider === provider)) return
setModelPreference({ scope: modelPreferenceKey, provider })
try { window.localStorage.setItem(modelPreferenceKey, provider) } catch { /* Selection still works without persistent storage. */ }
}
useEffect(() => {
let provider: string | null = null
try { provider = window.localStorage.getItem(modelPreferenceKey) } catch { /* Use the first available model. */ }
setModelPreference({ scope: modelPreferenceKey, provider })
}, [modelPreferenceKey])
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
const authorization = new Headers(options?.headers).get('Authorization')
?? `Bearer ${await tokenReaderRef.current()}`
const response = await fetch(`${apiBaseUrl}${path}`, {
...options,
headers: {
...(options?.body && !(options.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
Authorization: authorization,
...options?.headers
}
})
if (!response.ok) {
const payload = await response.json().catch(() => null) as { message?: string | string[] } | null
const message = Array.isArray(payload?.message) ? payload.message.join('\n') : payload?.message
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
}
if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens|conversationContext) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
}
return response.json() as Promise<T>
}, [apiBaseUrl])
const session = useMemo(() => {
const scope = JSON.stringify([apiBaseUrl, studentEmail.toLowerCase(), courseId, weekNumber])
const save = (batch: AgentConversationMessage[]) => requestApi<AgentSessionResponse>('/chat/agent/session/sync', {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
body: JSON.stringify({
courseId,
week: Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : undefined,
messages: batch.map(({ revealStartedAt: _revealStartedAt, ...message }) => message)
})
})
let sync = sessionSyncs.get(scope)
if (!sync) {
let storage: Storage | undefined
try { storage = window.localStorage } catch { /* memory queue still works */ }
sync = createAgentSessionSync({ save, storage, storageKey: `autonomy-studio.lms.agent-pending:${scope}` })
sessionSyncs.set(scope, sync)
} else sync.setSave(save)
return sync
}, [accessToken, apiBaseUrl, courseId, requestApi, studentEmail, weekNumber])
const persistenceError = useSyncExternalStore(session.subscribe, session.getError)
const messageStateRef = useRef({ session, messages })
const updateMessages = useCallback((update: (current: AgentConversationMessage[]) => AgentConversationMessage[]): void => {
if (messageStateRef.current.session !== session) return
const next = update(messageStateRef.current.messages)
messageStateRef.current = { session, messages: next }
setMessages(next)
session.enqueue(next)
}, [session])
useEffect(() => {
if (!accessToken || !courseId) {
setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
setStatusLoading(false)
return
}
let cancelled = false
let revision = 0
let initialLoad = true
const refresh = (): void => {
const requestRevision = ++revision
if (initialLoad) setStatusLoading(true)
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
.then(nextStatus => {
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) })
})
.catch(() => {
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
})
.finally(() => {
if (!cancelled && requestRevision === revision) {
initialLoad = false
setStatusLoading(false)
}
})
}
const refreshVisible = (): void => { if (!document.hidden) refresh() }
refresh()
window.addEventListener('focus', refresh)
document.addEventListener('visibilitychange', refreshVisible)
const timer = window.setInterval(refreshVisible, 30_000)
return () => {
cancelled = true
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refreshVisible)
window.clearInterval(timer)
}
}, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber])
// The workspace stays mounted while other app pages or Q&A tabs are shown.
// Request cancellation follows the conversation lifetime, not its visibility.
useEffect(() => {
setPending(false)
setRequestStartedAt(null)
setProgressSteps([])
setActivity('')
return () => {
abortControllerRef.current?.abort()
abortControllerRef.current = null
}
}, [authenticated, session])
useEffect(() => {
const cached = authenticated ? recoverInterruptedAgentMessages(session.restore([])) : []
messageStateRef.current = { session, messages: cached }
setMessages(cached)
setHistoryError(null)
if (!authenticated) {
setHistoryLoading(false)
return
}
let cancelled = false
setHistoryLoading(true)
const query = new URLSearchParams({ courseId })
if (Number.isInteger(weekNumber) && weekNumber >= 1) query.set('week', String(weekNumber))
requestApi<AgentSessionResponse>(`/chat/agent/session?${query.toString()}`)
.then((response) => {
if (!cancelled) {
const restored = recoverInterruptedAgentMessages(session.restore(Array.isArray(response.messages) ? response.messages : []))
messageStateRef.current = { session, messages: restored }
setMessages(restored)
}
})
.catch((error) => {
if (!cancelled) {
console.warn('[agent] failed to load conversation', error)
setHistoryError('대화 내역을 불러오지 못했습니다.')
}
})
.finally(() => {
if (!cancelled) {
setHistoryLoading(false)
}
})
void session.flush()
const retry = (): void => { void session.flush() }
const retryTimer = window.setInterval(retry, 10_000)
window.addEventListener('online', retry)
return () => {
cancelled = true
window.clearInterval(retryTimer)
window.removeEventListener('online', retry)
}
}, [authenticated, courseId, requestApi, session, weekNumber])
const requestPlan = async (
rawRequest: string,
modules: AgentWorkspaceModule[],
runtimeLogs: AgentRuntimeLog[] = [],
options: {
showUserMessage?: boolean
attachments?: ChatAttachment[]
loadInitialContext?: (signal: AbortSignal) => Promise<AgentContext>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote'
workspaceTarget?: string
workspaceRoot?: string
toolResults?: AgentToolResult[]
} = {}
): Promise<void> => {
const request = rawRequest.trim()
if (!request || pending || historyLoading || statusLoading || abortControllerRef.current || !status.enabled || !selectedModel) return
const requestModel = selectedModel
const selection = status.selectionSupported ? { provider: requestModel.provider } : {}
const startedAt = Date.now()
const workspace = agentConversationWorkspace(options.environment, options.workspaceTarget, options.workspaceRoot)
const conversationScope = workspace ? { workspace, createdAt: startedAt } : undefined
const conversationContext = buildAgentConversationContext(messageStateRef.current.messages, workspace, startedAt)
const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`,
role: 'user',
content: request,
...(conversationScope ? { conversationScope } : {})
}
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true)
setRequestStartedAt(startedAt)
setProgressSteps([])
setActivity('요청 분석 중')
const abortController = new AbortController()
abortControllerRef.current = abortController
const currentRequest = (): boolean => !abortController.signal.aborted && abortControllerRef.current === abortController && messageStateRef.current.session === session
const appendStep = (step: AgentProgressStep): void => {
if (currentRequest()) setProgressSteps(current => [...current, step])
}
try {
let initialContext: AgentContext = { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }
if (options.loadInitialContext) {
setActivity('모듈 상태 조회 중')
const loaded = await options.loadInitialContext(abortController.signal)
if (!currentRequest()) return
initialContext = { ...initialContext, ...loaded }
}
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
const imageAttachments: AgentImageReference[] = []
for (const file of options.attachments ?? []) {
if (file.kind !== 'image') continue
if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
setActivity(`이미지 업로드 중: ${file.name}`)
const body = new FormData()
body.append('courseId', courseId)
if (selection.provider) body.append('provider', selection.provider)
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
body.append('file', file.file, file.name)
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
if (!currentRequest()) return
imageAttachments.push(uploaded)
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
}
const imageTokens = imageAttachments.map(image => image.token)
setActivity('요청 분석 중')
const response = await resolveAgentContext({
initialContext,
signal: abortController.signal,
loadRequestedContext: options.loadRequestedContext,
executeTool: options.executeTool,
onProgress: label => { if (currentRequest()) setActivity(label) },
onStep: appendStep,
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST',
signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), environment: options.environment, workspaceTarget: options.workspaceTarget, ...(options.executeTool ? { toolProtocol: 1 } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context, ...(conversationContext ? { conversationContext } : {}) })
})
})
if (!currentRequest()) return
const plan: AgentPlan = {
...response.plan,
requestedModules: response.plan.requestedModules ?? [],
requestedLogs: response.plan.requestedLogs ?? [],
actionPlan: response.plan.actionPlan ?? [],
commands: response.plan.commands ?? []
}
const hasApplicableChanges = plan.changes.length > 0 || plan.createModule !== null
updateMessages((current) => [...current, {
id: `agent-plan-${Date.now()}`,
role: 'assistant',
revealStartedAt: Date.now(),
durationMs: Math.max(0, Date.now() - startedAt),
content: plan.summary,
...(conversationScope ? { conversationScope } : {}),
provider: response.provider,
model: response.model,
usage: response.usage,
plan,
applyState: hasApplicableChanges ? 'ready' : undefined,
commandExecutions: Object.fromEntries(plan.commands.map((command) => [command.id, { status: 'pending' }]))
}])
onResponseRef.current?.()
} catch (error) {
if (!currentRequest()) return
updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`,
role: 'assistant',
revealStartedAt: Date.now(),
durationMs: Math.max(0, Date.now() - startedAt),
content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.'
}])
onResponseRef.current?.()
} finally {
if (abortControllerRef.current === abortController) {
abortControllerRef.current = null
setPending(false)
setRequestStartedAt(null)
setActivity('')
setProgressSteps([])
}
}
}
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
if (messageStateRef.current.session !== session) return
const message = messageStateRef.current.messages.find((item) => item.id === messageId)
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled') 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)
updateMessages((current) => current.map((item) => item.id === messageId
? {
...item,
applyState: 'applied',
applyError: undefined,
commandExecutions: Object.fromEntries(Object.entries(item.commandExecutions ?? {}).map(([commandId, execution]) => [
commandId,
execution.status === 'completed' || execution.status === 'failed' ? { status: 'pending' as const } : execution
]))
}
: item))
} 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
}
}
const cancelPlan = (messageId: string): boolean => {
if (messageStateRef.current.session !== session || applyingRef.current || abortControllerRef.current) return false
const message = messageStateRef.current.messages.find((item) => item.id === messageId)
if (!message?.plan || (!message.plan.changes.length && !message.plan.createModule)
|| message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled'
|| Object.values(message.commandExecutions ?? {}).some(execution => execution.status === 'running')) return false
updateMessages(current => current.map(item => item.id === messageId ? {
...item,
applyState: 'cancelled',
applyError: undefined,
commandExecutions: Object.fromEntries(item.plan!.commands.map(command => {
const execution = item.commandExecutions?.[command.id]
return [command.id, execution?.status === 'completed' ? execution : { ...execution, status: 'rejected' as const, error: undefined }]
}))
} : item))
return true
}
const stop = (): void => {
abortControllerRef.current?.abort()
abortControllerRef.current = null
setPending(false)
setRequestStartedAt(null)
setActivity('')
setProgressSteps([])
}
const updateCommandExecution = (messageId: string, commandId: string, execution: AgentCommandExecution): void => {
updateMessages((current) => current.map((message) => message.id === messageId
? {
...message,
commandExecutions: {
...message.commandExecutions,
[commandId]: { ...message.commandExecutions?.[commandId], ...(execution.status === 'running' ? { error: undefined } : {}), ...execution }
}
}
: message))
}
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
if (navigatingRef.current || applyingRef.current || messageStateRef.current.session !== session) return
const message = messageStateRef.current.messages.find(message => message.id === messageId)
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
if (!reference) return
const latestUserMessageId = messageStateRef.current.messages.findLast(message => message.role === 'user')?.id
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
const update = (state: AgentCodeReferenceState): void => {
if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message))
}
navigatingRef.current = true
update({ status: 'opening' })
try {
await open(reference, () => isCurrent() && messageStateRef.current.messages.findLast(message => message.role === 'user')?.id === latestUserMessageId && !applyingRef.current)
update({ status: 'opened' })
} catch (error) {
update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })
} finally {
navigatingRef.current = false
}
}
const clear = (): void => {
if (!authenticated || historyLoading) return
stop()
setHistoryLoading(true)
const query = new URLSearchParams({ courseId })
if (Number.isInteger(weekNumber) && weekNumber >= 1) query.set('week', String(weekNumber))
void session.clear(() => requestApi<{ archivedCount: number }>(`/chat/agent/session?${query.toString()}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` }
})).then(() => {
if (messageStateRef.current.session !== session) return
messageStateRef.current = { session, messages: [] }
setMessages([])
setHistoryError(null)
}).catch((error) => {
console.warn('[agent] failed to archive conversation', error)
if (messageStateRef.current.session === session) setHistoryError('대화 내역을 삭제하지 못했습니다.')
}).finally(() => {
if (messageStateRef.current.session === session) setHistoryLoading(false)
})
}
return {
...status,
provider: selectedModel?.provider ?? null,
model: selectedModel?.model ?? null,
imagesEnabled: selectedModel?.imagesEnabled ?? false,
selectProvider,
statusLoading: statusLoading || historyLoading,
pending,
activity,
progressSteps,
requestStartedAt,
messages,
persistenceError: persistenceError ?? historyError,
requestPlan,
applyPlan,
cancelPlan,
updateCommandExecution,
openCodeReference,
stop,
clear
}
}
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
import type { Socket } from 'socket.io-client' import type { Socket } from 'socket.io-client'
export type QaMode = 'ai' | 'teacher' export type QaMode = 'ai' | 'agent' | 'teacher'
export type QaChatMessage = { export type QaChatMessage = {
id: string id: string
@@ -11,6 +11,7 @@ export type QaChatMessage = {
createdAt?: string createdAt?: string
senderAvatarUrl?: string | null senderAvatarUrl?: string | null
isTyping?: boolean isTyping?: boolean
revealStartedAt?: number
keepExpanded?: boolean keepExpanded?: boolean
status?: 'ACTIVE' | 'CANCELED' status?: 'ACTIVE' | 'CANCELED'
} }
@@ -55,6 +56,7 @@ type UseQaChatOptions = {
courseId: string courseId: string
weekNumber: number weekNumber: number
globalScope?: boolean globalScope?: boolean
revealInView?: boolean
apiBaseUrl: string apiBaseUrl: string
socketBaseUrl: string socketBaseUrl: string
socketPath: string socketPath: string
@@ -77,6 +79,7 @@ export function useQaChat({
courseId, courseId,
weekNumber, weekNumber,
globalScope = false, globalScope = false,
revealInView = false,
apiBaseUrl, apiBaseUrl,
socketBaseUrl, socketBaseUrl,
socketPath, socketPath,
@@ -221,7 +224,7 @@ export function useQaChat({
useEffect(() => { useEffect(() => {
const target = workspaceChatScrollRef.current const target = workspaceChatScrollRef.current
if (!target) return if (!target) return
const currentLoading = qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading const currentLoading = qaMode === 'ai' ? aiMessagesLoading : qaMode === 'teacher' ? teacherMessagesLoading : false
if (currentLoading) return if (currentLoading) return
const scrollToBottom = (): void => { const scrollToBottom = (): void => {
@@ -286,6 +289,7 @@ export function useQaChat({
const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => { const sendQaMessage = async (rawContent: string, options?: { contextWeek?: number }): Promise<void> => {
const content = rawContent.trim() const content = rawContent.trim()
if (!content || aiPending || aiTypingMessageId || !chatAvailable) return if (!content || aiPending || aiTypingMessageId || !chatAvailable) return
if (qaMode === 'agent') return
setQaCollapseSignal((value) => value + 1) setQaCollapseSignal((value) => value + 1)
if (qaMode !== 'ai') { if (qaMode !== 'ai') {
@@ -368,6 +372,17 @@ export function useQaChat({
}) })
expandedAiMessageIdsRef.current.add(response.message.id) expandedAiMessageIdsRef.current.add(response.message.id)
setAiPending(false) 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) setAiTypingMessageId(response.message.id)
setAiMessages((messages) => [ setAiMessages((messages) => [
...messages, ...messages,
@@ -427,6 +442,7 @@ export function useQaChat({
const clearQaConversation = async (): Promise<void> => { const clearQaConversation = async (): Promise<void> => {
if (!chatAvailable) return if (!chatAvailable) return
if (qaMode === 'agent') return
if (qaMode === 'ai') { if (qaMode === 'ai') {
aiRevealCancelRef.current = true aiRevealCancelRef.current = true
aiAbortControllerRef.current?.abort() aiAbortControllerRef.current?.abort()
@@ -493,7 +509,7 @@ export function useQaChat({
aiTypingMessageId, aiTypingMessageId,
teacherThreadStatus, teacherThreadStatus,
qaCollapseSignal, qaCollapseSignal,
qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : teacherMessagesLoading, qaMessagesLoading: qaMode === 'ai' ? aiMessagesLoading : qaMode === 'teacher' ? teacherMessagesLoading : false,
visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit), visibleAiMessages: aiMessages.filter((message) => message.id !== 'welcome').slice(-qaVisibleMessageLimit),
displayedAiMessages: selectedAiConversation displayedAiMessages: selectedAiConversation
? selectedAiConversation.messages ? selectedAiConversation.messages
@@ -11,8 +11,10 @@ type ModuleActionResult = {
ok: boolean ok: boolean
message?: string message?: string
} }
type ModuleControlOptions = { background?: boolean; freshSlam?: boolean; onResult?: (result: ModuleActionResult) => void }
type SavedMapPathOverrides = { type SavedMapPathOverrides = {
freshSlam?: boolean
savedMapYamlPath?: string | null savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null savedMapDatabasePath?: string | null
savedMapWorldLayoutPath?: string | null savedMapWorldLayoutPath?: string | null
@@ -70,7 +72,7 @@ type UseWorkspaceModuleControlResult = {
startAllModules: () => Promise<void> startAllModules: () => Promise<void>
stopAllModules: () => Promise<void> stopAllModules: () => Promise<void>
restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean> restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<void> runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options?: ModuleControlOptions) => Promise<boolean>
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void> updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
} }
@@ -108,10 +110,12 @@ export function useWorkspaceModuleControl({
const moduleActionIdRef = useRef(0) const moduleActionIdRef = useRef(0)
const controlGenerationRef = useRef(0) const controlGenerationRef = useRef(0)
const workspaceActiveRef = useRef(workspaceActive) const workspaceActiveRef = useRef(workspaceActive)
const backgroundControlsRef = useRef(0)
useEffect(() => () => { controlGenerationRef.current += 1 }, [courseKey, weekNumber, clonePathPreview, remoteWorkspace])
useEffect(() => { useEffect(() => {
workspaceActiveRef.current = workspaceActive workspaceActiveRef.current = workspaceActive
if (workspaceActive) return if (workspaceActive || backgroundControlsRef.current > 0) return
// Workspace pages remain mounted while another LMS page is shown. Do not // Workspace pages remain mounted while another LMS page is shown. Do not
// leave their control buttons locked behind a detached IPC request. // leave their control buttons locked behind a detached IPC request.
@@ -126,6 +130,7 @@ export function useWorkspaceModuleControl({
), [bulkExcludedModuleKeys]) ), [bulkExcludedModuleKeys])
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { const getModuleRosParams = useCallback((module: ModuleRow, overrides?: {
freshSlam?: boolean
simulatorWorldFile?: string | null simulatorWorldFile?: string | null
savedMapYamlPath?: string | null savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null savedMapDatabasePath?: string | null
@@ -133,7 +138,7 @@ export function useWorkspaceModuleControl({
}): string => { }): string => {
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath const mapDatabasePath = overrides?.freshSlam ? null : overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '') const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini') const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
@@ -175,12 +180,13 @@ export function useWorkspaceModuleControl({
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`) if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
} }
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '') 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) ?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) { if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`) parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true') 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(' ') return parameters.join(' ')
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles]) }, [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]) }, [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<void> => { const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options: ModuleControlOptions = {}): Promise<boolean> => {
if (!workspaceActiveRef.current) return if (!workspaceActiveRef.current && !options.background) return false
// The parking area only opts modules out of bulk actions; those modules // The parking area only opts modules out of bulk actions; those modules
// must remain individually controllable while the rest are stopping. // must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = ( const canControlExcludedModuleDuringBulkStop = (
@@ -484,22 +490,23 @@ export function useWorkspaceModuleControl({
if ( if (
(!canControlExcludedModuleDuringBulkStop && installingModuleName) (!canControlExcludedModuleDuringBulkStop && installingModuleName)
|| queuedModuleActionsRef.current.has(module.name) || queuedModuleActionsRef.current.has(module.name)
) return ) return false
const actionId = ++moduleActionIdRef.current const actionId = ++moduleActionIdRef.current
const controlGeneration = controlGenerationRef.current const controlGeneration = controlGenerationRef.current
queuedModuleActionsRef.current.set(module.name, actionId) queuedModuleActionsRef.current.set(module.name, actionId)
if (options.background) backgroundControlsRef.current += 1
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name])) setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null) setModuleInstallError(null)
const execute = async (): Promise<void> => { const execute = async (): Promise<boolean> => {
const isCurrentControl = (): boolean => ( const isCurrentControl = (): boolean => (
workspaceActiveRef.current (workspaceActiveRef.current || options.background === true)
&& controlGenerationRef.current === controlGeneration && controlGenerationRef.current === controlGeneration
&& queuedModuleActionsRef.current.get(module.name) === actionId && queuedModuleActionsRef.current.get(module.name) === actionId
) )
try { try {
if (!isCurrentControl()) return if (!isCurrentControl()) return false
const savedMapOverrides = action === 'start' || action === 'restart' const savedMapOverrides = options.freshSlam ? { freshSlam: true } : action === 'start' || action === 'restart'
? await prepareSavedMap?.() ? await prepareSavedMap?.()
: undefined : undefined
const result = action === 'stop' const result = action === 'stop'
@@ -517,8 +524,9 @@ export function useWorkspaceModuleControl({
: remoteWorkspace : remoteWorkspace
? await remoteModuleStart(module, savedMapOverrides) ? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork) : 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 (!result.ok) throw new Error(result.message)
if (!isCurrentControl()) return if (!isCurrentControl()) return false
if (action === 'start' || action === 'restart') { if (action === 'start' || action === 'restart') {
markModuleState(module, 'running') markModuleState(module, 'running')
@@ -537,9 +545,9 @@ export function useWorkspaceModuleControl({
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패') if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
} }
} }
if (!isCurrentControl()) return if (!isCurrentControl()) return false
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
if (!isCurrentControl()) return if (!isCurrentControl()) return false
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
? '종료되었습니다' ? '종료되었습니다'
: action === 'build' : action === 'build'
@@ -548,9 +556,11 @@ export function useWorkspaceModuleControl({
? '재시작되었습니다' ? '재시작되었습니다'
: '실행되었습니다' : '실행되었습니다'
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info') onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
return true
} catch (error) { } catch (error) {
console.error(`[modules] ${action} failed`, error) console.error(`[modules] ${action} failed`, error)
if (!isCurrentControl()) return options.onResult?.({ ok: false, message: error instanceof Error ? error.message : String(error) })
if (!isCurrentControl()) return false
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패') setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
? '종료 실패' ? '종료 실패'
@@ -561,7 +571,9 @@ export function useWorkspaceModuleControl({
: '실행 실패' : '실행 실패'
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error') onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
return false
} finally { } finally {
if (options.background) backgroundControlsRef.current -= 1
if (queuedModuleActionsRef.current.get(module.name) === actionId) { if (queuedModuleActionsRef.current.get(module.name) === actionId) {
queuedModuleActionsRef.current.delete(module.name) queuedModuleActionsRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name)) setControllingModuleNames((names) => names.filter((name) => name !== module.name))
@@ -570,13 +582,12 @@ export function useWorkspaceModuleControl({
} }
if (action === 'stop') { if (action === 'stop') {
await execute() return execute()
return
} }
const queuedWork = moduleActionQueueRef.current.then(execute, execute) const queuedWork = moduleActionQueueRef.current.then(execute, execute)
moduleActionQueueRef.current = queuedWork.then(() => undefined, () => undefined) moduleActionQueueRef.current = queuedWork.then(() => undefined, () => undefined)
await queuedWork return queuedWork
}, [ }, [
activeRuntimeImages, activeRuntimeImages,
availableModules, availableModules,
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript' if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp' if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
if (file.endsWith('.json')) return 'json' if (file.endsWith('.json')) return 'json'
if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml' if (/\.(xml|urdf|xacro|sdf|world)$/i.test(file) || /(^|\/)model\.config$/i.test(file)) return 'xml'
if (file.endsWith('.html')) return 'html' if (file.endsWith('.html')) return 'html'
if (file.endsWith('.css')) return 'css' if (file.endsWith('.css')) return 'css'
if (file.endsWith('.py')) return 'python' if (file.endsWith('.py')) return 'python'
+99 -1
View File
@@ -1,4 +1,18 @@
{ {
"에이전트 모델 선택": "Agent model",
"사용 가능한 모델 없음": "No models available",
"전송": "Send",
"파일 첨부": "Attach files",
"첨부 제거": "Remove attachment",
"대화내역을 삭제할까요?": "Clear this conversation?",
"현재 탭의 대화내역을 삭제합니다.": "The conversation in the current tab will be deleted.",
"파일은 최대 5개까지 첨부할 수 있습니다.": "You can attach up to 5 files.",
"코드·설정·로그 등 텍스트 파일만 첨부할 수 있습니다. 이미지와 PDF는 지원하지 않습니다.": "Only text files such as source code, configuration, and logs are supported. Images and PDFs are not supported.",
"파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.": "File names must be at most 255 characters and files at most 100KB.",
"UTF-8 텍스트 파일만 첨부할 수 있습니다.": "Only UTF-8 text files are supported.",
"바이너리 파일은 첨부할 수 없습니다.": "Binary files cannot be attached.",
"파일 하나의 내용은 5만 자 이하여야 합니다.": "Each attachment must contain at most 50,000 characters.",
"첨부 파일의 전체 내용은 10만 자 이하여야 합니다.": "All attachments together must contain at most 100,000 characters.",
"로컬 모듈 Git 가져오기": "Import local module from Git", "로컬 모듈 Git 가져오기": "Import local module from Git",
"로컬 사용자 모듈": "Local custom module", "로컬 사용자 모듈": "Local custom module",
"현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.", "현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.",
@@ -589,6 +603,15 @@
"추가 중": "Adding", "추가 중": "Adding",
"추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.", "추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.",
"취소": "Cancel", "취소": "Cancel",
"취소됨": "Cancelled",
"이미지 확대": "Enlarge image",
"첨부 이미지를 분석해주세요.": "Please analyze the attached images.",
"현재 모델은 이미지 첨부를 지원하지 않습니다. Qwen 3.7 Plus·Flash 또는 3.8 Max·Flash를 선택해주세요.": "This model does not support image attachments. Select Qwen 3.7 Plus/Flash or 3.8 Max/Flash.",
"이미지는 최대 3장까지 첨부할 수 있습니다.": "You can attach up to 3 images.",
"이미지는 장당 5MB 이하여야 합니다.": "Each image must be 5 MB or smaller.",
"이미지 파일을 읽지 못했습니다.": "Could not read the image file.",
"이미지는 1600만 픽셀 이하여야 합니다.": "Images must be 16 megapixels or smaller.",
"코드·설정·로그 또는 PNG·JPEG·WebP 이미지를 첨부해주세요. PDF는 지원하지 않습니다.": "Attach code, configuration, logs, or PNG/JPEG/WebP images. PDF is not supported.",
"카테고리": "Category", "카테고리": "Category",
"칸": " spaces", "칸": " spaces",
"커리큘럼": "Curriculum", "커리큘럼": "Curriculum",
@@ -662,5 +685,80 @@
"활성": "Active", "활성": "Active",
"활성 연결": "Active connections", "활성 연결": "Active connections",
"활성 연결만 보기": "Show active connections only", "활성 연결만 보기": "Show active connections only",
"활성 연결이 없습니다.": "No active connections." "활성 연결이 없습니다.": "No active connections.",
"에이전트": "Agent",
"에이전트 설정을 확인하는 중입니다.": "Checking agent settings.",
"강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.": "The agent becomes available after the instructor configures it for this course.",
"현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.": "Ask the agent to change the current module or create a new one.",
"변경 내용은 확인 후 직접 적용합니다.": "Review the proposed changes before applying them.",
"새 모듈": "New module",
"적용 중": "Applying",
"적용 완료": "Applied",
"다시 적용": "Retry apply",
"변경 적용": "Apply changes",
"변경사항 비교": "Compare changes",
"AI 변경안": "AI proposal",
"작업 계획": "Action plan",
"명령어": "Command",
"모듈 시작": "Start module",
"AI 에이전트 답변이 도착했습니다.": "A new AI agent response is available.",
"대화를 서버에 저장하지 못했습니다. 자동으로 다시 시도합니다.": "Could not save the conversation to the server. Retrying automatically.",
"대화 내역을 불러오지 못했습니다.": "Could not load the conversation history.",
"대화 내역을 삭제하지 못했습니다.": "Could not delete the conversation history.",
"코드 변경을 먼저 적용하세요.": "Apply the code changes first.",
"변경 적용 후 실행": "Run after applying changes",
"거부": "Reject",
"승인": "Approve",
"거부됨": "Rejected",
"실행 완료": "Completed",
"실행 실패": "Failed",
"다시 실행": "Run again",
"명령 출력이 없습니다.": "The command produced no output.",
"코드 변경안을 만드는 중입니다.": "Preparing code changes.",
"조회 근거": "Evidence",
"조회 완료": "Retrieved",
"조회 실패": "Retrieval failed",
"일부 결과": "Partial results",
"SLAM 실습 준비": "Prepare SLAM practice",
"승인하고 준비": "Approve and prepare",
"내 PC에서 직접 개발 환경을 실행합니다.": "Run the development environment directly on this PC.",
"진행 상황": "Progress",
"{{seconds}}초 동안 작업 중입니다": "Working for {{seconds}} seconds",
"{{seconds}}초 동안 작업함": "Worked for {{seconds}} seconds",
"모듈 목록": "Module list",
"{{round}}차 분석 완료": "Analysis round {{round}} completed",
"추가 조회 {{count}}건": "{{count}} additional lookups requested",
"이미지 업로드 완료": "Image uploaded",
"파일 목록": "File list",
"파일 검색": "File search",
"파일 읽기": "File read",
"로그 조회": "Log lookup",
"네트워크 조회": "Network inspection",
"ROS 상태 조회": "ROS inspection",
"실습 구성 조회": "Practice configuration",
"조회 결과 없음": "No results",
"대상 없음": "Not found",
"소스 파일": "Source files",
"{{count}}건": "{{count}} items",
"요청 분석 중": "Analyzing request",
"모듈 상태 조회 중": "Checking module status",
"조회 결과 분석 중": "Analyzing retrieved results",
"추가 자료 조회 중": "Retrieving additional context",
"최종 답변 정리 중": "Preparing the final answer",
"코드에서 보기": "View in editor",
"파일 여는 중...": "Opening file...",
"{{start}}~{{end}}줄": "Lines {{start}}-{{end}}",
"코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.": "Could not open the code location. Ask the agent to analyze it again.",
"코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.": "The code location has no verified source. Ask the agent to analyze it again.",
"코드가 변경되어 위치를 확정할 수 없습니다. 다시 분석을 요청해주세요.": "The code has changed and its location is no longer certain. Ask the agent to analyze it again.",
"코드 위치 열기가 중단되었습니다.": "Opening the code location was cancelled.",
"코드 편집기를 준비하지 못했습니다. 다시 시도해주세요.": "The editor could not be prepared. Please try again.",
"진행 중인 에이전트 작업이 끝난 뒤 열어주세요.": "Wait for the current agent task before opening the file.",
"코드 위치의 모듈을 찾을 수 없습니다. 다시 분석을 요청해주세요.": "The referenced module was not found. Ask the agent to analyze it again.",
"코드 파일을 읽지 못했습니다. 다시 분석을 요청해주세요.": "The code file could not be read. Ask the agent to analyze it again.",
"코드 복사": "Copy code",
"코드를 복사하지 못했습니다.": "Could not copy the code.",
"일반 텍스트": "Plain text",
"코드": "Code",
"표": "Table"
} }
+28 -30
View File
@@ -1,36 +1,11 @@
@import "tailwindcss"; @import "tailwindcss";
@import "driver.js/dist/driver.css"; @import "driver.js/dist/driver.css";
@import "./styles/nanumSquare.css";
@import "./styles/chatCode.css";
@font-face { .monaco-agent-code-highlight {
font-family: 'NanumSquare'; background: rgba(124, 58, 237, 0.16);
src: url('/assets/fonts/NanumSquareL.woff2') format('woff2'); border-left: 2px solid #8b5cf6;
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'NanumSquare';
src: url('/assets/fonts/NanumSquare.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'NanumSquare';
src: url('/assets/fonts/NanumSquareB.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'NanumSquare';
src: url('/assets/fonts/NanumSquareEB.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
} }
@layer base { @layer base {
@@ -248,6 +223,21 @@
} }
@layer utilities { @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 { .monaco-search-line-highlight {
background: rgba(47, 141, 255, 0.12); background: rgba(47, 141, 255, 0.12);
} }
@@ -304,4 +294,12 @@
background: rgba(148, 163, 184, 0.95); background: rgba(148, 163, 184, 0.95);
background-clip: content-box; background-clip: content-box;
} }
.module-content-scrollbar::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.65);
}
.module-content-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.95);
}
} }
+48
View File
@@ -0,0 +1,48 @@
.chat-code-block[data-code-theme='dark'] {
--code-keyword: #c5a5ee;
--code-string: #b5d69b;
--code-number: #efbc82;
--code-title: #8bc7ed;
--code-comment: #939da8;
--code-meta: #e9d38c;
--code-attribute: #92d6cc;
}
.chat-code-block[data-code-theme='light'] {
--code-keyword: #833ba3;
--code-string: #247041;
--code-number: #a34d11;
--code-title: #1765a4;
--code-comment: #64748b;
--code-meta: #896418;
--code-attribute: #14766e;
}
.chat-code-block :is(.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag) {
color: var(--code-keyword);
}
.chat-code-block :is(.hljs-string, .hljs-regexp, .hljs-addition) {
color: var(--code-string);
}
.chat-code-block :is(.hljs-number, .hljs-symbol, .hljs-bullet) {
color: var(--code-number);
}
.chat-code-block :is(.hljs-title, .hljs-built_in, .hljs-section, .hljs-selector-class) {
color: var(--code-title);
}
.chat-code-block :is(.hljs-comment, .hljs-quote) {
color: var(--code-comment);
font-style: italic;
}
.chat-code-block :is(.hljs-meta, .hljs-deletion) {
color: var(--code-meta);
}
.chat-code-block :is(.hljs-attr, .hljs-attribute, .hljs-variable, .hljs-type) {
color: var(--code-attribute);
}
+776
View File
@@ -0,0 +1,776 @@
/* Generated by scripts/generate-nanumsquare-css.py. Do not edit manually.
* Empty glyphs are excluded so the existing system-font fallback can render them.
* Font binaries and visible glyphs are unchanged.
*/
/* NanumSquareL.woff2 sha256: 310d868fb3afa240a7e8190c83f6eb29ef3ddd9764c3bdf62bfd15e7007f6c36 */
@font-face {
font-family: 'NanumSquare';
src: url('/assets/fonts/NanumSquareL.woff2') format('woff2');
font-weight: 300;
font-style: normal;
font-display: swap;
unicode-range:
U+20-7E, U+A1, U+A4, U+A7-AB, U+B0-B4, U+B6, U+B8-BF, U+C6, U+D0, U+D7-D8,
U+DE-DF, U+E6, U+F0, U+F7-F8, U+FE, U+111, U+126-127, U+131-133, U+138, U+13F-142,
U+149-14B, U+152-153, U+166-167, U+2C7, U+2D8-2DD, U+391-3A1, U+3A3-3A9, U+3B1-3C1, U+3C3-3C9, U+401,
U+410-44F, U+451, U+2010, U+2013-2014, U+2016, U+2018-2019, U+201C-201D, U+2020-2021, U+2025-2026, U+2030,
U+2032-2033, U+203B, U+2074, U+207F, U+2081-2084, U+20A9, U+2103, U+2109, U+2113, U+2116,
U+2121-2122, U+2126, U+212B, U+2153-2154, U+215B-215E, U+2160-2169, U+2170-2179, U+2190-2199, U+21D2, U+21D4,
U+2200, U+2202-2203, U+2207-2208, U+220B, U+220F, U+2211, U+221A, U+221D-221E, U+2220, U+2227-222C,
U+222E, U+2234-2236, U+223D, U+2252, U+2260-2261, U+2264-2265, U+2282-2283, U+2286-2287, U+22A5, U+22EF,
U+2312, U+2460-246E, U+2474-2482, U+249C-24B5, U+24D0-24E9, U+2500-2503, U+250C-254B, U+2592, U+25A0-25A1, U+25A3-25A9,
U+25B2-25B3, U+25B6-25B7, U+25BC-25BD, U+25C0-25C1, U+25C6-25C9, U+25CB, U+25CE-25D1, U+2605-2606, U+260E-260F, U+261C,
U+261E, U+2640, U+2642, U+2660-2661, U+2663-2665, U+2667-266A, U+266C-266D, U+3000-3003, U+3008-3011, U+3013-3015,
U+3041-3093, U+30A1-30F6, U+30FB, U+3131-318E, U+3200-321C, U+3260-327B, U+327F, U+3380-3384, U+3388-33CA, U+33CF-33D0,
U+33D3, U+33D6, U+33D8, U+33DB-33DD, U+AC00-AC01, U+AC04, U+AC07-AC0A, U+AC10-AC17, U+AC19-AC1D, U+AC20,
U+AC24, U+AC2C-AC2D, U+AC2F-AC31, U+AC38-AC39, U+AC3C, U+AC40, U+AC4B, U+AC4D, U+AC54, U+AC58,
U+AC5C, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A, U+AC80-AC81, U+AC83-AC86, U+AC89-AC8C, U+AC90, U+AC94,
U+AC9C-AC9D, U+AC9F-ACA1, U+ACA8-ACAA, U+ACAC, U+ACAF-ACB0, U+ACB8-ACB9, U+ACBB-ACBD, U+ACC1, U+ACC4, U+ACC8,
U+ACCC, U+ACD5, U+ACD7, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACEA, U+ACEC, U+ACEF-ACF1, U+ACF3,
U+ACF5-ACF6, U+ACFC-ACFD, U+AD00, U+AD04, U+AD06, U+AD0C-AD0D, U+AD0F, U+AD11, U+AD18, U+AD1C,
U+AD1E, U+AD20, U+AD29, U+AD2C-AD2D, U+AD34-AD35, U+AD38, U+AD3C, U+AD44-AD45, U+AD47, U+AD49,
U+AD50, U+AD54, U+AD58, U+AD61, U+AD63, U+AD65, U+AD6C-AD6D, U+AD70, U+AD73-AD76, U+AD7B-AD7D,
U+AD7F, U+AD81-AD82, U+AD88-AD89, U+AD8C, U+AD90, U+AD9C-AD9D, U+ADA4, U+ADB4, U+ADB7, U+ADC0-ADC1,
U+ADC4, U+ADC8, U+ADD0-ADD1, U+ADD3, U+ADDC, U+ADE0, U+ADE4, U+ADF8-ADF9, U+ADFC, U+ADFF-AE01,
U+AE08-AE09, U+AE0B, U+AE0D, U+AE14, U+AE1C, U+AE30-AE31, U+AE34, U+AE37-AE38, U+AE3A, U+AE40-AE41,
U+AE43, U+AE45-AE46, U+AE4A, U+AE4C-AE4E, U+AE50, U+AE54, U+AE56, U+AE5C-AE5D, U+AE5F-AE61, U+AE65,
U+AE68-AE69, U+AE6C, U+AE70, U+AE78-AE79, U+AE7B-AE7D, U+AE84-AE86, U+AE8C-AE8D, U+AEA0, U+AEBC-AEBE, U+AEC0,
U+AEC4, U+AECC-AECD, U+AECF-AED1, U+AED8-AED9, U+AEDC, U+AEE8, U+AEEB, U+AEED, U+AEF4, U+AEF8,
U+AEFC, U+AF07-AF08, U+AF0D, U+AF10, U+AF2C-AF2D, U+AF30, U+AF32, U+AF34, U+AF3C-AF3D, U+AF3F,
U+AF41-AF43, U+AF48-AF49, U+AF50, U+AF5C-AF5D, U+AF64-AF65, U+AF79, U+AF80, U+AF84, U+AF88, U+AF90-AF91,
U+AF95, U+AF9C, U+AFA4, U+AFB8-AFBA, U+AFBC, U+AFC0, U+AFC7-AFC9, U+AFCB, U+AFCD-AFCE, U+AFD4,
U+AFDC, U+AFE8-AFE9, U+AFF0-AFF1, U+AFF4, U+AFF8-AFF9, U+AFFF-B001, U+B004, U+B00C, U+B010, U+B014,
U+B01C-B01D, U+B028, U+B044-B045, U+B048, U+B04A, U+B04C, U+B04E, U+B053-B055, U+B057, U+B059,
U+B05D, U+B068, U+B07C-B07D, U+B080, U+B084, U+B08C-B08D, U+B08F, U+B091, U+B098-B09A, U+B09C,
U+B09F-B0A2, U+B0A8-B0A9, U+B0AB-B0AF, U+B0B1, U+B0B3-B0B5, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C7-B0C9, U+B0D0-B0D1,
U+B0D4, U+B0D8, U+B0E0, U+B0E5, U+B108-B109, U+B10B-B10C, U+B110, U+B112-B113, U+B118-B119, U+B11B-B11D,
U+B123-B125, U+B128, U+B12C, U+B134-B135, U+B137-B139, U+B140-B141, U+B144, U+B148, U+B150-B151, U+B154-B155,
U+B158, U+B15C, U+B160, U+B178-B179, U+B17C, U+B180, U+B182, U+B188-B189, U+B18B, U+B18D,
U+B192-B194, U+B198, U+B19C, U+B1A8, U+B1CC, U+B1D0, U+B1D4, U+B1DC-B1DD, U+B1DF, U+B1E8-B1E9,
U+B1EC, U+B1F0, U+B1F9, U+B1FB, U+B1FD, U+B204-B205, U+B208, U+B20B-B20C, U+B214-B215, U+B217,
U+B219, U+B220, U+B234, U+B23C, U+B24C-B24D, U+B251, U+B258, U+B25C, U+B260, U+B268-B269,
U+B274-B275, U+B27C, U+B284-B285, U+B289, U+B290-B291, U+B294, U+B298-B29A, U+B2A0-B2A1, U+B2A3, U+B2A5-B2A6,
U+B2AA, U+B2AC, U+B2B0, U+B2B4, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D2, U+B2D8-B2D9, U+B2DB,
U+B2DD, U+B2E2, U+B2E4-B2E6, U+B2E8, U+B2EB-B2EF, U+B2F3-B2F5, U+B2F7-B2FB, U+B2FF-B301, U+B304, U+B308,
U+B310-B311, U+B313-B315, U+B31C, U+B324, U+B32C, U+B331, U+B354-B356, U+B358, U+B35B-B35C, U+B35E-B35F,
U+B364-B365, U+B367, U+B369, U+B36B, U+B36E, U+B370-B371, U+B374, U+B378, U+B380-B381, U+B383-B385,
U+B38C, U+B390, U+B394, U+B3A0-B3A1, U+B3A8, U+B3AC, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE,
U+B3D0, U+B3D4-B3D5, U+B3D7, U+B3D9, U+B3DB, U+B3DD, U+B3E0, U+B3E4, U+B3E8, U+B3FC,
U+B400, U+B40F-B411, U+B418, U+B41C, U+B420, U+B428-B429, U+B42B-B42D, U+B434, U+B450-B451, U+B454,
U+B458, U+B460-B461, U+B463, U+B465, U+B46C, U+B480, U+B488, U+B49D, U+B4A4, U+B4A8,
U+B4AC, U+B4B5, U+B4B7, U+B4B9, U+B4C0, U+B4C4, U+B4C8, U+B4D0, U+B4D5, U+B4DC-B4DD,
U+B4E0, U+B4E3-B4E4, U+B4E6, U+B4EC-B4ED, U+B4EF, U+B4F1, U+B4F8, U+B514-B515, U+B518, U+B51B-B51C,
U+B524-B525, U+B527-B52A, U+B530-B531, U+B534, U+B538, U+B540-B541, U+B543-B545, U+B54B-B54D, U+B550, U+B554,
U+B55C-B55D, U+B55F-B561, U+B568, U+B584, U+B599, U+B5A0-B5A1, U+B5A4, U+B5A8, U+B5AA-B5AB, U+B5B0-B5B1,
U+B5B3-B5B5, U+B5BB-B5BD, U+B5C0, U+B5C4, U+B5CC-B5CD, U+B5CF-B5D1, U+B5D8, U+B5EC, U+B610-B611, U+B614,
U+B618, U+B620, U+B625, U+B62C, U+B634, U+B648, U+B65D, U+B664, U+B668, U+B69C-B69D,
U+B6A0, U+B6A4, U+B6AB-B6AC, U+B6B1, U+B6D4, U+B6F0, U+B6F4, U+B6F8, U+B700-B701, U+B705,
U+B71B, U+B728-B729, U+B72C, U+B72F-B730, U+B738-B739, U+B73B, U+B73D, U+B744, U+B748, U+B74C,
U+B754-B755, U+B760-B761, U+B764, U+B768, U+B770-B771, U+B773, U+B775, U+B77C-B77D, U+B780, U+B784,
U+B78C-B78D, U+B78F-B792, U+B796-B799, U+B79C, U+B7A0, U+B7A8-B7A9, U+B7AB-B7AD, U+B7B4-B7B5, U+B7B8, U+B7BC,
U+B7C7, U+B7C9, U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B810,
U+B818-B819, U+B81B, U+B81D, U+B824-B825, U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B844,
U+B851, U+B853, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D, U+B86F, U+B871, U+B878-B879, U+B87C,
U+B880, U+B88D, U+B894, U+B8A8, U+B8B0, U+B8B4, U+B8B8, U+B8C0-B8C1, U+B8C3, U+B8C5,
U+B8CC, U+B8D0, U+B8D4, U+B8DD, U+B8DF, U+B8E1, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9,
U+B8FB, U+B8FD, U+B904, U+B918, U+B920, U+B93C-B93D, U+B940, U+B944, U+B94C, U+B94F,
U+B951, U+B958-B959, U+B95C, U+B960, U+B968-B969, U+B96B, U+B96D, U+B974-B975, U+B978, U+B97C,
U+B984-B985, U+B987, U+B989-B98A, U+B98D-B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD, U+B9BF, U+B9C1,
U+B9C8-B9C9, U+B9CC, U+B9CE-B9D2, U+B9D8-B9D9, U+B9DB, U+B9DD-B9DE, U+B9E1, U+B9E3-B9E5, U+B9E8, U+B9EC,
U+B9F4-B9F5, U+B9F7-B9FA, U+BA00-BA01, U+BA04, U+BA08, U+BA15, U+BA38-BA39, U+BA3C, U+BA40, U+BA42,
U+BA48-BA49, U+BA4B, U+BA4D-BA4E, U+BA53-BA55, U+BA58, U+BA5C, U+BA64-BA65, U+BA67-BA69, U+BA70-BA71, U+BA74,
U+BA78, U+BA83-BA85, U+BA87, U+BA8C, U+BAA8-BAA9, U+BAAB-BAAC, U+BAB0, U+BAB2, U+BAB8-BAB9, U+BABB,
U+BABD, U+BAC4, U+BAC8, U+BAD8-BAD9, U+BAFC, U+BB00, U+BB04, U+BB0D, U+BB0F, U+BB11,
U+BB18, U+BB1C, U+BB20, U+BB29, U+BB2B, U+BB34-BB36, U+BB38, U+BB3B-BB3E, U+BB44-BB45, U+BB47,
U+BB49, U+BB4D, U+BB4F-BB50, U+BB54, U+BB58, U+BB61, U+BB63, U+BB65, U+BB6C, U+BB88,
U+BB8C, U+BB90, U+BBA4, U+BBA8, U+BBAC, U+BBB4, U+BBB7, U+BBC0, U+BBC4, U+BBC8,
U+BBD0, U+BBD3, U+BBDC, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08-BC09, U+BC0B-BC0D, U+BC0F,
U+BC11, U+BC14-BC18, U+BC1B-BC1F, U+BC24-BC25, U+BC27, U+BC29, U+BC2D, U+BC30-BC31, U+BC34, U+BC38,
U+BC40-BC41, U+BC43-BC45, U+BC49, U+BC4C-BC4D, U+BC50, U+BC5D, U+BC84-BC85, U+BC88, U+BC8B-BC8C, U+BC8E,
U+BC94-BC95, U+BC97, U+BC99-BC9A, U+BCA0-BCA1, U+BCA4, U+BCA7-BCA9, U+BCB0-BCB1, U+BCB3-BCB5, U+BCBC-BCBD, U+BCC0,
U+BCC4, U+BCCD, U+BCCF-BCD1, U+BCD5, U+BCD8, U+BCDC, U+BCF4-BCF6, U+BCF8, U+BCFC, U+BD04-BD05,
U+BD07, U+BD09, U+BD10, U+BD14, U+BD23-BD25, U+BD2C, U+BD35, U+BD40, U+BD48-BD49, U+BD4C,
U+BD50, U+BD58-BD59, U+BD64, U+BD68, U+BD79, U+BD80-BD81, U+BD84, U+BD87-BD8A, U+BD90-BD91, U+BD93,
U+BD95, U+BD99-BD9A, U+BD9C, U+BDA4, U+BDB0, U+BDB8-BDB9, U+BDC1, U+BDD4-BDD5, U+BDD8, U+BDDC,
U+BDE9, U+BDF0, U+BDF4, U+BDF8, U+BE00, U+BE03, U+BE05, U+BE0C-BE0D, U+BE10, U+BE14,
U+BE1C-BE1D, U+BE1F, U+BE44-BE45, U+BE48, U+BE4C, U+BE4E, U+BE54-BE55, U+BE57, U+BE59-BE5B, U+BE60-BE61,
U+BE64, U+BE68, U+BE6A, U+BE70-BE71, U+BE73-BE75, U+BE7B-BE7D, U+BE80, U+BE84, U+BE8C-BE8D, U+BE8F-BE91,
U+BE98-BE99, U+BEA8, U+BEAD, U+BEB4, U+BED0-BED1, U+BED4, U+BED7-BED8, U+BEE0, U+BEE3-BEE5, U+BEEC,
U+BEF5, U+BF01, U+BF08-BF09, U+BF18-BF19, U+BF1B-BF1D, U+BF40-BF41, U+BF44, U+BF48, U+BF50-BF51, U+BF55,
U+BF5C, U+BF71, U+BF94, U+BFB0, U+BFB8, U+BFC5, U+BFCC-BFCD, U+BFD0, U+BFD4, U+BFDC,
U+BFDF, U+BFE1, U+C00D, U+C03C, U+C04C, U+C051, U+C058, U+C05C, U+C060, U+C068-C069,
U+C090-C091, U+C094, U+C098, U+C0A0-C0A1, U+C0A3, U+C0A5, U+C0AC-C0AD, U+C0AF-C0B0, U+C0B3-C0B6, U+C0BC-C0BD,
U+C0BF-C0C1, U+C0C5, U+C0C8-C0C9, U+C0CC, U+C0D0, U+C0D8-C0D9, U+C0DB-C0DD, U+C0E4-C0E5, U+C0E8, U+C0EC,
U+C0F4-C0F5, U+C0F7, U+C0F9, U+C0FE, U+C100, U+C104, U+C108, U+C110, U+C115, U+C11C-C120,
U+C123-C124, U+C126-C127, U+C12C-C12D, U+C12F-C131, U+C136, U+C138-C139, U+C13C, U+C140, U+C142, U+C148-C149,
U+C14B-C14D, U+C154-C155, U+C158, U+C15C, U+C164-C165, U+C167-C169, U+C170, U+C174, U+C178, U+C185,
U+C18C-C18E, U+C190, U+C194, U+C196, U+C19C-C19D, U+C19F, U+C1A1, U+C1A5, U+C1A8-C1A9, U+C1AC,
U+C1B0, U+C1BD, U+C1C4, U+C1C8, U+C1CC, U+C1D4, U+C1D7-C1D8, U+C1E0, U+C1E4, U+C1E8,
U+C1F0-C1F1, U+C1F3, U+C1FC-C1FD, U+C200, U+C204, U+C20C-C20D, U+C20F, U+C211, U+C216, U+C218-C219,
U+C21C, U+C21F-C220, U+C228-C229, U+C22B, U+C22D, U+C22F, U+C231-C232, U+C234, U+C245, U+C248,
U+C250-C251, U+C254, U+C258, U+C25F-C260, U+C265, U+C26C-C26D, U+C270, U+C274, U+C27C-C27D, U+C27F,
U+C281, U+C288-C289, U+C28C, U+C290, U+C298-C299, U+C29B, U+C29D, U+C2A0, U+C2A4-C2A5, U+C2A8,
U+C2AC-C2AD, U+C2B4-C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD, U+C2E0, U+C2E3-C2E4, U+C2EB-C2ED, U+C2EF, U+C2F1,
U+C2F6, U+C2F8-C2F9, U+C2FB-C2FC, U+C300, U+C308-C309, U+C30C-C30D, U+C313-C315, U+C318, U+C31C, U+C324-C325,
U+C328-C329, U+C330, U+C338, U+C345, U+C34C, U+C368-C369, U+C36C, U+C370, U+C372, U+C378-C379,
U+C37B-C37D, U+C384, U+C388, U+C38C, U+C395, U+C399, U+C3B5, U+C3BC, U+C3C0, U+C3D8-C3D9,
U+C3DC, U+C3DF-C3E0, U+C3E2, U+C3E8-C3E9, U+C3ED, U+C3F4-C3F5, U+C3F8, U+C3FC, U+C408, U+C410,
U+C424, U+C42C, U+C430, U+C434, U+C43C-C43D, U+C448, U+C450, U+C45D, U+C464-C465, U+C468,
U+C46C, U+C474-C475, U+C479, U+C480, U+C494, U+C49C-C49D, U+C4B8, U+C4BC, U+C4CD, U+C4D4,
U+C4D8, U+C4E9, U+C4F0-C4F1, U+C4F4, U+C4F8, U+C4FA, U+C4FF-C501, U+C50C, U+C510, U+C514,
U+C51C, U+C521, U+C528-C529, U+C52C, U+C530, U+C538-C539, U+C53B, U+C53D, U+C544-C545, U+C548-C54A,
U+C54C-C54E, U+C553-C555, U+C557-C559, U+C55C-C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C573-C575, U+C57B-C57D,
U+C580, U+C584, U+C587, U+C58C-C58D, U+C58F, U+C591, U+C595, U+C597-C598, U+C59C, U+C5A0,
U+C5A9, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BE, U+C5C4-C5CA, U+C5CC, U+C5CE, U+C5D0-C5D1, U+C5D4, U+C5D8,
U+C5E0-C5E1, U+C5E3, U+C5E5, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6-C5F7, U+C5FC-C601, U+C605-C608, U+C60C,
U+C610, U+C618-C619, U+C61B-C61C, U+C624-C625, U+C628, U+C62C-C62E, U+C630, U+C633-C635, U+C637, U+C639,
U+C63B, U+C640-C641, U+C644, U+C648, U+C650-C651, U+C653-C655, U+C65C-C65D, U+C660, U+C664, U+C66C,
U+C66F, U+C671, U+C674, U+C678-C679, U+C67C, U+C680, U+C688-C689, U+C68B, U+C68D, U+C694-C695,
U+C698, U+C69C, U+C6A4-C6A5, U+C6A7, U+C6A9, U+C6B0-C6B1, U+C6B4, U+C6B8-C6BA, U+C6C0-C6C1, U+C6C3,
U+C6C5, U+C6CC-C6CD, U+C6D0, U+C6D4, U+C6DC-C6DD, U+C6DF-C6E1, U+C6E8-C6E9, U+C6EC, U+C6F0-C6F1, U+C6F8-C6F9,
U+C6FD, U+C704-C705, U+C708, U+C70C, U+C714-C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728,
U+C730-C731, U+C733, U+C735, U+C737, U+C73C-C73E, U+C740, U+C743-C744, U+C74A, U+C74C-C74F, U+C751-C758,
U+C75C, U+C760, U+C768, U+C76B, U+C774-C776, U+C778, U+C77C-C77E, U+C783-C785, U+C787-C78A, U+C78C,
U+C78E, U+C790-C791, U+C794, U+C796-C798, U+C79A, U+C7A0-C7A1, U+C7A3-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4,
U+C7BC-C7BD, U+C7BF-C7C1, U+C7C8-C7C9, U+C7CC, U+C7CE, U+C7D0, U+C7D8-C7D9, U+C7DD, U+C7E4, U+C7E8,
U+C7EC, U+C800-C801, U+C804, U+C808, U+C80A, U+C810-C811, U+C813, U+C815-C816, U+C81C-C81D, U+C820,
U+C824, U+C82C-C82D, U+C82F, U+C831, U+C838, U+C83C, U+C840, U+C848-C849, U+C84C-C84D, U+C854,
U+C870-C871, U+C874, U+C878, U+C87A, U+C880-C881, U+C883, U+C885-C887, U+C88B-C88D, U+C890, U+C894,
U+C89D, U+C89F, U+C8A1, U+C8A8, U+C8BC-C8BD, U+C8C4, U+C8C8, U+C8CC, U+C8D4-C8D5, U+C8D7,
U+C8D9, U+C8E0-C8E1, U+C8E4, U+C8E8, U+C8F5, U+C8FC-C8FD, U+C900, U+C904-C906, U+C90C-C90D, U+C90F,
U+C911, U+C918, U+C92C, U+C934-C935, U+C938, U+C950-C951, U+C954, U+C958, U+C960-C961, U+C963,
U+C965, U+C96C, U+C970, U+C974, U+C97C, U+C981, U+C988-C989, U+C98C, U+C990, U+C998-C999,
U+C99B, U+C99D, U+C9A4, U+C9A8, U+C9C0-C9C1, U+C9C4, U+C9C7-C9C8, U+C9CA, U+C9D0-C9D1, U+C9D3,
U+C9D5-C9D6, U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E2, U+C9E4, U+C9E7, U+C9EC-C9ED, U+C9EF-C9F1, U+C9F8-C9F9,
U+C9FC, U+CA00, U+CA08-CA09, U+CA0B-CA0D, U+CA14, U+CA18, U+CA1C, U+CA29, U+CA30, U+CA4C-CA4D,
U+CA50, U+CA54, U+CA5C-CA5D, U+CA5F-CA61, U+CA68, U+CA7D, U+CA84, U+CA98, U+CABC-CABD, U+CAC0,
U+CAC4, U+CACC-CACD, U+CACF, U+CAD1, U+CAD3, U+CAD8-CAD9, U+CAE0, U+CAEC, U+CAF4, U+CB08,
U+CB10, U+CB14, U+CB18, U+CB20-CB21, U+CB2C, U+CB41, U+CB48-CB49, U+CB4C, U+CB50, U+CB58-CB59,
U+CB5D, U+CB64, U+CB78-CB79, U+CB9C, U+CBB8, U+CBD4, U+CBE4-CBE5, U+CBE7, U+CBE9, U+CC0C-CC0D,
U+CC10, U+CC14, U+CC1C-CC1D, U+CC21-CC22, U+CC27-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38-CC39, U+CC3B-CC3E,
U+CC44-CC45, U+CC48, U+CC4C, U+CC54-CC55, U+CC57-CC59, U+CC60, U+CC64, U+CC66, U+CC68, U+CC70,
U+CC75, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9, U+CCAB-CCAD, U+CCB4-CCB5, U+CCB8, U+CCBC, U+CCC4-CCC5,
U+CCC7, U+CCC9, U+CCD0, U+CCD4, U+CCE1, U+CCE4, U+CCEC, U+CCF0, U+CD01, U+CD08-CD09,
U+CD0C, U+CD10, U+CD18-CD19, U+CD1B, U+CD1D, U+CD24, U+CD28, U+CD2C, U+CD39, U+CD5C-CD5D,
U+CD60, U+CD64, U+CD6C-CD6D, U+CD6F, U+CD71, U+CD78, U+CD88, U+CD94-CD95, U+CD98, U+CD9C,
U+CDA4-CDA5, U+CDA7, U+CDA9, U+CDB0, U+CDC4, U+CDCC-CDCD, U+CDD0, U+CDDD, U+CDDF, U+CDE8,
U+CDEC, U+CDF0, U+CDF8-CDF9, U+CDFB, U+CDFD, U+CE04, U+CE08, U+CE0C, U+CE14-CE15, U+CE19,
U+CE20-CE21, U+CE24, U+CE28, U+CE30-CE31, U+CE33, U+CE35, U+CE58-CE59, U+CE5C, U+CE5F-CE61, U+CE68-CE69,
U+CE6B, U+CE6D, U+CE74-CE75, U+CE78, U+CE7C, U+CE84-CE85, U+CE87, U+CE89, U+CE8C, U+CE90-CE91,
U+CE94, U+CE98, U+CEA0-CEA1, U+CEA3-CEA5, U+CEAC-CEAE, U+CEB4, U+CEC1, U+CEC4, U+CEE4-CEE5, U+CEE8,
U+CEEB-CEEC, U+CEF4-CEF5, U+CEF7-CEF9, U+CF00-CF01, U+CF04, U+CF08, U+CF10-CF11, U+CF13, U+CF15, U+CF18,
U+CF1C, U+CF20, U+CF24, U+CF2C-CF2D, U+CF2F-CF31, U+CF38, U+CF54-CF55, U+CF58, U+CF5C, U+CF64-CF65,
U+CF67, U+CF69, U+CF70-CF71, U+CF74, U+CF78, U+CF80, U+CF85, U+CF8C, U+CFA1, U+CFA8,
U+CFB0, U+CFC4, U+CFDC, U+CFE0-CFE1, U+CFE4, U+CFE8, U+CFF0-CFF1, U+CFF3, U+CFF5, U+CFFC-CFFD,
U+D000, U+D004, U+D011, U+D018, U+D02D, U+D034-D035, U+D038, U+D03C, U+D044-D045, U+D047,
U+D049, U+D050, U+D054, U+D058, U+D060, U+D06C-D06E, U+D070, U+D074, U+D07C-D07D, U+D07F,
U+D081, U+D084, U+D0A4-D0A5, U+D0A8, U+D0AC, U+D0B4-D0B5, U+D0B7, U+D0B9, U+D0C0-D0C1, U+D0C4,
U+D0C8-D0C9, U+D0D0-D0D1, U+D0D3-D0D5, U+D0DC-D0DD, U+D0E0, U+D0E4, U+D0EC-D0ED, U+D0EF-D0F1, U+D0F8, U+D10D,
U+D130-D131, U+D134, U+D138, U+D13A, U+D140-D141, U+D143-D145, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D,
U+D15F, U+D161, U+D168, U+D16C, U+D17C, U+D184, U+D188, U+D1A0-D1A1, U+D1A4, U+D1A8,
U+D1B0-D1B1, U+D1B3, U+D1B5, U+D1BA, U+D1BC, U+D1C0, U+D1D8, U+D1F4, U+D1F8, U+D207,
U+D209, U+D210, U+D22C-D22D, U+D230, U+D234, U+D23C-D23D, U+D23F, U+D241, U+D248, U+D25C,
U+D264, U+D277, U+D280-D281, U+D284, U+D288, U+D290-D291, U+D295, U+D29C, U+D2A0, U+D2A4,
U+D2AC, U+D2B1, U+D2B8-D2B9, U+D2BC, U+D2BF-D2C0, U+D2C2, U+D2C8-D2C9, U+D2CB, U+D2D4, U+D2D8,
U+D2DC, U+D2E4-D2E5, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D303, U+D305, U+D30C-D30E, U+D310,
U+D314, U+D316, U+D31C-D31D, U+D31F-D321, U+D325, U+D328-D329, U+D32C, U+D330, U+D338-D339, U+D33B-D33D,
U+D344-D345, U+D37C-D37D, U+D380, U+D384, U+D38C-D38D, U+D38F-D391, U+D398-D399, U+D39C, U+D3A0, U+D3A8-D3A9,
U+D3AB, U+D3AD, U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3D8, U+D3E1,
U+D3E3, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC-D3FD, U+D3FF, U+D401, U+D408, U+D41D, U+D440,
U+D444, U+D45C, U+D460, U+D464, U+D46D, U+D46F, U+D478-D479, U+D47C, U+D47F-D480, U+D482,
U+D488-D489, U+D48B, U+D48D, U+D494, U+D4A9, U+D4B0, U+D4CC, U+D4D0, U+D4D4, U+D4DC,
U+D4DF, U+D4E8, U+D4EC, U+D4F0, U+D4F8, U+D4FB, U+D4FD, U+D504, U+D508, U+D50C,
U+D514-D515, U+D517, U+D53C-D53D, U+D540, U+D544, U+D54C-D54D, U+D54F, U+D551, U+D558-D559, U+D55C,
U+D560, U+D565, U+D568-D569, U+D56B, U+D56D, U+D571, U+D573-D575, U+D578, U+D57C, U+D584-D585,
U+D587-D589, U+D58F-D590, U+D59D, U+D5A5, U+D5C8-D5C9, U+D5CC, U+D5D0, U+D5D2, U+D5D8-D5D9, U+D5DB,
U+D5DD, U+D5E4-D5E5, U+D5E8, U+D5EC, U+D5F3-D5F5, U+D5F7, U+D5F9, U+D5FF-D601, U+D604, U+D608,
U+D610-D611, U+D613-D615, U+D61C, U+D620, U+D624, U+D62D, U+D638-D639, U+D63C, U+D640, U+D645,
U+D648-D649, U+D64B, U+D64D, U+D651, U+D654-D655, U+D658, U+D65C, U+D664, U+D667, U+D669,
U+D670-D671, U+D674, U+D683, U+D685, U+D68C-D68D, U+D690, U+D694, U+D69D, U+D69F, U+D6A1,
U+D6A8, U+D6AC, U+D6B0, U+D6B9, U+D6BB, U+D6BD, U+D6C3-D6C5, U+D6C8, U+D6CC, U+D6D1,
U+D6D4-D6D5, U+D6D7, U+D6D9, U+D6E0, U+D6E4, U+D6E8, U+D6F0, U+D6F5, U+D6FC-D6FD, U+D700,
U+D704, U+D711, U+D718-D719, U+D71C, U+D720, U+D728-D729, U+D72B, U+D72D, U+D734-D735, U+D738,
U+D73C, U+D744, U+D747, U+D749, U+D750-D751, U+D754, U+D756-D759, U+D760-D761, U+D763, U+D765,
U+D769, U+D76C, U+D770, U+D774, U+D77C-D77D, U+D781, U+D788-D789, U+D78C, U+D790, U+D798-D799,
U+D79B, U+D79D, U+FF01-FF5E, U+FFE0-FFE3, U+FFE5-FFE6;
}
/* NanumSquare.woff2 sha256: 2e9ab2497693544b0dfeebbc409ac64a1da2f946748f360f34fc71cd2475185e */
@font-face {
font-family: 'NanumSquare';
src: url('/assets/fonts/NanumSquare.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
unicode-range:
U+20-7E, U+A1, U+A4, U+A7-AB, U+B0-B4, U+B6, U+B8-BF, U+C6, U+D0, U+D7-D8,
U+DE-DF, U+E6, U+F0, U+F7-F8, U+FE, U+111, U+126-127, U+131-133, U+138, U+13F-142,
U+149-14B, U+152-153, U+166-167, U+2C7, U+2D8-2DD, U+391-3A1, U+3A3-3A9, U+3B1-3C1, U+3C3-3C9, U+401,
U+410-44F, U+451, U+2010, U+2013-2014, U+2016, U+2018-2019, U+201C-201D, U+2020-2021, U+2025-2026, U+2030,
U+2032-2033, U+203B, U+2074, U+207F, U+2081-2084, U+20A9, U+2103, U+2109, U+2113, U+2116,
U+2121-2122, U+2126, U+212B, U+2153-2154, U+215B-215E, U+2160-2169, U+2170-2179, U+2190-2199, U+21D2, U+21D4,
U+2200, U+2202-2203, U+2207-2208, U+220B, U+220F, U+2211, U+221A, U+221D-221E, U+2220, U+2227-222C,
U+222E, U+2234-2236, U+223D, U+2252, U+2260-2261, U+2264-2265, U+2282-2283, U+2286-2287, U+22A5, U+22EF,
U+2312, U+2460-246E, U+2474-2482, U+249C-24B5, U+24D0-24E9, U+2500-2503, U+250C-254B, U+2592, U+25A0-25A1, U+25A3-25A9,
U+25B2-25B3, U+25B6-25B7, U+25BC-25BD, U+25C0-25C1, U+25C6-25C9, U+25CB, U+25CE-25D1, U+2605-2606, U+260E-260F, U+261C,
U+261E, U+2640, U+2642, U+2660-2661, U+2663-2665, U+2667-266A, U+266C-266D, U+3000-3003, U+3008-3011, U+3013-3015,
U+3041-3093, U+30A1-30F6, U+30FB, U+3131-318E, U+3200-321C, U+3260-327B, U+327F, U+3380-3384, U+3388-33CA, U+33CF-33D0,
U+33D3, U+33D6, U+33D8, U+33DB-33DD, U+AC00-AC01, U+AC04, U+AC07-AC0A, U+AC10-AC17, U+AC19-AC1D, U+AC20,
U+AC24, U+AC2C-AC2D, U+AC2F-AC31, U+AC38-AC39, U+AC3C, U+AC40, U+AC4B, U+AC4D, U+AC54, U+AC58,
U+AC5C, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A, U+AC80-AC81, U+AC83-AC86, U+AC89-AC8C, U+AC90, U+AC94,
U+AC9C-AC9D, U+AC9F-ACA1, U+ACA8-ACAA, U+ACAC, U+ACAF-ACB0, U+ACB8-ACB9, U+ACBB-ACBD, U+ACC1, U+ACC4, U+ACC8,
U+ACCC, U+ACD5, U+ACD7, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACEA, U+ACEC, U+ACEF-ACF1, U+ACF3,
U+ACF5-ACF6, U+ACFC-ACFD, U+AD00, U+AD04, U+AD06, U+AD0C-AD0D, U+AD0F, U+AD11, U+AD18, U+AD1C,
U+AD1E, U+AD20, U+AD29, U+AD2C-AD2D, U+AD34-AD35, U+AD38, U+AD3C, U+AD44-AD45, U+AD47, U+AD49,
U+AD50, U+AD54, U+AD58, U+AD61, U+AD63, U+AD65, U+AD6C-AD6D, U+AD70, U+AD73-AD76, U+AD7B-AD7D,
U+AD7F, U+AD81-AD82, U+AD88-AD89, U+AD8C, U+AD90, U+AD9C-AD9D, U+ADA4, U+ADB4, U+ADB7, U+ADC0-ADC1,
U+ADC4, U+ADC8, U+ADD0-ADD1, U+ADD3, U+ADDC, U+ADE0, U+ADE4, U+ADF8-ADF9, U+ADFC, U+ADFF-AE01,
U+AE08-AE09, U+AE0B, U+AE0D, U+AE14, U+AE1C, U+AE30-AE31, U+AE34, U+AE37-AE38, U+AE3A, U+AE40-AE41,
U+AE43, U+AE45-AE46, U+AE4A, U+AE4C-AE4E, U+AE50, U+AE54, U+AE56, U+AE5C-AE5D, U+AE5F-AE61, U+AE65,
U+AE68-AE69, U+AE6C, U+AE70, U+AE78-AE79, U+AE7B-AE7D, U+AE84-AE86, U+AE8C-AE8D, U+AEA0, U+AEBC-AEBE, U+AEC0,
U+AEC4, U+AECC-AECD, U+AECF-AED1, U+AED8-AED9, U+AEDC, U+AEE8, U+AEEB, U+AEED, U+AEF4, U+AEF8,
U+AEFC, U+AF07-AF08, U+AF0D, U+AF10, U+AF2C-AF2D, U+AF30, U+AF32, U+AF34, U+AF3C-AF3D, U+AF3F,
U+AF41-AF43, U+AF48-AF49, U+AF50, U+AF5C-AF5D, U+AF64-AF65, U+AF79, U+AF80, U+AF84, U+AF88, U+AF90-AF91,
U+AF95, U+AF9C, U+AFA4, U+AFB8-AFBA, U+AFBC, U+AFC0, U+AFC7-AFC9, U+AFCB, U+AFCD-AFCE, U+AFD4,
U+AFDC, U+AFE8-AFE9, U+AFF0-AFF1, U+AFF4, U+AFF8-AFF9, U+AFFF-B001, U+B004, U+B00C, U+B010, U+B014,
U+B01C-B01D, U+B028, U+B044-B045, U+B048, U+B04A, U+B04C, U+B04E, U+B053-B055, U+B057, U+B059,
U+B05D, U+B068, U+B07C-B07D, U+B080, U+B084, U+B08C-B08D, U+B08F, U+B091, U+B098-B09A, U+B09C,
U+B09F-B0A2, U+B0A8-B0A9, U+B0AB-B0AF, U+B0B1, U+B0B3-B0B5, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C7-B0C9, U+B0D0-B0D1,
U+B0D4, U+B0D8, U+B0E0, U+B0E5, U+B108-B109, U+B10B-B10C, U+B110, U+B112-B113, U+B118-B119, U+B11B-B11D,
U+B123-B125, U+B128, U+B12C, U+B134-B135, U+B137-B139, U+B140-B141, U+B144, U+B148, U+B150-B151, U+B154-B155,
U+B158, U+B15C, U+B160, U+B178-B179, U+B17C, U+B180, U+B182, U+B188-B189, U+B18B, U+B18D,
U+B192-B194, U+B198, U+B19C, U+B1A8, U+B1CC, U+B1D0, U+B1D4, U+B1DC-B1DD, U+B1DF, U+B1E8-B1E9,
U+B1EC, U+B1F0, U+B1F9, U+B1FB, U+B1FD, U+B204-B205, U+B208, U+B20B-B20C, U+B214-B215, U+B217,
U+B219, U+B220, U+B234, U+B23C, U+B24C-B24D, U+B251, U+B258, U+B25C, U+B260, U+B268-B269,
U+B274-B275, U+B27C, U+B284-B285, U+B289, U+B290-B291, U+B294, U+B298-B29A, U+B2A0-B2A1, U+B2A3, U+B2A5-B2A6,
U+B2AA, U+B2AC, U+B2B0, U+B2B4, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D2, U+B2D8-B2D9, U+B2DB,
U+B2DD, U+B2E2, U+B2E4-B2E6, U+B2E8, U+B2EB-B2EF, U+B2F3-B2F5, U+B2F7-B2FB, U+B2FF-B301, U+B304, U+B308,
U+B310-B311, U+B313-B315, U+B31C, U+B324, U+B32C, U+B331, U+B354-B356, U+B358, U+B35B-B35C, U+B35E-B35F,
U+B364-B365, U+B367, U+B369, U+B36B, U+B36E, U+B370-B371, U+B374, U+B378, U+B380-B381, U+B383-B385,
U+B38C, U+B390, U+B394, U+B3A0-B3A1, U+B3A8, U+B3AC, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE,
U+B3D0, U+B3D4-B3D5, U+B3D7, U+B3D9, U+B3DB, U+B3DD, U+B3E0, U+B3E4, U+B3E8, U+B3FC,
U+B400, U+B40F-B411, U+B418, U+B41C, U+B420, U+B428-B429, U+B42B-B42D, U+B434, U+B450-B451, U+B454,
U+B458, U+B460-B461, U+B463, U+B465, U+B46C, U+B480, U+B488, U+B49D, U+B4A4, U+B4A8,
U+B4AC, U+B4B5, U+B4B7, U+B4B9, U+B4C0, U+B4C4, U+B4C8, U+B4D0, U+B4D5, U+B4DC-B4DD,
U+B4E0, U+B4E3-B4E4, U+B4E6, U+B4EC-B4ED, U+B4EF, U+B4F1, U+B4F8, U+B514-B515, U+B518, U+B51B-B51C,
U+B524-B525, U+B527-B52A, U+B530-B531, U+B534, U+B538, U+B540-B541, U+B543-B545, U+B54B-B54D, U+B550, U+B554,
U+B55C-B55D, U+B55F-B561, U+B568, U+B584, U+B599, U+B5A0-B5A1, U+B5A4, U+B5A8, U+B5AA-B5AB, U+B5B0-B5B1,
U+B5B3-B5B5, U+B5BB-B5BD, U+B5C0, U+B5C4, U+B5CC-B5CD, U+B5CF-B5D1, U+B5D8, U+B5EC, U+B610-B611, U+B614,
U+B618, U+B620, U+B625, U+B62C, U+B634, U+B648, U+B65D, U+B664, U+B668, U+B69C-B69D,
U+B6A0, U+B6A4, U+B6AB-B6AC, U+B6B1, U+B6D4, U+B6F0, U+B6F4, U+B6F8, U+B700-B701, U+B705,
U+B71B, U+B728-B729, U+B72C, U+B72F-B730, U+B738-B739, U+B73B, U+B73D, U+B744, U+B748, U+B74C,
U+B754-B755, U+B760-B761, U+B764, U+B768, U+B770-B771, U+B773, U+B775, U+B77C-B77D, U+B780, U+B784,
U+B78C-B78D, U+B78F-B792, U+B796-B799, U+B79C, U+B7A0, U+B7A8-B7A9, U+B7AB-B7AD, U+B7B4-B7B5, U+B7B8, U+B7BC,
U+B7C7, U+B7C9, U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B810,
U+B818-B819, U+B81B, U+B81D, U+B824-B825, U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B844,
U+B851, U+B853, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D, U+B86F, U+B871, U+B878-B879, U+B87C,
U+B880, U+B88D, U+B894, U+B8A8, U+B8B0, U+B8B4, U+B8B8, U+B8C0-B8C1, U+B8C3, U+B8C5,
U+B8CC, U+B8D0, U+B8D4, U+B8DD, U+B8DF, U+B8E1, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9,
U+B8FB, U+B8FD, U+B904, U+B918, U+B920, U+B93C-B93D, U+B940, U+B944, U+B94C, U+B94F,
U+B951, U+B958-B959, U+B95C, U+B960, U+B968-B969, U+B96B, U+B96D, U+B974-B975, U+B978, U+B97C,
U+B984-B985, U+B987, U+B989-B98A, U+B98D-B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD, U+B9BF, U+B9C1,
U+B9C8-B9C9, U+B9CC, U+B9CE-B9D2, U+B9D8-B9D9, U+B9DB, U+B9DD-B9DE, U+B9E1, U+B9E3-B9E5, U+B9E8, U+B9EC,
U+B9F4-B9F5, U+B9F7-B9FA, U+BA00-BA01, U+BA04, U+BA08, U+BA15, U+BA38-BA39, U+BA3C, U+BA40, U+BA42,
U+BA48-BA49, U+BA4B, U+BA4D-BA4E, U+BA53-BA55, U+BA58, U+BA5C, U+BA64-BA65, U+BA67-BA69, U+BA70-BA71, U+BA74,
U+BA78, U+BA83-BA85, U+BA87, U+BA8C, U+BAA8-BAA9, U+BAAB-BAAC, U+BAB0, U+BAB2, U+BAB8-BAB9, U+BABB,
U+BABD, U+BAC4, U+BAC8, U+BAD8-BAD9, U+BAFC, U+BB00, U+BB04, U+BB0D, U+BB0F, U+BB11,
U+BB18, U+BB1C, U+BB20, U+BB29, U+BB2B, U+BB34-BB36, U+BB38, U+BB3B-BB3E, U+BB44-BB45, U+BB47,
U+BB49, U+BB4D, U+BB4F-BB50, U+BB54, U+BB58, U+BB61, U+BB63, U+BB65, U+BB6C, U+BB88,
U+BB8C, U+BB90, U+BBA4, U+BBA8, U+BBAC, U+BBB4, U+BBB7, U+BBC0, U+BBC4, U+BBC8,
U+BBD0, U+BBD3, U+BBDC, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08-BC09, U+BC0B-BC0D, U+BC0F,
U+BC11, U+BC14-BC18, U+BC1B-BC1F, U+BC24-BC25, U+BC27, U+BC29, U+BC2D, U+BC30-BC31, U+BC34, U+BC38,
U+BC40-BC41, U+BC43-BC45, U+BC49, U+BC4C-BC4D, U+BC50, U+BC5D, U+BC84-BC85, U+BC88, U+BC8B-BC8C, U+BC8E,
U+BC94-BC95, U+BC97, U+BC99-BC9A, U+BCA0-BCA1, U+BCA4, U+BCA7-BCA9, U+BCB0-BCB1, U+BCB3-BCB5, U+BCBC-BCBD, U+BCC0,
U+BCC4, U+BCCD, U+BCCF-BCD1, U+BCD5, U+BCD8, U+BCDC, U+BCF4-BCF6, U+BCF8, U+BCFC, U+BD04-BD05,
U+BD07, U+BD09, U+BD10, U+BD14, U+BD23-BD25, U+BD2C, U+BD35, U+BD40, U+BD48-BD49, U+BD4C,
U+BD50, U+BD58-BD59, U+BD64, U+BD68, U+BD79, U+BD80-BD81, U+BD84, U+BD87-BD8A, U+BD90-BD91, U+BD93,
U+BD95, U+BD99-BD9A, U+BD9C, U+BDA4, U+BDB0, U+BDB8-BDB9, U+BDC1, U+BDD4-BDD5, U+BDD8, U+BDDC,
U+BDE9, U+BDF0, U+BDF4, U+BDF8, U+BE00, U+BE03, U+BE05, U+BE0C-BE0D, U+BE10, U+BE14,
U+BE1C-BE1D, U+BE1F, U+BE44-BE45, U+BE48, U+BE4C, U+BE4E, U+BE54-BE55, U+BE57, U+BE59-BE5B, U+BE60-BE61,
U+BE64, U+BE68, U+BE6A, U+BE70-BE71, U+BE73-BE75, U+BE7B-BE7D, U+BE80, U+BE84, U+BE8C-BE8D, U+BE8F-BE91,
U+BE98-BE99, U+BEA8, U+BEAD, U+BEB4, U+BED0-BED1, U+BED4, U+BED7-BED8, U+BEE0, U+BEE3-BEE5, U+BEEC,
U+BEF5, U+BF01, U+BF08-BF09, U+BF18-BF19, U+BF1B-BF1D, U+BF40-BF41, U+BF44, U+BF48, U+BF50-BF51, U+BF55,
U+BF5C, U+BF71, U+BF94, U+BFB0, U+BFB8, U+BFC5, U+BFCC-BFCD, U+BFD0, U+BFD4, U+BFDC,
U+BFDF, U+BFE1, U+C00D, U+C03C, U+C04C, U+C051, U+C058, U+C05C, U+C060, U+C068-C069,
U+C090-C091, U+C094, U+C098, U+C0A0-C0A1, U+C0A3, U+C0A5, U+C0AC-C0AD, U+C0AF-C0B0, U+C0B3-C0B6, U+C0BC-C0BD,
U+C0BF-C0C1, U+C0C5, U+C0C8-C0C9, U+C0CC, U+C0D0, U+C0D8-C0D9, U+C0DB-C0DD, U+C0E4-C0E5, U+C0E8, U+C0EC,
U+C0F4-C0F5, U+C0F7, U+C0F9, U+C0FE, U+C100, U+C104, U+C108, U+C110, U+C115, U+C11C-C120,
U+C123-C124, U+C126-C127, U+C12C-C12D, U+C12F-C131, U+C136, U+C138-C139, U+C13C, U+C140, U+C142, U+C148-C149,
U+C14B-C14D, U+C154-C155, U+C158, U+C15C, U+C164-C165, U+C167-C169, U+C170, U+C174, U+C178, U+C185,
U+C18C-C18E, U+C190, U+C194, U+C196, U+C19C-C19D, U+C19F, U+C1A1, U+C1A5, U+C1A8-C1A9, U+C1AC,
U+C1B0, U+C1BD, U+C1C4, U+C1C8, U+C1CC, U+C1D4, U+C1D7-C1D8, U+C1E0, U+C1E4, U+C1E8,
U+C1F0-C1F1, U+C1F3, U+C1FC-C1FD, U+C200, U+C204, U+C20C-C20D, U+C20F, U+C211, U+C216, U+C218-C219,
U+C21C, U+C21F-C220, U+C228-C229, U+C22B, U+C22D, U+C22F, U+C231-C232, U+C234, U+C245, U+C248,
U+C250-C251, U+C254, U+C258, U+C25F-C260, U+C265, U+C26C-C26D, U+C270, U+C274, U+C27C-C27D, U+C27F,
U+C281, U+C288-C289, U+C28C, U+C290, U+C298-C299, U+C29B, U+C29D, U+C2A0, U+C2A4-C2A5, U+C2A8,
U+C2AC-C2AD, U+C2B4-C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD, U+C2E0, U+C2E3-C2E4, U+C2EB-C2ED, U+C2EF, U+C2F1,
U+C2F6, U+C2F8-C2F9, U+C2FB-C2FC, U+C300, U+C308-C309, U+C30C-C30D, U+C313-C315, U+C318, U+C31C, U+C324-C325,
U+C328-C329, U+C330, U+C338, U+C345, U+C34C, U+C368-C369, U+C36C, U+C370, U+C372, U+C378-C379,
U+C37B-C37D, U+C384, U+C388, U+C38C, U+C395, U+C399, U+C3B5, U+C3BC, U+C3C0, U+C3D8-C3D9,
U+C3DC, U+C3DF-C3E0, U+C3E2, U+C3E8-C3E9, U+C3ED, U+C3F4-C3F5, U+C3F8, U+C3FC, U+C408, U+C410,
U+C424, U+C42C, U+C430, U+C434, U+C43C-C43D, U+C448, U+C450, U+C45D, U+C464-C465, U+C468,
U+C46C, U+C474-C475, U+C479, U+C480, U+C494, U+C49C-C49D, U+C4B8, U+C4BC, U+C4CD, U+C4D4,
U+C4D8, U+C4E9, U+C4F0-C4F1, U+C4F4, U+C4F8, U+C4FA, U+C4FF-C501, U+C50C, U+C510, U+C514,
U+C51C, U+C521, U+C528-C529, U+C52C, U+C530, U+C538-C539, U+C53B, U+C53D, U+C544-C545, U+C548-C54A,
U+C54C-C54E, U+C553-C555, U+C557-C559, U+C55C-C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C573-C575, U+C57B-C57D,
U+C580, U+C584, U+C587, U+C58C-C58D, U+C58F, U+C591, U+C595, U+C597-C598, U+C59C, U+C5A0,
U+C5A9, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BE, U+C5C4-C5CA, U+C5CC, U+C5CE, U+C5D0-C5D1, U+C5D4, U+C5D8,
U+C5E0-C5E1, U+C5E3, U+C5E5, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6-C5F7, U+C5FC-C601, U+C605-C608, U+C60C,
U+C610, U+C618-C619, U+C61B-C61C, U+C624-C625, U+C628, U+C62C-C62E, U+C630, U+C633-C635, U+C637, U+C639,
U+C63B, U+C640-C641, U+C644, U+C648, U+C650-C651, U+C653-C655, U+C65C-C65D, U+C660, U+C664, U+C66C,
U+C66F, U+C671, U+C674, U+C678-C679, U+C67C, U+C680, U+C688-C689, U+C68B, U+C68D, U+C694-C695,
U+C698, U+C69C, U+C6A4-C6A5, U+C6A7, U+C6A9, U+C6B0-C6B1, U+C6B4, U+C6B8-C6BA, U+C6C0-C6C1, U+C6C3,
U+C6C5, U+C6CC-C6CD, U+C6D0, U+C6D4, U+C6DC-C6DD, U+C6DF-C6E1, U+C6E8-C6E9, U+C6EC, U+C6F0-C6F1, U+C6F8-C6F9,
U+C6FD, U+C704-C705, U+C708, U+C70C, U+C714-C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728,
U+C730-C731, U+C733, U+C735, U+C737, U+C73C-C73E, U+C740, U+C743-C744, U+C74A, U+C74C-C74F, U+C751-C758,
U+C75C, U+C760, U+C768, U+C76B, U+C774-C776, U+C778, U+C77C-C77E, U+C783-C785, U+C787-C78A, U+C78C,
U+C78E, U+C790-C791, U+C794, U+C796-C798, U+C79A, U+C7A0-C7A1, U+C7A3-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4,
U+C7BC-C7BD, U+C7BF-C7C1, U+C7C8-C7C9, U+C7CC, U+C7CE, U+C7D0, U+C7D8-C7D9, U+C7DD, U+C7E4, U+C7E8,
U+C7EC, U+C800-C801, U+C804, U+C808, U+C80A, U+C810-C811, U+C813, U+C815-C816, U+C81C-C81D, U+C820,
U+C824, U+C82C-C82D, U+C82F, U+C831, U+C838, U+C83C, U+C840, U+C848-C849, U+C84C-C84D, U+C854,
U+C870-C871, U+C874, U+C878, U+C87A, U+C880-C881, U+C883, U+C885-C887, U+C88B-C88D, U+C890, U+C894,
U+C89D, U+C89F, U+C8A1, U+C8A8, U+C8BC-C8BD, U+C8C4, U+C8C8, U+C8CC, U+C8D4-C8D5, U+C8D7,
U+C8D9, U+C8E0-C8E1, U+C8E4, U+C8E8, U+C8F5, U+C8FC-C8FD, U+C900, U+C904-C906, U+C90C-C90D, U+C90F,
U+C911, U+C918, U+C92C, U+C934-C935, U+C938, U+C950-C951, U+C954, U+C958, U+C960-C961, U+C963,
U+C965, U+C96C, U+C970, U+C974, U+C97C, U+C981, U+C988-C989, U+C98C, U+C990, U+C998-C999,
U+C99B, U+C99D, U+C9A4, U+C9A8, U+C9C0-C9C1, U+C9C4, U+C9C7-C9C8, U+C9CA, U+C9D0-C9D1, U+C9D3,
U+C9D5-C9D6, U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E2, U+C9E4, U+C9E7, U+C9EC-C9ED, U+C9EF-C9F1, U+C9F8-C9F9,
U+C9FC, U+CA00, U+CA08-CA09, U+CA0B-CA0D, U+CA14, U+CA18, U+CA1C, U+CA29, U+CA30, U+CA4C-CA4D,
U+CA50, U+CA54, U+CA5C-CA5D, U+CA5F-CA61, U+CA68, U+CA7D, U+CA84, U+CA98, U+CABC-CABD, U+CAC0,
U+CAC4, U+CACC-CACD, U+CACF, U+CAD1, U+CAD3, U+CAD8-CAD9, U+CAE0, U+CAEC, U+CAF4, U+CB08,
U+CB10, U+CB14, U+CB18, U+CB20-CB21, U+CB2C, U+CB41, U+CB48-CB49, U+CB4C, U+CB50, U+CB58-CB59,
U+CB5D, U+CB64, U+CB78-CB79, U+CB9C, U+CBB8, U+CBD4, U+CBE4-CBE5, U+CBE7, U+CBE9, U+CC0C-CC0D,
U+CC10, U+CC14, U+CC1C-CC1D, U+CC21-CC22, U+CC27-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38-CC39, U+CC3B-CC3E,
U+CC44-CC45, U+CC48, U+CC4C, U+CC54-CC55, U+CC57-CC59, U+CC60, U+CC64, U+CC66, U+CC68, U+CC70,
U+CC75, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9, U+CCAB-CCAD, U+CCB4-CCB5, U+CCB8, U+CCBC, U+CCC4-CCC5,
U+CCC7, U+CCC9, U+CCD0, U+CCD4, U+CCE1, U+CCE4, U+CCEC, U+CCF0, U+CD01, U+CD08-CD09,
U+CD0C, U+CD10, U+CD18-CD19, U+CD1B, U+CD1D, U+CD24, U+CD28, U+CD2C, U+CD39, U+CD5C-CD5D,
U+CD60, U+CD64, U+CD6C-CD6D, U+CD6F, U+CD71, U+CD78, U+CD88, U+CD94-CD95, U+CD98, U+CD9C,
U+CDA4-CDA5, U+CDA7, U+CDA9, U+CDB0, U+CDC4, U+CDCC-CDCD, U+CDD0, U+CDDD, U+CDDF, U+CDE8,
U+CDEC, U+CDF0, U+CDF8-CDF9, U+CDFB, U+CDFD, U+CE04, U+CE08, U+CE0C, U+CE14-CE15, U+CE19,
U+CE20-CE21, U+CE24, U+CE28, U+CE30-CE31, U+CE33, U+CE35, U+CE58-CE59, U+CE5C, U+CE5F-CE61, U+CE68-CE69,
U+CE6B, U+CE6D, U+CE74-CE75, U+CE78, U+CE7C, U+CE84-CE85, U+CE87, U+CE89, U+CE8C, U+CE90-CE91,
U+CE94, U+CE98, U+CEA0-CEA1, U+CEA3-CEA5, U+CEAC-CEAE, U+CEB4, U+CEC1, U+CEC4, U+CEE4-CEE5, U+CEE8,
U+CEEB-CEEC, U+CEF4-CEF5, U+CEF7-CEF9, U+CF00-CF01, U+CF04, U+CF08, U+CF10-CF11, U+CF13, U+CF15, U+CF18,
U+CF1C, U+CF20, U+CF24, U+CF2C-CF2D, U+CF2F-CF31, U+CF38, U+CF54-CF55, U+CF58, U+CF5C, U+CF64-CF65,
U+CF67, U+CF69, U+CF70-CF71, U+CF74, U+CF78, U+CF80, U+CF85, U+CF8C, U+CFA1, U+CFA8,
U+CFB0, U+CFC4, U+CFDC, U+CFE0-CFE1, U+CFE4, U+CFE8, U+CFF0-CFF1, U+CFF3, U+CFF5, U+CFFC-CFFD,
U+D000, U+D004, U+D011, U+D018, U+D02D, U+D034-D035, U+D038, U+D03C, U+D044-D045, U+D047,
U+D049, U+D050, U+D054, U+D058, U+D060, U+D06C-D06E, U+D070, U+D074, U+D07C-D07D, U+D07F,
U+D081, U+D084, U+D0A4-D0A5, U+D0A8, U+D0AC, U+D0B4-D0B5, U+D0B7, U+D0B9, U+D0C0-D0C1, U+D0C4,
U+D0C8-D0C9, U+D0D0-D0D1, U+D0D3-D0D5, U+D0DC-D0DD, U+D0E0, U+D0E4, U+D0EC-D0ED, U+D0EF-D0F1, U+D0F8, U+D10D,
U+D130-D131, U+D134, U+D138, U+D13A, U+D140-D141, U+D143-D145, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D,
U+D15F, U+D161, U+D168, U+D16C, U+D17C, U+D184, U+D188, U+D1A0-D1A1, U+D1A4, U+D1A8,
U+D1B0-D1B1, U+D1B3, U+D1B5, U+D1BA, U+D1BC, U+D1C0, U+D1D8, U+D1F4, U+D1F8, U+D207,
U+D209, U+D210, U+D22C-D22D, U+D230, U+D234, U+D23C-D23D, U+D23F, U+D241, U+D248, U+D25C,
U+D264, U+D277, U+D280-D281, U+D284, U+D288, U+D290-D291, U+D295, U+D29C, U+D2A0, U+D2A4,
U+D2AC, U+D2B1, U+D2B8-D2B9, U+D2BC, U+D2BF-D2C0, U+D2C2, U+D2C8-D2C9, U+D2CB, U+D2D4, U+D2D8,
U+D2DC, U+D2E4-D2E5, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D303, U+D305, U+D30C-D30E, U+D310,
U+D314, U+D316, U+D31C-D31D, U+D31F-D321, U+D325, U+D328-D329, U+D32C, U+D330, U+D338-D339, U+D33B-D33D,
U+D344-D345, U+D37C-D37D, U+D380, U+D384, U+D38C-D38D, U+D38F-D391, U+D398-D399, U+D39C, U+D3A0, U+D3A8-D3A9,
U+D3AB, U+D3AD, U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3D8, U+D3E1,
U+D3E3, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC-D3FD, U+D3FF, U+D401, U+D408, U+D41D, U+D440,
U+D444, U+D45C, U+D460, U+D464, U+D46D, U+D46F, U+D478-D479, U+D47C, U+D47F-D480, U+D482,
U+D488-D489, U+D48B, U+D48D, U+D494, U+D4A9, U+D4B0, U+D4CC, U+D4D0, U+D4D4, U+D4DC,
U+D4DF, U+D4E8, U+D4EC, U+D4F0, U+D4F8, U+D4FB, U+D4FD, U+D504, U+D508, U+D50C,
U+D514-D515, U+D517, U+D53C-D53D, U+D540, U+D544, U+D54C-D54D, U+D54F, U+D551, U+D558-D559, U+D55C,
U+D560, U+D565, U+D568-D569, U+D56B, U+D56D, U+D571, U+D573-D575, U+D578, U+D57C, U+D584-D585,
U+D587-D589, U+D58F-D590, U+D59D, U+D5A5, U+D5C8-D5C9, U+D5CC, U+D5D0, U+D5D2, U+D5D8-D5D9, U+D5DB,
U+D5DD, U+D5E4-D5E5, U+D5E8, U+D5EC, U+D5F3-D5F5, U+D5F7, U+D5F9, U+D5FF-D601, U+D604, U+D608,
U+D610-D611, U+D613-D615, U+D61C, U+D620, U+D624, U+D62D, U+D638-D639, U+D63C, U+D640, U+D645,
U+D648-D649, U+D64B, U+D64D, U+D651, U+D654-D655, U+D658, U+D65C, U+D664, U+D667, U+D669,
U+D670-D671, U+D674, U+D683, U+D685, U+D68C-D68D, U+D690, U+D694, U+D69D, U+D69F, U+D6A1,
U+D6A8, U+D6AC, U+D6B0, U+D6B9, U+D6BB, U+D6BD, U+D6C3-D6C5, U+D6C8, U+D6CC, U+D6D1,
U+D6D4-D6D5, U+D6D7, U+D6D9, U+D6E0, U+D6E4, U+D6E8, U+D6F0, U+D6F5, U+D6FC-D6FD, U+D700,
U+D704, U+D711, U+D718-D719, U+D71C, U+D720, U+D728-D729, U+D72B, U+D72D, U+D734-D735, U+D738,
U+D73C, U+D744, U+D747, U+D749, U+D750-D751, U+D754, U+D756-D759, U+D760-D761, U+D763, U+D765,
U+D769, U+D76C, U+D770, U+D774, U+D77C-D77D, U+D781, U+D788-D789, U+D78C, U+D790, U+D798-D799,
U+D79B, U+D79D, U+FF01-FF5E, U+FFE0-FFE3, U+FFE5-FFE6;
}
/* NanumSquareB.woff2 sha256: c6baca42985691111d4faa6b056277e11f65dd6ea4ed5693b1ea2cd0b7b43f25 */
@font-face {
font-family: 'NanumSquare';
src: url('/assets/fonts/NanumSquareB.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
unicode-range:
U+20-7E, U+A1, U+A4, U+A7-AB, U+B0-B4, U+B6, U+B8-BF, U+C6, U+D0, U+D7-D8,
U+DE-DF, U+E6, U+F0, U+F7-F8, U+FE, U+111, U+126-127, U+131-133, U+138, U+13F-142,
U+149-14B, U+152-153, U+166-167, U+2C7, U+2D8-2DD, U+391-3A1, U+3A3-3A9, U+3B1-3C1, U+3C3-3C9, U+401,
U+410-44F, U+451, U+2010, U+2013-2014, U+2016, U+2018-2019, U+201C-201D, U+2020-2021, U+2025-2026, U+2030,
U+2032-2033, U+203B, U+2074, U+207F, U+2081-2084, U+20A9, U+2103, U+2109, U+2113, U+2116,
U+2121-2122, U+2126, U+212B, U+2153-2154, U+215B-215E, U+2160-2169, U+2170-2179, U+2190-2199, U+21D2, U+21D4,
U+2200, U+2202-2203, U+2207-2208, U+220B, U+220F, U+2211, U+221A, U+221D-221E, U+2220, U+2227-222C,
U+222E, U+2234-2236, U+223D, U+2252, U+2260-2261, U+2264-2265, U+2282-2283, U+2286-2287, U+22A5, U+22EF,
U+2312, U+2460-246E, U+2474-2482, U+249C-24B5, U+24D0-24E9, U+2500-2503, U+250C-254B, U+2592, U+25A0-25A1, U+25A3-25A9,
U+25B2-25B3, U+25B6-25B7, U+25BC-25BD, U+25C0-25C1, U+25C6-25C9, U+25CB, U+25CE-25D1, U+2605-2606, U+260E-260F, U+261C,
U+261E, U+2640, U+2642, U+2660-2661, U+2663-2665, U+2667-266A, U+266C-266D, U+3000-3003, U+3008-3011, U+3013-3015,
U+3041-3093, U+30A1-30F6, U+30FB, U+3131-318E, U+3200-321C, U+3260-327B, U+327F, U+3380-3384, U+3388-33CA, U+33CF-33D0,
U+33D3, U+33D6, U+33D8, U+33DB-33DD, U+AC00-AC01, U+AC04, U+AC07-AC0A, U+AC10-AC17, U+AC19-AC1D, U+AC20,
U+AC24, U+AC2C-AC2D, U+AC2F-AC31, U+AC38-AC39, U+AC3C, U+AC40, U+AC4B, U+AC4D, U+AC54, U+AC58,
U+AC5C, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A, U+AC80-AC81, U+AC83-AC86, U+AC89-AC8C, U+AC90, U+AC94,
U+AC9C-AC9D, U+AC9F-ACA1, U+ACA8-ACAA, U+ACAC, U+ACAF-ACB0, U+ACB8-ACB9, U+ACBB-ACBD, U+ACC1, U+ACC4, U+ACC8,
U+ACCC, U+ACD5, U+ACD7, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACEA, U+ACEC, U+ACEF-ACF1, U+ACF3,
U+ACF5-ACF6, U+ACFC-ACFD, U+AD00, U+AD04, U+AD06, U+AD0C-AD0D, U+AD0F, U+AD11, U+AD18, U+AD1C,
U+AD1E, U+AD20, U+AD29, U+AD2C-AD2D, U+AD34-AD35, U+AD38, U+AD3C, U+AD44-AD45, U+AD47, U+AD49,
U+AD50, U+AD54, U+AD58, U+AD61, U+AD63, U+AD65, U+AD6C-AD6D, U+AD70, U+AD73-AD76, U+AD7B-AD7D,
U+AD7F, U+AD81-AD82, U+AD88-AD89, U+AD8C, U+AD90, U+AD9C-AD9D, U+ADA4, U+ADB4, U+ADB7, U+ADC0-ADC1,
U+ADC4, U+ADC8, U+ADD0-ADD1, U+ADD3, U+ADDC, U+ADE0, U+ADE4, U+ADF8-ADF9, U+ADFC, U+ADFF-AE01,
U+AE08-AE09, U+AE0B, U+AE0D, U+AE14, U+AE1C, U+AE30-AE31, U+AE34, U+AE37-AE38, U+AE3A, U+AE40-AE41,
U+AE43, U+AE45-AE46, U+AE4A, U+AE4C-AE4E, U+AE50, U+AE54, U+AE56, U+AE5C-AE5D, U+AE5F-AE61, U+AE65,
U+AE68-AE69, U+AE6C, U+AE70, U+AE78-AE79, U+AE7B-AE7D, U+AE84-AE86, U+AE8C-AE8D, U+AEA0, U+AEBC-AEBE, U+AEC0,
U+AEC4, U+AECC-AECD, U+AECF-AED1, U+AED8-AED9, U+AEDC, U+AEE8, U+AEEB, U+AEED, U+AEF4, U+AEF8,
U+AEFC, U+AF07-AF08, U+AF0D, U+AF10, U+AF2C-AF2D, U+AF30, U+AF32, U+AF34, U+AF3C-AF3D, U+AF3F,
U+AF41-AF43, U+AF48-AF49, U+AF50, U+AF5C-AF5D, U+AF64-AF65, U+AF79, U+AF80, U+AF84, U+AF88, U+AF90-AF91,
U+AF95, U+AF9C, U+AFA4, U+AFB8-AFBA, U+AFBC, U+AFC0, U+AFC7-AFC9, U+AFCB, U+AFCD-AFCE, U+AFD4,
U+AFDC, U+AFE8-AFE9, U+AFF0-AFF1, U+AFF4, U+AFF8-AFF9, U+AFFF-B001, U+B004, U+B00C, U+B010, U+B014,
U+B01C-B01D, U+B028, U+B044-B045, U+B048, U+B04A, U+B04C, U+B04E, U+B053-B055, U+B057, U+B059,
U+B05D, U+B068, U+B07C-B07D, U+B080, U+B084, U+B08C-B08D, U+B08F, U+B091, U+B098-B09A, U+B09C,
U+B09F-B0A2, U+B0A8-B0A9, U+B0AB-B0AF, U+B0B1, U+B0B3-B0B5, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C7-B0C9, U+B0D0-B0D1,
U+B0D4, U+B0D8, U+B0E0, U+B0E5, U+B108-B109, U+B10B-B10C, U+B110, U+B112-B113, U+B118-B119, U+B11B-B11D,
U+B123-B125, U+B128, U+B12C, U+B134-B135, U+B137-B139, U+B140-B141, U+B144, U+B148, U+B150-B151, U+B154-B155,
U+B158, U+B15C, U+B160, U+B178-B179, U+B17C, U+B180, U+B182, U+B188-B189, U+B18B, U+B18D,
U+B192-B194, U+B198, U+B19C, U+B1A8, U+B1CC, U+B1D0, U+B1D4, U+B1DC-B1DD, U+B1DF, U+B1E8-B1E9,
U+B1EC, U+B1F0, U+B1F9, U+B1FB, U+B1FD, U+B204-B205, U+B208, U+B20B-B20C, U+B214-B215, U+B217,
U+B219, U+B220, U+B234, U+B23C, U+B24C-B24D, U+B251, U+B258, U+B25C, U+B260, U+B268-B269,
U+B274-B275, U+B27C, U+B284-B285, U+B289, U+B290-B291, U+B294, U+B298-B29A, U+B2A0-B2A1, U+B2A3, U+B2A5-B2A6,
U+B2AA, U+B2AC, U+B2B0, U+B2B4, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D2, U+B2D8-B2D9, U+B2DB,
U+B2DD, U+B2E2, U+B2E4-B2E6, U+B2E8, U+B2EB-B2EF, U+B2F3-B2F5, U+B2F7-B2FB, U+B2FF-B301, U+B304, U+B308,
U+B310-B311, U+B313-B315, U+B31C, U+B324, U+B32C, U+B331, U+B354-B356, U+B358, U+B35B-B35C, U+B35E-B35F,
U+B364-B365, U+B367, U+B369, U+B36B, U+B36E, U+B370-B371, U+B374, U+B378, U+B380-B381, U+B383-B385,
U+B38C, U+B390, U+B394, U+B3A0-B3A1, U+B3A8, U+B3AC, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE,
U+B3D0, U+B3D4-B3D5, U+B3D7, U+B3D9, U+B3DB, U+B3DD, U+B3E0, U+B3E4, U+B3E8, U+B3FC,
U+B400, U+B40F-B411, U+B418, U+B41C, U+B420, U+B428-B429, U+B42B-B42D, U+B434, U+B450-B451, U+B454,
U+B458, U+B460-B461, U+B463, U+B465, U+B46C, U+B480, U+B488, U+B49D, U+B4A4, U+B4A8,
U+B4AC, U+B4B5, U+B4B7, U+B4B9, U+B4C0, U+B4C4, U+B4C8, U+B4D0, U+B4D5, U+B4DC-B4DD,
U+B4E0, U+B4E3-B4E4, U+B4E6, U+B4EC-B4ED, U+B4EF, U+B4F1, U+B4F8, U+B514-B515, U+B518, U+B51B-B51C,
U+B524-B525, U+B527-B52A, U+B530-B531, U+B534, U+B538, U+B540-B541, U+B543-B545, U+B54B-B54D, U+B550, U+B554,
U+B55C-B55D, U+B55F-B561, U+B568, U+B584, U+B599, U+B5A0-B5A1, U+B5A4, U+B5A8, U+B5AA-B5AB, U+B5B0-B5B1,
U+B5B3-B5B5, U+B5BB-B5BD, U+B5C0, U+B5C4, U+B5CC-B5CD, U+B5CF-B5D1, U+B5D8, U+B5EC, U+B610-B611, U+B614,
U+B618, U+B620, U+B625, U+B62C, U+B634, U+B648, U+B65D, U+B664, U+B668, U+B69C-B69D,
U+B6A0, U+B6A4, U+B6AB-B6AC, U+B6B1, U+B6D4, U+B6F0, U+B6F4, U+B6F8, U+B700-B701, U+B705,
U+B71B, U+B728-B729, U+B72C, U+B72F-B730, U+B738-B739, U+B73B, U+B73D, U+B744, U+B748, U+B74C,
U+B754-B755, U+B760-B761, U+B764, U+B768, U+B770-B771, U+B773, U+B775, U+B77C-B77D, U+B780, U+B784,
U+B78C-B78D, U+B78F-B792, U+B796-B799, U+B79C, U+B7A0, U+B7A8-B7A9, U+B7AB-B7AD, U+B7B4-B7B5, U+B7B8, U+B7BC,
U+B7C7, U+B7C9, U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B810,
U+B818-B819, U+B81B, U+B81D, U+B824-B825, U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B844,
U+B851, U+B853, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D, U+B86F, U+B871, U+B878-B879, U+B87C,
U+B880, U+B88D, U+B894, U+B8A8, U+B8B0, U+B8B4, U+B8B8, U+B8C0-B8C1, U+B8C3, U+B8C5,
U+B8CC, U+B8D0, U+B8D4, U+B8DD, U+B8DF, U+B8E1, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9,
U+B8FB, U+B8FD, U+B904, U+B918, U+B920, U+B93C-B93D, U+B940, U+B944, U+B94C, U+B94F,
U+B951, U+B958-B959, U+B95C, U+B960, U+B968-B969, U+B96B, U+B96D, U+B974-B975, U+B978, U+B97C,
U+B984-B985, U+B987, U+B989-B98A, U+B98D-B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD, U+B9BF, U+B9C1,
U+B9C8-B9C9, U+B9CC, U+B9CE-B9D2, U+B9D8-B9D9, U+B9DB, U+B9DD-B9DE, U+B9E1, U+B9E3-B9E5, U+B9E8, U+B9EC,
U+B9F4-B9F5, U+B9F7-B9FA, U+BA00-BA01, U+BA04, U+BA08, U+BA15, U+BA38-BA39, U+BA3C, U+BA40, U+BA42,
U+BA48-BA49, U+BA4B, U+BA4D-BA4E, U+BA53-BA55, U+BA58, U+BA5C, U+BA64-BA65, U+BA67-BA69, U+BA70-BA71, U+BA74,
U+BA78, U+BA83-BA85, U+BA87, U+BA8C, U+BAA8-BAA9, U+BAAB-BAAC, U+BAB0, U+BAB2, U+BAB8-BAB9, U+BABB,
U+BABD, U+BAC4, U+BAC8, U+BAD8-BAD9, U+BAFC, U+BB00, U+BB04, U+BB0D, U+BB0F, U+BB11,
U+BB18, U+BB1C, U+BB20, U+BB29, U+BB2B, U+BB34-BB36, U+BB38, U+BB3B-BB3E, U+BB44-BB45, U+BB47,
U+BB49, U+BB4D, U+BB4F-BB50, U+BB54, U+BB58, U+BB61, U+BB63, U+BB65, U+BB6C, U+BB88,
U+BB8C, U+BB90, U+BBA4, U+BBA8, U+BBAC, U+BBB4, U+BBB7, U+BBC0, U+BBC4, U+BBC8,
U+BBD0, U+BBD3, U+BBDC, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08-BC09, U+BC0B-BC0D, U+BC0F,
U+BC11, U+BC14-BC18, U+BC1B-BC1F, U+BC24-BC25, U+BC27, U+BC29, U+BC2D, U+BC30-BC31, U+BC34, U+BC38,
U+BC40-BC41, U+BC43-BC45, U+BC49, U+BC4C-BC4D, U+BC50, U+BC5D, U+BC84-BC85, U+BC88, U+BC8B-BC8C, U+BC8E,
U+BC94-BC95, U+BC97, U+BC99-BC9A, U+BCA0-BCA1, U+BCA4, U+BCA7-BCA9, U+BCB0-BCB1, U+BCB3-BCB5, U+BCBC-BCBD, U+BCC0,
U+BCC4, U+BCCD, U+BCCF-BCD1, U+BCD5, U+BCD8, U+BCDC, U+BCF4-BCF6, U+BCF8, U+BCFC, U+BD04-BD05,
U+BD07, U+BD09, U+BD10, U+BD14, U+BD23-BD25, U+BD2C, U+BD35, U+BD40, U+BD48-BD49, U+BD4C,
U+BD50, U+BD58-BD59, U+BD64, U+BD68, U+BD79, U+BD80-BD81, U+BD84, U+BD87-BD8A, U+BD90-BD91, U+BD93,
U+BD95, U+BD99-BD9A, U+BD9C, U+BDA4, U+BDB0, U+BDB8-BDB9, U+BDC1, U+BDD4-BDD5, U+BDD8, U+BDDC,
U+BDE9, U+BDF0, U+BDF4, U+BDF8, U+BE00, U+BE03, U+BE05, U+BE0C-BE0D, U+BE10, U+BE14,
U+BE1C-BE1D, U+BE1F, U+BE44-BE45, U+BE48, U+BE4C, U+BE4E, U+BE54-BE55, U+BE57, U+BE59-BE5B, U+BE60-BE61,
U+BE64, U+BE68, U+BE6A, U+BE70-BE71, U+BE73-BE75, U+BE7B-BE7D, U+BE80, U+BE84, U+BE8C-BE8D, U+BE8F-BE91,
U+BE98-BE99, U+BEA8, U+BEAD, U+BEB4, U+BED0-BED1, U+BED4, U+BED7-BED8, U+BEE0, U+BEE3-BEE5, U+BEEC,
U+BEF5, U+BF01, U+BF08-BF09, U+BF18-BF19, U+BF1B-BF1D, U+BF40-BF41, U+BF44, U+BF48, U+BF50-BF51, U+BF55,
U+BF5C, U+BF71, U+BF94, U+BFB0, U+BFB8, U+BFC5, U+BFCC-BFCD, U+BFD0, U+BFD4, U+BFDC,
U+BFDF, U+BFE1, U+C00D, U+C03C, U+C04C, U+C051, U+C058, U+C05C, U+C060, U+C068-C069,
U+C090-C091, U+C094, U+C098, U+C0A0-C0A1, U+C0A3, U+C0A5, U+C0AC-C0AD, U+C0AF-C0B0, U+C0B3-C0B6, U+C0BC-C0BD,
U+C0BF-C0C1, U+C0C5, U+C0C8-C0C9, U+C0CC, U+C0D0, U+C0D8-C0D9, U+C0DB-C0DD, U+C0E4-C0E5, U+C0E8, U+C0EC,
U+C0F4-C0F5, U+C0F7, U+C0F9, U+C0FE, U+C100, U+C104, U+C108, U+C110, U+C115, U+C11C-C120,
U+C123-C124, U+C126-C127, U+C12C-C12D, U+C12F-C131, U+C136, U+C138-C139, U+C13C, U+C140, U+C142, U+C148-C149,
U+C14B-C14D, U+C154-C155, U+C158, U+C15C, U+C164-C165, U+C167-C169, U+C170, U+C174, U+C178, U+C185,
U+C18C-C18E, U+C190, U+C194, U+C196, U+C19C-C19D, U+C19F, U+C1A1, U+C1A5, U+C1A8-C1A9, U+C1AC,
U+C1B0, U+C1BD, U+C1C4, U+C1C8, U+C1CC, U+C1D4, U+C1D7-C1D8, U+C1E0, U+C1E4, U+C1E8,
U+C1F0-C1F1, U+C1F3, U+C1FC-C1FD, U+C200, U+C204, U+C20C-C20D, U+C20F, U+C211, U+C216, U+C218-C219,
U+C21C, U+C21F-C220, U+C228-C229, U+C22B, U+C22D, U+C22F, U+C231-C232, U+C234, U+C245, U+C248,
U+C250-C251, U+C254, U+C258, U+C25F-C260, U+C265, U+C26C-C26D, U+C270, U+C274, U+C27C-C27D, U+C27F,
U+C281, U+C288-C289, U+C28C, U+C290, U+C298-C299, U+C29B, U+C29D, U+C2A0, U+C2A4-C2A5, U+C2A8,
U+C2AC-C2AD, U+C2B4-C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD, U+C2E0, U+C2E3-C2E4, U+C2EB-C2ED, U+C2EF, U+C2F1,
U+C2F6, U+C2F8-C2F9, U+C2FB-C2FC, U+C300, U+C308-C309, U+C30C-C30D, U+C313-C315, U+C318, U+C31C, U+C324-C325,
U+C328-C329, U+C330, U+C338, U+C345, U+C34C, U+C368-C369, U+C36C, U+C370, U+C372, U+C378-C379,
U+C37B-C37D, U+C384, U+C388, U+C38C, U+C395, U+C399, U+C3B5, U+C3BC, U+C3C0, U+C3D8-C3D9,
U+C3DC, U+C3DF-C3E0, U+C3E2, U+C3E8-C3E9, U+C3ED, U+C3F4-C3F5, U+C3F8, U+C3FC, U+C408, U+C410,
U+C424, U+C42C, U+C430, U+C434, U+C43C-C43D, U+C448, U+C450, U+C45D, U+C464-C465, U+C468,
U+C46C, U+C474-C475, U+C479, U+C480, U+C494, U+C49C-C49D, U+C4B8, U+C4BC, U+C4CD, U+C4D4,
U+C4D8, U+C4E9, U+C4F0-C4F1, U+C4F4, U+C4F8, U+C4FA, U+C4FF-C501, U+C50C, U+C510, U+C514,
U+C51C, U+C521, U+C528-C529, U+C52C, U+C530, U+C538-C539, U+C53B, U+C53D, U+C544-C545, U+C548-C54A,
U+C54C-C54E, U+C553-C555, U+C557-C559, U+C55C-C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C573-C575, U+C57B-C57D,
U+C580, U+C584, U+C587, U+C58C-C58D, U+C58F, U+C591, U+C595, U+C597-C598, U+C59C, U+C5A0,
U+C5A9, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BE, U+C5C4-C5CA, U+C5CC, U+C5CE, U+C5D0-C5D1, U+C5D4, U+C5D8,
U+C5E0-C5E1, U+C5E3, U+C5E5, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6-C5F7, U+C5FC-C601, U+C605-C608, U+C60C,
U+C610, U+C618-C619, U+C61B-C61C, U+C624-C625, U+C628, U+C62C-C62E, U+C630, U+C633-C635, U+C637, U+C639,
U+C63B, U+C640-C641, U+C644, U+C648, U+C650-C651, U+C653-C655, U+C65C-C65D, U+C660, U+C664, U+C66C,
U+C66F, U+C671, U+C674, U+C678-C679, U+C67C, U+C680, U+C688-C689, U+C68B, U+C68D, U+C694-C695,
U+C698, U+C69C, U+C6A4-C6A5, U+C6A7, U+C6A9, U+C6B0-C6B1, U+C6B4, U+C6B8-C6BA, U+C6C0-C6C1, U+C6C3,
U+C6C5, U+C6CC-C6CD, U+C6D0, U+C6D4, U+C6DC-C6DD, U+C6DF-C6E1, U+C6E8-C6E9, U+C6EC, U+C6F0-C6F1, U+C6F8-C6F9,
U+C6FD, U+C704-C705, U+C708, U+C70C, U+C714-C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728,
U+C730-C731, U+C733, U+C735, U+C737, U+C73C-C73E, U+C740, U+C743-C744, U+C74A, U+C74C-C74F, U+C751-C758,
U+C75C, U+C760, U+C768, U+C76B, U+C774-C776, U+C778, U+C77C-C77E, U+C783-C785, U+C787-C78A, U+C78C,
U+C78E, U+C790-C791, U+C794, U+C796-C798, U+C79A, U+C7A0-C7A1, U+C7A3-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4,
U+C7BC-C7BD, U+C7BF-C7C1, U+C7C8-C7C9, U+C7CC, U+C7CE, U+C7D0, U+C7D8-C7D9, U+C7DD, U+C7E4, U+C7E8,
U+C7EC, U+C800-C801, U+C804, U+C808, U+C80A, U+C810-C811, U+C813, U+C815-C816, U+C81C-C81D, U+C820,
U+C824, U+C82C-C82D, U+C82F, U+C831, U+C838, U+C83C, U+C840, U+C848-C849, U+C84C-C84D, U+C854,
U+C870-C871, U+C874, U+C878, U+C87A, U+C880-C881, U+C883, U+C885-C887, U+C88B-C88D, U+C890, U+C894,
U+C89D, U+C89F, U+C8A1, U+C8A8, U+C8BC-C8BD, U+C8C4, U+C8C8, U+C8CC, U+C8D4-C8D5, U+C8D7,
U+C8D9, U+C8E0-C8E1, U+C8E4, U+C8E8, U+C8F5, U+C8FC-C8FD, U+C900, U+C904-C906, U+C90C-C90D, U+C90F,
U+C911, U+C918, U+C92C, U+C934-C935, U+C938, U+C950-C951, U+C954, U+C958, U+C960-C961, U+C963,
U+C965, U+C96C, U+C970, U+C974, U+C97C, U+C981, U+C988-C989, U+C98C, U+C990, U+C998-C999,
U+C99B, U+C99D, U+C9A4, U+C9A8, U+C9C0-C9C1, U+C9C4, U+C9C7-C9C8, U+C9CA, U+C9D0-C9D1, U+C9D3,
U+C9D5-C9D6, U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E2, U+C9E4, U+C9E7, U+C9EC-C9ED, U+C9EF-C9F1, U+C9F8-C9F9,
U+C9FC, U+CA00, U+CA08-CA09, U+CA0B-CA0D, U+CA14, U+CA18, U+CA1C, U+CA29, U+CA30, U+CA4C-CA4D,
U+CA50, U+CA54, U+CA5C-CA5D, U+CA5F-CA61, U+CA68, U+CA7D, U+CA84, U+CA98, U+CABC-CABD, U+CAC0,
U+CAC4, U+CACC-CACD, U+CACF, U+CAD1, U+CAD3, U+CAD8-CAD9, U+CAE0, U+CAEC, U+CAF4, U+CB08,
U+CB10, U+CB14, U+CB18, U+CB20-CB21, U+CB2C, U+CB41, U+CB48-CB49, U+CB4C, U+CB50, U+CB58-CB59,
U+CB5D, U+CB64, U+CB78-CB79, U+CB9C, U+CBB8, U+CBD4, U+CBE4-CBE5, U+CBE7, U+CBE9, U+CC0C-CC0D,
U+CC10, U+CC14, U+CC1C-CC1D, U+CC21-CC22, U+CC27-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38-CC39, U+CC3B-CC3E,
U+CC44-CC45, U+CC48, U+CC4C, U+CC54-CC55, U+CC57-CC59, U+CC60, U+CC64, U+CC66, U+CC68, U+CC70,
U+CC75, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9, U+CCAB-CCAD, U+CCB4-CCB5, U+CCB8, U+CCBC, U+CCC4-CCC5,
U+CCC7, U+CCC9, U+CCD0, U+CCD4, U+CCE1, U+CCE4, U+CCEC, U+CCF0, U+CD01, U+CD08-CD09,
U+CD0C, U+CD10, U+CD18-CD19, U+CD1B, U+CD1D, U+CD24, U+CD28, U+CD2C, U+CD39, U+CD5C-CD5D,
U+CD60, U+CD64, U+CD6C-CD6D, U+CD6F, U+CD71, U+CD78, U+CD88, U+CD94-CD95, U+CD98, U+CD9C,
U+CDA4-CDA5, U+CDA7, U+CDA9, U+CDB0, U+CDC4, U+CDCC-CDCD, U+CDD0, U+CDDD, U+CDDF, U+CDE8,
U+CDEC, U+CDF0, U+CDF8-CDF9, U+CDFB, U+CDFD, U+CE04, U+CE08, U+CE0C, U+CE14-CE15, U+CE19,
U+CE20-CE21, U+CE24, U+CE28, U+CE30-CE31, U+CE33, U+CE35, U+CE58-CE59, U+CE5C, U+CE5F-CE61, U+CE68-CE69,
U+CE6B, U+CE6D, U+CE74-CE75, U+CE78, U+CE7C, U+CE84-CE85, U+CE87, U+CE89, U+CE8C, U+CE90-CE91,
U+CE94, U+CE98, U+CEA0-CEA1, U+CEA3-CEA5, U+CEAC-CEAE, U+CEB4, U+CEC1, U+CEC4, U+CEE4-CEE5, U+CEE8,
U+CEEB-CEEC, U+CEF4-CEF5, U+CEF7-CEF9, U+CF00-CF01, U+CF04, U+CF08, U+CF10-CF11, U+CF13, U+CF15, U+CF18,
U+CF1C, U+CF20, U+CF24, U+CF2C-CF2D, U+CF2F-CF31, U+CF38, U+CF54-CF55, U+CF58, U+CF5C, U+CF64-CF65,
U+CF67, U+CF69, U+CF70-CF71, U+CF74, U+CF78, U+CF80, U+CF85, U+CF8C, U+CFA1, U+CFA8,
U+CFB0, U+CFC4, U+CFDC, U+CFE0-CFE1, U+CFE4, U+CFE8, U+CFF0-CFF1, U+CFF3, U+CFF5, U+CFFC-CFFD,
U+D000, U+D004, U+D011, U+D018, U+D02D, U+D034-D035, U+D038, U+D03C, U+D044-D045, U+D047,
U+D049, U+D050, U+D054, U+D058, U+D060, U+D06C-D06E, U+D070, U+D074, U+D07C-D07D, U+D07F,
U+D081, U+D084, U+D0A4-D0A5, U+D0A8, U+D0AC, U+D0B4-D0B5, U+D0B7, U+D0B9, U+D0C0-D0C1, U+D0C4,
U+D0C8-D0C9, U+D0D0-D0D1, U+D0D3-D0D5, U+D0DC-D0DD, U+D0E0, U+D0E4, U+D0EC-D0ED, U+D0EF-D0F1, U+D0F8, U+D10D,
U+D130-D131, U+D134, U+D138, U+D13A, U+D140-D141, U+D143-D145, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D,
U+D15F, U+D161, U+D168, U+D16C, U+D17C, U+D184, U+D188, U+D1A0-D1A1, U+D1A4, U+D1A8,
U+D1B0-D1B1, U+D1B3, U+D1B5, U+D1BA, U+D1BC, U+D1C0, U+D1D8, U+D1F4, U+D1F8, U+D207,
U+D209, U+D210, U+D22C-D22D, U+D230, U+D234, U+D23C-D23D, U+D23F, U+D241, U+D248, U+D25C,
U+D264, U+D277, U+D280-D281, U+D284, U+D288, U+D290-D291, U+D295, U+D29C, U+D2A0, U+D2A4,
U+D2AC, U+D2B1, U+D2B8-D2B9, U+D2BC, U+D2BF-D2C0, U+D2C2, U+D2C8-D2C9, U+D2CB, U+D2D4, U+D2D8,
U+D2DC, U+D2E4-D2E5, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D303, U+D305, U+D30C-D30E, U+D310,
U+D314, U+D316, U+D31C-D31D, U+D31F-D321, U+D325, U+D328-D329, U+D32C, U+D330, U+D338-D339, U+D33B-D33D,
U+D344-D345, U+D37C-D37D, U+D380, U+D384, U+D38C-D38D, U+D38F-D391, U+D398-D399, U+D39C, U+D3A0, U+D3A8-D3A9,
U+D3AB, U+D3AD, U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3D8, U+D3E1,
U+D3E3, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC-D3FD, U+D3FF, U+D401, U+D408, U+D41D, U+D440,
U+D444, U+D45C, U+D460, U+D464, U+D46D, U+D46F, U+D478-D479, U+D47C, U+D47F-D480, U+D482,
U+D488-D489, U+D48B, U+D48D, U+D494, U+D4A9, U+D4B0, U+D4CC, U+D4D0, U+D4D4, U+D4DC,
U+D4DF, U+D4E8, U+D4EC, U+D4F0, U+D4F8, U+D4FB, U+D4FD, U+D504, U+D508, U+D50C,
U+D514-D515, U+D517, U+D53C-D53D, U+D540, U+D544, U+D54C-D54D, U+D54F, U+D551, U+D558-D559, U+D55C,
U+D560, U+D565, U+D568-D569, U+D56B, U+D56D, U+D571, U+D573-D575, U+D578, U+D57C, U+D584-D585,
U+D587-D589, U+D58F-D590, U+D59D, U+D5A5, U+D5C8-D5C9, U+D5CC, U+D5D0, U+D5D2, U+D5D8-D5D9, U+D5DB,
U+D5DD, U+D5E4-D5E5, U+D5E8, U+D5EC, U+D5F3-D5F5, U+D5F7, U+D5F9, U+D5FF-D601, U+D604, U+D608,
U+D610-D611, U+D613-D615, U+D61C, U+D620, U+D624, U+D62D, U+D638-D639, U+D63C, U+D640, U+D645,
U+D648-D649, U+D64B, U+D64D, U+D651, U+D654-D655, U+D658, U+D65C, U+D664, U+D667, U+D669,
U+D670-D671, U+D674, U+D683, U+D685, U+D68C-D68D, U+D690, U+D694, U+D69D, U+D69F, U+D6A1,
U+D6A8, U+D6AC, U+D6B0, U+D6B9, U+D6BB, U+D6BD, U+D6C3-D6C5, U+D6C8, U+D6CC, U+D6D1,
U+D6D4-D6D5, U+D6D7, U+D6D9, U+D6E0, U+D6E4, U+D6E8, U+D6F0, U+D6F5, U+D6FC-D6FD, U+D700,
U+D704, U+D711, U+D718-D719, U+D71C, U+D720, U+D728-D729, U+D72B, U+D72D, U+D734-D735, U+D738,
U+D73C, U+D744, U+D747, U+D749, U+D750-D751, U+D754, U+D756-D759, U+D760-D761, U+D763, U+D765,
U+D769, U+D76C, U+D770, U+D774, U+D77C-D77D, U+D781, U+D788-D789, U+D78C, U+D790, U+D798-D799,
U+D79B, U+D79D, U+FF01-FF5E, U+FFE0-FFE3, U+FFE5-FFE6;
}
/* NanumSquareEB.woff2 sha256: 8b8bda555af380ee62435880cb302af570a4c0364a3842fdb79b847ee220fb33 */
@font-face {
font-family: 'NanumSquare';
src: url('/assets/fonts/NanumSquareEB.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
unicode-range:
U+20-7E, U+A1, U+A4, U+A7-AB, U+B0-B4, U+B6, U+B8-BF, U+C6, U+D0, U+D7-D8,
U+DE-DF, U+E6, U+F0, U+F7-F8, U+FE, U+111, U+126-127, U+131-133, U+138, U+13F-142,
U+149-14B, U+152-153, U+166-167, U+2C7, U+2D8-2DD, U+391-3A1, U+3A3-3A9, U+3B1-3C1, U+3C3-3C9, U+401,
U+410-44F, U+451, U+2010, U+2013-2014, U+2016, U+2018-2019, U+201C-201D, U+2020-2021, U+2025-2026, U+2030,
U+2032-2033, U+203B, U+2074, U+207F, U+2081-2084, U+20A9, U+2103, U+2109, U+2113, U+2116,
U+2121-2122, U+2126, U+212B, U+2153-2154, U+215B-215E, U+2160-2169, U+2170-2179, U+2190-2199, U+21D2, U+21D4,
U+2200, U+2202-2203, U+2207-2208, U+220B, U+220F, U+2211, U+221A, U+221D-221E, U+2220, U+2227-222C,
U+222E, U+2234-2236, U+223D, U+2252, U+2260-2261, U+2264-2265, U+2282-2283, U+2286-2287, U+22A5, U+22EF,
U+2312, U+2460-246E, U+2474-2482, U+249C-24B5, U+24D0-24E9, U+2500-2503, U+250C-254B, U+2592, U+25A0-25A1, U+25A3-25A9,
U+25B2-25B3, U+25B6-25B7, U+25BC-25BD, U+25C0-25C1, U+25C6-25C9, U+25CB, U+25CE-25D1, U+2605-2606, U+260E-260F, U+261C,
U+261E, U+2640, U+2642, U+2660-2661, U+2663-2665, U+2667-266A, U+266C-266D, U+3000-3003, U+3008-3011, U+3013-3015,
U+3041-3093, U+30A1-30F6, U+30FB, U+3131-318E, U+3200-321C, U+3260-327B, U+327F, U+3380-3384, U+3388-33CA, U+33CF-33D0,
U+33D3, U+33D6, U+33D8, U+33DB-33DD, U+AC00-AC01, U+AC04, U+AC07-AC0A, U+AC10-AC17, U+AC19-AC1D, U+AC20,
U+AC24, U+AC2C-AC2D, U+AC2F-AC31, U+AC38-AC39, U+AC3C, U+AC40, U+AC4B, U+AC4D, U+AC54, U+AC58,
U+AC5C, U+AC70-AC71, U+AC74, U+AC77-AC78, U+AC7A, U+AC80-AC81, U+AC83-AC86, U+AC89-AC8C, U+AC90, U+AC94,
U+AC9C-AC9D, U+AC9F-ACA1, U+ACA8-ACAA, U+ACAC, U+ACAF-ACB0, U+ACB8-ACB9, U+ACBB-ACBD, U+ACC1, U+ACC4, U+ACC8,
U+ACCC, U+ACD5, U+ACD7, U+ACE0-ACE1, U+ACE4, U+ACE7-ACE8, U+ACEA, U+ACEC, U+ACEF-ACF1, U+ACF3,
U+ACF5-ACF6, U+ACFC-ACFD, U+AD00, U+AD04, U+AD06, U+AD0C-AD0D, U+AD0F, U+AD11, U+AD18, U+AD1C,
U+AD1E, U+AD20, U+AD29, U+AD2C-AD2D, U+AD34-AD35, U+AD38, U+AD3C, U+AD44-AD45, U+AD47, U+AD49,
U+AD50, U+AD54, U+AD58, U+AD61, U+AD63, U+AD65, U+AD6C-AD6D, U+AD70, U+AD73-AD76, U+AD7B-AD7D,
U+AD7F, U+AD81-AD82, U+AD88-AD89, U+AD8C, U+AD90, U+AD9C-AD9D, U+ADA4, U+ADB4, U+ADB7, U+ADC0-ADC1,
U+ADC4, U+ADC8, U+ADD0-ADD1, U+ADD3, U+ADDC, U+ADE0, U+ADE4, U+ADF8-ADF9, U+ADFC, U+ADFF-AE01,
U+AE08-AE09, U+AE0B, U+AE0D, U+AE14, U+AE1C, U+AE30-AE31, U+AE34, U+AE37-AE38, U+AE3A, U+AE40-AE41,
U+AE43, U+AE45-AE46, U+AE4A, U+AE4C-AE4E, U+AE50, U+AE54, U+AE56, U+AE5C-AE5D, U+AE5F-AE61, U+AE65,
U+AE68-AE69, U+AE6C, U+AE70, U+AE78-AE79, U+AE7B-AE7D, U+AE84-AE86, U+AE8C-AE8D, U+AEA0, U+AEBC-AEBE, U+AEC0,
U+AEC4, U+AECC-AECD, U+AECF-AED1, U+AED8-AED9, U+AEDC, U+AEE8, U+AEEB, U+AEED, U+AEF4, U+AEF8,
U+AEFC, U+AF07-AF08, U+AF0D, U+AF10, U+AF2C-AF2D, U+AF30, U+AF32, U+AF34, U+AF3C-AF3D, U+AF3F,
U+AF41-AF43, U+AF48-AF49, U+AF50, U+AF5C-AF5D, U+AF64-AF65, U+AF79, U+AF80, U+AF84, U+AF88, U+AF90-AF91,
U+AF95, U+AF9C, U+AFA4, U+AFB8-AFBA, U+AFBC, U+AFC0, U+AFC7-AFC9, U+AFCB, U+AFCD-AFCE, U+AFD4,
U+AFDC, U+AFE8-AFE9, U+AFF0-AFF1, U+AFF4, U+AFF8-AFF9, U+AFFF-B001, U+B004, U+B00C, U+B010, U+B014,
U+B01C-B01D, U+B028, U+B044-B045, U+B048, U+B04A, U+B04C, U+B04E, U+B053-B055, U+B057, U+B059,
U+B05D, U+B068, U+B07C-B07D, U+B080, U+B084, U+B08C-B08D, U+B08F, U+B091, U+B098-B09A, U+B09C,
U+B09F-B0A2, U+B0A8-B0A9, U+B0AB-B0AF, U+B0B1, U+B0B3-B0B5, U+B0B8, U+B0BC, U+B0C4-B0C5, U+B0C7-B0C9, U+B0D0-B0D1,
U+B0D4, U+B0D8, U+B0E0, U+B0E5, U+B108-B109, U+B10B-B10C, U+B110, U+B112-B113, U+B118-B119, U+B11B-B11D,
U+B123-B125, U+B128, U+B12C, U+B134-B135, U+B137-B139, U+B140-B141, U+B144, U+B148, U+B150-B151, U+B154-B155,
U+B158, U+B15C, U+B160, U+B178-B179, U+B17C, U+B180, U+B182, U+B188-B189, U+B18B, U+B18D,
U+B192-B194, U+B198, U+B19C, U+B1A8, U+B1CC, U+B1D0, U+B1D4, U+B1DC-B1DD, U+B1DF, U+B1E8-B1E9,
U+B1EC, U+B1F0, U+B1F9, U+B1FB, U+B1FD, U+B204-B205, U+B208, U+B20B-B20C, U+B214-B215, U+B217,
U+B219, U+B220, U+B234, U+B23C, U+B24C-B24D, U+B251, U+B258, U+B25C, U+B260, U+B268-B269,
U+B274-B275, U+B27C, U+B284-B285, U+B289, U+B290-B291, U+B294, U+B298-B29A, U+B2A0-B2A1, U+B2A3, U+B2A5-B2A6,
U+B2AA, U+B2AC, U+B2B0, U+B2B4, U+B2C8-B2C9, U+B2CC, U+B2D0, U+B2D2, U+B2D8-B2D9, U+B2DB,
U+B2DD, U+B2E2, U+B2E4-B2E6, U+B2E8, U+B2EB-B2EF, U+B2F3-B2F5, U+B2F7-B2FB, U+B2FF-B301, U+B304, U+B308,
U+B310-B311, U+B313-B315, U+B31C, U+B324, U+B32C, U+B331, U+B354-B356, U+B358, U+B35B-B35C, U+B35E-B35F,
U+B364-B365, U+B367, U+B369, U+B36B, U+B36E, U+B370-B371, U+B374, U+B378, U+B380-B381, U+B383-B385,
U+B38C, U+B390, U+B394, U+B3A0-B3A1, U+B3A8, U+B3AC, U+B3C4-B3C5, U+B3C8, U+B3CB-B3CC, U+B3CE,
U+B3D0, U+B3D4-B3D5, U+B3D7, U+B3D9, U+B3DB, U+B3DD, U+B3E0, U+B3E4, U+B3E8, U+B3FC,
U+B400, U+B40F-B411, U+B418, U+B41C, U+B420, U+B428-B429, U+B42B-B42D, U+B434, U+B450-B451, U+B454,
U+B458, U+B460-B461, U+B463, U+B465, U+B46C, U+B480, U+B488, U+B49D, U+B4A4, U+B4A8,
U+B4AC, U+B4B5, U+B4B7, U+B4B9, U+B4C0, U+B4C4, U+B4C8, U+B4D0, U+B4D5, U+B4DC-B4DD,
U+B4E0, U+B4E3-B4E4, U+B4E6, U+B4EC-B4ED, U+B4EF, U+B4F1, U+B4F8, U+B514-B515, U+B518, U+B51B-B51C,
U+B524-B525, U+B527-B52A, U+B530-B531, U+B534, U+B538, U+B540-B541, U+B543-B545, U+B54B-B54D, U+B550, U+B554,
U+B55C-B55D, U+B55F-B561, U+B568, U+B584, U+B599, U+B5A0-B5A1, U+B5A4, U+B5A8, U+B5AA-B5AB, U+B5B0-B5B1,
U+B5B3-B5B5, U+B5BB-B5BD, U+B5C0, U+B5C4, U+B5CC-B5CD, U+B5CF-B5D1, U+B5D8, U+B5EC, U+B610-B611, U+B614,
U+B618, U+B620, U+B625, U+B62C, U+B634, U+B648, U+B65D, U+B664, U+B668, U+B69C-B69D,
U+B6A0, U+B6A4, U+B6AB-B6AC, U+B6B1, U+B6D4, U+B6F0, U+B6F4, U+B6F8, U+B700-B701, U+B705,
U+B71B, U+B728-B729, U+B72C, U+B72F-B730, U+B738-B739, U+B73B, U+B73D, U+B744, U+B748, U+B74C,
U+B754-B755, U+B760-B761, U+B764, U+B768, U+B770-B771, U+B773, U+B775, U+B77C-B77D, U+B780, U+B784,
U+B78C-B78D, U+B78F-B792, U+B796-B799, U+B79C, U+B7A0, U+B7A8-B7A9, U+B7AB-B7AD, U+B7B4-B7B5, U+B7B8, U+B7BC,
U+B7C7, U+B7C9, U+B7EC-B7ED, U+B7F0, U+B7F4, U+B7FC-B7FD, U+B7FF-B801, U+B807-B809, U+B80C, U+B810,
U+B818-B819, U+B81B, U+B81D, U+B824-B825, U+B828, U+B82C, U+B834-B835, U+B837-B839, U+B840, U+B844,
U+B851, U+B853, U+B85C-B85D, U+B860, U+B864, U+B86C-B86D, U+B86F, U+B871, U+B878-B879, U+B87C,
U+B880, U+B88D, U+B894, U+B8A8, U+B8B0, U+B8B4, U+B8B8, U+B8C0-B8C1, U+B8C3, U+B8C5,
U+B8CC, U+B8D0, U+B8D4, U+B8DD, U+B8DF, U+B8E1, U+B8E8-B8E9, U+B8EC, U+B8F0, U+B8F8-B8F9,
U+B8FB, U+B8FD, U+B904, U+B918, U+B920, U+B93C-B93D, U+B940, U+B944, U+B94C, U+B94F,
U+B951, U+B958-B959, U+B95C, U+B960, U+B968-B969, U+B96B, U+B96D, U+B974-B975, U+B978, U+B97C,
U+B984-B985, U+B987, U+B989-B98A, U+B98D-B98E, U+B9AC-B9AD, U+B9B0, U+B9B4, U+B9BC-B9BD, U+B9BF, U+B9C1,
U+B9C8-B9C9, U+B9CC, U+B9CE-B9D2, U+B9D8-B9D9, U+B9DB, U+B9DD-B9DE, U+B9E1, U+B9E3-B9E5, U+B9E8, U+B9EC,
U+B9F4-B9F5, U+B9F7-B9FA, U+BA00-BA01, U+BA04, U+BA08, U+BA15, U+BA38-BA39, U+BA3C, U+BA40, U+BA42,
U+BA48-BA49, U+BA4B, U+BA4D-BA4E, U+BA53-BA55, U+BA58, U+BA5C, U+BA64-BA65, U+BA67-BA69, U+BA70-BA71, U+BA74,
U+BA78, U+BA83-BA85, U+BA87, U+BA8C, U+BAA8-BAA9, U+BAAB-BAAC, U+BAB0, U+BAB2, U+BAB8-BAB9, U+BABB,
U+BABD, U+BAC4, U+BAC8, U+BAD8-BAD9, U+BAFC, U+BB00, U+BB04, U+BB0D, U+BB0F, U+BB11,
U+BB18, U+BB1C, U+BB20, U+BB29, U+BB2B, U+BB34-BB36, U+BB38, U+BB3B-BB3E, U+BB44-BB45, U+BB47,
U+BB49, U+BB4D, U+BB4F-BB50, U+BB54, U+BB58, U+BB61, U+BB63, U+BB65, U+BB6C, U+BB88,
U+BB8C, U+BB90, U+BBA4, U+BBA8, U+BBAC, U+BBB4, U+BBB7, U+BBC0, U+BBC4, U+BBC8,
U+BBD0, U+BBD3, U+BBDC, U+BBF8-BBF9, U+BBFC, U+BBFF-BC00, U+BC02, U+BC08-BC09, U+BC0B-BC0D, U+BC0F,
U+BC11, U+BC14-BC18, U+BC1B-BC1F, U+BC24-BC25, U+BC27, U+BC29, U+BC2D, U+BC30-BC31, U+BC34, U+BC38,
U+BC40-BC41, U+BC43-BC45, U+BC49, U+BC4C-BC4D, U+BC50, U+BC5D, U+BC84-BC85, U+BC88, U+BC8B-BC8C, U+BC8E,
U+BC94-BC95, U+BC97, U+BC99-BC9A, U+BCA0-BCA1, U+BCA4, U+BCA7-BCA9, U+BCB0-BCB1, U+BCB3-BCB5, U+BCBC-BCBD, U+BCC0,
U+BCC4, U+BCCD, U+BCCF-BCD1, U+BCD5, U+BCD8, U+BCDC, U+BCF4-BCF6, U+BCF8, U+BCFC, U+BD04-BD05,
U+BD07, U+BD09, U+BD10, U+BD14, U+BD23-BD25, U+BD2C, U+BD35, U+BD40, U+BD48-BD49, U+BD4C,
U+BD50, U+BD58-BD59, U+BD64, U+BD68, U+BD79, U+BD80-BD81, U+BD84, U+BD87-BD8A, U+BD90-BD91, U+BD93,
U+BD95, U+BD99-BD9A, U+BD9C, U+BDA4, U+BDB0, U+BDB8-BDB9, U+BDC1, U+BDD4-BDD5, U+BDD8, U+BDDC,
U+BDE9, U+BDF0, U+BDF4, U+BDF8, U+BE00, U+BE03, U+BE05, U+BE0C-BE0D, U+BE10, U+BE14,
U+BE1C-BE1D, U+BE1F, U+BE44-BE45, U+BE48, U+BE4C, U+BE4E, U+BE54-BE55, U+BE57, U+BE59-BE5B, U+BE60-BE61,
U+BE64, U+BE68, U+BE6A, U+BE70-BE71, U+BE73-BE75, U+BE7B-BE7D, U+BE80, U+BE84, U+BE8C-BE8D, U+BE8F-BE91,
U+BE98-BE99, U+BEA8, U+BEAD, U+BEB4, U+BED0-BED1, U+BED4, U+BED7-BED8, U+BEE0, U+BEE3-BEE5, U+BEEC,
U+BEF5, U+BF01, U+BF08-BF09, U+BF18-BF19, U+BF1B-BF1D, U+BF40-BF41, U+BF44, U+BF48, U+BF50-BF51, U+BF55,
U+BF5C, U+BF71, U+BF94, U+BFB0, U+BFB8, U+BFC5, U+BFCC-BFCD, U+BFD0, U+BFD4, U+BFDC,
U+BFDF, U+BFE1, U+C00D, U+C03C, U+C04C, U+C051, U+C058, U+C05C, U+C060, U+C068-C069,
U+C090-C091, U+C094, U+C098, U+C0A0-C0A1, U+C0A3, U+C0A5, U+C0AC-C0AD, U+C0AF-C0B0, U+C0B3-C0B6, U+C0BC-C0BD,
U+C0BF-C0C1, U+C0C5, U+C0C8-C0C9, U+C0CC, U+C0D0, U+C0D8-C0D9, U+C0DB-C0DD, U+C0E4-C0E5, U+C0E8, U+C0EC,
U+C0F4-C0F5, U+C0F7, U+C0F9, U+C0FE, U+C100, U+C104, U+C108, U+C110, U+C115, U+C11C-C120,
U+C123-C124, U+C126-C127, U+C12C-C12D, U+C12F-C131, U+C136, U+C138-C139, U+C13C, U+C140, U+C142, U+C148-C149,
U+C14B-C14D, U+C154-C155, U+C158, U+C15C, U+C164-C165, U+C167-C169, U+C170, U+C174, U+C178, U+C185,
U+C18C-C18E, U+C190, U+C194, U+C196, U+C19C-C19D, U+C19F, U+C1A1, U+C1A5, U+C1A8-C1A9, U+C1AC,
U+C1B0, U+C1BD, U+C1C4, U+C1C8, U+C1CC, U+C1D4, U+C1D7-C1D8, U+C1E0, U+C1E4, U+C1E8,
U+C1F0-C1F1, U+C1F3, U+C1FC-C1FD, U+C200, U+C204, U+C20C-C20D, U+C20F, U+C211, U+C216, U+C218-C219,
U+C21C, U+C21F-C220, U+C228-C229, U+C22B, U+C22D, U+C22F, U+C231-C232, U+C234, U+C245, U+C248,
U+C250-C251, U+C254, U+C258, U+C25F-C260, U+C265, U+C26C-C26D, U+C270, U+C274, U+C27C-C27D, U+C27F,
U+C281, U+C288-C289, U+C28C, U+C290, U+C298-C299, U+C29B, U+C29D, U+C2A0, U+C2A4-C2A5, U+C2A8,
U+C2AC-C2AD, U+C2B4-C2B5, U+C2B7, U+C2B9, U+C2DC-C2DD, U+C2E0, U+C2E3-C2E4, U+C2EB-C2ED, U+C2EF, U+C2F1,
U+C2F6, U+C2F8-C2F9, U+C2FB-C2FC, U+C300, U+C308-C309, U+C30C-C30D, U+C313-C315, U+C318, U+C31C, U+C324-C325,
U+C328-C329, U+C330, U+C338, U+C345, U+C34C, U+C368-C369, U+C36C, U+C370, U+C372, U+C378-C379,
U+C37B-C37D, U+C384, U+C388, U+C38C, U+C395, U+C399, U+C3B5, U+C3BC, U+C3C0, U+C3D8-C3D9,
U+C3DC, U+C3DF-C3E0, U+C3E2, U+C3E8-C3E9, U+C3ED, U+C3F4-C3F5, U+C3F8, U+C3FC, U+C408, U+C410,
U+C424, U+C42C, U+C430, U+C434, U+C43C-C43D, U+C448, U+C450, U+C45D, U+C464-C465, U+C468,
U+C46C, U+C474-C475, U+C479, U+C480, U+C494, U+C49C-C49D, U+C4B8, U+C4BC, U+C4CD, U+C4D4,
U+C4D8, U+C4E9, U+C4F0-C4F1, U+C4F4, U+C4F8, U+C4FA, U+C4FF-C501, U+C50C, U+C510, U+C514,
U+C51C, U+C521, U+C528-C529, U+C52C, U+C530, U+C538-C539, U+C53B, U+C53D, U+C544-C545, U+C548-C54A,
U+C54C-C54E, U+C553-C555, U+C557-C559, U+C55C-C55E, U+C560-C561, U+C564, U+C568, U+C570-C571, U+C573-C575, U+C57B-C57D,
U+C580, U+C584, U+C587, U+C58C-C58D, U+C58F, U+C591, U+C595, U+C597-C598, U+C59C, U+C5A0,
U+C5A9, U+C5B4-C5B5, U+C5B8-C5B9, U+C5BB-C5BE, U+C5C4-C5CA, U+C5CC, U+C5CE, U+C5D0-C5D1, U+C5D4, U+C5D8,
U+C5E0-C5E1, U+C5E3, U+C5E5, U+C5EC-C5EE, U+C5F0, U+C5F4, U+C5F6-C5F7, U+C5FC-C601, U+C605-C608, U+C60C,
U+C610, U+C618-C619, U+C61B-C61C, U+C624-C625, U+C628, U+C62C-C62E, U+C630, U+C633-C635, U+C637, U+C639,
U+C63B, U+C640-C641, U+C644, U+C648, U+C650-C651, U+C653-C655, U+C65C-C65D, U+C660, U+C664, U+C66C,
U+C66F, U+C671, U+C674, U+C678-C679, U+C67C, U+C680, U+C688-C689, U+C68B, U+C68D, U+C694-C695,
U+C698, U+C69C, U+C6A4-C6A5, U+C6A7, U+C6A9, U+C6B0-C6B1, U+C6B4, U+C6B8-C6BA, U+C6C0-C6C1, U+C6C3,
U+C6C5, U+C6CC-C6CD, U+C6D0, U+C6D4, U+C6DC-C6DD, U+C6DF-C6E1, U+C6E8-C6E9, U+C6EC, U+C6F0-C6F1, U+C6F8-C6F9,
U+C6FD, U+C704-C705, U+C708, U+C70C, U+C714-C715, U+C717, U+C719, U+C720-C721, U+C724, U+C728,
U+C730-C731, U+C733, U+C735, U+C737, U+C73C-C73E, U+C740, U+C743-C744, U+C74A, U+C74C-C74F, U+C751-C758,
U+C75C, U+C760, U+C768, U+C76B, U+C774-C776, U+C778, U+C77C-C77E, U+C783-C785, U+C787-C78A, U+C78C,
U+C78E, U+C790-C791, U+C794, U+C796-C798, U+C79A, U+C7A0-C7A1, U+C7A3-C7A6, U+C7AC-C7AD, U+C7B0, U+C7B4,
U+C7BC-C7BD, U+C7BF-C7C1, U+C7C8-C7C9, U+C7CC, U+C7CE, U+C7D0, U+C7D8-C7D9, U+C7DD, U+C7E4, U+C7E8,
U+C7EC, U+C800-C801, U+C804, U+C808, U+C80A, U+C810-C811, U+C813, U+C815-C816, U+C81C-C81D, U+C820,
U+C824, U+C82C-C82D, U+C82F, U+C831, U+C838, U+C83C, U+C840, U+C848-C849, U+C84C-C84D, U+C854,
U+C870-C871, U+C874, U+C878, U+C87A, U+C880-C881, U+C883, U+C885-C887, U+C88B-C88D, U+C890, U+C894,
U+C89D, U+C89F, U+C8A1, U+C8A8, U+C8BC-C8BD, U+C8C4, U+C8C8, U+C8CC, U+C8D4-C8D5, U+C8D7,
U+C8D9, U+C8E0-C8E1, U+C8E4, U+C8E8, U+C8F5, U+C8FC-C8FD, U+C900, U+C904-C906, U+C90C-C90D, U+C90F,
U+C911, U+C918, U+C92C, U+C934-C935, U+C938, U+C950-C951, U+C954, U+C958, U+C960-C961, U+C963,
U+C965, U+C96C, U+C970, U+C974, U+C97C, U+C981, U+C988-C989, U+C98C, U+C990, U+C998-C999,
U+C99B, U+C99D, U+C9A4, U+C9A8, U+C9C0-C9C1, U+C9C4, U+C9C7-C9C8, U+C9CA, U+C9D0-C9D1, U+C9D3,
U+C9D5-C9D6, U+C9D9-C9DA, U+C9DC-C9DD, U+C9E0, U+C9E2, U+C9E4, U+C9E7, U+C9EC-C9ED, U+C9EF-C9F1, U+C9F8-C9F9,
U+C9FC, U+CA00, U+CA08-CA09, U+CA0B-CA0D, U+CA14, U+CA18, U+CA1C, U+CA29, U+CA30, U+CA4C-CA4D,
U+CA50, U+CA54, U+CA5C-CA5D, U+CA5F-CA61, U+CA68, U+CA7D, U+CA84, U+CA98, U+CABC-CABD, U+CAC0,
U+CAC4, U+CACC-CACD, U+CACF, U+CAD1, U+CAD3, U+CAD8-CAD9, U+CAE0, U+CAEC, U+CAF4, U+CB08,
U+CB10, U+CB14, U+CB18, U+CB20-CB21, U+CB2C, U+CB41, U+CB48-CB49, U+CB4C, U+CB50, U+CB58-CB59,
U+CB5D, U+CB64, U+CB78-CB79, U+CB9C, U+CBB8, U+CBD4, U+CBE4-CBE5, U+CBE7, U+CBE9, U+CC0C-CC0D,
U+CC10, U+CC14, U+CC1C-CC1D, U+CC21-CC22, U+CC27-CC29, U+CC2C, U+CC2E, U+CC30, U+CC38-CC39, U+CC3B-CC3E,
U+CC44-CC45, U+CC48, U+CC4C, U+CC54-CC55, U+CC57-CC59, U+CC60, U+CC64, U+CC66, U+CC68, U+CC70,
U+CC75, U+CC98-CC99, U+CC9C, U+CCA0, U+CCA8-CCA9, U+CCAB-CCAD, U+CCB4-CCB5, U+CCB8, U+CCBC, U+CCC4-CCC5,
U+CCC7, U+CCC9, U+CCD0, U+CCD4, U+CCE1, U+CCE4, U+CCEC, U+CCF0, U+CD01, U+CD08-CD09,
U+CD0C, U+CD10, U+CD18-CD19, U+CD1B, U+CD1D, U+CD24, U+CD28, U+CD2C, U+CD39, U+CD5C-CD5D,
U+CD60, U+CD64, U+CD6C-CD6D, U+CD6F, U+CD71, U+CD78, U+CD88, U+CD94-CD95, U+CD98, U+CD9C,
U+CDA4-CDA5, U+CDA7, U+CDA9, U+CDB0, U+CDC4, U+CDCC-CDCD, U+CDD0, U+CDDD, U+CDDF, U+CDE8,
U+CDEC, U+CDF0, U+CDF8-CDF9, U+CDFB, U+CDFD, U+CE04, U+CE08, U+CE0C, U+CE14-CE15, U+CE19,
U+CE20-CE21, U+CE24, U+CE28, U+CE30-CE31, U+CE33, U+CE35, U+CE58-CE59, U+CE5C, U+CE5F-CE61, U+CE68-CE69,
U+CE6B, U+CE6D, U+CE74-CE75, U+CE78, U+CE7C, U+CE84-CE85, U+CE87, U+CE89, U+CE8C, U+CE90-CE91,
U+CE94, U+CE98, U+CEA0-CEA1, U+CEA3-CEA5, U+CEAC-CEAE, U+CEB4, U+CEC1, U+CEC4, U+CEE4-CEE5, U+CEE8,
U+CEEB-CEEC, U+CEF4-CEF5, U+CEF7-CEF9, U+CF00-CF01, U+CF04, U+CF08, U+CF10-CF11, U+CF13, U+CF15, U+CF18,
U+CF1C, U+CF20, U+CF24, U+CF2C-CF2D, U+CF2F-CF31, U+CF38, U+CF54-CF55, U+CF58, U+CF5C, U+CF64-CF65,
U+CF67, U+CF69, U+CF70-CF71, U+CF74, U+CF78, U+CF80, U+CF85, U+CF8C, U+CFA1, U+CFA8,
U+CFB0, U+CFC4, U+CFDC, U+CFE0-CFE1, U+CFE4, U+CFE8, U+CFF0-CFF1, U+CFF3, U+CFF5, U+CFFC-CFFD,
U+D000, U+D004, U+D011, U+D018, U+D02D, U+D034-D035, U+D038, U+D03C, U+D044-D045, U+D047,
U+D049, U+D050, U+D054, U+D058, U+D060, U+D06C-D06E, U+D070, U+D074, U+D07C-D07D, U+D07F,
U+D081, U+D084, U+D0A4-D0A5, U+D0A8, U+D0AC, U+D0B4-D0B5, U+D0B7, U+D0B9, U+D0C0-D0C1, U+D0C4,
U+D0C8-D0C9, U+D0D0-D0D1, U+D0D3-D0D5, U+D0DC-D0DD, U+D0E0, U+D0E4, U+D0EC-D0ED, U+D0EF-D0F1, U+D0F8, U+D10D,
U+D130-D131, U+D134, U+D138, U+D13A, U+D140-D141, U+D143-D145, U+D14C-D14D, U+D150, U+D154, U+D15C-D15D,
U+D15F, U+D161, U+D168, U+D16C, U+D17C, U+D184, U+D188, U+D1A0-D1A1, U+D1A4, U+D1A8,
U+D1B0-D1B1, U+D1B3, U+D1B5, U+D1BA, U+D1BC, U+D1C0, U+D1D8, U+D1F4, U+D1F8, U+D207,
U+D209, U+D210, U+D22C-D22D, U+D230, U+D234, U+D23C-D23D, U+D23F, U+D241, U+D248, U+D25C,
U+D264, U+D277, U+D280-D281, U+D284, U+D288, U+D290-D291, U+D295, U+D29C, U+D2A0, U+D2A4,
U+D2AC, U+D2B1, U+D2B8-D2B9, U+D2BC, U+D2BF-D2C0, U+D2C2, U+D2C8-D2C9, U+D2CB, U+D2D4, U+D2D8,
U+D2DC, U+D2E4-D2E5, U+D2F0-D2F1, U+D2F4, U+D2F8, U+D300-D301, U+D303, U+D305, U+D30C-D30E, U+D310,
U+D314, U+D316, U+D31C-D31D, U+D31F-D321, U+D325, U+D328-D329, U+D32C, U+D330, U+D338-D339, U+D33B-D33D,
U+D344-D345, U+D37C-D37D, U+D380, U+D384, U+D38C-D38D, U+D38F-D391, U+D398-D399, U+D39C, U+D3A0, U+D3A8-D3A9,
U+D3AB, U+D3AD, U+D3B4, U+D3B8, U+D3BC, U+D3C4-D3C5, U+D3C8-D3C9, U+D3D0, U+D3D8, U+D3E1,
U+D3E3, U+D3EC-D3ED, U+D3F0, U+D3F4, U+D3FC-D3FD, U+D3FF, U+D401, U+D408, U+D41D, U+D440,
U+D444, U+D45C, U+D460, U+D464, U+D46D, U+D46F, U+D478-D479, U+D47C, U+D47F-D480, U+D482,
U+D488-D489, U+D48B, U+D48D, U+D494, U+D4A9, U+D4B0, U+D4CC, U+D4D0, U+D4D4, U+D4DC,
U+D4DF, U+D4E8, U+D4EC, U+D4F0, U+D4F8, U+D4FB, U+D4FD, U+D504, U+D508, U+D50C,
U+D514-D515, U+D517, U+D53C-D53D, U+D540, U+D544, U+D54C-D54D, U+D54F, U+D551, U+D558-D559, U+D55C,
U+D560, U+D565, U+D568-D569, U+D56B, U+D56D, U+D571, U+D573-D575, U+D578, U+D57C, U+D584-D585,
U+D587-D589, U+D58F-D590, U+D59D, U+D5A5, U+D5C8-D5C9, U+D5CC, U+D5D0, U+D5D2, U+D5D8-D5D9, U+D5DB,
U+D5DD, U+D5E4-D5E5, U+D5E8, U+D5EC, U+D5F3-D5F5, U+D5F7, U+D5F9, U+D5FF-D601, U+D604, U+D608,
U+D610-D611, U+D613-D615, U+D61C, U+D620, U+D624, U+D62D, U+D638-D639, U+D63C, U+D640, U+D645,
U+D648-D649, U+D64B, U+D64D, U+D651, U+D654-D655, U+D658, U+D65C, U+D664, U+D667, U+D669,
U+D670-D671, U+D674, U+D683, U+D685, U+D68C-D68D, U+D690, U+D694, U+D69D, U+D69F, U+D6A1,
U+D6A8, U+D6AC, U+D6B0, U+D6B9, U+D6BB, U+D6BD, U+D6C3-D6C5, U+D6C8, U+D6CC, U+D6D1,
U+D6D4-D6D5, U+D6D7, U+D6D9, U+D6E0, U+D6E4, U+D6E8, U+D6F0, U+D6F5, U+D6FC-D6FD, U+D700,
U+D704, U+D711, U+D718-D719, U+D71C, U+D720, U+D728-D729, U+D72B, U+D72D, U+D734-D735, U+D738,
U+D73C, U+D744, U+D747, U+D749, U+D750-D751, U+D754, U+D756-D759, U+D760-D761, U+D763, U+D765,
U+D769, U+D76C, U+D770, U+D774, U+D77C-D77D, U+D781, U+D788-D789, U+D78C, U+D790, U+D798-D799,
U+D79B, U+D79D, U+FF01-FF5E, U+FFE0-FFE3, U+FFE5-FFE6;
}
+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
}
+11
View File
@@ -0,0 +1,11 @@
export type ChatTextMenuOptions = {
kind: 'message' | 'input'
canCopy: boolean
canPaste?: boolean
canClear?: boolean
labels: { copy: string; paste?: string; clear?: string }
}
export type ChatTextMenuAction = 'clear' | null
export type ChatClipboardAction = { action: 'copy'; text: string } | { action: 'paste' }
+55
View File
@@ -0,0 +1,55 @@
const {test} = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
const os = require('node:os')
const vm = require('node:vm')
const ts = require('typescript')
const {createHash} = require('node:crypto')
const {spawnSync} = require('node:child_process')
test('remote visualization stages the bundled bridge over stdin and mounts that revision read-only', async () => {
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
const fn = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'startRemoteVisualizationBridge')
assert.ok(fn)
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-camera-bridge-'))
const resource = path.resolve(__dirname, '../resources/slam-ros-bridge')
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
let launch, transfers = 0
const context = {
normalizeVisualizationStack: () => 'slam3d', normalizeRemoteTarget: target => target,
prepareRemoteRosNetworkConfig: async () => null, visualizationBridgeContainerName: () => 'slam-ros-bridge',
resolveVisualizationBridgeImage: () => 'fixture:arm64', bridgeArtifact: () => null,
slamRosBridgeImage: 'slam:local',
bridgeContainerCommand: () => 'python3 /app/bridge.py', resolveResourceContextPath: () => resource,
readFile: fs.readFile, join: path.join, createHash, quoteShellSingle: quote,
expandRemotePathVar: () => [], reportVisualizationProgress() {},
runUnqueuedSshOutput: async (_target, command, timeout, input) => {
transfers++
assert.equal(timeout, 30000)
assert.ok(input.length > 1000)
const {spawn} = require('node:child_process')
await new Promise((resolve, reject) => {
const child = spawn('bash', ['-c', command], {env: {...process.env, HOME: dir}, stdio:['pipe','ignore','pipe']})
let error = ''
child.stderr.on('data', data => {error += data})
child.on('error', reject)
child.on('close', code => code === 0 ? resolve() : reject(Error(error)))
child.stdin.end(input)
})
return ''
},
runSsh: async (_target, command) => { launch = command }
}
vm.runInNewContext(ts.transpileModule(fn.getText(source), {compilerOptions:{target:ts.ScriptTarget.ES2022}}).outputText, context)
try {
await context.startRemoteVisualizationBridge({workspaceRoot:'/workspace'}, 'slam3d', '77')
assert.equal(transfers, 1)
assert.match(launch, /-v "\$HOME\/\.autonomy-studio\/runtime\/bridges\/[a-f0-9]{64}:\/app:ro"/)
const roots = await fs.readdir(path.join(dir, '.autonomy-studio/runtime/bridges'))
for (const name of ['bridge.py','requirements.txt']) assert.equal(await fs.readFile(path.join(dir,'.autonomy-studio/runtime/bridges',roots[0],name),'utf8'), await fs.readFile(path.join(resource,name),'utf8'))
const syntax = spawnSync('bash', ['-n'], {input:launch,encoding:'utf8'})
assert.equal(syntax.status, 0, syntax.stderr)
assert.ok(launch.includes('docker run'))
} finally {await fs.rm(dir,{recursive:true,force:true})}
})
+4 -1
View File
@@ -35,7 +35,10 @@ for (const script of [run, build]) {
} }
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/); assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/); assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/); assert.match(run, /--device "\$camera_device:\$camera_device"/);
assert.match(run, /CAMERA_DEVICE" = \/dev/);
assert.match(run, /mapfile -t CAMERA_DEVICES/);
assert.doesNotMatch(run, /-v \/dev:\/dev/);
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/); assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
assert.match(run, /--device "\$capture_device:\$capture_device"/); assert.match(run, /--device "\$capture_device:\$capture_device"/);
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/); assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
+101 -68
View File
@@ -1,70 +1,103 @@
const assert = require('node:assert/strict'); const { test } = require('node:test')
const fs = require('node:fs'); const assert = require('node:assert/strict')
const path = require('node:path'); const fs = require('node:fs')
const vm = require('node:vm'); const vm = require('node:vm')
const ts = require('typescript')
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8'); function harness() {
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'")); const exports = {}, requests = [], revoked = [], pending = new Map()
let now = 10000; let now = 10000, state = null, receivedAt = now, visible = false, enabledFetch = true
let requests = 0; let sequence = 0
let ok = true; function element() {
let visible = false; return { children: [], dataset: {}, src: '', append(...children) { this.children.push(...children); children.forEach(child => { child.parent = this }) }, remove() { this.parent.children = this.parent.children.filter(child => child !== this) }, removeAttribute() { this.src = '' }, addEventListener() {} }
let afterBlob = () => {}; }
const context = vm.createContext({ const root = element(), document = { hidden: false, createElement: element, addEventListener() {}, removeEventListener() {} }
ENABLE_CAMERA_PREVIEW: true, const globals = { exports, document, AbortController, Date: { now: () => now },
cameraFrameRequestInFlight: false, window: { setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {} },
cameraFrameRetryAt: 0, URL: { createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: url => revoked.push(url) },
cameraFrameObjectUrl: null, fetch: async (url, options) => {
cameraPreview: {}, requests.push({ url, signal: options.signal })
latestSlamState: null, if (pending.has(url)) await pending.get(url)
latestSlamStateReceivedAt: now, return { ok: enabledFetch, blob: async () => ({}) }
document: { hidden: false }, }
Date: { now: () => now }, }
BACKEND_URL: 'http://localhost:8765', vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/public/slam-viewer/camera-preview.js'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, globals)
AbortSignal, const controller = exports.createCameraPreview({ root, baseUrl: 'http://camera.test', getState: () => ({ state, receivedAt }), onAvailable: value => { visible = value } })
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} }, return { root, requests, revoked, pending, document, controller,
setCameraPreviewAvailable(value) { visible = value; }, state(value) { state = value; receivedAt = now }, advance(ms) { now += ms }, fail(value) { enabledFetch = !value }, visible: () => visible,
async fetch() { settle: () => new Promise(resolve => setImmediate(resolve)) }
requests++; }
return { ok, async blob() { afterBlob(); return {}; } };
},
});
vm.runInContext(source, context);
const refresh = () => context.refreshCameraFrame();
const available = (value) => {
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
context.latestSlamStateReceivedAt = now;
};
(async () => { test('legacy camera polling stays compatible and skips absent, hidden and stale cameras', async () => {
await refresh(); const h = harness()
available(false); h.controller.setEnabled(true)
await refresh(); assert.equal(h.requests.length, 0)
assert.equal(requests, 0, 'Absent camera must not fetch frames'); h.state({ topic_status: { camera_image: { available: true } } })
available(true); h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.requests[0].url, 'http://camera.test/camera-frame')
assert.equal(requests, 1); assert.equal(h.root.children[0].children[1].src, 'blob:1')
assert.equal(context.cameraPreview.src, 'blob:frame'); h.document.hidden = true
now += 3001; h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.requests.length, 1)
assert.equal(requests, 1, 'Stale websocket state must stop polling'); h.advance(3001)
assert.equal(visible, false); h.controller.refresh()
available(true); assert.equal(h.visible(), false)
ok = false; assert.equal(h.root.children.length, 0)
await refresh(); assert.deepEqual(h.revoked, ['blob:1'])
await refresh(); })
assert.equal(requests, 2, 'HTTP errors must back off');
now += 5000; test('multiple camera frames are isolated and one unavailable camera cannot hide another', async () => {
available(true); const h = harness()
ok = true; h.state({ cameras: [{ id: 'video0', name: '/dev/video0', available: true }, { id: 'video2', name: '/dev/video2', available: true }] })
afterBlob = () => available(false); h.controller.setEnabled(true); await h.settle()
context.cameraPreview.src = ''; assert.equal(h.root.children.length, 2)
await refresh(); assert.equal(h.requests.length, 2)
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera'); assert.deepEqual(h.requests.map(request => request.url), ['http://camera.test/camera-frame?camera_id=video0', 'http://camera.test/camera-frame?camera_id=video2'])
assert.equal(context.cameraFrameRequestInFlight, false); const first = h.root.children[0].children[1].src
afterBlob = () => {}; h.state({ cameras: [{ id: 'video2', name: '/dev/video2', available: true }] })
available(true); h.controller.refresh(); await h.settle()
await refresh(); assert.equal(h.root.children.length, 1)
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames'); assert.equal(h.root.children[0].dataset.cameraId, 'video2')
console.log('camera preview polling tests passed'); assert.ok(h.revoked.includes(first))
})().catch((error) => { console.error(error); process.exitCode = 1; }); assert.equal(h.visible(), true)
h.controller.dispose()
assert.equal(h.root.children.length, 0)
assert.equal(h.visible(), false)
})
test('in-flight requests are bounded and late frames cannot resurrect a removed or disabled camera', async () => {
const h = harness()
let release
h.pending.set('http://camera.test/camera-frame?camera_id=video0', new Promise(resolve => { release = resolve }))
h.state({ cameras: [{ id: 'video0', available: true }] })
h.controller.setEnabled(true)
h.controller.refresh()
assert.equal(h.requests.length, 1)
h.controller.setEnabled(false)
assert.equal(h.requests[0].signal.aborted, true)
release(); await h.settle()
assert.equal(h.root.children.length, 0)
assert.equal(h.visible(), false)
})
test('HTTP failures back off per camera and reconnecting restores frames', async () => {
const h = harness()
h.state({ cameras: [{ id: 'video0', available: true }] })
h.fail(true)
h.controller.setEnabled(true); await h.settle()
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 1)
h.advance(5000); h.state({ cameras: [{ id: 'video0', available: true }] }); h.fail(false)
h.controller.refresh(); await h.settle()
assert.equal(h.requests.length, 2)
assert.ok(h.root.children[0].children[1].src.startsWith('blob:'))
})
test('camera identifiers are validated, deduplicated and limited without executing names as markup', async () => {
const h = harness()
h.state({ cameras: [{ id: '../etc', available: true }, ...Array.from({ length: 12 }, (_, i) => ({ id: `video${i}`, available: true, name: '<script>unsafe</script>' })), { id: 'video0', available: true }] })
h.controller.setEnabled(true); await h.settle()
assert.equal(h.requests.length, 8)
assert.equal(h.root.children.length, 8)
assert.equal(h.root.children[0].children[0].textContent, '<script>unsafe</script>')
})