Compare commits

10 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
56 changed files with 4003 additions and 328 deletions
+27 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.19", "version": "1.2.25",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.2.19", "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,6 +17,7 @@
"@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",
@@ -5249,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",
@@ -6178,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",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.2.19", "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,6 +58,7 @@
"@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",
+51 -6
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: 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:
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,8 +1341,14 @@ 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:
if camera_id is not None:
entry = self._camera_frames.get(camera_id)
if entry is None:
return None
image, received_at, _topic = entry
else:
image = self._camera_image image = self._camera_image
received_at = self._last_camera_image_wall_time 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:
@@ -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)
})
+5 -1
View File
@@ -5,9 +5,13 @@ const vm = require('node:vm')
const ts = require('typescript') const ts = require('typescript')
const output = {} 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'), { 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 } compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: output, DOMException }) }).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
const { resolveAgentContext } = output const { resolveAgentContext } = output
const plain = value => JSON.parse(JSON.stringify(value)) const plain = value => JSON.parse(JSON.stringify(value))
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files }) const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
+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)
})
+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')
})
+1
View File
@@ -118,6 +118,7 @@ const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(
const renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, { const renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
...require('lucide-react'), t: key => key, useLocaleRender() {}, ...require('lucide-react'), t: key => key, useLocaleRender() {},
ProgressiveChatResponse: () => null, MessageCopyButton: () => null, ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
AgentWorkDuration: () => null,
agentMessageCopyText: message => message.content agentMessageCopyText: message => message.content
}).AgentMessages }).AgentMessages
+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]])
})
+21 -5
View File
@@ -4,9 +4,13 @@ const fs = require('node:fs')
const vm = require('node:vm') const vm = require('node:vm')
const ts = require('typescript') const ts = require('typescript')
const contextApi = {} 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'), { 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 } compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
}).outputText, { exports: contextApi, DOMException }) }).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) const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
let payloadExpression let payloadExpression
@@ -28,15 +32,16 @@ test('user messages preserve only typed text while attachments remain separate m
find(source) find(source)
assert.ok(initializer) assert.ok(initializer)
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) { for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
const message = vm.runInNewContext(`(${initializer})`, { request, options: { attachments: [{ name: '스크린샷.png' }] } }) const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
assert.equal(message.content, request) assert.equal(message.content, request)
} }
}) })
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = []) { function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
return JSON.parse(vm.runInNewContext(payloadExpression, { return JSON.parse(vm.runInNewContext(payloadExpression, {
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize, courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
textAttachments: attachments ?? [], imageTokens, textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
selection: provider ? { provider } : {},
agentPlanRequestText: contextApi.agentPlanRequestText, agentPlanRequestText: contextApi.agentPlanRequestText,
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] } context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
})) }))
@@ -70,7 +75,7 @@ test('finalization instruction reaches the API without sending unsupported DTO f
assert.match(body.request, /설정 위치 알려줘/) assert.match(body.request, /설정 위치 알려줘/)
assert.match(body.request, /최종 답변/) assert.match(body.request, /최종 답변/)
assert.equal(Object.hasOwn(body, 'finalize'), false) assert.equal(Object.hasOwn(body, 'finalize'), false)
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'request', 'runtimeLogs', 'week']) assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week'])
}) })
test('finalization keeps the request within the backend 4000-character limit', () => { test('finalization keeps the request within the backend 4000-character limit', () => {
@@ -78,3 +83,14 @@ test('finalization keeps the request within the backend 4000-character limit', (
assert.equal(body.request.length, 4000) assert.equal(body.request.length, 4000)
assert.match(body.request, /원래 학생 질문/) 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)
})
+9 -3
View File
@@ -72,7 +72,7 @@ test('long answers are grouped into at most twenty steps and finish within five
test('only the newly appended paragraph gets the fade class', () => { test('only the newly appended paragraph gets the fade class', () => {
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {} '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
}) })
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.') const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL) const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
@@ -92,6 +92,12 @@ function renderResponse(message, now, reducedMotion = false, extra = {}) {
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })) return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
} }
test('internal cumulative memory is not rendered in the response bubble', () => {
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
assert.match(html, /화면 설명/)
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
})
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => { test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0) const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
assert.ok(html.includes('A-Copilot')) assert.ok(html.includes('A-Copilot'))
@@ -168,7 +174,7 @@ for (const dark of [false, true]) {
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => { test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
let expanded let expanded
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
react: { ...React, useEffect() {}, useState(initial) { react: { ...React, useEffect() {}, useState(initial) {
if (expanded === undefined) expanded = initial if (expanded === undefined) expanded = initial
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }] return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
@@ -192,7 +198,7 @@ for (const dark of [false, true]) {
test('short answers do not show an unnecessary collapse button', () => { test('short answers do not show an unnecessary collapse button', () => {
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', { const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, '../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] } react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
}) })
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true }) const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
+15
View File
@@ -63,15 +63,30 @@ for (const backend of ['local', 'remote']) {
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' })) 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.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
assert.equal(full.data.complete, true) 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 })) 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.content, '조향: ackermann')
assert.equal(partial.data.complete, false) assert.equal(partial.data.complete, false)
assert.equal(partial.truncated, true) 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' })) const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
assert.equal(missing.data.exists, false) assert.equal(missing.data.exists, false)
assert.equal(missing.data.hash, null) assert.equal(missing.data.hash, null)
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' }))) 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 => { test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
const root = await fixture(t) const root = await fixture(t)
await fs.mkdir(path.join(root, 'code')) await fs.mkdir(path.join(root, 'code'))
+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')
})
}
+62 -1
View File
@@ -16,22 +16,56 @@ function compile(source, overrides = {}, globals = {}) {
} }
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), { const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {} '../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 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 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 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 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'), {}, { const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
t: key => key, useLocaleRender() {}, t: key => key, useLocaleRender() {},
ChatResponseSkeleton: widgets.ChatResponseSkeleton, ChatResponseSkeleton: widgets.ChatResponseSkeleton,
ChatEmptyState: widgets.ChatEmptyState, ChatEmptyState: widgets.ChatEmptyState,
ProgressiveChatResponse, ProgressiveChatResponse,
ChatImageAttachments, ChatImageAttachments,
AgentCodeReferences,
AgentProgressBubble,
AgentWorkDuration,
MessageCopyButton: () => null, MessageCopyButton: () => null,
agentMessageCopyText: message => message.content,
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg') 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', () => { test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
const tree = messages.AgentMessages({ const tree = messages.AgentMessages({
dark: true, enabled: true, statusLoading: false, pending: false, dark: true, enabled: true, statusLoading: false, pending: false,
@@ -76,6 +110,33 @@ test('AI answer forwards reveal timing and collapse settings to the same present
assert.match(html, /전체 답변/) 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 dark of [false, true]) {
for (const [mode, expected] of [ for (const [mode, expected] of [
['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/], ['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/],
+4
View File
@@ -39,6 +39,10 @@ function setup(overrides = {}, browser = {}) {
'../../components/ChatWidgets': { QaInputBox }, '../../components/ChatWidgets': { QaInputBox },
'../../components/ChatTextContextMenu': { ChatTextContextMenu }, '../../components/ChatTextContextMenu': { ChatTextContextMenu },
'../../components/ChatImageAttachments': {}, '../../components/ChatImageAttachments': {},
'../../components/AgentProgressBubble': {},
'../../components/AgentWorkDuration': {},
'../../components/AgentCodeReferences': {},
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
'react-dom': { createPortal: child => child }, 'react-dom': { createPortal: child => child },
react: { react: {
...React, ...React,
+1
View File
@@ -36,6 +36,7 @@ function harness() {
if (name === './chatAttachments') return {} if (name === './chatAttachments') return {}
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null } if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null } if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
if (name === './ChatCodeBlock') return {}
if (name === 'react') return { if (name === 'react') return {
...React, ...React,
useState(initial) { useState(initial) {
+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')
})
+1 -1
View File
@@ -125,7 +125,7 @@ for (const selected of [false, true]) {
let prevented = false let prevented = false
let stopped = false let stopped = false
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', { const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, '../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 }) } 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 } } } }) }, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} }) const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
+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')
})
+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')
})
+7 -3
View File
@@ -43,10 +43,14 @@ export async function runAgentFileTool(root: string, request: AgentToolRequest):
try { try {
const content = await readText(root, path) const content = await readText(root, path)
const lines = content.split('\n') const lines = content.split('\n')
const partial = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
const startLine = args.startLine ?? 1 const startLine = args.startLine ?? 1
const selected = partial ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n').slice(0, 40_000) : content const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete: !partial, startLine, totalLines: lines.length }, truncated: partial } 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) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
// A missing leaf in an existing directory is evidence for a new file. // A missing leaf in an existing directory is evidence for a new file.
+8 -3
View File
@@ -116,10 +116,15 @@ else:
try: try:
content = read(root, path) content = read(root, path)
lines = content.split('\n') lines = content.split('\n')
partial = 'startLine' in options or 'lineCount' in options or len(content) > 40000 ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000
start = options.get('startLine', 1) start = options.get('startLine', 1)
selected = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)])[:40000] if partial else content selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': not partial, 'startLine': start, 'totalLines': len(lines)} 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: except FileNotFoundError:
checked(root, path, True) checked(root, path, True)
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
+30 -3
View File
@@ -78,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> = {
@@ -2563,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('시각화 시작 중...')
@@ -2580,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',
@@ -4777,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',
'', '',
@@ -4800,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',
+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('/'):
@@ -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);
},
};
}
+78 -108
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,28 +281,42 @@
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; #cameraPresetSelect option {
background-size:5px 5px, 5px 5px; color:rgba(255,255,255,.88);
background-repeat:no-repeat; 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,
@@ -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() {
+66 -13
View File
@@ -60,7 +60,8 @@ import { useQaChat } from './features/workspace/useQaChat'
import { useAiAgent } from './features/workspace/useAiAgent' import { useAiAgent } from './features/workspace/useAiAgent'
import { validateAgentOperation, validateAgentTool, type AgentToolRequest, type AgentToolResult } from '../../shared/agentTools' import { validateAgentOperation, validateAgentTool, type AgentToolRequest, type AgentToolResult } from '../../shared/agentTools'
import { AGENT_PRACTICE_PROFILES, inspectAgentPractice, prepareAgentPractice } from './features/workspace/agentPractice' import { AGENT_PRACTICE_PROFILES, inspectAgentPractice, prepareAgentPractice } from './features/workspace/agentPractice'
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent' 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 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'
@@ -629,15 +630,20 @@ function LocalPcWorkspaceMockup({
enabled: agentEnabled, enabled: agentEnabled,
provider: agentProvider, provider: agentProvider,
imagesEnabled: agentImagesEnabled, imagesEnabled: agentImagesEnabled,
models: agentModels,
selectProvider: selectAgentProvider,
statusLoading: agentStatusLoading, statusLoading: agentStatusLoading,
pending: agentPending, pending: agentPending,
activity: agentActivity, activity: agentActivity,
progressSteps: agentProgressSteps,
requestStartedAt: agentRequestStartedAt,
messages: agentMessages, messages: agentMessages,
persistenceError: agentPersistenceError, persistenceError: agentPersistenceError,
requestPlan: requestAgentPlan, requestPlan: requestAgentPlan,
applyPlan: applyAgentPlan, applyPlan: applyAgentPlan,
cancelPlan: cancelAgentPlan, cancelPlan: cancelAgentPlan,
updateCommandExecution: updateAgentCommandExecution, updateCommandExecution: updateAgentCommandExecution,
openCodeReference: openAgentCodeReference,
stop: stopAgent, stop: stopAgent,
clear: clearAgentConversation clear: clearAgentConversation
} = useAiAgent({ } = useAiAgent({
@@ -855,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)
@@ -2446,6 +2453,10 @@ function LocalPcWorkspaceMockup({
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() : ''
@@ -3517,9 +3528,9 @@ function LocalPcWorkspaceMockup({
const createCustomModule = async (input: CustomModuleInput): Promise<void> => { const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
await createCustomModuleRecord(input) await createCustomModuleRecord(input)
} }
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => { const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise<AgentWorkspaceModule[]> => {
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase())) const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({ const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({
name: module.name, name: module.name,
folderName: module.folderName, folderName: module.folderName,
category: module.category, category: module.category,
@@ -3533,7 +3544,7 @@ function LocalPcWorkspaceMockup({
} }
} : {}) } : {})
})) }))
const targetModules = availableModules.filter((module) => ( const targetModules = moduleSnapshot.filter((module) => (
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase()) module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
)) ))
if (!targetModules.length) return workspaceModules if (!targetModules.length) return workspaceModules
@@ -3668,18 +3679,51 @@ function LocalPcWorkspaceMockup({
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => { const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
if (agentActionControllerRef.current || agentApplying) return if (agentActionControllerRef.current || agentApplying) return
try { try {
const modules = await collectAgentWorkspace() await requestAgentPlan(content, [], [], {
await requestAgentPlan(content, modules, [], {
attachments, attachments,
loadInitialContext: async (signal) => {
const modules = await readAgentModules()
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
return { modules: await collectAgentWorkspace([], modules), runtimeLogs: [] }
},
loadRequestedContext: loadAgentRequestedContext, loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool, executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local' environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
}) })
} catch (error) { } catch (error) {
console.error('[agent] workspace collection failed', error) console.error('[agent] workspace collection failed', error)
onTitlebarNotice(error instanceof Error ? error.message : '모듈 파일을 읽지 못했습니다.', '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 openAgentChangePreview = async (messageId: string, change: AgentFileChange): Promise<void> => {
const module = availableModules.find((item) => item.name === change.moduleName) const module = availableModules.find((item) => item.name === change.moduleName)
?? (selectedModule?.name === change.moduleName ? selectedModule : null) ?? (selectedModule?.name === change.moduleName ? selectedModule : null)
@@ -3788,7 +3832,9 @@ function LocalPcWorkspaceMockup({
const currentModules = await readAgentModules() const currentModules = await readAgentModules()
assertActive() assertActive()
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], { await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
showUserMessage: false, environment, executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext, showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview,
executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }] toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
}) })
} catch (error) { } catch (error) {
@@ -3870,7 +3916,9 @@ function LocalPcWorkspaceMockup({
showUserMessage: false, showUserMessage: false,
loadRequestedContext: loadAgentRequestedContext, loadRequestedContext: loadAgentRequestedContext,
executeTool: executeWorkspaceAgentTool, executeTool: executeWorkspaceAgentTool,
environment: remoteWorkspace ? 'remote' : 'local' environment: remoteWorkspace ? 'remote' : 'local',
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
}) })
} catch (error) { } catch (error) {
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.') const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
@@ -4716,9 +4764,13 @@ function LocalPcWorkspaceMockup({
agentEnabled={agentEnabled} agentEnabled={agentEnabled}
agentProvider={agentProvider} agentProvider={agentProvider}
agentImagesEnabled={Boolean(agentImagesEnabled)} agentImagesEnabled={Boolean(agentImagesEnabled)}
agentModels={agentModels}
onAgentProviderChange={selectAgentProvider}
agentStatusLoading={agentStatusLoading} agentStatusLoading={agentStatusLoading}
agentPending={agentPending} agentPending={agentPending}
agentActivity={agentActivity} agentActivity={agentActivity}
agentProgressSteps={agentProgressSteps}
agentRequestStartedAt={agentRequestStartedAt}
agentActionBusy={agentActionBusy || agentApplying} agentActionBusy={agentActionBusy || agentApplying}
agentPersistenceError={agentPersistenceError} agentPersistenceError={agentPersistenceError}
aiPending={aiPending} aiPending={aiPending}
@@ -4752,6 +4804,7 @@ function LocalPcWorkspaceMockup({
if (agentActionControllerRef.current || agentPending) return if (agentActionControllerRef.current || agentPending) return
if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId) if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId)
}} }}
onOpenAgentCodeReference={(messageId, referenceId) => void openAgentCodeReference(messageId, referenceId, openWorkspaceAgentCodeReference)}
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => { onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => {
await applyWorkspaceAgentPlan(plan) await applyWorkspaceAgentPlan(plan)
closeAgentDiffPreviewsForMessage(messageId) closeAgentDiffPreviewsForMessage(messageId)
@@ -5545,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>
}
@@ -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>
}
+22 -12
View File
@@ -4,6 +4,7 @@ import type React from 'react'
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react' import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { CHAT_ATTACHMENT_ACCEPT, CHAT_IMAGE_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments' import { CHAT_ATTACHMENT_ACCEPT, CHAT_IMAGE_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments'
import { ChatImageAttachments } from './ChatImageAttachments' 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' import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu'
@@ -66,7 +67,6 @@ export function MarkdownMessage({ content, dark, fadeFrom }: { content: string;
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' : '' const fadeClass = (offset?: number): string => fadeFrom !== undefined && offset !== undefined && offset >= fadeFrom ? ' agent-response-fade' : ''
return ( return (
@@ -92,30 +92,37 @@ export function MarkdownMessage({ content, dark, fadeFrom }: { content: string;
{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, node }) => <li className={`max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</li>, 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, node }) => ( blockquote: ({ children, node }) => (
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}${fadeClass(node?.position?.start.offset)}`}> <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: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />, hr: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />,
pre: ({ children, node }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}${fadeClass(node?.position?.start.offset)}`}>{children}</pre>, 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, node }) => ( table: ({ children, node }) => (
<div className={`my-2 max-w-full overflow-hidden${fadeClass(node?.position?.start.offset)}`}> <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}
@@ -291,6 +298,7 @@ export const QaInputBox = memo(function QaInputBox({
imagesEnabled = false, imagesEnabled = false,
onClearConversation, onClearConversation,
clearDisabled = false, clearDisabled = false,
footerAction,
onStop, onStop,
onSubmit onSubmit
}: { }: {
@@ -302,6 +310,7 @@ export const QaInputBox = memo(function QaInputBox({
imagesEnabled?: boolean imagesEnabled?: boolean
onClearConversation?: () => void onClearConversation?: () => void
clearDisabled?: boolean clearDisabled?: boolean
footerAction?: React.ReactNode
onStop: () => void onStop: () => void
onSubmit: (content: string, attachments?: ChatAttachment[]) => void onSubmit: (content: string, attachments?: ChatAttachment[]) => void
}): React.JSX.Element { }): React.JSX.Element {
@@ -449,8 +458,9 @@ export const QaInputBox = memo(function QaInputBox({
)} )}
<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))} /> <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>} {attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
{(attachmentsEnabled || onClearConversation) && ( {(attachmentsEnabled || onClearConversation || footerAction) && (
<div className={['flex h-9 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}> <div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
{footerAction}
{attachmentsEnabled && ( {attachmentsEnabled && (
<> <>
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => { <input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
@@ -49,14 +49,14 @@ export function ProgressiveChatResponse({ message, dark, children, footerAction,
}, [frame.duration, message.revealStartedAt, reducedMotion]) }, [frame.duration, message.revealStartedAt, reducedMotion])
return ( return (
<div className="grid gap-3" aria-busy={!frame.complete}> <div className="grid min-w-0 max-w-full gap-3" aria-busy={!frame.complete}>
<div> <div className="min-w-0">
{message.plan && ( {message.plan && (
<div className={['font-black', frame.fadeFrom === 0 ? 'agent-response-fade' : ''].join(' ')}> <div className={['font-black', frame.fadeFrom === 0 ? 'agent-response-fade' : ''].join(' ')}>
<MarkdownMessage content={message.plan.summary} dark={dark} /> <MarkdownMessage content={message.plan.summary} dark={dark} />
</div> </div>
)} )}
<div className={message.plan ? 'mt-1' : ''}> <div className={message.plan ? 'mt-1 min-w-0' : 'min-w-0'}>
{frame.complete && frame.fadeFrom === undefined ? ( {frame.complete && frame.fadeFrom === undefined ? (
<ExpandableMarkdownMessage <ExpandableMarkdownMessage
content={content} content={content}
@@ -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}
@@ -7,6 +7,12 @@ import { ConfirmStatusModal } from '../../components/AppStatusModals'
import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse' import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
import { ChatTextContextMenu, type ChatTextContextMenuState } from '../../components/ChatTextContextMenu' import { ChatTextContextMenu, type ChatTextContextMenuState } from '../../components/ChatTextContextMenu'
import { ChatImageAttachments } from '../../components/ChatImageAttachments' 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 { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent' import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
import type { ChatAttachment } from '../../components/chatAttachments' import type { ChatAttachment } from '../../components/chatAttachments'
@@ -43,9 +49,13 @@ type WorkspaceQaPanelProps = {
agentEnabled: boolean agentEnabled: boolean
agentProvider?: string | null agentProvider?: string | null
agentImagesEnabled?: boolean agentImagesEnabled?: boolean
agentModels?: AgentModelOption[]
onAgentProviderChange?: (provider: string) => void
agentStatusLoading: boolean agentStatusLoading: boolean
agentPending: boolean agentPending: boolean
agentActivity?: string agentActivity?: string
agentProgressSteps?: AgentProgressStep[]
agentRequestStartedAt?: number | null
agentActionBusy?: boolean agentActionBusy?: boolean
agentPersistenceError?: string | null agentPersistenceError?: string | null
aiPending: boolean aiPending: boolean
@@ -64,6 +74,7 @@ type WorkspaceQaPanelProps = {
onOpenAgentChange: (messageId: string, change: AgentFileChange) => void onOpenAgentChange: (messageId: string, change: AgentFileChange) => void
onRunAgentCommand: (messageId: string, command: AgentCommand) => void onRunAgentCommand: (messageId: string, command: AgentCommand) => void
onRejectAgentCommand: (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 { function MessageCopyButton({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
@@ -117,9 +128,13 @@ export default function WorkspaceQaPanel({
agentEnabled, agentEnabled,
agentProvider, agentProvider,
agentImagesEnabled = false, agentImagesEnabled = false,
agentModels = [],
onAgentProviderChange,
agentStatusLoading, agentStatusLoading,
agentPending, agentPending,
agentActivity, agentActivity,
agentProgressSteps = [],
agentRequestStartedAt,
agentActionBusy, agentActionBusy,
agentPersistenceError, agentPersistenceError,
aiPending, aiPending,
@@ -135,6 +150,7 @@ export default function WorkspaceQaPanel({
onClearConversation, onClearConversation,
onApplyAgentPlan, onApplyAgentPlan,
onCancelAgentPlan, onCancelAgentPlan,
onOpenAgentCodeReference,
onOpenAgentChange, onOpenAgentChange,
onRunAgentCommand, onRunAgentCommand,
onRejectAgentCommand onRejectAgentCommand
@@ -197,6 +213,8 @@ export default function WorkspaceQaPanel({
} }
}, [ }, [
agentMessages.length, agentMessages.length,
agentProgressSteps.length,
agentActivity,
agentPending, agentPending,
aiPending, aiPending,
lastMessage?.content, lastMessage?.content,
@@ -304,10 +322,13 @@ export default function WorkspaceQaPanel({
statusLoading={agentStatusLoading} statusLoading={agentStatusLoading}
pending={agentPending} pending={agentPending}
activity={agentActivity} activity={agentActivity}
progressSteps={agentProgressSteps}
requestStartedAt={agentRequestStartedAt}
busy={agentPending || agentActionBusy} busy={agentPending || agentActionBusy}
messages={agentMessages} messages={agentMessages}
onApply={onApplyAgentPlan} onApply={onApplyAgentPlan}
onCancel={onCancelAgentPlan} onCancel={onCancelAgentPlan}
onOpenCodeReference={onOpenAgentCodeReference}
onOpenChange={onOpenAgentChange} onOpenChange={onOpenAgentChange}
onRunCommand={onRunAgentCommand} onRunCommand={onRunAgentCommand}
onRejectCommand={onRejectAgentCommand} onRejectCommand={onRejectAgentCommand}
@@ -333,6 +354,7 @@ export default function WorkspaceQaPanel({
imagesEnabled={qaMode === 'agent' && agentImagesEnabled} imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
onClearConversation={requestClearConversation} onClearConversation={requestClearConversation}
clearDisabled={clearDisabled} clearDisabled={clearDisabled}
footerAction={qaMode === 'agent' && onAgentProviderChange ? <AgentModelSelector models={agentModels} provider={agentProvider ?? null} dark={dark} disabled={inputBusy || agentStatusLoading || inputDisabled} onChange={onAgentProviderChange} /> : undefined}
/> />
{clearConfirmationOpen && createPortal( {clearConfirmationOpen && createPortal(
<ConfirmStatusModal <ConfirmStatusModal
@@ -361,19 +383,24 @@ function AgentMessages({
statusLoading, statusLoading,
pending, pending,
activity, activity,
progressSteps = [],
requestStartedAt,
busy, busy,
messages, messages,
onApply, onApply,
onCancel, onCancel,
onOpenChange, onOpenChange,
onRunCommand, onRunCommand,
onRejectCommand onRejectCommand,
onOpenCodeReference
}: { }: {
dark: boolean dark: boolean
enabled: boolean enabled: boolean
statusLoading: boolean statusLoading: boolean
pending: boolean pending: boolean
activity?: string activity?: string
progressSteps?: AgentProgressStep[]
requestStartedAt?: number | null
busy?: boolean busy?: boolean
messages: AgentConversationMessage[] messages: AgentConversationMessage[]
onApply: (messageId: string, plan: AgentPlan) => void onApply: (messageId: string, plan: AgentPlan) => void
@@ -381,6 +408,7 @@ function AgentMessages({
onOpenChange: (messageId: string, change: AgentFileChange) => void onOpenChange: (messageId: string, change: AgentFileChange) => void
onRunCommand: (messageId: string, command: AgentCommand) => void onRunCommand: (messageId: string, command: AgentCommand) => void
onRejectCommand: (messageId: string, command: AgentCommand) => void onRejectCommand: (messageId: string, command: AgentCommand) => void
onOpenCodeReference?: (messageId: string, referenceId: string) => void
}): React.JSX.Element { }): React.JSX.Element {
useLocaleRender() useLocaleRender()
if (statusLoading) { if (statusLoading) {
@@ -408,6 +436,8 @@ function AgentMessages({
) )
} }
const codeNavigationBusy = messages.some(message => message.applyState === 'applying')
return ( return (
<div className="grid gap-3"> <div className="grid gap-3">
{messages.map((message, index) => message.role === 'user' ? ( {messages.map((message, index) => message.role === 'user' ? (
@@ -422,9 +452,11 @@ function AgentMessages({
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span> <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="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(' ')}> <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} />}> <ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
{message.plan && ( {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 && ( {!!message.plan.evidence?.length && (
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}> <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> <summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
@@ -584,10 +616,7 @@ function AgentMessages({
{pending ? ( {pending ? (
<div data-qa-message="true" className="flex gap-2.5"> <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> <span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
<div className="min-w-0 flex-1"> <AgentProgressBubble dark={dark} steps={progressSteps} activity={activity} startedAt={requestStartedAt} />
{activity ? <p role="status" className={['mb-2 break-words text-[11px]', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>{activity}</p> : null}
<ChatResponseSkeleton dark={dark} label={t("코드 변경안을 만드는 중입니다.")} />
</div>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -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('코드 편집기를 준비하지 못했습니다. 다시 시도해주세요.')
}
@@ -1,8 +1,33 @@
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent' import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { compactAgentEvidence, describeAgentEvidence, missingAgentRead, readFileEvidence } from './agentEvidence'
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[] } export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[]; contextNotes?: string[] }
export type AgentContextRequest = { modules: string[]; logs: 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 = { export type AgentPlanResponse = {
provider: string provider: string
model: string model: string
@@ -14,21 +39,22 @@ export function recoverInterruptedAgentMessages(messages: AgentConversationMessa
let changed = false let changed = false
const recovered = messages.map(message => { const recovered = messages.map(message => {
const interrupted = Object.entries(message.commandExecutions ?? {}).some(([, execution]) => execution.status === 'running') const interrupted = Object.entries(message.commandExecutions ?? {}).some(([, execution]) => execution.status === 'running')
if (!interrupted && message.applyState !== 'applying') return message const interruptedNavigation = Object.values(message.codeReferenceStates ?? {}).some(state => state.status === 'opening')
if (!interrupted && !interruptedNavigation && message.applyState !== 'applying') return message
changed = true changed = true
const error = '이전 작업의 완료 결과를 확인할 수 없습니다. 현재 모듈·파일 상태를 다시 조회한 뒤 진행해주세요.' const error = '이전 작업의 완료 결과를 확인할 수 없습니다. 현재 모듈·파일 상태를 다시 조회한 뒤 진행해주세요.'
return { ...message, ...(message.applyState === 'applying' ? { applyState: 'error' as const, applyError: error } : {}), commandExecutions: Object.fromEntries(Object.entries(message.commandExecutions ?? {}).map(([id, execution]) => [id, execution.status === 'running' ? { ...execution, status: 'failed' as const, error } : execution])) } return { ...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 return changed ? recovered : messages
} }
export function agentPlanRequestText(request: string, finalize = false): string { export function agentPlanRequestText(request: string, finalize = false): string {
if (!finalize) return request if (!finalize) return request
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n' 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) return instruction + request.slice(0, 4000 - instruction.length)
} }
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = []): AgentContext { 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 orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
const selected = new Map<string, AgentWorkspaceModule['files']>() const selected = new Map<string, AgentWorkspaceModule['files']>()
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 })) const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
@@ -86,12 +112,28 @@ function fitAgentContext(context: AgentContext, preferredModules: string[] = [],
return { ...log, content, ...(notes ? { message: notes } : {}) } return { ...log, content, ...(notes ? { message: notes } : {}) }
}) })
let toolSize = 0 let toolSize = 0
const toolResults = [...(context.toolResults ?? [])].reverse().filter(result => { const evidence = compactAgentEvidence(context.toolResults ?? [])
if (result.data.length > 60_000 || toolSize + result.data.length > 180_000) return false 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 toolSize += result.data.length
return true }
}).slice(0, 12).reverse() const toolResults = evidence.filter(result => retained.has(result))
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults } : {}) } 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({ export async function resolveAgentContext({
@@ -100,6 +142,7 @@ export async function resolveAgentContext({
loadRequestedContext, loadRequestedContext,
executeTool, executeTool,
onProgress, onProgress,
onStep,
signal signal
}: { }: {
initialContext: AgentContext initialContext: AgentContext
@@ -107,6 +150,7 @@ export async function resolveAgentContext({
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext> loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult> executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
onProgress?: (label: string) => void onProgress?: (label: string) => void
onStep?: (step: AgentProgressStep) => void
signal: AbortSignal signal: AbortSignal
}): Promise<AgentPlanResponse> { }): Promise<AgentPlanResponse> {
let cachedContext = initialContext let cachedContext = initialContext
@@ -115,6 +159,7 @@ export async function resolveAgentContext({
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName)) const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
const queriedTools = new Set<string>() const queriedTools = new Set<string>()
let toolCount = 0 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 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 usage: NonNullable<AgentConversationMessage['usage']> = {}
const checkAborted = (): void => { const checkAborted = (): void => {
@@ -127,6 +172,7 @@ export async function resolveAgentContext({
} }
const finalize = async (): Promise<AgentPlanResponse> => { const finalize = async (): Promise<AgentPlanResponse> => {
checkAborted() checkAborted()
onProgress?.('최종 답변 정리 중')
const response = await requestPlan(context, true) const response = await requestPlan(context, true)
checkAborted() checkAborted()
addUsage(response) addUsage(response)
@@ -134,9 +180,17 @@ export async function resolveAgentContext({
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => ( 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')}` `- ${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) )).join('\n').slice(0, 8000)
const toolStatus = (context.toolResults ?? []).map(result => `- ${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`).join('\n').slice(0, 5000) const 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 const explanation = stillRequesting
? `AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.` ? `${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 : response.plan.explanation
return { return {
...response, ...response,
@@ -144,7 +198,8 @@ export async function resolveAgentContext({
// Recovery is analysis-only, even if the model proposes actions despite the instruction. // Recovery is analysis-only, even if the model proposes actions despite the instruction.
plan: { plan: {
...response.plan, ...response.plan,
summary: stillRequesting ? '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary, summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}),
explanation, explanation,
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
} }
@@ -154,32 +209,47 @@ export async function resolveAgentContext({
const maxRounds = executeTool ? 8 : 5 const maxRounds = executeTool ? 8 : 5
for (let round = 0; round <= maxRounds; round += 1) { for (let round = 0; round <= maxRounds; round += 1) {
checkAborted() checkAborted()
onProgress?.(round === 0 ? '요청 분석 중' : '조회 결과 분석 중')
const response = await requestPlan(context) const response = await requestPlan(context)
checkAborted() checkAborted()
addUsage(response) addUsage(response)
if (response.plan.codeReferences?.length) confirmedAnswer = response
const modules = [...new Set(response.plan.requestedModules ?? [])] const modules = [...new Set(response.plan.requestedModules ?? [])]
const logs = [...new Set(response.plan.requestedLogs ?? [])] const logs = [...new Set(response.plan.requestedLogs ?? [])]
const tools = response.plan.toolRequests ?? [] 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 (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
if (round === maxRounds) return finalize() if (round === maxRounds) {
context = fitAgentContext(cachedContext, [], [], tools)
return finalize()
}
if (tools.length) { if (tools.length) {
if (!executeTool || tools.length > 6) return finalize() if (!executeTool || tools.length > 6) return finalize()
const fresh = tools.filter(tool => !queriedTools.has(fingerprint(tool))) const fresh = tools.filter(tool => {
if (!fresh.length || toolCount + fresh.length > 24) return finalize() 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 ?? [])] const results = [...(cachedContext.toolResults ?? [])]
for (const [index, tool] of fresh.entries()) { for (const [index, proposedTool] of fresh.entries()) {
checkAborted() checkAborted()
const tool = missingAgentRead(proposedTool, results)
if (!tool) continue
const key = fingerprint(tool) const key = fingerprint(tool)
if (queriedTools.has(key)) continue if (queriedTools.has(key)) continue
queriedTools.add(key) queriedTools.add(key)
onProgress?.(`${tool.moduleName ? `${tool.moduleName} · ` : ''}${tool.name} 조회 중`) onProgress?.(`${tool.moduleName ? `${tool.moduleName} · ` : ''}${tool.name} 조회 중`)
const result = await executeTool({ ...tool, id: `query-${round}-${index}-${tool.id}`.slice(0, 80) }) const result = await executeTool({ ...tool, id: `query-${round}-${index}-${tool.id}`.slice(0, 80) })
checkAborted() checkAborted()
onStep?.(toolProgressStep(tool, result, `tool-${round}-${index}`))
toolCount++ toolCount++
results.push(result) results.push(result)
} }
cachedContext = { ...cachedContext, toolResults: results } cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) }
context = fitAgentContext(cachedContext) context = fitAgentContext(cachedContext, [], [], tools)
onProgress?.('조회 결과 분석 중') onProgress?.('조회 결과 분석 중')
// A model may also use legacy source requests; process those on the next pass. // A model may also use legacy source requests; process those on the next pass.
continue continue
@@ -195,8 +265,19 @@ export async function resolveAgentContext({
} }
nextRequest.modules.forEach((name) => requestedModules.add(name)) nextRequest.modules.forEach((name) => requestedModules.add(name))
nextRequest.logs.forEach((name) => requestedLogs.add(name)) nextRequest.logs.forEach((name) => requestedLogs.add(name))
onProgress?.('추가 자료 조회 중')
const loaded = await loadRequestedContext(nextRequest) const loaded = await loadRequestedContext(nextRequest)
checkAborted() 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 loadedModules = new Map(loaded.modules.map((module) => [module.name, module]))
const mergedModules = cachedContext.modules.map((module) => { const mergedModules = cachedContext.modules.map((module) => {
const updated = loadedModules.get(module.name) const updated = loadedModules.get(module.name)
@@ -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}`
}
+143 -26
View File
@@ -1,8 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react' import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import { createAgentSessionSync } from './agentSessionSync' import { createAgentSessionSync } from './agentSessionSync'
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse } from './agentContext' import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments' import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools' import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
import { agentConversationWorkspace, buildAgentConversationContext, type AgentConversationScope } from './agentConversationContext'
export type AgentFileChange = { export type AgentFileChange = {
expectedHash?: string | null expectedHash?: string | null
@@ -12,6 +14,22 @@ export type AgentFileChange = {
reason: 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 = { export type AgentModulePlan = {
name: string name: string
category: string category: string
@@ -37,6 +55,8 @@ export type AgentCommandExecution = {
} }
export type AgentPlan = { export type AgentPlan = {
conversationSummary?: string
codeReferences?: AgentCodeReference[]
toolRequests?: AgentToolRequest[] toolRequests?: AgentToolRequest[]
evidence?: AgentToolResult[] evidence?: AgentToolResult[]
summary: string summary: string
@@ -70,17 +90,20 @@ export type AgentRuntimeLog = {
} }
export type AgentConversationMessage = { export type AgentConversationMessage = {
conversationScope?: AgentConversationScope
id: string id: string
role: 'user' | 'assistant' role: 'user' | 'assistant'
content: string content: string
provider?: string provider?: string
model?: string model?: string
revealStartedAt?: number revealStartedAt?: number
durationMs?: number
imageAttachments?: AgentImageReference[] imageAttachments?: AgentImageReference[]
plan?: AgentPlan plan?: AgentPlan
applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled' applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled'
applyError?: string applyError?: string
commandExecutions?: Record<string, AgentCommandExecution> commandExecutions?: Record<string, AgentCommandExecution>
codeReferenceStates?: Record<string, AgentCodeReferenceState>
usage?: { usage?: {
promptTokens?: number promptTokens?: number
completionTokens?: number completionTokens?: number
@@ -98,13 +121,6 @@ type UseAiAgentOptions = {
onResponse?: () => void onResponse?: () => void
} }
type AgentStatus = {
enabled: boolean
provider: string | null
model: string | null
imagesEnabled?: boolean
}
type AgentSessionResponse = { type AgentSessionResponse = {
sessionId: string | null sessionId: string | null
messages: AgentConversationMessage[] messages: AgentConversationMessage[]
@@ -113,21 +129,40 @@ type AgentSessionResponse = {
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>() const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) { export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, apiBaseUrl, getFreshAccessToken, onResponse }: UseAiAgentOptions) {
const [status, setStatus] = useState<AgentStatus>({ enabled: false, provider: null, model: null }) 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 [statusLoading, setStatusLoading] = useState(Boolean(accessToken && courseId))
const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId)) const [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
const [historyError, setHistoryError] = useState<string | null>(null) const [historyError, setHistoryError] = useState<string | null>(null)
const [pending, setPending] = useState(false) const [pending, setPending] = useState(false)
const [activity, setActivity] = useState('') const [activity, setActivity] = useState('')
const [progressSteps, setProgressSteps] = useState<AgentProgressStep[]>([])
const [requestStartedAt, setRequestStartedAt] = useState<number | null>(null)
const [messages, setMessages] = useState<AgentConversationMessage[]>([]) const [messages, setMessages] = useState<AgentConversationMessage[]>([])
const abortControllerRef = useRef<AbortController | null>(null) const abortControllerRef = useRef<AbortController | null>(null)
const applyingRef = useRef(false) const applyingRef = useRef(false)
const navigatingRef = useRef(false)
const tokenReaderRef = useRef(getFreshAccessToken) const tokenReaderRef = useRef(getFreshAccessToken)
tokenReaderRef.current = getFreshAccessToken tokenReaderRef.current = getFreshAccessToken
const onResponseRef = useRef(onResponse) const onResponseRef = useRef(onResponse)
onResponseRef.current = onResponse onResponseRef.current = onResponse
const authenticated = Boolean(accessToken && courseId && studentEmail) 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 requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
const authorization = new Headers(options?.headers).get('Authorization') const authorization = new Headers(options?.headers).get('Authorization')
?? `Bearer ${await tokenReaderRef.current()}` ?? `Bearer ${await tokenReaderRef.current()}`
@@ -145,7 +180,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) { if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.') throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
} }
if (response.status === 400 && /property (?:toolProtocol|toolResults|environment|imageTokens) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.') if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens|conversationContext) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.') throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
} }
return response.json() as Promise<T> return response.json() as Promise<T>
@@ -183,32 +218,51 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
useEffect(() => { useEffect(() => {
if (!accessToken || !courseId) { if (!accessToken || !courseId) {
setStatus({ enabled: false, provider: null, model: null }) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
setStatusLoading(false) setStatusLoading(false)
return return
} }
let cancelled = false let cancelled = false
setStatusLoading(true) let revision = 0
let initialLoad = true
const refresh = (): void => {
const requestRevision = ++revision
if (initialLoad) setStatusLoading(true)
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`) requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
.then((nextStatus) => { .then(nextStatus => {
if (!cancelled) setStatus(nextStatus) if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) })
}) })
.catch(() => { .catch(() => {
if (!cancelled) setStatus({ enabled: false, provider: null, model: null }) if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
}) })
.finally(() => { .finally(() => {
if (!cancelled) setStatusLoading(false) 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 () => { return () => {
cancelled = true cancelled = true
window.removeEventListener('focus', refresh)
document.removeEventListener('visibilitychange', refreshVisible)
window.clearInterval(timer)
} }
}, [accessToken, courseId, requestApi, weekNumber]) }, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber])
// The workspace stays mounted while other app pages or Q&A tabs are shown. // The workspace stays mounted while other app pages or Q&A tabs are shown.
// Request cancellation follows the conversation lifetime, not its visibility. // Request cancellation follows the conversation lifetime, not its visibility.
useEffect(() => { useEffect(() => {
setPending(false) setPending(false)
setRequestStartedAt(null)
setProgressSteps([])
setActivity('')
return () => { return () => {
abortControllerRef.current?.abort() abortControllerRef.current?.abort()
abortControllerRef.current = null abortControllerRef.current = null
@@ -265,56 +319,82 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
options: { options: {
showUserMessage?: boolean showUserMessage?: boolean
attachments?: ChatAttachment[] attachments?: ChatAttachment[]
loadInitialContext?: (signal: AbortSignal) => Promise<AgentContext>
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext> loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult> executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
environment?: 'local' | 'remote' environment?: 'local' | 'remote'
workspaceTarget?: string
workspaceRoot?: string
toolResults?: AgentToolResult[] toolResults?: AgentToolResult[]
} = {} } = {}
): Promise<void> => { ): Promise<void> => {
const request = rawRequest.trim() const request = rawRequest.trim()
if (!request || pending || historyLoading || abortControllerRef.current || !status.enabled) return 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 = { const userMessage: AgentConversationMessage = {
id: `agent-user-${Date.now()}`, id: `agent-user-${Date.now()}`,
role: 'user', role: 'user',
content: request content: request,
...(conversationScope ? { conversationScope } : {})
} }
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage]) if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
setPending(true) setPending(true)
setRequestStartedAt(startedAt)
setProgressSteps([])
setActivity('요청 분석 중') setActivity('요청 분석 중')
const abortController = new AbortController() const abortController = new AbortController()
abortControllerRef.current = 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 { try {
let initialContext: AgentContext = { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }
if (options.loadInitialContext) {
setActivity('모듈 상태 조회 중')
const loaded = await options.loadInitialContext(abortController.signal)
if (!currentRequest()) return
initialContext = { ...initialContext, ...loaded }
}
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }]) const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
const imageAttachments: AgentImageReference[] = [] const imageAttachments: AgentImageReference[] = []
for (const file of options.attachments ?? []) { for (const file of options.attachments ?? []) {
if (file.kind !== 'image') continue if (file.kind !== 'image') continue
if (!status.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.') if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
setActivity(`이미지 업로드 중: ${file.name}`) setActivity(`이미지 업로드 중: ${file.name}`)
const body = new FormData() const body = new FormData()
body.append('courseId', courseId) body.append('courseId', courseId)
if (selection.provider) body.append('provider', selection.provider)
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber)) if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
body.append('file', file.file, file.name) body.append('file', file.file, file.name)
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal }) const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
if (!currentRequest()) return
imageAttachments.push(uploaded) imageAttachments.push(uploaded)
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item)) if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
if (abortController.signal.aborted) return appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
} }
const imageTokens = imageAttachments.map(image => image.token) const imageTokens = imageAttachments.map(image => image.token)
setActivity('요청 분석 중') setActivity('요청 분석 중')
const response = await resolveAgentContext({ const response = await resolveAgentContext({
initialContext: { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }, initialContext,
signal: abortController.signal, signal: abortController.signal,
loadRequestedContext: options.loadRequestedContext, loadRequestedContext: options.loadRequestedContext,
executeTool: options.executeTool, executeTool: options.executeTool,
onProgress: setActivity, onProgress: label => { if (currentRequest()) setActivity(label) },
onStep: appendStep,
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', { requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
method: 'POST', method: 'POST',
signal: abortController.signal, signal: abortController.signal,
body: JSON.stringify({ courseId, week: weekNumber, request: agentPlanRequestText(request, finalize), ...(options.executeTool ? { toolProtocol: 1, environment: options.environment } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context }) body: JSON.stringify({ courseId, week: weekNumber, ...selection, request: agentPlanRequestText(request, finalize), environment: options.environment, workspaceTarget: options.workspaceTarget, ...(options.executeTool ? { toolProtocol: 1 } : {}), ...(textAttachments.length ? { attachments: textAttachments } : {}), ...(imageTokens.length ? { imageTokens } : {}), ...context, ...(conversationContext ? { conversationContext } : {}) })
}) })
}) })
if (abortController.signal.aborted) return if (!currentRequest()) return
const plan: AgentPlan = { const plan: AgentPlan = {
...response.plan, ...response.plan,
requestedModules: response.plan.requestedModules ?? [], requestedModules: response.plan.requestedModules ?? [],
@@ -327,7 +407,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
id: `agent-plan-${Date.now()}`, id: `agent-plan-${Date.now()}`,
role: 'assistant', role: 'assistant',
revealStartedAt: Date.now(), revealStartedAt: Date.now(),
durationMs: Math.max(0, Date.now() - startedAt),
content: plan.summary, content: plan.summary,
...(conversationScope ? { conversationScope } : {}),
provider: response.provider, provider: response.provider,
model: response.model, model: response.model,
usage: response.usage, usage: response.usage,
@@ -337,11 +419,12 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
}]) }])
onResponseRef.current?.() onResponseRef.current?.()
} catch (error) { } catch (error) {
if (abortController.signal.aborted) return if (!currentRequest()) return
updateMessages((current) => [...current, { updateMessages((current) => [...current, {
id: `agent-error-${Date.now()}`, id: `agent-error-${Date.now()}`,
role: 'assistant', role: 'assistant',
revealStartedAt: Date.now(), revealStartedAt: Date.now(),
durationMs: Math.max(0, Date.now() - startedAt),
content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.' content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.'
}]) }])
onResponseRef.current?.() onResponseRef.current?.()
@@ -349,7 +432,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
if (abortControllerRef.current === abortController) { if (abortControllerRef.current === abortController) {
abortControllerRef.current = null abortControllerRef.current = null
setPending(false) setPending(false)
setRequestStartedAt(null)
setActivity('') setActivity('')
setProgressSteps([])
} }
} }
} }
@@ -404,6 +489,9 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
abortControllerRef.current?.abort() abortControllerRef.current?.abort()
abortControllerRef.current = null abortControllerRef.current = null
setPending(false) setPending(false)
setRequestStartedAt(null)
setActivity('')
setProgressSteps([])
} }
const updateCommandExecution = (messageId: string, commandId: string, execution: AgentCommandExecution): void => { const updateCommandExecution = (messageId: string, commandId: string, execution: AgentCommandExecution): void => {
@@ -418,6 +506,28 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
: message)) : 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 => { const clear = (): void => {
if (!authenticated || historyLoading) return if (!authenticated || historyLoading) return
stop() stop()
@@ -441,15 +551,22 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
return { return {
...status, ...status,
provider: selectedModel?.provider ?? null,
model: selectedModel?.model ?? null,
imagesEnabled: selectedModel?.imagesEnabled ?? false,
selectProvider,
statusLoading: statusLoading || historyLoading, statusLoading: statusLoading || historyLoading,
pending, pending,
activity, activity,
progressSteps,
requestStartedAt,
messages, messages,
persistenceError: persistenceError ?? historyError, persistenceError: persistenceError ?? historyError,
requestPlan, requestPlan,
applyPlan, applyPlan,
cancelPlan, cancelPlan,
updateCommandExecution, updateCommandExecution,
openCodeReference,
stop, stop,
clear clear
} }
@@ -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'
+42 -1
View File
@@ -1,4 +1,6 @@
{ {
"에이전트 모델 선택": "Agent model",
"사용 가능한 모델 없음": "No models available",
"전송": "Send", "전송": "Send",
"파일 첨부": "Attach files", "파일 첨부": "Attach files",
"첨부 제거": "Remove attachment", "첨부 제거": "Remove attachment",
@@ -719,5 +721,44 @@
"일부 결과": "Partial results", "일부 결과": "Partial results",
"SLAM 실습 준비": "Prepare SLAM practice", "SLAM 실습 준비": "Prepare SLAM practice",
"승인하고 준비": "Approve and prepare", "승인하고 준비": "Approve and prepare",
"내 PC에서 직접 개발 환경을 실행합니다.": "Run the development environment directly on this PC." "내 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"
} }
+13 -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 {
@@ -319,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;
}
+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>')
})