Compare commits
21 Commits
ef260c8fb4
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 017093077f | |||
| af6dc560ea | |||
| 065de83ffd | |||
| a8dd23675a | |||
| cb5872c326 | |||
| af54e875df | |||
| c17763f59d | |||
| 00a83ce4ac | |||
| b7870cffa2 | |||
| 8b501266ff | |||
| af21fa0510 | |||
| 7cfd466350 | |||
| 4321d944b5 | |||
| 234c9ad467 | |||
| 6512f5fa04 | |||
| 7b32668e1d | |||
| 105342fbf3 | |||
| cc589fd8cf | |||
| 098af30fa7 | |||
| 280b000496 | |||
| 4bdc5af620 |
Generated
+30
-3
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.14",
|
||||
"version": "1.2.25",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lms",
|
||||
"version": "1.2.14",
|
||||
"version": "1.2.25",
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.1",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
@@ -17,14 +17,17 @@
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"driver.js": "^1.4.0",
|
||||
"i18next": "^26.4.2",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"react-i18next": "^17.0.15",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"three": "^0.181.2"
|
||||
"three": "^0.181.2",
|
||||
"unified": "11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
@@ -5247,6 +5250,15 @@
|
||||
"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": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||
@@ -6176,6 +6188,21 @@
|
||||
"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": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
||||
+5
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.2.14",
|
||||
"version": "1.2.25",
|
||||
"description": "Autonomy Studio LMS Electron app",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
@@ -58,14 +58,17 @@
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"driver.js": "^1.4.0",
|
||||
"i18next": "^26.4.2",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"react-i18next": "^17.0.15",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"remark-parse": "11.0.0",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"three": "^0.181.2"
|
||||
"three": "^0.181.2",
|
||||
"unified": "11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
|
||||
@@ -6,6 +6,7 @@ import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import struct
|
||||
@@ -207,6 +208,8 @@ class SlamNode(Node):
|
||||
self._fallback_point_cloud: Optional[PointCloud2] = None
|
||||
self._color_point_cloud: Optional[PointCloud2] = None
|
||||
self._camera_image: Optional[CompressedImage] = None
|
||||
self._camera_frames = {}
|
||||
self._camera_subscriptions = {}
|
||||
self._icp_odom: Optional[Odometry] = None
|
||||
self._map: Optional[OccupancyGrid] = 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)
|
||||
if not os.environ.get("CAMERA_IMAGE_TOPIC"):
|
||||
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, reliable_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._last_color_point_cloud_wall_time = time.time()
|
||||
|
||||
def _on_camera_image(self, msg: CompressedImage):
|
||||
def _discover_cameras(self):
|
||||
try:
|
||||
topics = sorted(name for name, types in self.get_topic_names_and_types()
|
||||
if re.fullmatch(r'/camera_test/cameras/video[0-9]+/image_jpeg', name)
|
||||
and 'sensor_msgs/msg/CompressedImage' in types)[:8]
|
||||
except Exception:
|
||||
return
|
||||
for topic in topics:
|
||||
camera_id = topic.split('/')[-2]
|
||||
if camera_id not in self._camera_subscriptions:
|
||||
self._camera_subscriptions[camera_id] = self.create_subscription(
|
||||
CompressedImage, topic,
|
||||
lambda msg, key=camera_id, name=topic: self._on_camera_image(msg, key, name),
|
||||
qos_profile_sensor_data)
|
||||
active = {topic.split('/')[-2] for topic in topics}
|
||||
for camera_id in list(self._camera_subscriptions):
|
||||
if camera_id not in active:
|
||||
self.destroy_subscription(self._camera_subscriptions.pop(camera_id))
|
||||
with self._lock:
|
||||
self._camera_frames.pop(camera_id, None)
|
||||
|
||||
def _on_camera_image(self, msg: CompressedImage, camera_id='legacy', topic=None):
|
||||
with self._lock:
|
||||
if camera_id != 'legacy' and camera_id not in self._camera_subscriptions:
|
||||
return
|
||||
self._camera_image = msg
|
||||
self._camera_image_count += 1
|
||||
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]:
|
||||
with self._lock:
|
||||
@@ -687,7 +726,6 @@ class SlamNode(Node):
|
||||
point_cloud_last = self._last_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
|
||||
camera_image_last = self._last_camera_image_wall_time
|
||||
map_count = self._map_count
|
||||
plan_count = self._plan_count
|
||||
|
||||
@@ -727,7 +765,7 @@ class SlamNode(Node):
|
||||
},
|
||||
"camera_image": {
|
||||
"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},
|
||||
"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,
|
||||
"color_point_cloud": color_point_cloud_payload,
|
||||
"camera_image": camera_image_payload,
|
||||
"cameras": self.camera_sources(),
|
||||
"map": map_payload,
|
||||
"tf": {
|
||||
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
|
||||
@@ -1302,10 +1341,16 @@ class SlamNode(Node):
|
||||
payload["received_at"] = received_at
|
||||
return payload
|
||||
|
||||
def camera_frame_jpeg(self) -> Optional[bytes]:
|
||||
def camera_frame_jpeg(self, camera_id=None) -> Optional[bytes]:
|
||||
with self._lock:
|
||||
image = self._camera_image
|
||||
received_at = self._last_camera_image_wall_time
|
||||
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
|
||||
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:
|
||||
return None
|
||||
if not image.data or "jpeg" not in image.format.lower():
|
||||
@@ -1531,10 +1576,10 @@ def color_point_cloud():
|
||||
|
||||
|
||||
@app.get("/camera-frame")
|
||||
def camera_frame():
|
||||
def camera_frame(camera_id: Optional[str] = None):
|
||||
if not slam_node:
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
|
||||
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
||||
|
||||
@@ -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()
|
||||
@@ -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)}")
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,371 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
|
||||
const output = {}
|
||||
const evidenceApi = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: evidenceApi })
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
|
||||
const { resolveAgentContext } = output
|
||||
const plain = value => JSON.parse(JSON.stringify(value))
|
||||
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
|
||||
const response = (requestedModules = [], requestedLogs = []) => ({
|
||||
provider: 'QWEN', model: 'test', usage: { totalTokens: 10 },
|
||||
plan: { summary: 'result', explanation: 'analysis', requestedModules, requestedLogs, actionPlan: [], commands: [], changes: [], createModule: null }
|
||||
})
|
||||
const options = overrides => ({
|
||||
initialContext: { modules: [moduleInfo('MKMini'), moduleInfo('Control')], runtimeLogs: [] },
|
||||
signal: new AbortController().signal,
|
||||
...overrides
|
||||
})
|
||||
|
||||
test('continues MKMini -> Control -> final analysis and retains earlier source files', async () => {
|
||||
const inputs = []
|
||||
const loads = []
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
inputs.push(plain(context))
|
||||
return [response(['MKMini']), response(['Control']), response()][inputs.length - 1]
|
||||
},
|
||||
loadRequestedContext: async request => {
|
||||
loads.push(plain(request))
|
||||
return { modules: ['MKMini', 'Control'].map(name => moduleInfo(name, request.modules.includes(name) ? [{ path: `${name}.py`, content: `${name} source` }] : [])), runtimeLogs: [] }
|
||||
}
|
||||
}))
|
||||
assert.equal(inputs.length, 3)
|
||||
assert.deepEqual(loads, [{ modules: ['MKMini'], logs: [] }, { modules: ['Control'], logs: [] }])
|
||||
assert.equal(inputs[2].modules[0].files[0].content, 'MKMini source')
|
||||
assert.equal(inputs[2].modules[1].files[0].content, 'Control source')
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
assert.equal(result.usage.totalTokens, 30)
|
||||
})
|
||||
|
||||
test('keeps logs while loading a later source module', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
calls += 1
|
||||
if (calls === 3) assert.equal(context.runtimeLogs[0].content, 'error log')
|
||||
return [response([], ['Control']), response(['Control']), response()][calls - 1]
|
||||
},
|
||||
loadRequestedContext: async request => ({
|
||||
modules: [moduleInfo('Control', request.modules.length ? [{ path: 'node.py', content: 'code' }] : [])],
|
||||
runtimeLogs: request.logs.length ? [{ moduleName: 'Control', state: 'running', content: 'error log' }] : []
|
||||
})
|
||||
}))
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('makes one analysis-only completion when the model repeats a context request', async () => {
|
||||
let loads = 0
|
||||
let calls = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async (context, finalize) => {
|
||||
calls++
|
||||
if (!finalize) return response(['Control'])
|
||||
assert.equal(context.modules.find(module => module.name === 'Control').files[0].path, 'controller.py')
|
||||
return { ...response(), plan: { ...response().plan, explanation: 'controller.py에서 조향 설정을 확인했습니다.' } }
|
||||
},
|
||||
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'steering = 1' }])], runtimeLogs: [] } }
|
||||
}))
|
||||
assert.equal(loads, 1)
|
||||
assert.equal(calls, 3)
|
||||
assert.match(result.plan.explanation, /조향 설정을 확인/)
|
||||
assert.equal(result.usage.totalTokens, 30)
|
||||
})
|
||||
|
||||
test('finishes honestly without an infinite loop if the model ignores the finalization request', async () => {
|
||||
let loads = 0
|
||||
let calls = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async () => { calls++; return response(['Control']) },
|
||||
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
|
||||
}))
|
||||
assert.equal(loads, 1)
|
||||
assert.equal(calls, 3)
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
assert.deepEqual(plain(result.plan.actionPlan), [])
|
||||
assert.match(result.plan.explanation, /결론을 확정하지 못했습니다/)
|
||||
assert.match(result.plan.explanation, /Control: 제공된 파일 0개/)
|
||||
assert.equal(result.plan.createModule, null)
|
||||
})
|
||||
|
||||
test('uses already provided source files without loading the same module again', async () => {
|
||||
let loads = 0
|
||||
let calls = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'steering = 1' }])], runtimeLogs: [] },
|
||||
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) },
|
||||
loadRequestedContext: async () => { loads++; return { modules: [], runtimeLogs: [] } }
|
||||
}))
|
||||
assert.equal(loads, 0)
|
||||
assert.equal(calls, 2)
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
})
|
||||
|
||||
test('recovery cannot turn repeated source requests into executable commands or edits', async () => {
|
||||
const result = await resolveAgentContext(options({
|
||||
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'source' }])], runtimeLogs: [] },
|
||||
requestPlan: async (_, finalize) => finalize ? {
|
||||
...response(),
|
||||
plan: {
|
||||
...response().plan, actionPlan: ['execute'], commands: [{ id: 'run', command: './run.sh' }],
|
||||
changes: [{ moduleName: 'Control', path: 'control.py', content: 'changed' }], createModule: { name: 'new' }
|
||||
}
|
||||
} : response(['Control']),
|
||||
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
|
||||
}))
|
||||
for (const field of ['commands', 'changes', 'actionPlan']) assert.deepEqual(plain(result.plan[field]), [])
|
||||
assert.equal(result.plan.createModule, null)
|
||||
})
|
||||
|
||||
test('does not publish a finalization response received after cancellation', async () => {
|
||||
const controller = new AbortController()
|
||||
let calls = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
signal: controller.signal,
|
||||
requestPlan: async (_, finalize) => {
|
||||
calls++
|
||||
if (finalize) controller.abort()
|
||||
return finalize ? response() : response(['Control'])
|
||||
}
|
||||
})), { name: 'AbortError' })
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('requests only new resources when the model also repeats an earlier module', async () => {
|
||||
let count = 0
|
||||
const loads = []
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async () => [response(['MKMini']), response(['MKMini', 'Control']), response()][count++],
|
||||
loadRequestedContext: async request => { loads.push(plain(request)); return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
|
||||
}))
|
||||
assert.deepEqual(loads[1], { modules: ['Control'], logs: [] })
|
||||
})
|
||||
|
||||
test('does not start another API call after cancellation during context loading', async () => {
|
||||
const controller = new AbortController()
|
||||
let calls = 0
|
||||
await assert.rejects(resolveAgentContext(options({
|
||||
signal: controller.signal,
|
||||
requestPlan: async () => { calls += 1; return response(['Control']) },
|
||||
loadRequestedContext: async () => { controller.abort(); return { modules: [], runtimeLogs: [] } }
|
||||
})), { name: 'AbortError' })
|
||||
assert.equal(calls, 1)
|
||||
})
|
||||
|
||||
test('bounds the number of distinct automatic context requests', async () => {
|
||||
let calls = 0
|
||||
let loads = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response([`Module${calls}`]) },
|
||||
loadRequestedContext: async request => { loads++; return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
|
||||
}))
|
||||
assert.equal(calls, 7)
|
||||
assert.equal(loads, 5)
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
})
|
||||
|
||||
test('passes file-read failures to the model for a concrete final explanation', async () => {
|
||||
let count = 0
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
if (count++ === 0) return response(['Control'])
|
||||
assert.equal(context.modules.find(module => module.name === 'Control').fileContext.status, 'error')
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [{ ...moduleInfo('Control'), fileContext: { status: 'error', message: 'permission denied' } }], runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
|
||||
test('excludes oversized source files with an explicit notice and continues with complete smaller files', async () => {
|
||||
let calls = 0
|
||||
const smallFile = { path: 'config/steering.yaml', content: 'kinematics: ackermann' }
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['Control'])
|
||||
const control = context.modules.find(module => module.name === 'Control')
|
||||
assert.deepEqual(plain(control.files), [smallFile])
|
||||
assert.equal(control.fileContext.status, 'limited')
|
||||
assert.match(control.fileContext.message, /전체 2개 중 1개/)
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }, smallFile])], runtimeLogs: [] })
|
||||
}))
|
||||
assert.equal(calls, 2)
|
||||
})
|
||||
|
||||
test('requests a final answer rather than leaving a context-only response when no loader is available', async () => {
|
||||
let calls = 0
|
||||
const result = await resolveAgentContext(options({
|
||||
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) }
|
||||
}))
|
||||
assert.equal(calls, 2)
|
||||
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||
})
|
||||
|
||||
test('keeps a just-started module running when a stale collector supplies source files', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
initialContext: { modules: [{ ...moduleInfo('Control'), state: 'running' }], runtimeLogs: [] },
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['Control'])
|
||||
assert.equal(context.modules[0].state, 'running')
|
||||
assert.equal(context.modules[0].files[0].content, 'code')
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
|
||||
const files = (name, count, size = 10) => Array.from({ length: count }, (_, index) => ({ path: `${name}-${index}.py`, content: String(index).padEnd(size, 'x') }))
|
||||
const sourceSize = modules => modules.reduce((sum, module) => sum + module.files.reduce((total, file) => total + file.path.length + file.content.length, 0), 0)
|
||||
|
||||
test('balances 24 old and 24 new files instead of failing the second module lookup', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
calls++
|
||||
if (calls === 1) return response(['MKMini'])
|
||||
if (calls === 2) return response(['Control'])
|
||||
assert.equal(context.modules.reduce((sum, module) => sum + module.files.length, 0), 24)
|
||||
for (const name of ['MKMini', 'Control']) {
|
||||
const module = context.modules.find(module => module.name === name)
|
||||
assert.equal(module.files.length, 12)
|
||||
assert.equal(module.fileContext.status, 'limited')
|
||||
assert.match(module.fileContext.message, /전체 24개 중 12개/)
|
||||
}
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name, files(name, 24))), runtimeLogs: [] })
|
||||
}))
|
||||
assert.equal(calls, 3)
|
||||
})
|
||||
|
||||
test('enforces the combined character budget without slicing source file contents', async () => {
|
||||
let calls = 0
|
||||
const contents = new Set(['a'.repeat(60_000), 'b'.repeat(60_000)])
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['Control'])
|
||||
assert.ok(sourceSize(context.modules) <= 160_000)
|
||||
for (const module of context.modules) {
|
||||
assert.equal(module.files.length, 1)
|
||||
assert.ok(contents.has(module.files[0].content))
|
||||
assert.equal(module.fileContext.status, 'limited')
|
||||
}
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: ['MKMini', 'Control'].map((name, index) => moduleInfo(name, [
|
||||
{ path: 'one.py', content: [...contents][index] },
|
||||
{ path: 'two.py', content: [...contents][index] }
|
||||
])), runtimeLogs: [] }),
|
||||
initialContext: { modules: [moduleInfo('MKMini', [{ path: 'one.py', content: [...contents][0] }, { path: 'two.py', content: [...contents][0] }]), moduleInfo('Control')], runtimeLogs: [] }
|
||||
}))
|
||||
})
|
||||
|
||||
test('prioritizes newly requested source when earlier context nearly fills the character budget', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
initialContext: { modules: [moduleInfo('MKMini', files('old', 2, 70_000)), moduleInfo('Control')], runtimeLogs: [] },
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['Control'])
|
||||
assert.equal(context.modules.find(module => module.name === 'Control').files[0].content, 'new'.repeat(20_000))
|
||||
assert.ok(context.modules.find(module => module.name === 'MKMini').files.length > 0)
|
||||
assert.ok(sourceSize(context.modules) <= 160_000)
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'new'.repeat(20_000) }])], runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
|
||||
test('budgets initial source context before sending the first API request', async () => {
|
||||
const initialContext = { modules: [moduleInfo('Control', files('source', 30))], runtimeLogs: [] }
|
||||
await resolveAgentContext(options({
|
||||
initialContext,
|
||||
requestPlan: async context => {
|
||||
assert.equal(context.modules[0].files.length, 24)
|
||||
assert.equal(context.modules[0].fileContext.status, 'limited')
|
||||
return response()
|
||||
}
|
||||
}))
|
||||
assert.equal(initialContext.modules[0].files.length, 30)
|
||||
assert.equal(initialContext.modules[0].fileContext, undefined)
|
||||
})
|
||||
|
||||
test('rotates older logs out with a notice rather than failing on a fourth module', async () => {
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${moduleName} log` })) },
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response([], ['D'])
|
||||
assert.deepEqual(plain(context.runtimeLogs.map(log => log.moduleName)), ['D', 'C', 'B'])
|
||||
assert.match(context.runtimeLogs[0].message, /이전 로그 제외: A/)
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [{ moduleName: 'D', state: 'running', content: 'latest error' }] })
|
||||
}))
|
||||
})
|
||||
|
||||
test('counts log messages in the server budget and keeps recent log content', async () => {
|
||||
await resolveAgentContext(options({
|
||||
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${'x'.repeat(20_000)}LATEST`, message: 'm'.repeat(500) })) },
|
||||
requestPlan: async context => {
|
||||
assert.equal(context.runtimeLogs.length, 3)
|
||||
assert.ok(context.runtimeLogs.reduce((sum, log) => sum + log.content.length + (log.message?.length ?? 0), 0) <= 60_000)
|
||||
for (const log of context.runtimeLogs) {
|
||||
assert.ok(log.content.endsWith('LATEST'))
|
||||
assert.match(log.message, /최근 구간/)
|
||||
assert.ok(log.message.length <= 500)
|
||||
}
|
||||
return response()
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
test('collects candidates from every requested module and prioritizes configuration before large source trees', async () => {
|
||||
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
let collector
|
||||
function visit(node) {
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'collectAgentWorkspace') collector = node.initializer.getText(source)
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
assert.ok(collector)
|
||||
const api = {}
|
||||
vm.runInNewContext(ts.transpileModule(`exports.collect = ${collector}`, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, {
|
||||
exports: api,
|
||||
availableModules: ['MKMini', 'Control'].map(name => ({ name, state: 'stopped' })),
|
||||
remoteWorkspace: false,
|
||||
clonePathPreview: '/workspace',
|
||||
currentActiveEditorFile: null,
|
||||
editorFiles: {},
|
||||
window: { localWorkspace: { moduleFiles: { read: async name => ({ files: Object.fromEntries([
|
||||
...files(name, 30).map((file, index) => [`src/node${index}.py`, file.content]),
|
||||
['config/steering.yaml', 'kinematics: ackermann']
|
||||
]) }) } } }
|
||||
})
|
||||
const modules = await api.collect(['MKMini', 'Control'])
|
||||
assert.deepEqual(plain(modules.map(module => module.files.length)), [24, 24])
|
||||
for (const module of modules) assert.equal(module.files[0].path, 'config/steering.yaml')
|
||||
let calls = 0
|
||||
await resolveAgentContext(options({
|
||||
requestPlan: async context => {
|
||||
if (calls++ === 0) return response(['MKMini', 'Control'])
|
||||
for (const module of context.modules) {
|
||||
assert.equal(module.files.length, 12)
|
||||
assert.equal(module.files[0].path, 'config/steering.yaml')
|
||||
}
|
||||
return response()
|
||||
},
|
||||
loadRequestedContext: async request => ({ modules: await api.collect(request.modules), runtimeLogs: [] })
|
||||
}))
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const api = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceEditorPane.tsx'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText, {
|
||||
exports: api,
|
||||
require: id => id === '../../i18n' ? { t: value => value, useLocaleRender() {} }
|
||||
: id === '@monaco-editor/react' ? { __esModule: true, default: 'Editor', DiffEditor: 'DiffEditor' }
|
||||
: id === 'lucide-react' ? {} : require(id)
|
||||
})
|
||||
const props = { dark: true, textDim: '', editorPaneRef: { current: null }, currentActiveEditorFile: 'config/control.yaml', activeFileIsImage: false, editorLanguage: 'yaml', editorValue: 'steering: ackermann', fontSize: 12, tabSize: 4, minimapEnabled: false, exampleActive: false, beforeMount() {}, onMount() {}, onChange() {} }
|
||||
for (const locked of [false, true]) {
|
||||
test(`the source editor is read-only only while an agent file batch is applying (locked=${locked})`, () => {
|
||||
const tree = api.default({ ...props, editingLocked: locked })
|
||||
assert.equal(tree.props.children.type, 'Editor')
|
||||
assert.equal(tree.props.children.props.options.readOnly, locked)
|
||||
})
|
||||
}
|
||||
test('agent diff previews remain read-only and set tab width through text models', () => {
|
||||
const tree = api.default({ ...props, diffPreview: { id: 'diff', originalContent: 'old', modifiedContent: 'new' } })
|
||||
const diff = tree.props.children
|
||||
assert.equal(diff.type, 'DiffEditor')
|
||||
assert.equal(diff.props.options.readOnly, true)
|
||||
assert.equal('tabSize' in diff.props.options, false)
|
||||
const updates = []
|
||||
const editor = { getModel: () => ({ updateOptions: options => updates.push(options.tabSize) }) }
|
||||
diff.props.onMount({ getOriginalEditor: () => editor, getModifiedEditor: () => editor })
|
||||
assert.deepEqual(updates, [4, 4])
|
||||
})
|
||||
@@ -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, /확정하지 못했습니다/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
|
||||
function compile(source, globals) {
|
||||
const exports = {}
|
||||
vm.runInNewContext(ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, { exports, require, ...globals })
|
||||
return exports
|
||||
}
|
||||
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
||||
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
|
||||
const proposal = (extra = {}) => ({
|
||||
id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready',
|
||||
plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null,
|
||||
changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }],
|
||||
commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] },
|
||||
commandExecutions: { build: { status: 'pending' } }, ...extra
|
||||
})
|
||||
function harness(message) {
|
||||
const session = {}
|
||||
const messageStateRef = { current: { session, messages: [message] } }
|
||||
const applyingRef = { current: false }
|
||||
const abortControllerRef = { current: null }
|
||||
const saved = []
|
||||
const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), {
|
||||
session, messageStateRef, applyingRef, abortControllerRef,
|
||||
updateMessages(update) {
|
||||
const next = update(messageStateRef.current.messages)
|
||||
messageStateRef.current.messages = next
|
||||
saved.push(JSON.parse(JSON.stringify(next)))
|
||||
}
|
||||
})
|
||||
return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] }
|
||||
}
|
||||
|
||||
test('cancel marks the proposal and unfinished commands without applying files', async () => {
|
||||
const h = harness(proposal({ applyError: 'old error' }))
|
||||
assert.equal(h.cancelPlan('proposal'), true)
|
||||
assert.equal(h.current().applyState, 'cancelled')
|
||||
assert.equal(h.current().applyError, undefined)
|
||||
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
||||
assert.equal(h.saved.at(-1)[0].applyState, 'cancelled')
|
||||
let applied = false
|
||||
await h.applyPlan('proposal', async () => { applied = true })
|
||||
assert.equal(applied, false)
|
||||
assert.equal(h.cancelPlan('proposal'), false)
|
||||
})
|
||||
|
||||
test('cancel preserves existing successful command results and rejects failed commands', () => {
|
||||
const message = proposal()
|
||||
message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' })
|
||||
message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } }
|
||||
const h = harness(message)
|
||||
h.cancelPlan('proposal')
|
||||
assert.equal(h.current().commandExecutions.inspect.status, 'completed')
|
||||
assert.equal(h.current().commandExecutions.inspect.output, 'done')
|
||||
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
||||
assert.equal(h.current().commandExecutions.build.output, 'build failed')
|
||||
})
|
||||
|
||||
for (const applyState of ['applying', 'applied', 'cancelled']) {
|
||||
test(`cannot cancel a proposal in ${applyState} state`, () => {
|
||||
const h = harness(proposal({ applyState }))
|
||||
assert.equal(h.cancelPlan('proposal'), false)
|
||||
assert.equal(h.saved.length, 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => {
|
||||
for (const setup of [
|
||||
h => { h.current().commandExecutions.build.status = 'running' },
|
||||
h => { h.applyingRef.current = true },
|
||||
h => { h.abortControllerRef.current = {} },
|
||||
h => { h.messageStateRef.current.session = {} }
|
||||
]) {
|
||||
const h = harness(proposal())
|
||||
setup(h)
|
||||
assert.equal(h.cancelPlan('proposal'), false)
|
||||
assert.equal(h.saved.length, 0)
|
||||
}
|
||||
})
|
||||
|
||||
test('an apply already started cannot be cancelled and completes normally', async () => {
|
||||
const h = harness(proposal())
|
||||
let finish
|
||||
const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve }))
|
||||
assert.equal(h.current().applyState, 'applying')
|
||||
assert.equal(h.cancelPlan('proposal'), false)
|
||||
finish()
|
||||
await applying
|
||||
assert.equal(h.current().applyState, 'applied')
|
||||
})
|
||||
|
||||
test('new module proposals can be cancelled without creating anything', () => {
|
||||
const message = proposal()
|
||||
message.plan.changes = []
|
||||
message.plan.createModule = { name: 'Example' }
|
||||
const h = harness(message)
|
||||
assert.equal(h.cancelPlan('proposal'), true)
|
||||
assert.equal(h.current().applyState, 'cancelled')
|
||||
})
|
||||
|
||||
function elements(root, predicate) {
|
||||
if (!React.isValidElement(root)) return []
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||
}
|
||||
function text(root) {
|
||||
if (typeof root === 'string') return root
|
||||
return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : ''
|
||||
}
|
||||
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
|
||||
...require('lucide-react'), t: key => key, useLocaleRender() {},
|
||||
ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
|
||||
AgentWorkDuration: () => null,
|
||||
agentMessageCopyText: message => message.content
|
||||
}).AgentMessages
|
||||
|
||||
for (const dark of [true, false]) {
|
||||
test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => {
|
||||
let cancelled
|
||||
const props = { dark, enabled: true, statusLoading: false, pending: false,
|
||||
messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} }
|
||||
let tree = renderAgent(props)
|
||||
let buttons = elements(tree, node => node.type === 'button')
|
||||
const cancel = buttons.find(button => text(button) === '취소')
|
||||
const apply = buttons.find(button => text(button) === '변경 적용')
|
||||
assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply))
|
||||
const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0]
|
||||
assert.match(cancelLabel.props.className, /top-px leading-none/)
|
||||
cancel.props.onClick()
|
||||
assert.equal(cancelled, 'proposal')
|
||||
const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0]
|
||||
assert.equal(busyCancel.props.disabled, true)
|
||||
tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] })
|
||||
buttons = elements(tree, node => node.type === 'button')
|
||||
assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false)
|
||||
assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const api = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentPractice.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: api, DOMException, setTimeout })
|
||||
const { AGENT_PRACTICE_PROFILES: profiles, inspectAgentPractice, verifyAgentPractice, prepareAgentPractice } = api
|
||||
const plain = value => JSON.parse(JSON.stringify(value))
|
||||
const profile = profiles.find(item => item.id === 'mkmini-2d')
|
||||
const modulesFor = p => p.modules.map((name, sortOrder) => ({ name, sortOrder, category: name.includes('MKMini') || name.includes('TurtleBot') || name === 'NAV2-Yahboom' ? 'Vehicle' : name.includes('Gazebo') ? 'Simulator' : name.includes('Perception') ? 'Sensing' : 'System', state: 'stopped', parameters: [`sim_mode:=${p.environment === 'local'}`, `platform:=${p.platform}`, `robot:=${p.platform}`, `level:=${p.level}`] }))
|
||||
const probeFor = (p, overrides = {}) => ({ id: 'probe', name: 'ros.inspect', status: 'ok', target: 'test:container', observedAt: new Date().toISOString(), truncated: false, data: JSON.stringify({ topics: p.topics.map(name => ({ name, publishers: 1, samples: 3, validSamples: 3, frameIds: name === p.topics[0] ? ['laser'] : [] })), tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_footprint' }, { parent: 'base_footprint', child: 'base_link' }, { parent: 'base_link', child: 'laser' }], ...overrides }) })
|
||||
function scenario(overrides = {}) {
|
||||
const modules = modulesFor(profile)
|
||||
const starts = []
|
||||
const controller = new AbortController()
|
||||
const options = {
|
||||
profileId: profile.id, environment: 'remote', signal: controller.signal,
|
||||
approvedStarts: [...profile.modules],
|
||||
list: async () => modules.map(module => ({ ...module, parameters: [...module.parameters] })),
|
||||
start: async module => { starts.push(module.name); modules.find(item => item.name === module.name).state = 'running'; return true },
|
||||
probe: async () => probeFor(profile), progress: () => {}, pause: async () => {}, ...overrides
|
||||
}
|
||||
return { modules, starts, controller, options }
|
||||
}
|
||||
|
||||
test('SLAM profiles follow the module guide module sequences', () => {
|
||||
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
let expression
|
||||
const visit = node => { if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'moduleGuidePages') expression = node.initializer.getText(source); ts.forEachChild(node, visit) }
|
||||
visit(source)
|
||||
const guide = {}
|
||||
vm.runInNewContext(ts.transpileModule(`exports.pages = ${expression}`, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: guide })
|
||||
for (const p of profiles) assert.ok(guide.pages.some(page => JSON.stringify(page.slamModules) === JSON.stringify(p.modules)))
|
||||
})
|
||||
|
||||
test('preflight rejects wrong environments, missing modules, conflicting parameters and unrelated running modules', () => {
|
||||
const modules = modulesFor(profile)
|
||||
assert.equal(inspectAgentPractice(profile.id, modules, 'remote').canPrepare, true)
|
||||
assert.equal(inspectAgentPractice(profile.id, modules, 'local').canPrepare, false)
|
||||
assert.equal(inspectAgentPractice(profile.id, modules.slice(1), 'remote').canPrepare, false)
|
||||
modules[0].parameters[0] = 'sim_mode:=true'
|
||||
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
|
||||
modules[0].parameters = []
|
||||
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
|
||||
const conflicting = [...modulesFor(profile), { name: 'NAV2-Control', category: 'Control', state: 'running', parameters: [] }]
|
||||
assert.match(inspectAgentPractice(profile.id, conflicting, 'remote').blockers.join(), /NAV2-Control/)
|
||||
assert.equal(inspectAgentPractice(profile.id, [...modulesFor(profile), { name: 'NAV2-Camera', category: 'Sensing', state: 'running', parameters: [] }], 'remote').canPrepare, true)
|
||||
})
|
||||
|
||||
test('readiness requires observed sensor, odometry, map data and a connected TF chain', () => {
|
||||
assert.equal(verifyAgentPractice(profile.id, probeFor(profile)).ready, true)
|
||||
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [] })).ready, false)
|
||||
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { topics: profile.topics.map(name => ({ name, publishers: 1, samples: 0 })) })).ready, false)
|
||||
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_link' }] })).ready, false)
|
||||
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), status: 'error' }).ready, false)
|
||||
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), truncated: true }).ready, false)
|
||||
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), data: 'null' }).ready, false)
|
||||
})
|
||||
|
||||
test('prepares only missing starts, in dependency order, and verifies in the SLAM container', async () => {
|
||||
const s = scenario()
|
||||
s.modules[0].state = 'running'
|
||||
let observed
|
||||
s.options.probe = async (module, topics) => { observed = { module: module.name, topics }; return probeFor(profile) }
|
||||
const result = await prepareAgentPractice(s.options)
|
||||
assert.equal(result.ready, true)
|
||||
assert.deepEqual(s.starts, [...profile.modules].slice(1))
|
||||
assert.equal(observed.module, 'NAV2-SLAM')
|
||||
assert.ok(observed.topics.includes('/tf_static'))
|
||||
assert.equal(result.steps[0].status, 'already-running')
|
||||
})
|
||||
|
||||
test('a failed start stops subsequent starts and does not issue rollback stop commands', async () => {
|
||||
const s = scenario()
|
||||
s.options.start = async module => { s.starts.push(module.name); if (s.starts.length === 2) return false; s.modules[0].state = 'running'; return true }
|
||||
const result = await prepareAgentPractice(s.options)
|
||||
assert.equal(result.ready, false)
|
||||
assert.deepEqual(s.starts, [...profile.modules].slice(0, 2))
|
||||
assert.match(result.failures[0], /자동 종료하지/)
|
||||
})
|
||||
|
||||
test('does not start modules that became stopped after approval', async () => {
|
||||
const s = scenario({ approvedStarts: ['NAV2-SLAM'] })
|
||||
await assert.rejects(prepareAgentPractice(s.options), /승인 이후/)
|
||||
assert.equal(s.starts.length, 0)
|
||||
})
|
||||
|
||||
test('does not start with changed parameters after approval', async () => {
|
||||
const s = scenario()
|
||||
s.options.approvedParameters = Object.fromEntries(s.modules.map(module => [module.name, [...module.parameters]]))
|
||||
s.modules[0].parameters.push('custom_speed:=9')
|
||||
await assert.rejects(prepareAgentPractice(s.options), /실행 파라미터가 바뀌/)
|
||||
assert.equal(s.starts.length, 0)
|
||||
})
|
||||
|
||||
test('cancellation during a start prevents any subsequent module start', async () => {
|
||||
const s = scenario()
|
||||
s.options.start = async module => { s.starts.push(module.name); s.controller.abort(); return true }
|
||||
await assert.rejects(prepareAgentPractice(s.options), { name: 'AbortError' })
|
||||
assert.equal(s.starts.length, 1)
|
||||
})
|
||||
|
||||
test('verification retries are bounded and never report ready for publisher-only data', async () => {
|
||||
const s = scenario()
|
||||
let probes = 0
|
||||
s.options.probe = async () => { probes++; return probeFor(profile, { topics: [] }) }
|
||||
const result = await prepareAgentPractice(s.options)
|
||||
assert.equal(result.ready, false)
|
||||
assert.equal(probes, 3)
|
||||
assert.equal(result.failures.length, 4)
|
||||
assert.ok(result.failures.some(message => message.includes('센서 프레임')))
|
||||
})
|
||||
|
||||
test('the common confirmation and apply-before-build guard are connected in the workspace', () => {
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
|
||||
assert.match(source, /<ConfirmStatusModal[\s\S]*?title="SLAM 실습 준비"/)
|
||||
assert.match(source, /requiresAppliedChanges && message.applyState !== 'applied'/)
|
||||
assert.match(source, /agent\.applyFiles\(/)
|
||||
assert.match(source, /getDirtyModuleFiles\(change.moduleName\)/)
|
||||
assert.match(source, /background: true, freshSlam: true/)
|
||||
assert.match(source, /onResult: result =>/)
|
||||
})
|
||||
@@ -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]])
|
||||
})
|
||||
@@ -0,0 +1,96 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const contextApi = {}
|
||||
const evidenceApi = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: evidenceApi })
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: contextApi, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
|
||||
|
||||
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
let payloadExpression
|
||||
function visit(node) {
|
||||
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('imageTokens')) {
|
||||
payloadExpression = node.getText(source)
|
||||
}
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
|
||||
|
||||
test('user messages preserve only typed text while attachments remain separate metadata', () => {
|
||||
let initializer
|
||||
function find(node) {
|
||||
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'userMessage') initializer = node.initializer.getText(source)
|
||||
ts.forEachChild(node, find)
|
||||
}
|
||||
find(source)
|
||||
assert.ok(initializer)
|
||||
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
|
||||
const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
|
||||
assert.equal(message.content, request)
|
||||
}
|
||||
})
|
||||
|
||||
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
|
||||
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
||||
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
||||
textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
|
||||
selection: provider ? { provider } : {},
|
||||
agentPlanRequestText: contextApi.agentPlanRequestText,
|
||||
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
||||
}))
|
||||
}
|
||||
|
||||
test('sends image reference tokens without image bytes or public URLs on every context round', () => {
|
||||
const body = serialize(undefined, true, '이미지 분석', ['signed-image-token'])
|
||||
assert.deepEqual(body.imageTokens, ['signed-image-token'])
|
||||
assert.equal(Object.hasOwn(body, 'attachments'), false)
|
||||
assert.equal(JSON.stringify(body).includes('base64'), false)
|
||||
})
|
||||
|
||||
for (const attachments of [undefined, []]) {
|
||||
test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => {
|
||||
const body = serialize(attachments)
|
||||
assert.equal(Object.hasOwn(body, 'attachments'), false)
|
||||
assert.equal(body.request, '설정 위치 알려줘')
|
||||
assert.equal(body.modules[0].name, 'Control')
|
||||
assert.equal(body.week, 3)
|
||||
})
|
||||
}
|
||||
|
||||
test('preserves actual attachment contents rather than silently dropping them', () => {
|
||||
const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
|
||||
assert.deepEqual(serialize(attachments).attachments, attachments)
|
||||
})
|
||||
|
||||
test('finalization instruction reaches the API without sending unsupported DTO fields', () => {
|
||||
const body = serialize(undefined, true)
|
||||
assert.match(body.request, /자료 조회는 종료/)
|
||||
assert.match(body.request, /설정 위치 알려줘/)
|
||||
assert.match(body.request, /최종 답변/)
|
||||
assert.equal(Object.hasOwn(body, 'finalize'), false)
|
||||
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week'])
|
||||
})
|
||||
|
||||
test('finalization keeps the request within the backend 4000-character limit', () => {
|
||||
const body = serialize(undefined, true, 'x'.repeat(4000))
|
||||
assert.equal(body.request.length, 4000)
|
||||
assert.match(body.request, /원래 학생 질문/)
|
||||
})
|
||||
|
||||
test('sends only a provider selection rather than student-supplied models or keys', () => {
|
||||
for (const finalize of [false, true]) {
|
||||
const body = serialize(undefined, finalize, '확인', [], 'OPENAI')
|
||||
assert.equal(body.provider, 'OPENAI')
|
||||
assert.equal(Object.hasOwn(body, 'model'), false)
|
||||
assert.equal(Object.hasOwn(body, 'apiKey'), false)
|
||||
assert.equal(Object.hasOwn(body, 'endpointUrl'), false)
|
||||
}
|
||||
assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false)
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
function load(file, overrides = {}, globals = {}) {
|
||||
const exports = {}
|
||||
const source = fs.readFileSync(require.resolve(file), 'utf8')
|
||||
const code = ts.transpileModule(source, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
|
||||
} }).outputText
|
||||
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||
return exports
|
||||
}
|
||||
|
||||
const reveal = load('../src/renderer/src/components/agentResponseReveal.ts')
|
||||
const { createAgentRevealDocument, agentRevealFrame, AGENT_REVEAL_INTERVAL } = reveal
|
||||
const paragraph = 'MK-mini는 바퀴가 4개라도 전륜 조향 모델을 사용합니다.'
|
||||
const content = `${paragraph}\n\n1. 물리적 바퀴 수와 운동학 모델은 다릅니다.\n - 앞바퀴로 조향합니다.\n2. 제어 설정을 확인합니다.\n\n마지막 설명입니다.`
|
||||
|
||||
test('reveals paragraphs and complete list items at fixed intervals without splitting Korean sentences', () => {
|
||||
const document = createAgentRevealDocument(content)
|
||||
const first = agentRevealFrame(document, 1000, 1000)
|
||||
assert.equal(first.content, paragraph)
|
||||
assert.equal(first.complete, false)
|
||||
const second = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL)
|
||||
assert.match(second.content, /1\. 물리적/)
|
||||
assert.match(second.content, /앞바퀴로 조향합니다\./)
|
||||
assert.doesNotMatch(second.content, /2\. 제어/)
|
||||
const third = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL * 2)
|
||||
assert.match(third.content, /2\. 제어 설정을 확인합니다\./)
|
||||
assert.doesNotMatch(third.content, /마지막 설명/)
|
||||
})
|
||||
|
||||
test('keeps fenced code and GFM tables intact', () => {
|
||||
const text = '설명입니다.\n\n```python\nprint("시작")\nprint("종료")\n```\n\n| 항목 | 값 |\n| --- | --- |\n| 조향 | 전륜 |'
|
||||
const document = createAgentRevealDocument(text)
|
||||
assert.equal(document.boundaries.length, 3)
|
||||
const code = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL).content
|
||||
assert.equal((code.match(/```/g) ?? []).length, 2)
|
||||
assert.match(code, /print\("종료"\)/)
|
||||
assert.doesNotMatch(code, /\| 항목/)
|
||||
assert.equal(agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL * 2).content, text)
|
||||
})
|
||||
|
||||
test('reference link definitions are available before their position in the answer is revealed', () => {
|
||||
const document = createAgentRevealDocument('[설명][guide]\n\n다음 문단\n\n[guide]: https://example.com/guide')
|
||||
const first = agentRevealFrame(document, 0, 0)
|
||||
assert.match(first.content, /\[guide\]: https:\/\/example.com\/guide/)
|
||||
assert.doesNotMatch(first.content, /다음 문단/)
|
||||
})
|
||||
|
||||
test('history, elapsed answers and reduced-motion users see the complete response immediately', () => {
|
||||
const document = createAgentRevealDocument(content)
|
||||
for (const frame of [agentRevealFrame(document, undefined, 0), agentRevealFrame(document, 0, 60_000), agentRevealFrame(document, 0, 0, true)]) {
|
||||
assert.equal(frame.content, content)
|
||||
assert.equal(frame.complete, true)
|
||||
assert.equal(frame.fadeFrom, undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('long answers are grouped into at most twenty steps and finish within five seconds', () => {
|
||||
const text = Array.from({ length: 100 }, (_, index) => `문단 ${index}입니다.`).join('\n\n')
|
||||
const document = createAgentRevealDocument(text)
|
||||
assert.ok(document.boundaries.length <= 20)
|
||||
assert.ok(agentRevealFrame(document, 0, 0).duration < 5000)
|
||||
assert.equal(agentRevealFrame(document, 0, 5000).content, text)
|
||||
})
|
||||
|
||||
test('only the newly appended paragraph gets the fade class', () => {
|
||||
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
|
||||
})
|
||||
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
|
||||
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
|
||||
const html = renderToStaticMarkup(React.createElement(MarkdownMessage, { content: frame.content, fadeFrom: frame.fadeFrom, dark: true }))
|
||||
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
|
||||
assert.match(html, /첫 번째 문단/)
|
||||
assert.match(html, /두 번째 문단/)
|
||||
assert.doesNotMatch(html, /세 번째 문단/)
|
||||
})
|
||||
|
||||
function renderResponse(message, now, reducedMotion = false, extra = {}) {
|
||||
const MarkdownMessage = ({ content }) => React.createElement('p', null, content)
|
||||
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
|
||||
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage: MarkdownMessage }, './agentResponseReveal': reveal,
|
||||
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
|
||||
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: reducedMotion }) } })
|
||||
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
|
||||
}
|
||||
|
||||
test('internal cumulative memory is not rendered in the response bubble', () => {
|
||||
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
|
||||
assert.match(html, /화면 설명/)
|
||||
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
|
||||
})
|
||||
|
||||
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
|
||||
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
|
||||
assert.ok(html.includes('A-Copilot'))
|
||||
assert.ok(!html.includes('A_COPILOT'))
|
||||
assert.ok(!html.includes('A-Copilot · A-Copilot'))
|
||||
assert.equal((html.match(/>A-Copilot<\/p>/g) ?? []).length, 1)
|
||||
assert.ok(renderResponse({ content: '결과', provider: 'QWEN', model: 'qwen3.8-max' }, 0).includes('QWEN · qwen3.8-max'))
|
||||
})
|
||||
|
||||
test('approval controls wait for the explanation; restored history does not replay', () => {
|
||||
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '조향 설명', explanation: content }, provider: 'QWEN', model: 'test' }
|
||||
const first = renderResponse(message, 1000)
|
||||
assert.match(first, /조향 설명/)
|
||||
assert.match(first, /aria-busy="true"/)
|
||||
assert.doesNotMatch(first, /승인|QWEN/)
|
||||
for (const html of [renderResponse(message, 10_000), renderResponse({ ...message, revealStartedAt: undefined }, 1000), renderResponse(message, 1000, true)]) {
|
||||
assert.match(html, /aria-busy="false"/)
|
||||
assert.match(html, /마지막 설명/)
|
||||
assert.match(html, /승인/)
|
||||
assert.match(html, /QWEN/)
|
||||
}
|
||||
})
|
||||
|
||||
test('reveal timing is not included in conversation persistence API payloads', () => {
|
||||
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||
let expression
|
||||
function visit(node) {
|
||||
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('messages: batch')) expression = node.getText(source)
|
||||
ts.forEachChild(node, visit)
|
||||
}
|
||||
visit(source)
|
||||
assert.ok(expression)
|
||||
const batch = [{ id: '1', role: 'assistant', content: '전체 답변', revealStartedAt: 1000 }]
|
||||
const payload = JSON.parse(vm.runInNewContext(expression, { batch, courseId: 'course', weekNumber: 1 }))
|
||||
assert.equal(Object.hasOwn(payload.messages[0], 'revealStartedAt'), false)
|
||||
assert.equal(payload.messages[0].content, '전체 답변')
|
||||
})
|
||||
|
||||
test('animation honors reduced motion in CSS', () => {
|
||||
const css = fs.readFileSync(require.resolve('../src/renderer/src/index.css'), 'utf8')
|
||||
assert.match(css, /@keyframes agent-response-fade/)
|
||||
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*?\.agent-response-fade\s*\{\s*animation: none/)
|
||||
})
|
||||
|
||||
function elements(root, predicate) {
|
||||
if (!React.isValidElement(root)) return []
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||
}
|
||||
|
||||
test('agent answers use the shared collapse widget after reveal, without hiding action controls', () => {
|
||||
const MarkdownMessage = () => null
|
||||
const ExpandableMarkdownMessage = () => null
|
||||
let now = 1000
|
||||
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
|
||||
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage }, './agentResponseReveal': reveal,
|
||||
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
|
||||
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: false }) } })
|
||||
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '요약', explanation: '상세 내용입니다.\n\n'.repeat(60) } }
|
||||
const render = (extra = {}) => ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })
|
||||
assert.equal(elements(render(), node => node.type === ExpandableMarkdownMessage).length, 0)
|
||||
now = 10_000
|
||||
const fresh = render()
|
||||
assert.equal(elements(fresh, node => node.type === ExpandableMarkdownMessage)[0].props.defaultCollapsed, false)
|
||||
assert.equal(elements(fresh, node => node.type === 'button').length, 1)
|
||||
const history = render({ message: { ...message, revealStartedAt: undefined }, collapseOnSignal: true, collapseSignal: 4 })
|
||||
const widget = elements(history, node => node.type === ExpandableMarkdownMessage)[0]
|
||||
assert.equal(widget.props.defaultCollapsed, true)
|
||||
assert.equal(widget.props.collapseOnSignal, true)
|
||||
assert.equal(widget.props.collapseSignal, 4)
|
||||
assert.equal(elements(history, node => node.type === 'button').length, 1)
|
||||
})
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
|
||||
let expanded
|
||||
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
|
||||
react: { ...React, useEffect() {}, useState(initial) {
|
||||
if (expanded === undefined) expanded = initial
|
||||
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
|
||||
} }
|
||||
})
|
||||
const content = '파일과 파라미터에 대한 상세 설명입니다.\n'.repeat(50)
|
||||
const render = () => ExpandableMarkdownMessage({ content, dark, defaultCollapsed: true })
|
||||
let tree = render()
|
||||
let button = elements(tree, node => node.type === 'button')[0]
|
||||
assert.equal(button.props['aria-label'], '자세히 보기')
|
||||
assert.ok(elements(tree, node => node.type === MarkdownMessage)[0].props.content.length < content.length)
|
||||
button.props.onClick()
|
||||
tree = render()
|
||||
button = elements(tree, node => node.type === 'button')[0]
|
||||
assert.equal(button.props['aria-label'], '접기')
|
||||
assert.equal(elements(tree, node => node.type === MarkdownMessage)[0].props.content, content)
|
||||
button.props.onClick()
|
||||
assert.equal(elements(render(), node => node.type === 'button')[0].props['aria-label'], '자세히 보기')
|
||||
})
|
||||
}
|
||||
|
||||
test('short answers do not show an unnecessary collapse button', () => {
|
||||
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
|
||||
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
|
||||
})
|
||||
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
|
||||
assert.equal(elements(tree, node => node.type === 'button').length, 0)
|
||||
})
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
test(`model label and copy action share the last row after the operation controls (${dark ? 'dark' : 'light'})`, () => {
|
||||
const html = renderResponse({ content: '전체 답변', provider: 'QWEN', model: 'qwen-model-name'.repeat(8) }, 1000, false, {
|
||||
dark,
|
||||
footerAction: React.createElement('button', { title: '복사', className: 'copy-action' }, 'Copy')
|
||||
})
|
||||
assert.match(html, /class="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2"><p[^>]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p><button title="복사"/)
|
||||
assert.ok(html.indexOf('승인') < html.indexOf('QWEN'))
|
||||
})
|
||||
}
|
||||
|
||||
test('copy footer remains available when the message has no model metadata', () => {
|
||||
const html = renderResponse({ content: '오류 안내' }, 1000, false, {
|
||||
footerAction: React.createElement('button', { title: '복사' }, 'Copy')
|
||||
})
|
||||
assert.match(html, /min-h-6/)
|
||||
assert.match(html, /title="복사"/)
|
||||
})
|
||||
|
||||
test('reply copy is an inline fixed-size hover button and still copies the full response', async () => {
|
||||
const source = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const declaration = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'MessageCopyButton')
|
||||
let copied = false
|
||||
let clipboardText
|
||||
const { MessageCopyButton } = compileCopy(declaration.getText(source))
|
||||
function compileCopy(text) {
|
||||
const code = ts.transpileModule(`export ${text}`, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
|
||||
} }).outputText
|
||||
const exports = {}
|
||||
vm.runInNewContext(code, {
|
||||
exports, require,
|
||||
useState: () => [copied, value => { copied = value }], t: key => key,
|
||||
Check: () => null, Copy: () => null,
|
||||
navigator: { clipboard: { writeText: async text => { clipboardText = text } } },
|
||||
window: { setTimeout() {} }
|
||||
})
|
||||
return exports
|
||||
}
|
||||
const content = '# 요약\n\n전체 답변입니다.'
|
||||
const button = MessageCopyButton({ content, dark: true })
|
||||
assert.match(button.props.className, /opacity-0.*group-hover:opacity-100/)
|
||||
assert.match(button.props.className, /shrink-0 cursor-pointer/)
|
||||
assert.doesNotMatch(button.props.className, /absolute|bottom-|right-/)
|
||||
button.props.onClick()
|
||||
await Promise.resolve()
|
||||
assert.equal(clipboardText, content)
|
||||
assert.equal(MessageCopyButton({ content, dark: true }).props.title, '복사됨')
|
||||
})
|
||||
@@ -0,0 +1,222 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs/promises')
|
||||
const path = require('node:path')
|
||||
const os = require('node:os')
|
||||
const { spawnSync } = require('node:child_process')
|
||||
const ts = require('typescript')
|
||||
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(require('node:fs').readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, filename)
|
||||
const { validateAgentTool, validateAgentOperation, safeAgentPath } = require('../src/shared/agentTools.ts')
|
||||
const { runAgentFileTool, applyAgentFiles, agentFileHash } = require('../src/main/agentFileTools.ts')
|
||||
const { agentRemoteFilesScript } = require('../src/main/agentRemoteFiles.ts')
|
||||
const { inspectAgentRuntime, agentRosProbePython } = require('../src/main/agentRuntimeTools.ts')
|
||||
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
|
||||
const plain = value => JSON.parse(JSON.stringify(value))
|
||||
const request = (name, args = {}) => ({ id: 'query-1', name, moduleName: 'NAV2-MKMini', args })
|
||||
function remote(input) {
|
||||
const result = spawnSync('bash', ['--noprofile', '--norc', '-s'], { input: agentRemoteFilesScript(input), encoding: 'utf8', maxBuffer: 4_000_000, timeout: 10_000 })
|
||||
if (result.error || result.status !== 0) throw new Error(result.stderr || result.error?.message)
|
||||
return JSON.parse(result.stdout)
|
||||
}
|
||||
async function fixture(t) {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-agent-tools-'))
|
||||
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
||||
await fs.mkdir(path.join(root, 'config'))
|
||||
await fs.writeFile(path.join(root, 'config', 'sensor.yaml'), 'host_ip: 192.168.2.88\n조향: ackermann\n')
|
||||
await fs.writeFile(path.join(root, 'CMakeLists.txt'), 'project(vehicle)\n')
|
||||
return root
|
||||
}
|
||||
|
||||
test('tool requests reject writes, extra shell arguments, unsafe paths and unbounded observations', () => {
|
||||
for (const name of ['shell', 'files.write', 'network.set', 'ros.publish']) assert.throws(() => validateAgentTool(request(name)))
|
||||
for (const name of ['files.list', 'files.read', 'files.search']) {
|
||||
for (const p of ['../secret', '/etc/passwd', '.env', 'config/.env', 'build/secret.py', 'config/id_rsa', 'config/key.pem', 'config\\x.py', 'a//b']) assert.throws(() => validateAgentTool(request(name, { path: p, ...(name === 'files.search' ? { query: 'ip' } : {}) })))
|
||||
}
|
||||
assert.throws(() => validateAgentTool(request('network.inspect', { command: 'ip addr replace' })))
|
||||
assert.throws(() => validateAgentTool(request('ros.inspect', { topics: ['/scan; reboot'] })))
|
||||
assert.throws(() => validateAgentTool(request('files.read', { path: 'a.py', lineCount: 401 })))
|
||||
assert.throws(() => validateAgentOperation({ name: 'modules.stop' }))
|
||||
assert.throws(() => validateAgentOperation({ name: 'practice.prepare', profile: 'guess' }))
|
||||
assert.equal(validateAgentOperation({ name: 'practice.prepare', profile: 'mkmini-3d' }).name, 'practice.prepare')
|
||||
assert.equal(safeAgentPath('src/launch/vehicle.launch.py'), 'src/launch/vehicle.launch.py')
|
||||
})
|
||||
|
||||
for (const backend of ['local', 'remote']) {
|
||||
const read = async (root, tool) => backend === 'local' ? runAgentFileTool(root, validateAgentTool(tool)) : remote({ root, request: validateAgentTool(tool) })
|
||||
const apply = changes => backend === 'local' ? applyAgentFiles(changes) : Promise.resolve().then(() => remote({ changes }))
|
||||
test(`${backend}: lists and searches sources without hidden, generated or symlinked data`, async t => {
|
||||
const root = await fixture(t)
|
||||
await fs.writeFile(path.join(root, '.env'), 'host_ip: PRIVATE')
|
||||
await fs.mkdir(path.join(root, 'build'))
|
||||
await fs.writeFile(path.join(root, 'build', 'generated.yaml'), 'host_ip: PRIVATE')
|
||||
await fs.symlink('/etc/passwd', path.join(root, 'config', 'outside.txt'))
|
||||
const listing = await read(root, request('files.list'))
|
||||
assert.deepEqual([...listing.data.paths].sort(), ['CMakeLists.txt', 'config/sensor.yaml'])
|
||||
const search = await read(root, request('files.search', { query: 'HOST_IP' }))
|
||||
assert.equal(search.data.matches.length, 1)
|
||||
assert.equal(search.data.matches[0].path, 'config/sensor.yaml')
|
||||
assert.equal(search.data.matches[0].line, 1)
|
||||
await assert.rejects(read(root, request('files.read', { path: 'config/outside.txt' })))
|
||||
})
|
||||
test(`${backend}: full file reads include a reproducible hash, partial and missing reads are explicit`, async t => {
|
||||
const root = await fixture(t)
|
||||
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
|
||||
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
|
||||
assert.equal(full.data.complete, true)
|
||||
const rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 }))
|
||||
assert.equal(rangedFull.data.complete, true)
|
||||
assert.equal(rangedFull.truncated, false)
|
||||
assert.equal(rangedFull.data.endLine, 3)
|
||||
assert.equal(rangedFull.data.contentTruncated, false)
|
||||
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
|
||||
assert.equal(partial.data.content, '조향: ackermann')
|
||||
assert.equal(partial.data.complete, false)
|
||||
assert.equal(partial.truncated, true)
|
||||
assert.equal(partial.data.endLine, 2)
|
||||
assert.equal(partial.data.contentTruncated, false)
|
||||
const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
|
||||
assert.equal(missing.data.exists, false)
|
||||
assert.equal(missing.data.hash, null)
|
||||
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
|
||||
})
|
||||
test(`${backend}: character truncation never advertises a cut line as fully observed`, async t => {
|
||||
const root = await fixture(t)
|
||||
await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`)
|
||||
const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 }))
|
||||
assert.equal(readout.data.complete, false)
|
||||
assert.equal(readout.data.contentTruncated, true)
|
||||
assert.equal(readout.data.endLine, 1)
|
||||
})
|
||||
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
|
||||
const root = await fixture(t)
|
||||
await fs.mkdir(path.join(root, 'code'))
|
||||
for (let i = 0; i < 5; i++) await fs.writeFile(path.join(root, 'code', `large${i}.py`), 'x'.repeat(900_000))
|
||||
const result = await read(root, request('files.search', { query: 'host_ip', path: 'config' }))
|
||||
assert.equal(result.truncated, false)
|
||||
assert.equal(result.data.matches[0].path, 'config/sensor.yaml')
|
||||
})
|
||||
test(`${backend}: validates every original before changing any file and preserves permissions`, async t => {
|
||||
const root = await fixture(t)
|
||||
const original = await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')
|
||||
const first = { root, moduleName: 'NAV2-MKMini', path: 'config/sensor.yaml', content: 'host_ip: 192.168.1.88\n', expectedHash: agentFileHash(original) }
|
||||
const stale = { root, moduleName: 'NAV2-MKMini', path: 'CMakeLists.txt', content: 'changed', expectedHash: '0'.repeat(64) }
|
||||
await assert.rejects(apply([first, stale]))
|
||||
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), original)
|
||||
await fs.chmod(path.join(root, first.path), 0o664)
|
||||
await apply([first, { root, moduleName: 'NAV2-MKMini', path: 'config/new.yaml', content: 'created: true', expectedHash: null }])
|
||||
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), first.content)
|
||||
assert.equal((await fs.stat(path.join(root, first.path))).mode & 0o777, 0o664)
|
||||
assert.equal(await fs.readFile(path.join(root, 'config/new.yaml'), 'utf8'), 'created: true')
|
||||
await assert.rejects(apply([first]))
|
||||
})
|
||||
}
|
||||
|
||||
test('remote batch streams large content rather than putting it into command arguments', async t => {
|
||||
const root = await fixture(t)
|
||||
const content = 'large YAML value '.repeat(11_000)
|
||||
remote({ changes: [{ root, moduleName: 'NAV2-MKMini', path: 'config/large.yaml', expectedHash: null, content }] })
|
||||
assert.equal(await fs.readFile(path.join(root, 'config/large.yaml'), 'utf8'), content)
|
||||
})
|
||||
|
||||
test('local batch rolls back earlier replacements if a later replacement fails', async t => {
|
||||
const root = await fixture(t)
|
||||
const rename = fs.rename
|
||||
let calls = 0
|
||||
fs.rename = async (...args) => { if (++calls === 2) throw new Error('injected rename error'); return rename(...args) }
|
||||
try {
|
||||
await assert.rejects(applyAgentFiles([
|
||||
{ root, moduleName: 'M', path: 'config/new.yaml', expectedHash: null, content: 'new' },
|
||||
{ root, moduleName: 'M', path: 'CMakeLists.txt', expectedHash: agentFileHash('project(vehicle)\n'), content: 'changed' }
|
||||
]), /injected/)
|
||||
await assert.rejects(fs.stat(path.join(root, 'config/new.yaml')), { code: 'ENOENT' })
|
||||
assert.equal(await fs.readFile(path.join(root, 'CMakeLists.txt'), 'utf8'), 'project(vehicle)\n')
|
||||
} finally { fs.rename = rename }
|
||||
})
|
||||
|
||||
test('network inspection keeps real host and container observations separate', async () => {
|
||||
const calls = []
|
||||
const result = await inspectAgentRuntime(request('network.inspect'), async (executable, args) => { calls.push([executable, args]); return '[{"ifname":"eth0"}]' }, async command => { calls.push(command); throw new Error('container is stopped') })
|
||||
assert.equal(result.host.interfaces[0].ifname, 'eth0')
|
||||
assert.match(result.container.error, /stopped/)
|
||||
assert.equal(calls[0][0], 'ip')
|
||||
assert.equal(calls[1][0], 'ip')
|
||||
assert.equal(calls[2], 'ip -j address show')
|
||||
})
|
||||
|
||||
test('ROS probe samples retained maps with matching QoS and distinguishes empty sensor payloads', () => {
|
||||
const preamble = String.raw`
|
||||
import sys, types, time
|
||||
NS = types.SimpleNamespace
|
||||
tick = [0]
|
||||
def monotonic():
|
||||
tick[0] += 1
|
||||
return tick[0]
|
||||
time.monotonic = monotonic
|
||||
class Node:
|
||||
def get_topic_names_and_types(self):
|
||||
return [(topic, ['test/Message']) for topic in ['/map', '/scan', '/tf_static']]
|
||||
def get_publishers_info_by_topic(self, topic):
|
||||
return [NS(qos_profile=NS(durability='retained' if topic in ['/map', '/tf_static'] else 'volatile'))]
|
||||
def create_subscription(self, message_type, topic, callback, qos):
|
||||
if topic in ['/map', '/tf_static'] and qos.durability != 'retained':
|
||||
raise ValueError('wrong durability')
|
||||
if topic == '/map':
|
||||
callback(NS(info=NS(width=10, height=10), data=[]))
|
||||
elif topic == '/scan':
|
||||
callback(NS(ranges=[1, 2, 3]))
|
||||
else:
|
||||
callback(NS(transforms=[NS(header=NS(frame_id='map'), child_frame_id='odom')]))
|
||||
return object()
|
||||
def count_publishers(self, topic):
|
||||
return 1
|
||||
def get_node_names(self):
|
||||
return ['probe']
|
||||
def destroy_node(self):
|
||||
pass
|
||||
sys.modules['rclpy'] = NS(init=lambda **kw: None, create_node=lambda name: Node(), spin_once=lambda *a, **kw: None, shutdown=lambda: None)
|
||||
sys.modules['rclpy.qos'] = NS(QoSProfile=lambda **kw: NS(**kw), ReliabilityPolicy=NS(BEST_EFFORT='best-effort'), DurabilityPolicy=NS(TRANSIENT_LOCAL='retained', VOLATILE='volatile'))
|
||||
sys.modules['rosidl_runtime_py'] = types.ModuleType('rosidl_runtime_py')
|
||||
sys.modules['rosidl_runtime_py.utilities'] = NS(get_message=lambda name: object)
|
||||
`
|
||||
const run = spawnSync('python3', ['-c', `${preamble}\nexec(${JSON.stringify(agentRosProbePython)})`, Buffer.from(JSON.stringify(['/map', '/scan', '/tf_static'])).toString('base64')], { encoding: 'utf8', timeout: 5000 })
|
||||
assert.equal(run.status, 0, run.stderr)
|
||||
const data = JSON.parse(run.stdout)
|
||||
assert.equal(data.topics.find(topic => topic.name === '/map').samples, 1)
|
||||
assert.equal(data.topics.find(topic => topic.name === '/map').validSamples, 0)
|
||||
assert.equal(data.topics.find(topic => topic.name === '/scan').validSamples, 1)
|
||||
assert.deepEqual(data.tfEdges, [{ parent: 'map', child: 'odom' }])
|
||||
})
|
||||
|
||||
test('restoring an interrupted action does not leave history permanently running', () => {
|
||||
const { recoverInterruptedAgentMessages } = require('../src/renderer/src/features/workspace/agentContext.ts')
|
||||
const messages = [{ id: 'reply', role: 'assistant', content: 'result', applyState: 'applying', commandExecutions: { start: { status: 'running', output: 'started one module' }, build: { status: 'completed' } } }]
|
||||
const restored = recoverInterruptedAgentMessages(messages)
|
||||
assert.equal(restored[0].applyState, 'error')
|
||||
assert.equal(restored[0].commandExecutions.start.status, 'failed')
|
||||
assert.equal(restored[0].commandExecutions.start.output, 'started one module')
|
||||
assert.equal(restored[0].commandExecutions.build.status, 'completed')
|
||||
assert.equal(messages[0].commandExecutions.start.status, 'running')
|
||||
})
|
||||
|
||||
test('tool loop sends actual evidence, deduplicates requests despite new IDs and never runs actions', async () => {
|
||||
let calls = 0
|
||||
const executed = []
|
||||
const result = await resolveAgentContext({
|
||||
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
|
||||
executeTool: async tool => { executed.push(tool); return { id: tool.id, name: tool.name, status: 'ok', target: 'test', observedAt: new Date().toISOString(), data: '{"paths":["config/a.yaml"]}', truncated: false } },
|
||||
requestPlan: async (context, finalize) => {
|
||||
calls++
|
||||
if (calls === 2) assert.equal(context.toolResults.length, 1)
|
||||
return { provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'explanation', requestedModules: [], requestedLogs: [], actionPlan: [], commands: finalize ? [{ operation: { name: 'modules.start' } }] : [], changes: [], createModule: null, toolRequests: [request('files.list', {})] } }
|
||||
}
|
||||
})
|
||||
assert.equal(executed.length, 1)
|
||||
assert.equal(calls, 3)
|
||||
assert.deepEqual(plain(result.plan.commands), [])
|
||||
assert.deepEqual(plain(result.plan.toolRequests), [])
|
||||
assert.match(result.plan.explanation, /files.list: ok/)
|
||||
})
|
||||
|
||||
test('backend and desktop share exactly the same tool validation contract', async () => {
|
||||
assert.equal(await fs.readFile(path.resolve(__dirname, '../../lms-be/src/ai/agent-tools.ts'), 'utf8'), await fs.readFile(path.resolve(__dirname, '../src/shared/agentTools.ts'), 'utf8'))
|
||||
})
|
||||
@@ -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초 동안 작업 중입니다/)
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
function compile(source, overrides = {}, globals = {}) {
|
||||
const exports = {}
|
||||
const code = ts.transpileModule(source, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
|
||||
} }).outputText
|
||||
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||
return exports
|
||||
}
|
||||
|
||||
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
|
||||
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
|
||||
})
|
||||
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
||||
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
|
||||
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
|
||||
const AgentCodeReferences = () => null
|
||||
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
||||
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
|
||||
})
|
||||
const { AgentProgressBubble } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentProgressBubble.tsx'), 'utf8'), {
|
||||
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} },
|
||||
react: { ...React, useEffect() {}, useRef: () => ({ current: null }) },
|
||||
'./AgentWorkDuration': { AgentWorkDuration }
|
||||
})
|
||||
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
|
||||
t: key => key, useLocaleRender() {},
|
||||
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
|
||||
ChatEmptyState: widgets.ChatEmptyState,
|
||||
ProgressiveChatResponse,
|
||||
ChatImageAttachments,
|
||||
AgentCodeReferences,
|
||||
AgentProgressBubble,
|
||||
AgentWorkDuration,
|
||||
MessageCopyButton: () => null,
|
||||
agentMessageCopyText: message => message.content,
|
||||
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
||||
})
|
||||
|
||||
test('existing code locations stay usable during model and command work but not file application', () => {
|
||||
for (const applying of [false, true]) {
|
||||
const onOpen = () => {}
|
||||
const tree = messages.AgentMessages({
|
||||
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
|
||||
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
|
||||
onOpenCodeReference: onOpen
|
||||
})
|
||||
let references
|
||||
function visit(element) {
|
||||
if (!React.isValidElement(element)) return
|
||||
if (element.type === AgentCodeReferences) references = element
|
||||
React.Children.toArray(element.props.children).forEach(visit)
|
||||
}
|
||||
visit(tree)
|
||||
assert.ok(references)
|
||||
assert.equal(references.props.busy, applying)
|
||||
assert.equal(typeof references.props.onOpen, 'function')
|
||||
}
|
||||
})
|
||||
|
||||
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
|
||||
const tree = messages.AgentMessages({
|
||||
dark: true, enabled: true, statusLoading: false, pending: false,
|
||||
messages: [{ id: 'user', role: 'user', content: '이 화면을 확인해줘\n두 번째 줄', imageAttachments: [{ name: '스크린샷.png', url: 'https://files.test/image.png' }] }]
|
||||
})
|
||||
let row
|
||||
function visit(element) {
|
||||
if (!React.isValidElement(element)) return
|
||||
const children = React.Children.toArray(element.props.children)
|
||||
if (children.some(child => React.isValidElement(child) && child.props['data-agent-user-bubble'])) row = children
|
||||
children.forEach(visit)
|
||||
}
|
||||
visit(tree)
|
||||
assert.ok(row)
|
||||
assert.equal(row[0].props.children, '이 화면을 확인해줘\n두 번째 줄')
|
||||
assert.match(row[0].props.className, /whitespace-pre-wrap/)
|
||||
assert.equal(row[1].type, ChatImageAttachments)
|
||||
assert.equal(row[1].props.variant, 'sent')
|
||||
assert.equal(row[1].props.images.length, 1)
|
||||
})
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
for (const mode of ['AiMessages', 'AgentMessages']) {
|
||||
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
|
||||
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, statusLoading: false, pending: true, messages: [] }))
|
||||
assert.match(html, /role="status"/)
|
||||
assert.equal((html.match(/<span class="h-2 /g) ?? []).length, 3)
|
||||
for (const width of ['w-full', 'w-[76%]', 'w-[52%]']) assert.ok(html.includes(width))
|
||||
assert.doesNotMatch(html, /animate-spin/)
|
||||
assert.match(html, /motion-reduce:animate-none/)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('AI answer forwards reveal timing and collapse settings to the same presentation component', () => {
|
||||
const html = renderToStaticMarkup(messages.AiMessages({
|
||||
dark: true, pending: false, collapseSignal: 2,
|
||||
messages: [{ id: 'answer', role: 'assistant', content: '전체 답변입니다.', revealStartedAt: 1234, keepExpanded: true }]
|
||||
}))
|
||||
assert.match(html, /data-reveal-start="1234"/)
|
||||
assert.match(html, /data-collapsed="false"/)
|
||||
assert.match(html, /전체 답변/)
|
||||
})
|
||||
|
||||
for (const dark of [true, false]) {
|
||||
test(`agent progress accumulates only during the request and is replaced by the final reply (${dark ? 'dark' : 'light'})`, () => {
|
||||
const progressSteps = [
|
||||
{ id: 'analysis-0', kind: 'analysis', status: 'ok', round: 1, requests: 1 },
|
||||
{ id: 'tool-0-0', kind: 'tool', name: 'files.list', moduleName: 'Camera', status: 'ok', count: 3 },
|
||||
{ id: 'tool-1-0', kind: 'tool', name: 'files.read', moduleName: 'Camera', path: 'config/camera.yaml', status: 'missing' },
|
||||
{ id: 'tool-1-1', kind: 'tool', name: 'logs.read', moduleName: 'Camera', status: 'error' },
|
||||
{ id: 'tool-1-2', kind: 'tool', name: 'files.search', moduleName: 'Camera', status: 'partial', count: 2 }
|
||||
]
|
||||
const props = { dark, enabled: true, statusLoading: false, pending: true, activity: '조회 결과 분석 중', progressSteps, messages: [] }
|
||||
const html = renderToStaticMarkup(messages.AgentMessages(props))
|
||||
assert.equal((html.match(/data-agent-progress="true"/g) ?? []).length, 1)
|
||||
assert.match(html, /1차 분석 완료 · 추가 조회 1건/)
|
||||
assert.match(html, /Camera · 파일 목록 · 조회 완료 · 3건/)
|
||||
assert.match(html, /Camera · 파일 읽기 · config\/camera.yaml · 대상 없음/)
|
||||
assert.match(html, /Camera · 로그 조회 · 조회 실패/)
|
||||
assert.match(html, /Camera · 파일 검색 · 일부 결과 · 2건/)
|
||||
assert.match(html, /조회 결과 분석 중/)
|
||||
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 5)
|
||||
const final = renderToStaticMarkup(messages.AgentMessages({ ...props, pending: false, messages: [{ id: 'answer', role: 'assistant', content: '최종 답변입니다.', durationMs: 20_900 }] }))
|
||||
assert.doesNotMatch(final, /data-agent-progress|config\/camera.yaml|진행 상황/)
|
||||
assert.match(final, /최종 답변입니다/)
|
||||
assert.match(final, /20초 동안 작업함/)
|
||||
assert.ok(final.indexOf('20초 동안 작업함') < final.indexOf('최종 답변입니다'))
|
||||
})
|
||||
}
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
for (const [mode, expected] of [
|
||||
['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/],
|
||||
['AgentMessages', /현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요\.<br\/>변경 내용은 확인 후 직접 적용합니다\./],
|
||||
['TeacherMessages', /강사에게 문의할<br\/>내용을 입력하세요/]
|
||||
]) {
|
||||
test(`empty ${mode} view uses the shared speech bubble (${dark ? 'dark' : 'light'})`, () => {
|
||||
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, pending: false, messages: [] }))
|
||||
assert.match(html, expected)
|
||||
assert.match(html, /rounded-lg border px-8 py-5/)
|
||||
assert.match(html, /aria-hidden="true"[^>]*rotate-45 border-b border-r/)
|
||||
assert.doesNotMatch(html, /<button|role="status"/)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function chatHarness(revealInView) {
|
||||
const states = []
|
||||
const refs = []
|
||||
let stateIndex = 0
|
||||
let refIndex = 0
|
||||
let waits = 0
|
||||
const content = '첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n마지막 문단입니다.'
|
||||
const { useQaChat } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useQaChat.ts'), 'utf8'), {
|
||||
react: {
|
||||
useEffect() {},
|
||||
useState(initial) {
|
||||
const index = stateIndex++
|
||||
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
|
||||
return [states[index], value => { states[index] = typeof value === 'function' ? value(states[index]) : value }]
|
||||
},
|
||||
useRef(initial) { const index = refIndex++; return refs[index] ??= { current: initial } }
|
||||
},
|
||||
'socket.io-client': {}
|
||||
}, {
|
||||
URLSearchParams, AbortController, DOMException,
|
||||
fetch: async () => ({ ok: true, json: async () => ({ threadId: 'thread', content, message: { id: 'answer', content, createdAt: '2026-10-19T03:00:00Z' } }) }),
|
||||
window: { setTimeout(fn) { waits++; fn() } }
|
||||
})
|
||||
return {
|
||||
render() {
|
||||
stateIndex = 0; refIndex = 0
|
||||
return useQaChat({ accessToken: 'test', courseId: 'course', weekNumber: 1, revealInView, getFreshAccessToken: async () => 'test', onTitlebarNotice() {} })
|
||||
},
|
||||
content, waits: () => waits
|
||||
}
|
||||
}
|
||||
|
||||
test('workspace AI stores the complete answer immediately and leaves animation to the view', async () => {
|
||||
const harness = chatHarness(true)
|
||||
await harness.render().sendQaMessage('조향 설정을 알려줘')
|
||||
const state = harness.render()
|
||||
const answer = state.visibleAiMessages.find(message => message.id === 'answer')
|
||||
assert.equal(answer.content, harness.content)
|
||||
assert.equal(typeof answer.revealStartedAt, 'number')
|
||||
assert.equal(answer.keepExpanded, true)
|
||||
assert.equal(state.aiPending, false)
|
||||
assert.equal(state.aiTypingMessageId, null)
|
||||
assert.equal(harness.waits(), 0)
|
||||
})
|
||||
|
||||
test('the separate AI guide keeps its existing reveal mode unless explicitly opted in', async () => {
|
||||
const harness = chatHarness(false)
|
||||
await harness.render().sendQaMessage('질문')
|
||||
const state = harness.render()
|
||||
assert.equal(state.visibleAiMessages.find(message => message.id === 'answer').content, harness.content)
|
||||
assert.ok(harness.waits() > 0)
|
||||
assert.equal(state.aiTypingMessageId, null)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const output = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, { exports: output, TextDecoder })
|
||||
const { readChatAttachments } = output
|
||||
const file = (name, content) => new File([content], name)
|
||||
|
||||
test('reads source/config/log files and preserves Korean UTF-8 text', async () => {
|
||||
const files = await readChatAttachments([file('control.py', '# 조향 설정'), file('config.yaml', 'robot: mkmini'), file('runtime.log', 'started')], [])
|
||||
assert.equal(files[0].content, '# 조향 설정')
|
||||
assert.equal(files[2].name, 'runtime.log')
|
||||
})
|
||||
test('rejects unsupported image/PDF files', async () => {
|
||||
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /현재 모델/)
|
||||
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /PDF/)
|
||||
})
|
||||
test('rejects more than five attachments including existing ones', async () => {
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a')], Array(5).fill({ name: 'b.txt', content: '' })), /최대 5개/)
|
||||
})
|
||||
test('rejects binary and non-UTF8 content even under a text extension', async () => {
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a\0b')], []), /바이너리/)
|
||||
await assert.rejects(readChatAttachments([file('a.txt', new Uint8Array([255, 254]))], []), /UTF-8/)
|
||||
})
|
||||
test('rejects oversized files instead of silently truncating', async () => {
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(100_001))], []), /100KB/)
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_001))], []), /5만 자/)
|
||||
})
|
||||
test('rejects aggregate attachment context above 100000 characters', async () => {
|
||||
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_000)), file('b.txt', 'a'.repeat(50_000))], []), /10만 자/)
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
function compile(file, overrides = {}, globals = {}) {
|
||||
const exports = {}
|
||||
const source = fs.readFileSync(require.resolve(file), 'utf8')
|
||||
const code = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText
|
||||
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||
return exports
|
||||
}
|
||||
|
||||
const i18n = { t: key => key, useLocaleRender() {} }
|
||||
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
|
||||
const QaInputBox = () => null
|
||||
const ChatTextContextMenu = () => null
|
||||
|
||||
function elements(root, predicate) {
|
||||
if (!React.isValidElement(root)) return []
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||
}
|
||||
|
||||
function setup(overrides = {}, browser = {}) {
|
||||
const state = []
|
||||
const effects = []
|
||||
let index = 0
|
||||
let cleared = 0
|
||||
const modeChanges = []
|
||||
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
|
||||
'../../i18n': i18n,
|
||||
'../../components/AppStatusModals': { ConfirmStatusModal },
|
||||
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
|
||||
'../../components/ChatWidgets': { QaInputBox },
|
||||
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
|
||||
'../../components/ChatImageAttachments': {},
|
||||
'../../components/AgentProgressBubble': {},
|
||||
'../../components/AgentWorkDuration': {},
|
||||
'../../components/AgentCodeReferences': {},
|
||||
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
|
||||
'react-dom': { createPortal: child => child },
|
||||
react: {
|
||||
...React,
|
||||
useEffect(effect) { effects.push(effect) },
|
||||
useState(initial) {
|
||||
const slot = index++
|
||||
if (!(slot in state)) state[slot] = initial
|
||||
return [state[slot], value => { state[slot] = value }]
|
||||
}
|
||||
}
|
||||
}, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default
|
||||
const props = {
|
||||
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
|
||||
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
|
||||
onModeChange(mode) { modeChanges.push(mode) },
|
||||
onClearConversation() { cleared++ }, ...overrides
|
||||
}
|
||||
return {
|
||||
render() { index = 0; effects.length = 0; return panel(props) },
|
||||
cleared: () => cleared,
|
||||
runEffects() { effects.forEach(effect => effect()) },
|
||||
modeChanges
|
||||
}
|
||||
}
|
||||
|
||||
for (const agentProvider of ['A_COPILOT', 'QWEN', 'OPENAI', 'ANTHROPIC', 'GEMINI']) {
|
||||
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
||||
test(`attachment controls follow the provider and chat mode (${agentProvider}, ${qaMode})`, () => {
|
||||
const harness = setup({ qaMode, agentProvider })
|
||||
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
||||
assert.equal(input.props.attachmentsEnabled, qaMode === 'agent' && agentProvider !== 'A_COPILOT')
|
||||
assert.equal(typeof input.props.onClearConversation, 'function')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('the workspace passes the current provider from agent status to the chat panel', () => {
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
|
||||
assert.match(source, /provider: agentProvider,[\s\S]*?\} = useAiAgent\(/)
|
||||
assert.match(source, /<WorkspaceQaPanel[\s\S]*?agentProvider=\{agentProvider\}/)
|
||||
})
|
||||
|
||||
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
||||
test(`${qaMode} history is deleted only after shared modal confirmation`, () => {
|
||||
const harness = setup({ qaMode, agentMessages: [{ id: 'a', content: 'answer' }], visibleTeacherMessages: [{ id: 't', content: 'answer' }] })
|
||||
const input = tree => elements(tree, node => node.type === QaInputBox)[0]
|
||||
const modal = tree => elements(tree, node => node.type === ConfirmStatusModal)[0]
|
||||
assert.equal(modal(harness.render()), undefined)
|
||||
input(harness.render()).props.onClearConversation()
|
||||
assert.equal(harness.cleared(), 0)
|
||||
modal(harness.render()).props.onCancel()
|
||||
assert.equal(modal(harness.render()), undefined)
|
||||
assert.equal(harness.cleared(), 0)
|
||||
input(harness.render()).props.onClearConversation()
|
||||
modal(harness.render()).props.onConfirm()
|
||||
assert.equal(harness.cleared(), 1)
|
||||
assert.equal(modal(harness.render()), undefined)
|
||||
})
|
||||
}
|
||||
|
||||
test('context menu deletion also requests confirmation', () => {
|
||||
const harness = setup()
|
||||
const root = harness.render()
|
||||
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
|
||||
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
|
||||
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||
menu.props.onClear()
|
||||
assert.equal(harness.cleared(), 0)
|
||||
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
|
||||
})
|
||||
|
||||
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
|
||||
test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
|
||||
const node = {}
|
||||
const harness = setup({}, {
|
||||
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
|
||||
chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') }
|
||||
})
|
||||
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
|
||||
area.props.onContextMenu({
|
||||
currentTarget: { contains: () => inside }, target: { closest: () => ({}) },
|
||||
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
|
||||
})
|
||||
await Promise.resolve()
|
||||
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||
assert.equal(menu.props.menu.kind, 'message')
|
||||
assert.equal(Boolean(menu.props.menu.text), selected && inside)
|
||||
assert.equal(menu.props.canClear, true)
|
||||
assert.equal(harness.cleared(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('themed menu deletion still goes through the shared confirmation dialog', async () => {
|
||||
const harness = setup()
|
||||
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
|
||||
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
|
||||
await Promise.resolve()
|
||||
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||
menu.props.onClear()
|
||||
assert.equal(harness.cleared(), 0)
|
||||
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
||||
assert.ok(modal)
|
||||
modal.props.onConfirm()
|
||||
assert.equal(harness.cleared(), 1)
|
||||
})
|
||||
|
||||
for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
|
||||
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
|
||||
const harness = setup(props)
|
||||
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
||||
assert.equal(input.props.clearDisabled, true)
|
||||
input.props.onClearConversation()
|
||||
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 0)
|
||||
assert.equal(harness.cleared(), 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
test(`shared confirmation renders cancel left and delete right (${dark ? 'dark' : 'light'})`, () => {
|
||||
const harness = setup({ dark })
|
||||
elements(harness.render(), node => node.type === QaInputBox)[0].props.onClearConversation()
|
||||
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
||||
assert.equal(modal.props.dark, dark)
|
||||
assert.match(modal.props.overlayClassName, /fixed.*z-\[180\]/)
|
||||
const html = renderToStaticMarkup(modal)
|
||||
assert.match(html, /취소<\/button><button[^>]*>삭제<\/button>/)
|
||||
assert.match(html, /bg-\[#d14b4b\]/)
|
||||
})
|
||||
}
|
||||
|
||||
test('input no longer opens a native confirmation dialog', () => {
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||
assert.doesNotMatch(source, /window\.confirm/)
|
||||
assert.match(source, /onClick=\{onClearConversation\}/)
|
||||
})
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
for (const agentEnabled of [false, true]) {
|
||||
test(`agent availability controls tab count and layout (${dark ? 'dark' : 'light'}, enabled=${agentEnabled})`, () => {
|
||||
const harness = setup({ dark, agentEnabled })
|
||||
const grids = elements(harness.render(), node => node.props.className?.includes('mt-3 grid'))
|
||||
assert.equal(grids.length, 1)
|
||||
const tabs = elements(grids[0], node => node.type === 'button')
|
||||
assert.deepEqual(tabs.map(tab => tab.props.children[1].props.children), agentEnabled ? ['AI', '에이전트', '강사'] : ['AI', '강사'])
|
||||
assert.ok(grids[0].props.className.includes(agentEnabled ? 'grid-cols-3' : 'grid-cols-2'))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('returns to AI when the selected agent is disabled after status loading', () => {
|
||||
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: false, minimized: true })
|
||||
harness.render()
|
||||
harness.runEffects()
|
||||
assert.deepEqual(harness.modeChanges, ['ai'])
|
||||
})
|
||||
|
||||
test('does not reset the selected agent while status is still loading', () => {
|
||||
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: true, minimized: true })
|
||||
harness.render()
|
||||
harness.runEffects()
|
||||
assert.deepEqual(harness.modeChanges, [])
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
function compile(file, overrides = {}, globals = {}) {
|
||||
const exports = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||
return exports
|
||||
}
|
||||
|
||||
test('custom menu clipboard IPC validates the main frame and rechecks paste availability', () => {
|
||||
let clipboard = ''
|
||||
let pastes = 0
|
||||
const frame = {}
|
||||
const sender = { mainFrame: frame, isDestroyed: () => false, paste() { pastes++ } }
|
||||
const event = { sender, senderFrame: frame }
|
||||
const native = compile('../src/main/chatTextMenu.ts', {
|
||||
electron: { BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, clipboard: { readText: () => clipboard, writeText: text => { clipboard = text } } }
|
||||
})
|
||||
assert.equal(native.readChatClipboardState(event).hasText, false)
|
||||
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
|
||||
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'selected text' }), true)
|
||||
assert.equal(clipboard, 'selected text')
|
||||
assert.equal(native.readChatClipboardState(event).hasText, true)
|
||||
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), true)
|
||||
assert.equal(pastes, 1)
|
||||
clipboard = ''
|
||||
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
|
||||
assert.equal(pastes, 1)
|
||||
event.senderFrame = {}
|
||||
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'blocked' }), false)
|
||||
assert.equal(native.readChatClipboardState(event).hasText, false)
|
||||
event.senderFrame = frame
|
||||
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: '' }), false)
|
||||
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'x'.repeat(5_000_001) }), false)
|
||||
assert.equal(native.performChatClipboardAction(event, { action: 'unknown' }), false)
|
||||
})
|
||||
|
||||
function renderMenu({ dark, kind = 'message', text = '', hasText = false, readOnly = false }) {
|
||||
const menu = { kind, x: 20, y: 30, text, input: { readOnly, disabled: false } }
|
||||
let state = 0
|
||||
const { ChatTextContextMenu } = compile('../src/renderer/src/components/ChatTextContextMenu.tsx', {
|
||||
'../i18n': { t: key => key, useLocaleRender() {} },
|
||||
'react-dom': { createPortal: child => child },
|
||||
react: { ...React, useEffect() {}, useLayoutEffect() {}, useRef: initial => ({ current: initial }), useState() { return [state++ === 0 ? { menu, hasText } : { x: 20, y: 30 }, () => {}] } }
|
||||
}, { document: { body: {} } })
|
||||
return ChatTextContextMenu({ dark, menu, onClose() {}, onClear() {}, canClear: true })
|
||||
}
|
||||
|
||||
for (const dark of [true, false]) {
|
||||
test(`chat menu uses the terminal menu surface and item metrics (${dark ? 'dark' : 'light'})`, () => {
|
||||
const tree = renderMenu({ dark, text: 'selected' })
|
||||
const html = renderToStaticMarkup(tree)
|
||||
assert.match(html, dark ? /bg-\[#252526\]/ : /bg-white/)
|
||||
assert.match(html, dark ? /enabled:hover:bg-\[#37373d\]/ : /enabled:hover:bg-\[#eef1f7\]/)
|
||||
assert.match(html, /rounded-md border py-1 text-\[12px\] font-medium/)
|
||||
assert.match(html, /flex h-8 w-full cursor-pointer/)
|
||||
assert.match(html, /role="menu"/)
|
||||
assert.match(html, /role="menuitem"/)
|
||||
assert.match(html, /대화내역 삭제/)
|
||||
assert.doesNotMatch(html, /붙여넣기/)
|
||||
})
|
||||
}
|
||||
|
||||
for (const hasText of [true, false]) {
|
||||
for (const readOnly of [true, false]) {
|
||||
test(`paste disabled state follows clipboard and input editability (clipboard=${hasText}, readOnly=${readOnly})`, () => {
|
||||
const tree = renderMenu({ dark: true, kind: 'input', hasText, readOnly })
|
||||
const buttons = React.Children.toArray(tree.props.children).filter(child => child.type === 'button')
|
||||
assert.equal(buttons[0].props.disabled, true)
|
||||
assert.equal(buttons[1].props.disabled, !hasText || readOnly)
|
||||
assert.doesNotMatch(renderToStaticMarkup(tree), /대화내역 삭제/)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
function setup() {
|
||||
const api = {}
|
||||
const revoked = []
|
||||
let count = 0
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||
}).outputText, {
|
||||
exports: api, TextDecoder,
|
||||
createImageBitmap: async file => {
|
||||
if (file.invalid) throw Error('invalid')
|
||||
return { width: file.width ?? 10, height: file.height ?? 10, close() {} }
|
||||
},
|
||||
URL: { createObjectURL: () => `blob:fixture-${++count}`, revokeObjectURL: url => revoked.push(url) }
|
||||
})
|
||||
return { api, read: (files, existing = []) => api.readChatAttachments(files, existing, { imagesEnabled: true }), revoked }
|
||||
}
|
||||
const image = (extra = {}) => ({ name: 'screen.png', size: 100, ...extra })
|
||||
|
||||
test('image and text attachments coexist without counting image bytes as text context', async () => {
|
||||
const h = setup()
|
||||
const files = await h.read([image({ size: 5 * 1024 * 1024 }), { name: 'config.yaml', size: 10, arrayBuffer: async () => new TextEncoder().encode('robot: mini').buffer }])
|
||||
assert.equal(files[0].kind, 'image')
|
||||
assert.equal(files[0].previewUrl, 'blob:fixture-1')
|
||||
assert.equal(files[1].content, 'robot: mini')
|
||||
})
|
||||
|
||||
test('bounds image counts, byte sizes, decoded pixels and rejects malformed images', async () => {
|
||||
const h = setup()
|
||||
await assert.rejects(h.read([image({ size: 5 * 1024 * 1024 + 1 })]), /5MB/)
|
||||
await assert.rejects(h.read([image({ width: 4001, height: 4001 })]), /1600만/)
|
||||
await assert.rejects(h.read([image({ invalid: true })]), /읽지 못/)
|
||||
await assert.rejects(h.read(Array.from({ length: 4 }, () => image())), /3장/)
|
||||
assert.equal(h.revoked.length, 3)
|
||||
})
|
||||
|
||||
test('a mixed batch failure revokes only newly created image previews', async () => {
|
||||
const h = setup()
|
||||
const existing = await h.read([image()])
|
||||
await assert.rejects(h.read([image(), { name: 'file.pdf', size: 20 }], existing), /PDF/)
|
||||
assert.deepEqual(h.revoked, ['blob:fixture-2'])
|
||||
assert.equal(existing[0].previewUrl, 'blob:fixture-1')
|
||||
})
|
||||
|
||||
test('restores cached multipart filenames while preserving valid Unicode and Latin-1 names', () => {
|
||||
const { api } = setup()
|
||||
const name = '스크린샷 2026-10-19 14-30-45.png'
|
||||
assert.equal(api.displayAttachmentName(Buffer.from(name).toString('latin1')), name)
|
||||
for (const value of [name, 'screen.png', 'caf\u00e9.png', '\uD83D\uDCF7.png']) assert.equal(api.displayAttachmentName(value), value)
|
||||
})
|
||||
|
||||
function previewHarness() {
|
||||
const { api } = setup()
|
||||
const exports = {}
|
||||
let preview = null
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatImageAttachments.tsx'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, {
|
||||
exports,
|
||||
document: { body: {} },
|
||||
require(name) {
|
||||
if (name === 'react') return { ...React, useEffect() {}, useRef: () => ({ current: null }), useState: () => [preview, value => { preview = value }] }
|
||||
if (name === 'react-dom') return { createPortal: child => child }
|
||||
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||
if (name === './chatAttachments') return api
|
||||
return require(name)
|
||||
}
|
||||
})
|
||||
const find = (node, predicate) => {
|
||||
if (!React.isValidElement(node)) return null
|
||||
if (predicate(node)) return node
|
||||
return React.Children.toArray(node.props.children).map(child => find(child, predicate)).find(Boolean)
|
||||
}
|
||||
return { render: props => exports.ChatImageAttachments(props), find }
|
||||
}
|
||||
|
||||
for (const dark of [true, false]) {
|
||||
test(`draft combines thumbnail, truncated filename and removal in one bounded item (${dark ? 'dark' : 'light'})`, () => {
|
||||
const h = previewHarness()
|
||||
const name = '스크린샷-긴파일이름-'.repeat(12) + '.png'
|
||||
let removed
|
||||
const props = { images: [{ name, url: 'blob:fixture' }], dark, onRemove: image => { removed = image.url } }
|
||||
const tree = h.render(props)
|
||||
const item = h.find(tree, node => node.props['data-chat-image-draft'])
|
||||
assert.ok(item)
|
||||
assert.match(item.props.className, /w-56.*max-w-full/)
|
||||
assert.ok(h.find(item, node => node.type === 'img'))
|
||||
const label = h.find(item, node => node.type === 'span')
|
||||
assert.equal(label.props.children, name)
|
||||
assert.match(label.props.className, /truncate/)
|
||||
const zoom = h.find(item, node => node.type === 'button' && node.props.title === name)
|
||||
assert.equal(zoom.props.title, name)
|
||||
h.find(item, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.onClick()
|
||||
assert.equal(removed, 'blob:fixture')
|
||||
const busyItem = h.find(h.render({ ...props, removeDisabled: true }), node => node.props['data-chat-image-draft'])
|
||||
assert.equal(h.find(busyItem, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.disabled, true)
|
||||
})
|
||||
}
|
||||
|
||||
test('cached garbled names are restored in the thumbnail label and enlarged dialog', () => {
|
||||
const h = previewHarness()
|
||||
const name = '스크린샷 2026-10-19 14-30-45.png'
|
||||
const props = { images: [{ name: Buffer.from(name).toString('latin1'), url: 'https://files.test/image.png' }], variant: 'sent' }
|
||||
const tree = h.render(props)
|
||||
assert.equal(h.find(tree, node => node.type === 'button').props.title, name)
|
||||
assert.equal(h.find(tree, node => node.props['data-chat-image-draft']), undefined)
|
||||
h.find(tree, node => node.type === 'button').props.onClick()
|
||||
const html = renderToStaticMarkup(h.render(props))
|
||||
assert.match(html, /<dialog[^>]*aria-label="스크린샷 2026-10-19 14-30-45.png"/)
|
||||
assert.ok(html.includes(`>${name}</span>`))
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
|
||||
function harness() {
|
||||
let layoutEffects = []
|
||||
let effects = []
|
||||
let resizeCallback
|
||||
let disconnected = false
|
||||
const states = []
|
||||
const refs = []
|
||||
let stateIndex = 0
|
||||
let refIndex = 0
|
||||
const submissions = []
|
||||
const input = {
|
||||
style: {}, scrollHeight: 56,
|
||||
getBoundingClientRect: () => ({ width: 300 })
|
||||
}
|
||||
const exports = {}
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||
vm.runInNewContext(ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, {
|
||||
exports,
|
||||
window: { getComputedStyle: () => ({ lineHeight: '20px', paddingTop: '10px', paddingBottom: '10px', borderTopWidth: '0px', borderBottomWidth: '0px', minHeight: '56px' }) },
|
||||
ResizeObserver: class {
|
||||
constructor(callback) { resizeCallback = callback }
|
||||
observe(element) { assert.equal(element, input) }
|
||||
disconnect() { disconnected = true }
|
||||
},
|
||||
require(name) {
|
||||
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||
if (name === './chatAttachments') return {}
|
||||
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
|
||||
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
|
||||
if (name === './ChatCodeBlock') return {}
|
||||
if (name === 'react') return {
|
||||
...React,
|
||||
useState(initial) {
|
||||
const index = stateIndex++
|
||||
if (!(index in states)) states[index] = initial
|
||||
return [states[index], value => { states[index] = value }]
|
||||
},
|
||||
useRef(initial) { return refs[refIndex++] ??= { current: initial } },
|
||||
useEffect(callback) { effects.push(callback) },
|
||||
useLayoutEffect(callback) { layoutEffects.push(callback) }
|
||||
}
|
||||
return require(name)
|
||||
}
|
||||
})
|
||||
function find(node, type) {
|
||||
if (!React.isValidElement(node)) return null
|
||||
if (node.type === type) return node
|
||||
return React.Children.toArray(node.props.children).map(child => find(child, type)).find(Boolean)
|
||||
}
|
||||
function render() {
|
||||
stateIndex = 0
|
||||
refIndex = 0
|
||||
layoutEffects = []
|
||||
effects = []
|
||||
const tree = exports.QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit: content => submissions.push(content) })
|
||||
const textarea = find(tree, 'textarea')
|
||||
textarea.props.ref.current = input
|
||||
layoutEffects.forEach(callback => callback())
|
||||
return textarea
|
||||
}
|
||||
return {
|
||||
input, render, submissions,
|
||||
mountEffects: () => effects.map(callback => callback()),
|
||||
resize: width => resizeCallback([{ contentRect: { width } }]),
|
||||
disconnected: () => disconnected
|
||||
}
|
||||
}
|
||||
|
||||
test('input grows to eight visible lines and scrolls only above the cap', () => {
|
||||
const h = harness()
|
||||
h.render()
|
||||
assert.equal(h.input.style.height, '56px')
|
||||
assert.equal(h.input.style.overflowY, 'hidden')
|
||||
h.input.scrollHeight = 100
|
||||
h.render()
|
||||
assert.equal(h.input.style.height, '100px')
|
||||
h.input.scrollHeight = 180
|
||||
h.render()
|
||||
assert.equal(h.input.style.height, '180px')
|
||||
assert.equal(h.input.style.overflowY, 'hidden')
|
||||
h.input.scrollHeight = 300
|
||||
h.render()
|
||||
assert.equal(h.input.style.height, '180px')
|
||||
assert.equal(h.input.style.overflowY, 'auto')
|
||||
})
|
||||
|
||||
test('deleting text and submitting restore the initial height', () => {
|
||||
const h = harness()
|
||||
let textarea = h.render()
|
||||
textarea.props.onChange({ target: { value: 'question\nsecond line\nthird line' } })
|
||||
h.input.scrollHeight = 80
|
||||
textarea = h.render()
|
||||
assert.equal(h.input.style.height, '80px')
|
||||
let prevented = false
|
||||
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: {}, preventDefault() { prevented = true } })
|
||||
assert.equal(prevented, true)
|
||||
assert.equal(h.submissions.length, 1)
|
||||
h.input.scrollHeight = 40
|
||||
textarea = h.render()
|
||||
assert.equal(textarea.props.value, '')
|
||||
assert.equal(h.input.style.height, '56px')
|
||||
assert.equal(h.input.style.overflowY, 'hidden')
|
||||
})
|
||||
|
||||
test('Shift+Enter and IME composition do not submit', () => {
|
||||
const h = harness()
|
||||
let textarea = h.render()
|
||||
textarea.props.onChange({ target: { value: 'question' } })
|
||||
textarea = h.render()
|
||||
const preventDefault = () => assert.fail('newline or composing Enter must not be intercepted')
|
||||
textarea.props.onKeyDown({ key: 'Enter', shiftKey: true, nativeEvent: {}, preventDefault })
|
||||
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: true }, preventDefault })
|
||||
assert.equal(h.submissions.length, 0)
|
||||
})
|
||||
|
||||
test('panel width changes recalculate wrapped lines and observer disconnects on unmount', () => {
|
||||
const h = harness()
|
||||
h.render()
|
||||
const cleanups = h.mountEffects()
|
||||
h.input.scrollHeight = 120
|
||||
h.resize(180)
|
||||
assert.equal(h.input.style.height, '120px')
|
||||
h.input.scrollHeight = 50
|
||||
h.resize(400)
|
||||
assert.equal(h.input.style.height, '56px')
|
||||
cleanups.forEach(cleanup => cleanup?.())
|
||||
assert.equal(h.disconnected(), true)
|
||||
})
|
||||
@@ -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 <value>'))
|
||||
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, /</)
|
||||
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')
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
|
||||
function compile(file, overrides = {}, globals = {}) {
|
||||
const exports = {}
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||
return exports
|
||||
}
|
||||
|
||||
function nativeMenu() {
|
||||
let template
|
||||
let close
|
||||
let clipboard = ''
|
||||
let copies = 0
|
||||
let pastes = 0
|
||||
const frame = {}
|
||||
const sender = { mainFrame: frame, isDestroyed: () => false, copy() { copies++ }, paste() { pastes++ } }
|
||||
const event = { sender, senderFrame: frame }
|
||||
const { showChatTextMenu } = compile('../src/main/chatTextMenu.ts', {
|
||||
electron: {
|
||||
BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) },
|
||||
clipboard: { readText: () => clipboard },
|
||||
Menu: { buildFromTemplate(items) {
|
||||
template = items
|
||||
return { popup(options) { close = options.callback } }
|
||||
} }
|
||||
}
|
||||
})
|
||||
return {
|
||||
show: options => showChatTextMenu(event, options),
|
||||
items: () => template,
|
||||
close: () => close(),
|
||||
setClipboard: text => { clipboard = text },
|
||||
copies: () => copies, pastes: () => pastes, event
|
||||
}
|
||||
}
|
||||
|
||||
const labels = { copy: '복사', paste: '붙여넣기', clear: '대화내역 삭제' }
|
||||
|
||||
for (const canCopy of [false, true]) {
|
||||
test(`message copy is enabled only with a selection (selected=${canCopy})`, async () => {
|
||||
const menu = nativeMenu()
|
||||
const result = menu.show({ kind: 'message', canCopy, canClear: true, labels })
|
||||
assert.equal(menu.items()[0].enabled, canCopy)
|
||||
assert.equal(menu.items()[0].label, '복사')
|
||||
assert.equal(menu.items().some(item => item.label === '붙여넣기'), false)
|
||||
menu.items()[0].click()
|
||||
assert.equal(await result, null)
|
||||
assert.equal(menu.copies(), canCopy ? 1 : 0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const clipboard of ['', '복사해 둔 내용']) {
|
||||
test(`input paste availability is read from the OS clipboard (empty=${!clipboard})`, async () => {
|
||||
const menu = nativeMenu()
|
||||
menu.setClipboard(clipboard)
|
||||
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
|
||||
assert.equal(menu.items().length, 2)
|
||||
assert.equal(menu.items()[0].enabled, false)
|
||||
assert.equal(menu.items()[1].label, '붙여넣기')
|
||||
assert.equal(menu.items()[1].enabled, Boolean(clipboard))
|
||||
menu.items()[1].click()
|
||||
await result
|
||||
assert.equal(menu.pastes(), clipboard ? 1 : 0)
|
||||
})
|
||||
}
|
||||
|
||||
test('read-only input never allows paste, even with clipboard text', async () => {
|
||||
const menu = nativeMenu()
|
||||
menu.setClipboard('text')
|
||||
const result = menu.show({ kind: 'input', canCopy: true, canPaste: false, labels })
|
||||
assert.equal(menu.items()[1].enabled, false)
|
||||
menu.items()[1].click()
|
||||
await result
|
||||
assert.equal(menu.pastes(), 0)
|
||||
})
|
||||
|
||||
test('paste rechecks an emptied clipboard before editing the input', async () => {
|
||||
const menu = nativeMenu()
|
||||
menu.setClipboard('text')
|
||||
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
|
||||
assert.equal(menu.items()[1].enabled, true)
|
||||
menu.setClipboard('')
|
||||
menu.items()[1].click()
|
||||
await result
|
||||
assert.equal(menu.pastes(), 0)
|
||||
})
|
||||
|
||||
test('delete selection returns a confirmation request rather than deleting data', async () => {
|
||||
const menu = nativeMenu()
|
||||
const result = menu.show({ kind: 'message', canCopy: false, canClear: true, labels })
|
||||
menu.items().find(item => item.label === '대화내역 삭제').click()
|
||||
assert.equal(await result, 'clear')
|
||||
})
|
||||
|
||||
test('closing a menu without choosing an action does nothing', async () => {
|
||||
const menu = nativeMenu()
|
||||
const result = menu.show({ kind: 'message', canCopy: true, labels })
|
||||
menu.close()
|
||||
assert.equal(await result, null)
|
||||
assert.equal(menu.copies(), 0)
|
||||
})
|
||||
|
||||
test('untrusted frames and invalid menu targets cannot open a native menu', () => {
|
||||
const menu = nativeMenu()
|
||||
assert.equal(menu.show({ kind: 'unknown', canCopy: true, labels }), null)
|
||||
menu.event.senderFrame = {}
|
||||
assert.equal(menu.show({ kind: 'input', canCopy: true, labels }), null)
|
||||
})
|
||||
|
||||
function elements(root, predicate) {
|
||||
if (!React.isValidElement(root)) return []
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||
}
|
||||
|
||||
for (const selected of [false, true]) {
|
||||
test(`textarea opens the themed menu with a selection snapshot (selected=${selected})`, async () => {
|
||||
let options
|
||||
let prevented = false
|
||||
let stopped = false
|
||||
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, './ChatCodeBlock': {},
|
||||
react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }) }
|
||||
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
|
||||
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
|
||||
const textarea = elements(tree, node => node.type === 'textarea')[0]
|
||||
textarea.props.onContextMenu({
|
||||
currentTarget: { value: 'abcdef', selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
|
||||
preventDefault() { prevented = true }, stopPropagation() { stopped = true }
|
||||
})
|
||||
await Promise.resolve()
|
||||
assert.equal(options.kind, 'input')
|
||||
assert.equal(options.text, selected ? 'cde' : '')
|
||||
assert.equal(options.input.disabled, false)
|
||||
assert.equal(options.selectionStart, 2)
|
||||
assert.equal(prevented, true)
|
||||
assert.equal(stopped, true)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/AppStatusModals.tsx'), 'utf8')
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText
|
||||
|
||||
function loadModals() {
|
||||
const exports = {}
|
||||
vm.runInNewContext(compiled, {
|
||||
exports,
|
||||
require: name => name === '../i18n' ? { t: key => key, useLocaleRender() {} } : require(name)
|
||||
})
|
||||
return exports
|
||||
}
|
||||
|
||||
function buttons(element) {
|
||||
if (!React.isValidElement(element)) return []
|
||||
if (element.type === 'button') return [element]
|
||||
return React.Children.toArray(element.props.children).flatMap(buttons)
|
||||
}
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
test(`extension action stays on the right in both dialogs (${dark ? 'dark' : 'light'})`, () => {
|
||||
const { StatusNoticeModal, ControlExtensionConfirmModal } = loadModals()
|
||||
let confirmed = 0
|
||||
let extended = 0
|
||||
const notice = StatusNoticeModal({
|
||||
overlayClassName: 'overlay', dark, tone: 'clock', title: '제어 시간 안내', message: '제어 시간이 곧 종료됩니다.',
|
||||
actionLabel: '연장', onAction: () => { extended++ }, onConfirm: () => { confirmed++ }
|
||||
})
|
||||
const noticeButtons = buttons(notice)
|
||||
assert.deepEqual(noticeButtons.map(button => button.props.children), ['확인', '연장'])
|
||||
assert.match(noticeButtons[1].props.className, /bg-\[#6f4df6\]/)
|
||||
assert.doesNotMatch(noticeButtons[0].props.className, /bg-\[#6f4df6\]/)
|
||||
noticeButtons[0].props.onClick()
|
||||
noticeButtons[1].props.onClick()
|
||||
assert.equal(confirmed, 1)
|
||||
assert.equal(extended, 1)
|
||||
|
||||
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark, platformName: 'MK-mini', onCancel() {}, onConfirm() {} })
|
||||
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장'])
|
||||
assert.match(buttons(confirmation)[1].props.className, /bg-\[#6f4df6\]/)
|
||||
assert.match(renderToStaticMarkup(confirmation), /취소<\/button><button[^>]*>연장<\/button>/)
|
||||
})
|
||||
}
|
||||
|
||||
test('notices without an extension action retain their single primary confirmation button', () => {
|
||||
const { StatusNoticeModal } = loadModals()
|
||||
const notice = StatusNoticeModal({ overlayClassName: 'overlay', dark: true, tone: 'warning', title: '안내', message: '메시지', onConfirm() {} })
|
||||
assert.equal(buttons(notice).length, 1)
|
||||
assert.equal(buttons(notice)[0].props.children, '확인')
|
||||
assert.match(buttons(notice)[0].props.className, /bg-\[#6f4df6\]/)
|
||||
})
|
||||
|
||||
test('the extension confirmation keeps the right button disabled while submitting', () => {
|
||||
const { ControlExtensionConfirmModal } = loadModals()
|
||||
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark: true, platformName: 'MK-mini', submitting: true, onCancel() {}, onConfirm() {} })
|
||||
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장 중'])
|
||||
assert.ok(buttons(confirmation).every(button => button.props.disabled))
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const fs = require('node:fs')
|
||||
const vm = require('node:vm')
|
||||
const ts = require('typescript')
|
||||
const React = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
|
||||
const source = fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx'), 'utf8')
|
||||
const output = {}
|
||||
let videos = []
|
||||
vm.runInNewContext(ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||
}).outputText, {
|
||||
exports: output,
|
||||
require: name => name === '../../i18n' ? { t: key => key, useLocaleRender() {} }
|
||||
: name === 'react' ? { ...React, useId: () => 'guide' } : require(name),
|
||||
URL,
|
||||
window: { learningVideo: { open: async url => { videos.push(url) } } }
|
||||
})
|
||||
|
||||
function elements(root, predicate) {
|
||||
if (!React.isValidElement(root)) return []
|
||||
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||
}
|
||||
|
||||
function setup(overrides = {}) {
|
||||
const toggled = []
|
||||
const props = {
|
||||
dark: true, workspaceActive: true, minimized: false, resourceMode: 'material',
|
||||
materialItems: [{ title: 'SLAM', description: '센서 토픽과 TF 상태를 점검하고 지도를 생성합니다.', concepts: ['TF 점검', '지도 저장'], videoUrl: '' }],
|
||||
activeMaterialIndex: 0, expandedMaterialIndexes: [0], topics: [], topicDetails: [], overview: '',
|
||||
resources: [], downloadingResourceIds: [], onToggleMaterial: index => toggled.push(index), ...overrides
|
||||
}
|
||||
const tree = output.default(props)
|
||||
const headers = elements(tree, node => node.type === 'button' && node.props['aria-controls'])
|
||||
return { tree, headers, toggled }
|
||||
}
|
||||
|
||||
for (const dark of [false, true]) {
|
||||
test(`only the material header toggles the card (${dark ? 'dark' : 'light'})`, () => {
|
||||
const { tree, headers, toggled } = setup({ dark })
|
||||
assert.equal(headers.length, 1)
|
||||
const header = headers[0]
|
||||
assert.equal(header.props.type, 'button')
|
||||
assert.equal(header.props['aria-expanded'], true)
|
||||
assert.equal(header.props.disabled, false)
|
||||
assert.doesNotMatch(header.props.className, /hover:bg/)
|
||||
assert.match(header.props.className, /focus-visible:ring-2/)
|
||||
const card = elements(tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
|
||||
assert.ok(card)
|
||||
assert.equal(card.props.onClick, undefined)
|
||||
assert.equal(card.props.onKeyDown, undefined)
|
||||
assert.equal(card.props.role, undefined)
|
||||
assert.equal(card.props.tabIndex, undefined)
|
||||
header.props.onClick()
|
||||
assert.deepEqual(toggled, [0])
|
||||
})
|
||||
|
||||
test(`description is selectable outside the interactive header (${dark ? 'dark' : 'light'})`, () => {
|
||||
const { tree, headers, toggled } = setup({ dark })
|
||||
const contentId = headers[0].props['aria-controls']
|
||||
const body = elements(tree, node => node.props.id === contentId)[0]
|
||||
assert.match(body.props.className, /select-text/)
|
||||
assert.equal(body.props.onClick, undefined)
|
||||
assert.equal(elements(headers[0], node => node.props.id === contentId).length, 0)
|
||||
assert.ok(renderToStaticMarkup(body).includes('센서 토픽과 TF 상태'))
|
||||
assert.equal(elements(body, node => Boolean(node.props.onClick || node.props.onKeyDown)).length, 0)
|
||||
assert.deepEqual(toggled, [])
|
||||
})
|
||||
|
||||
test(`header padding and dimensions stay identical when toggled (${dark ? 'dark' : 'light'})`, () => {
|
||||
const expanded = setup({ dark, expandedMaterialIndexes: [0] })
|
||||
const collapsed = setup({ dark, expandedMaterialIndexes: [] })
|
||||
for (const state of [expanded, collapsed]) {
|
||||
const header = state.headers[0]
|
||||
const card = elements(state.tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
|
||||
assert.match(card.props.className, /\bpx-3 pt-3\b/)
|
||||
assert.doesNotMatch(card.props.className, /px-2\.5|pt-2\.5/)
|
||||
assert.match(header.props.className, /\bflex h-6 w-full\b/)
|
||||
}
|
||||
assert.equal(expanded.headers[0].props.className, collapsed.headers[0].props.className)
|
||||
})
|
||||
}
|
||||
|
||||
test('future material remains disabled, while previous/current steps keep native keyboard buttons', () => {
|
||||
const items = ['이전', '현재', '다음'].map(title => ({ title, description: '', videoUrl: '' }))
|
||||
const { headers } = setup({ materialItems: items, activeMaterialIndex: 1, expandedMaterialIndexes: [1] })
|
||||
assert.deepEqual(headers.map(header => header.props.disabled), [false, false, true])
|
||||
assert.equal(headers[2].props.onClick, undefined)
|
||||
for (const header of headers) {
|
||||
assert.equal(header.props.type, 'button')
|
||||
assert.equal(header.props.onKeyDown, undefined)
|
||||
}
|
||||
assert.ok(setup({ materialItems: items, allowFutureMaterialSteps: true }).headers.every(header => !header.props.disabled))
|
||||
})
|
||||
|
||||
test('video playback is not nested in the collapse button and does not toggle the card', async () => {
|
||||
videos = []
|
||||
const { tree, headers, toggled } = setup({ materialItems: [{ title: 'SLAM', description: '설명', videoUrl: 'https://www.youtube.com/watch?v=test123' }] })
|
||||
const video = elements(tree, node => node.type === 'button' && node.props.className?.includes('aspect-video'))[0]
|
||||
assert.ok(video)
|
||||
assert.equal(elements(headers[0], node => node === video).length, 0)
|
||||
video.props.onClick({ stopPropagation() {} })
|
||||
await Promise.resolve()
|
||||
assert.deepEqual(videos, ['https://www.youtube.com/embed/test123'])
|
||||
assert.deepEqual(toggled, [])
|
||||
for (const button of elements(tree, node => node.type === 'button')) {
|
||||
assert.equal(React.Children.toArray(button.props.children).flatMap(child => elements(child, node => node.type === 'button')).length, 0)
|
||||
}
|
||||
})
|
||||
@@ -9,6 +9,39 @@ const { createInstance } = require('i18next')
|
||||
const root = path.resolve(__dirname, '../src/renderer/src')
|
||||
const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8'))
|
||||
|
||||
test('only the default simulation description follows language; vehicle and custom text stay unchanged', () => {
|
||||
const { api } = setup('ko')
|
||||
const exports = {}
|
||||
const source = fs.readFileSync(path.join(root, 'features/platforms/PlatformCards.tsx'), 'utf8')
|
||||
const code = ts.transpileModule(source, { compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||
} }).outputText
|
||||
vm.runInNewContext(code, { exports, require: name => name === '../../i18n'
|
||||
? { ...api, useLocaleRender() {} } : require(name) })
|
||||
const { createElement } = require('react')
|
||||
const { renderToStaticMarkup } = require('react-dom/server')
|
||||
const description = '내 PC에서 직접 개발 환경을 실행합니다.'
|
||||
const platform = { id: 'local', platformKey: 'futuredrive-local-pc', title: '로컬 PC', description, state: 'ready' }
|
||||
const render = value => renderToStaticMarkup(createElement(exports.PlatformOptionCard, {
|
||||
platform: value, Icon: () => null, dotClassName: '', unavailable: false,
|
||||
statusPending: false, displayStatus: '사용 가능', primaryAction: '연결',
|
||||
actionDisabled: false, running: false, onAction() {}, onSettings() {}
|
||||
}))
|
||||
assert.ok(render(platform).includes(description))
|
||||
api.setLanguage('en')
|
||||
assert.ok(render(platform).includes('Run the development environment directly on this PC.'))
|
||||
assert.ok(!render(platform).includes(description))
|
||||
const vehicleDescription = '기초 SLAM 및 자율주행 실습 장비입니다.'
|
||||
assert.ok(render({ ...platform, platformKey: 'mk-mini', description: vehicleDescription }).includes(vehicleDescription))
|
||||
assert.ok(render({ ...platform, platformKey: 'mk-mini' }).includes(description))
|
||||
assert.ok(render({ ...platform, description: '강사가 작성한 시뮬레이션 안내' }).includes('강사가 작성한 시뮬레이션 안내'))
|
||||
assert.ok(render({ ...platform, description: '설정' }).includes('>설정</p>'))
|
||||
api.setLanguage('ko')
|
||||
assert.ok(render(platform).includes(description))
|
||||
assert.equal(platform.description, description)
|
||||
})
|
||||
|
||||
test('close studio modal translates the built-in Local PC title but preserves vehicle names', () => {
|
||||
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
|
||||
const expression = source.match(/platformTitle=\{(runningPlatformCloseTarget\.platformKey[^\n]+)\}/)?.[1]
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
})
|
||||
@@ -46,3 +46,21 @@ test('theme synchronizes across windows without writing runtime settings', () =>
|
||||
events.get('storage')({ key: null })
|
||||
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'))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { createHash, randomUUID } from 'node:crypto'
|
||||
import { chmod, lstat, readdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
||||
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import { safeAgentPath, type AgentCheckedChange, type AgentToolRequest } from '../shared/agentTools'
|
||||
|
||||
const textFile = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$/i
|
||||
export const agentFileHash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
|
||||
|
||||
async function checkedPath(root: string, path: string, allowMissing = false): Promise<string> {
|
||||
safeAgentPath(path)
|
||||
const base = await realpath(root)
|
||||
const absolute = resolve(base, path)
|
||||
if (!absolute.startsWith(base + sep)) throw new Error('모듈 경로를 벗어날 수 없습니다.')
|
||||
let current = base
|
||||
const parts = relative(base, absolute).split(sep)
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
current = join(current, parts[i])
|
||||
try {
|
||||
const info = await lstat(current)
|
||||
if (info.isSymbolicLink() || (i < parts.length - 1 && !info.isDirectory())) throw new Error('심볼릭 링크와 일반 파일이 아닌 경로는 사용할 수 없습니다.')
|
||||
} catch (error) {
|
||||
if (allowMissing && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') return absolute
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return absolute
|
||||
}
|
||||
|
||||
async function readText(root: string, path: string): Promise<string> {
|
||||
if (!textFile.test(path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 조회·수정할 수 있습니다.')
|
||||
const absolute = await checkedPath(root, path)
|
||||
const info = await lstat(absolute)
|
||||
if (!info.isFile() || info.size > 1_048_576) throw new Error('일반 텍스트 파일(1MB 이하)만 조회할 수 있습니다.')
|
||||
const text = await readFile(absolute, 'utf8')
|
||||
if (text.includes('\0') || text.includes('\uFFFD')) throw new Error('UTF-8 텍스트 파일이 아닙니다.')
|
||||
return text
|
||||
}
|
||||
|
||||
export async function runAgentFileTool(root: string, request: AgentToolRequest): Promise<{ data: unknown; truncated: boolean }> {
|
||||
const args = request.args ?? {}
|
||||
if (request.name === 'files.read') {
|
||||
const path = safeAgentPath(args.path)
|
||||
try {
|
||||
const content = await readText(root, path)
|
||||
const lines = content.split('\n')
|
||||
const startLine = args.startLine ?? 1
|
||||
const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
|
||||
const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content
|
||||
const selected = selection.slice(0, 40_000)
|
||||
const contentTruncated = selected.length < selection.length
|
||||
const complete = startLine === 1 && selected === content
|
||||
const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated))
|
||||
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete }
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
// A missing leaf in an existing directory is evidence for a new file.
|
||||
await checkedPath(root, path, true)
|
||||
return { data: { path, exists: false, hash: null, content: '', complete: true }, truncated: false }
|
||||
}
|
||||
}
|
||||
const prefix = safeAgentPath(args.path, true)
|
||||
if (prefix) await checkedPath(root, prefix)
|
||||
const paths: string[] = []
|
||||
const matches: Array<{ path: string; line: number; text: string }> = []
|
||||
let visited = 0
|
||||
let scanned = 0
|
||||
let truncated = false
|
||||
const visit = async (directory: string, depth: number): Promise<void> => {
|
||||
if (depth > 10 || visited >= 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; return }
|
||||
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
|
||||
for (const entry of entries) {
|
||||
const path = relative(root, join(directory, entry.name)).split(sep).join('/')
|
||||
if (prefix && path !== prefix && !path.startsWith(prefix + '/') && !prefix.startsWith(path + '/')) continue
|
||||
if (++visited > 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; break }
|
||||
try { safeAgentPath(path) } catch { continue }
|
||||
if (entry.isSymbolicLink()) continue
|
||||
if (entry.isDirectory()) { await visit(join(directory, entry.name), depth + 1); continue }
|
||||
if (!entry.isFile() || !textFile.test(entry.name)) continue
|
||||
if (prefix && path !== prefix && !path.startsWith(prefix + '/')) continue
|
||||
if (request.name === 'files.list') { paths.push(path); continue }
|
||||
let content: string
|
||||
try { content = await readText(root, path) } catch { continue }
|
||||
scanned += content.length
|
||||
for (const [index, line] of content.split('\n').entries()) {
|
||||
if (line.toLowerCase().includes(args.query!.toLowerCase())) {
|
||||
matches.push({ path, line: index + 1, text: line.slice(0, 500) })
|
||||
if (matches.length >= 80) { truncated = true; break }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit(root, 0)
|
||||
return { data: request.name === 'files.list' ? { paths } : { query: args.query, matches }, truncated }
|
||||
}
|
||||
|
||||
export async function applyAgentFiles(changes: Array<AgentCheckedChange & { root: string }>): Promise<void> {
|
||||
if (!changes.length || changes.length > 24 || changes.reduce((n, c) => n + c.content.length, 0) > 1_000_000) throw new Error('변경 파일 용량을 확인하세요.')
|
||||
const prepared: Array<{ path: string; temp: string; original: string | null; mode: number }> = []
|
||||
const committed: typeof prepared = []
|
||||
const seen = new Set<string>()
|
||||
try {
|
||||
for (const change of changes) {
|
||||
if (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash)) throw new Error('원본 파일 조회 정보가 없습니다. 다시 분석해주세요.')
|
||||
if (!textFile.test(change.path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 수정할 수 있습니다.')
|
||||
const path = await checkedPath(change.root, change.path, true)
|
||||
if (seen.has(path)) throw new Error('중복된 변경 파일입니다.')
|
||||
seen.add(path)
|
||||
let original: string | null = null
|
||||
let mode = 0o644
|
||||
try { original = await readText(change.root, change.path); mode = (await lstat(path)).mode } catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if ((original === null ? null : agentFileHash(original)) !== change.expectedHash) throw new Error(`분석 이후 파일이 변경되었습니다: ${change.moduleName}/${change.path}. 다시 분석해주세요.`)
|
||||
prepared.push({ path, original, mode, temp: join(dirname(path), `.lms-agent-${randomUUID()}.tmp`) })
|
||||
}
|
||||
for (const [index, file] of prepared.entries()) {
|
||||
await writeFile(file.temp, changes[index].content, { encoding: 'utf8', mode: file.mode, flag: 'wx' })
|
||||
await chmod(file.temp, file.mode & 0o777)
|
||||
}
|
||||
// Recheck the entire batch immediately before committing any file.
|
||||
for (const file of prepared) {
|
||||
let latest: string | null = null
|
||||
try { latest = await readFile(file.path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
||||
if (latest !== file.original) throw new Error('적용 직전 파일이 변경되었습니다. 다시 분석해주세요.')
|
||||
}
|
||||
for (const file of prepared) { await rename(file.temp, file.path); committed.push(file) }
|
||||
} catch (error) {
|
||||
const failures: string[] = []
|
||||
for (const file of committed.reverse()) {
|
||||
try { if (file.original === null) await unlink(file.path); else await writeFile(file.path, file.original, { mode: file.mode }) } catch { failures.push(file.path) }
|
||||
}
|
||||
if (failures.length) throw new Error(`일부 파일 복구에 실패했습니다: ${failures.join(', ')}. ${String(error)}`)
|
||||
throw error
|
||||
} finally {
|
||||
await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { AgentCheckedChange, AgentToolRequest } from '../shared/agentTools'
|
||||
|
||||
export function agentRemoteFilesScript(input: { root: string; request: AgentToolRequest } | { changes: Array<AgentCheckedChange & { root: string }> }): string {
|
||||
const encoded = Buffer.from(JSON.stringify(input)).toString('base64')
|
||||
return `python3 - <<'LMS_AGENT_FILES'
|
||||
import sys
|
||||
sys.argv = ['lms-agent', '${encoded}']
|
||||
${agentRemoteFilesPython}
|
||||
LMS_AGENT_FILES`
|
||||
}
|
||||
|
||||
export const agentRemoteFilesPython = String.raw`
|
||||
import base64, hashlib, json, os, re, stat, sys, tempfile
|
||||
args = json.loads(base64.b64decode(sys.argv[1]))
|
||||
blocked = {'node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'}
|
||||
text_pattern = re.compile(r'\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$', re.I)
|
||||
def safe(value):
|
||||
if not isinstance(value, str) or not value or len(value) > 500 or value.startswith('/') or any(c in value for c in '\\\x00\r\n'):
|
||||
raise ValueError('Invalid source path')
|
||||
if any(not p or p.startswith('.') or p.lower() in blocked or re.search(r'\.(pem|key|p12|pfx)$', p, re.I) for p in value.split('/')):
|
||||
raise ValueError('Private or excluded source path')
|
||||
return value
|
||||
def checked(root, path, missing=False):
|
||||
safe(path)
|
||||
root = os.path.realpath(root)
|
||||
current = root
|
||||
parts = path.split('/')
|
||||
for i, part in enumerate(parts):
|
||||
current = os.path.join(current, part)
|
||||
try:
|
||||
info = os.lstat(current)
|
||||
if stat.S_ISLNK(info.st_mode) or (i < len(parts)-1 and not stat.S_ISDIR(info.st_mode)):
|
||||
raise ValueError('Symlink or non-directory source path')
|
||||
except FileNotFoundError:
|
||||
if not (missing and i == len(parts)-1):
|
||||
raise
|
||||
if os.path.commonpath([root, current]) != root:
|
||||
raise ValueError('Source path escapes module')
|
||||
return current
|
||||
def read(root, path):
|
||||
if not text_pattern.search(os.path.basename(path)):
|
||||
raise ValueError('Only text source files are supported')
|
||||
absolute = checked(root, path)
|
||||
info = os.lstat(absolute)
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_size > 1048576:
|
||||
raise ValueError('Only regular source files up to 1 MB are supported')
|
||||
with open(absolute, 'rb') as fp:
|
||||
content = fp.read(1048577)
|
||||
if len(content) > 1048576 or b'\x00' in content:
|
||||
raise ValueError('Binary or oversized file')
|
||||
return content.decode('utf-8')
|
||||
def digest(content):
|
||||
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
||||
if 'changes' in args:
|
||||
changes = args['changes']
|
||||
if not changes or len(changes) > 24 or sum(len(c['content']) for c in changes) > 1000000:
|
||||
raise ValueError('Invalid change batch size')
|
||||
prepared, committed, seen = [], [], set()
|
||||
try:
|
||||
for c in changes:
|
||||
path = checked(c['root'], c['path'], True)
|
||||
if path in seen or not text_pattern.search(os.path.basename(path)):
|
||||
raise ValueError('Duplicate or unsupported change file')
|
||||
seen.add(path)
|
||||
try:
|
||||
original = read(c['root'], c['path'])
|
||||
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||
except FileNotFoundError:
|
||||
original, mode = None, 0o644
|
||||
if (None if original is None else digest(original)) != c.get('expectedHash', 'missing'):
|
||||
raise ValueError('File changed after analysis: ' + c['moduleName'] + '/' + c['path'])
|
||||
prepared.append({'path': path, 'original': original, 'mode': mode, 'content': c['content'], 'temp': None})
|
||||
for p in prepared:
|
||||
fd, p['temp'] = tempfile.mkstemp(prefix='.lms-agent-', dir=os.path.dirname(p['path']))
|
||||
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as fp:
|
||||
fp.write(p['content'])
|
||||
os.chmod(p['temp'], p['mode'])
|
||||
for p in prepared:
|
||||
try:
|
||||
with open(p['path'], 'r', encoding='utf-8', newline='') as fp:
|
||||
latest = fp.read()
|
||||
except FileNotFoundError:
|
||||
latest = None
|
||||
if latest != p['original']:
|
||||
raise ValueError('File changed immediately before apply')
|
||||
for p in prepared:
|
||||
os.replace(p['temp'], p['path'])
|
||||
committed.append(p)
|
||||
except Exception as error:
|
||||
failed = []
|
||||
for p in reversed(committed):
|
||||
try:
|
||||
if p['original'] is None:
|
||||
os.unlink(p['path'])
|
||||
else:
|
||||
with open(p['path'], 'w', encoding='utf-8', newline='') as fp:
|
||||
fp.write(p['original'])
|
||||
os.chmod(p['path'], p['mode'])
|
||||
except Exception:
|
||||
failed.append(p['path'])
|
||||
if failed:
|
||||
raise ValueError('Rollback failed: ' + ', '.join(failed)) from error
|
||||
raise
|
||||
finally:
|
||||
for p in prepared:
|
||||
if p['temp'] and os.path.exists(p['temp']):
|
||||
os.unlink(p['temp'])
|
||||
print(json.dumps({'ok': True}))
|
||||
else:
|
||||
root = os.path.realpath(args['root'])
|
||||
req = args['request']
|
||||
options = req.get('args') or {}
|
||||
name = req['name']
|
||||
if name == 'files.read':
|
||||
path = options['path']
|
||||
try:
|
||||
content = read(root, path)
|
||||
lines = content.split('\n')
|
||||
ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000
|
||||
start = options.get('startLine', 1)
|
||||
selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
|
||||
selected = selection[:40000]
|
||||
content_truncated = len(selected) < len(selection)
|
||||
complete = start == 1 and selected == content
|
||||
partial = not complete
|
||||
end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated))
|
||||
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)}
|
||||
except FileNotFoundError:
|
||||
checked(root, path, True)
|
||||
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
|
||||
print(json.dumps({'data': data, 'truncated': partial}, ensure_ascii=False))
|
||||
else:
|
||||
prefix = options.get('path', '')
|
||||
if prefix:
|
||||
checked(root, prefix)
|
||||
paths, matches = [], []
|
||||
visited, scanned, truncated = 0, 0, False
|
||||
def fail(error):
|
||||
raise error
|
||||
for directory, dirs, files in os.walk(root, followlinks=False, onerror=fail):
|
||||
depth = len(os.path.relpath(directory, root).split('/')) if directory != root else 0
|
||||
kept = []
|
||||
for d in sorted(dirs):
|
||||
rel = os.path.relpath(os.path.join(directory, d), root)
|
||||
if prefix and rel != prefix and not rel.startswith(prefix + '/') and not prefix.startswith(rel + '/'):
|
||||
continue
|
||||
visited += 1
|
||||
try:
|
||||
safe(rel)
|
||||
if not os.path.islink(os.path.join(directory, d)):
|
||||
kept.append(d)
|
||||
except ValueError:
|
||||
pass
|
||||
dirs[:] = kept if depth < 10 else []
|
||||
for f in sorted(files):
|
||||
path = os.path.relpath(os.path.join(directory, f), root)
|
||||
if prefix and path != prefix and not path.startswith(prefix + '/'):
|
||||
continue
|
||||
visited += 1
|
||||
if visited > 5000 or len(paths) >= 250 or len(matches) >= 80 or scanned >= 4000000:
|
||||
truncated = True
|
||||
break
|
||||
try:
|
||||
safe(path)
|
||||
if os.path.islink(os.path.join(directory, f)) or not text_pattern.search(f):
|
||||
continue
|
||||
if prefix and path != prefix and not path.startswith(prefix + '/'):
|
||||
continue
|
||||
if name == 'files.list':
|
||||
paths.append(path)
|
||||
continue
|
||||
content = read(root, path)
|
||||
except (ValueError, OSError, UnicodeError):
|
||||
continue
|
||||
scanned += len(content)
|
||||
for number, line in enumerate(content.split('\n'), 1):
|
||||
if options['query'].lower() in line.lower():
|
||||
matches.append({'path': path, 'line': number, 'text': line[:500]})
|
||||
if len(matches) >= 80:
|
||||
truncated = True
|
||||
break
|
||||
if truncated:
|
||||
break
|
||||
data = {'paths': paths} if name == 'files.list' else {'query': options['query'], 'matches': matches}
|
||||
print(json.dumps({'data': data, 'truncated': truncated}, ensure_ascii=False))
|
||||
`
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { AgentToolRequest } from '../shared/agentTools'
|
||||
|
||||
export const DEFAULT_AGENT_TOPICS = ['/raw/lidar', '/scan', '/lidar_filtered', '/cloud_map', '/odom', '/map', '/tf', '/tf_static']
|
||||
|
||||
export function agentRosProbe(topics: string[] = DEFAULT_AGENT_TOPICS): string {
|
||||
const encoded = Buffer.from(JSON.stringify(topics)).toString('base64')
|
||||
return `python3 -c ${shellQuote(agentRosProbePython)} ${shellQuote(encoded)}`
|
||||
}
|
||||
export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'` }
|
||||
|
||||
export const agentRosProbePython = String.raw`
|
||||
import base64, json, sys, time
|
||||
import rclpy
|
||||
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
|
||||
from rosidl_runtime_py.utilities import get_message
|
||||
rclpy.init(args=[])
|
||||
node = rclpy.create_node('lms_agent_readonly_probe_' + str(int(time.time() * 1000)))
|
||||
topics = json.loads(base64.b64decode(sys.argv[1]))
|
||||
samples, valid_samples, frames, edges, errors, subscriptions = {}, {}, {}, set(), {}, []
|
||||
try:
|
||||
deadline = time.monotonic() + 1.5
|
||||
while time.monotonic() < deadline:
|
||||
rclpy.spin_once(node, timeout_sec=0.1)
|
||||
graph = dict(node.get_topic_names_and_types())
|
||||
def callback(topic):
|
||||
def receive(message):
|
||||
samples[topic] = samples.get(topic, 0) + 1
|
||||
if hasattr(message, 'header') and message.header.frame_id:
|
||||
frame_set = frames.setdefault(topic, set())
|
||||
if len(frame_set) < 8:
|
||||
frame_set.add(message.header.frame_id)
|
||||
valid = True
|
||||
if hasattr(message, 'ranges'):
|
||||
valid = len(message.ranges) > 0
|
||||
elif hasattr(message, 'width') and hasattr(message, 'data'):
|
||||
valid = message.width * message.height > 0 and len(message.data) > 0
|
||||
elif hasattr(message, 'info') and hasattr(message, 'data'):
|
||||
valid = message.info.width * message.info.height > 0 and len(message.data) > 0
|
||||
if valid:
|
||||
valid_samples[topic] = valid_samples.get(topic, 0) + 1
|
||||
if topic in ('/tf', '/tf_static'):
|
||||
for transform in message.transforms:
|
||||
if len(edges) < 200:
|
||||
edges.add((transform.header.frame_id, transform.child_frame_id))
|
||||
return receive
|
||||
for topic in topics:
|
||||
if topic not in graph:
|
||||
continue
|
||||
try:
|
||||
retained = any(info.qos_profile.durability == DurabilityPolicy.TRANSIENT_LOCAL for info in node.get_publishers_info_by_topic(topic))
|
||||
qos = QoSProfile(depth=1, reliability=ReliabilityPolicy.BEST_EFFORT, durability=DurabilityPolicy.TRANSIENT_LOCAL if retained else DurabilityPolicy.VOLATILE)
|
||||
subscriptions.append(node.create_subscription(get_message(graph[topic][0]), topic, callback(topic), qos))
|
||||
except Exception as error:
|
||||
errors[topic] = str(error)[:500]
|
||||
deadline = time.monotonic() + 3.0
|
||||
while time.monotonic() < deadline:
|
||||
rclpy.spin_once(node, timeout_sec=0.1)
|
||||
graph = dict(node.get_topic_names_and_types())
|
||||
print(json.dumps({'nodes': node.get_node_names()[:200], 'topics': [{'name': t, 'types': graph.get(t, []), 'publishers': node.count_publishers(t), 'samples': samples.get(t, 0), 'validSamples': valid_samples.get(t, 0), 'frameIds': sorted(frames.get(t, [])), 'error': errors.get(t)} for t in topics], 'tfEdges': [{'parent': p, 'child': c} for p, c in sorted(edges)], 'observationSeconds': 3.0}))
|
||||
finally:
|
||||
node.destroy_node()
|
||||
rclpy.shutdown()
|
||||
`
|
||||
|
||||
export async function inspectAgentRuntime(
|
||||
request: AgentToolRequest,
|
||||
host: (executable: string, args: string[]) => Promise<string>,
|
||||
container: (command: string) => Promise<string>
|
||||
): Promise<unknown> {
|
||||
if (request.name === 'network.inspect') {
|
||||
const interfaces = JSON.parse(await host('ip', ['-j', 'address', 'show']))
|
||||
const routes = JSON.parse(await host('ip', ['-j', 'route', 'show']))
|
||||
let containerNetwork: unknown
|
||||
try { containerNetwork = JSON.parse(await container('ip -j address show')) } catch (error) { containerNetwork = { error: String(error).slice(0, 500) } }
|
||||
return { host: { interfaces, routes }, container: containerNetwork, note: '호스트는 해당 모듈이 설치된 로컬 또는 SSH 대상 장치입니다. 라이다·공유기 자체 설정은 조회하지 않았습니다.' }
|
||||
}
|
||||
if (request.name === 'ros.inspect') return JSON.parse(await container(agentRosProbe(request.args?.topics)))
|
||||
throw new Error('지원하지 않는 런타임 조회입니다.')
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { BrowserWindow, clipboard, Menu } from 'electron'
|
||||
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
|
||||
import type { ChatClipboardAction, ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu'
|
||||
|
||||
function trustedChatFrame(event: IpcMainInvokeEvent): boolean {
|
||||
if (event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return false
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
return Boolean(window && !window.isDestroyed())
|
||||
}
|
||||
|
||||
export function readChatClipboardState(event: IpcMainInvokeEvent): { hasText: boolean } {
|
||||
return { hasText: trustedChatFrame(event) && clipboard.readText().length > 0 }
|
||||
}
|
||||
|
||||
export function performChatClipboardAction(event: IpcMainInvokeEvent, input: ChatClipboardAction): boolean {
|
||||
if (!trustedChatFrame(event) || !input) return false
|
||||
if (input.action === 'copy' && typeof input.text === 'string' && input.text.length > 0 && input.text.length <= 5_000_000) {
|
||||
clipboard.writeText(input.text)
|
||||
return true
|
||||
}
|
||||
if (input.action === 'paste' && clipboard.readText().length > 0) {
|
||||
event.sender.paste()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null {
|
||||
if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window || window.isDestroyed()) return null
|
||||
const label = (value: unknown, fallback: string): string => typeof value === 'string' && value.trim() ? value.slice(0, 80) : fallback
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const items: MenuItemConstructorOptions[] = [{
|
||||
label: label(input.labels?.copy, 'Copy'),
|
||||
enabled: input.canCopy === true,
|
||||
click: () => {
|
||||
if (!event.sender.isDestroyed() && input.canCopy === true) event.sender.copy()
|
||||
resolve(null)
|
||||
}
|
||||
}]
|
||||
if (input.kind === 'input') {
|
||||
items.push({
|
||||
label: label(input.labels?.paste, 'Paste'),
|
||||
enabled: input.canPaste === true && clipboard.readText().length > 0,
|
||||
click: () => {
|
||||
if (!event.sender.isDestroyed() && input.canPaste === true && clipboard.readText().length > 0) event.sender.paste()
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
} else if (input.canClear !== undefined) {
|
||||
items.push({ type: 'separator' }, {
|
||||
label: label(input.labels?.clear, 'Delete conversation'),
|
||||
enabled: input.canClear === true,
|
||||
click: () => resolve(input.canClear === true ? 'clear' : null)
|
||||
})
|
||||
}
|
||||
Menu.buildFromTemplate(items).popup({ window, callback: () => resolve(null) })
|
||||
})
|
||||
}
|
||||
+148
-9
@@ -1,8 +1,13 @@
|
||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
||||
import { appLog } from './appLogging'
|
||||
import { checkDockerStatus } from './dockerStatus'
|
||||
import { performChatClipboardAction, readChatClipboardState, showChatTextMenu } from './chatTextMenu'
|
||||
import { cameraRuntimeArtifacts } from './cameraRuntime'
|
||||
import { remoteModuleFilesScript } from './remoteModuleFiles'
|
||||
import { applyAgentFiles, runAgentFileTool } from './agentFileTools'
|
||||
import { agentRemoteFilesScript } from './agentRemoteFiles'
|
||||
import { inspectAgentRuntime } from './agentRuntimeTools'
|
||||
import { validateAgentTool, type AgentCheckedChange, type AgentToolRequest, type AgentToolResult } from '../shared/agentTools'
|
||||
import { resolveContainerTerminal } from './containerTerminal'
|
||||
import { remoteShellCommand, remoteShellEnvironment } from './remoteShell'
|
||||
import { listLocalGitModules, localGitModulePath, remoteBranches, repositoryAddress } from './gitModuleImport'
|
||||
@@ -73,7 +78,7 @@ async function readAppDiagnostics(): Promise<{ path: string; content: string }>
|
||||
}
|
||||
}
|
||||
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 maxImagePreviewBytes = 10 * 1024 * 1024
|
||||
const explicitModuleFolders: Record<string, string> = {
|
||||
@@ -1794,7 +1799,7 @@ function handleModuleOperation(channel: string, handler: Parameters<typeof ipcMa
|
||||
})
|
||||
}
|
||||
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined'): Promise<string> {
|
||||
async function runCommandOutput(command: string, args: string[], cwd?: string, env?: NodeJS.ProcessEnv, timeoutMs = 120_000, outputMode: 'combined' | 'stdout' = 'combined', input?: string): Promise<string> {
|
||||
const readProgress = createModuleProgressParser()
|
||||
return new Promise<string>((resolvePromise, rejectPromise) => {
|
||||
let output = ''
|
||||
@@ -1803,7 +1808,7 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
detached: process.platform !== 'win32',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
...env,
|
||||
@@ -1826,13 +1831,17 @@ async function runCommandOutput(command: string, args: string[], cwd?: string, e
|
||||
rejectPromise(new Error(`${command} ${args.join(' ')} timed out`))
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stdout.on('data', (data: string) => {
|
||||
if (input !== undefined) {
|
||||
child.stdin?.on('error', () => { /* Process exit is reported by close/error. */ })
|
||||
child.stdin?.end(input)
|
||||
}
|
||||
child.stdout?.setEncoding('utf8')
|
||||
child.stdout?.on('data', (data: string) => {
|
||||
stdout += data.toString()
|
||||
output += data.toString()
|
||||
readProgress(data.toString())
|
||||
})
|
||||
child.stderr.on('data', (data: Buffer) => {
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
output += data.toString()
|
||||
})
|
||||
child.on('error', (error) => {
|
||||
@@ -2219,7 +2228,7 @@ async function runSshOutput(target: RemoteWorkspaceTarget, command: string, time
|
||||
))
|
||||
}
|
||||
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number): Promise<string> {
|
||||
async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: string, timeoutMs: number, input?: string): Promise<string> {
|
||||
const normalized = normalizeRemoteTarget(target)
|
||||
return runCommandOutput(
|
||||
'ssh',
|
||||
@@ -2249,6 +2258,7 @@ async function runUnqueuedSshOutput(target: RemoteWorkspaceTarget, command: stri
|
||||
remoteShellEnvironment(process.env),
|
||||
timeoutMs,
|
||||
'stdout',
|
||||
input,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2553,6 +2563,23 @@ async function startRemoteVisualizationBridge(
|
||||
].join('\n'), 900_000)
|
||||
}
|
||||
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('시각화 시작 중...')
|
||||
|
||||
@@ -2570,6 +2597,7 @@ async function startRemoteVisualizationBridge(
|
||||
'docker run -d --name "$CONTAINER" --net=host',
|
||||
'-v /dev/shm:/dev/shm',
|
||||
'-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'] : []),
|
||||
'-e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID"',
|
||||
'-e ROS_LOCALHOST_ONLY=0',
|
||||
@@ -4767,10 +4795,19 @@ function buildNav2DockerRunScript(): string {
|
||||
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
|
||||
...yahboomDevicePreflight(),
|
||||
'CAMERA_DEVICE=""',
|
||||
'CAMERA_DEVICES=()',
|
||||
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
|
||||
' CAMERA_DEVICE=/dev/video0',
|
||||
' 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',
|
||||
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
|
||||
'',
|
||||
@@ -4790,7 +4827,7 @@ function buildNav2DockerRunScript(): string {
|
||||
')',
|
||||
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
|
||||
'if [ -n "$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',
|
||||
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
|
||||
' done',
|
||||
@@ -6016,6 +6053,86 @@ async function runRemoteAgentCommand(targetInput: RemoteWorkspaceTarget, moduleN
|
||||
}
|
||||
}
|
||||
|
||||
function agentFolder(folderName?: string | null): string | undefined {
|
||||
if (folderName == null) return undefined
|
||||
if (!/^[A-Za-z0-9[\](). _-]{1,160}$/.test(folderName) || folderName === '.' || folderName === '..') throw new Error('모듈 폴더명이 올바르지 않습니다.')
|
||||
return folderName
|
||||
}
|
||||
|
||||
async function runWorkspaceAgentTool(input: AgentToolRequest, workspaceRoot?: string, folderName?: string, remote?: RemoteWorkspaceTarget): Promise<AgentToolResult> {
|
||||
const request = validateAgentTool(input)
|
||||
const target = remote ? `ssh:${normalizeRemoteTarget(remote).host}` : 'local-host'
|
||||
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
|
||||
try {
|
||||
const folder = agentFolder(folderName)
|
||||
const resolved = remote ? await resolveRemoteGitModuleRoot(remote, request.moduleName!, folder) : null
|
||||
const root = resolved?.moduleRoot ?? await findWorkspaceModuleRoot(request.moduleName!, workspaceRoot, folder)
|
||||
if (!root) throw new Error('설치된 모듈을 찾을 수 없습니다.')
|
||||
let payload: unknown
|
||||
if (request.name.startsWith('files.')) {
|
||||
const response = remote
|
||||
? JSON.parse(await runUnqueuedSshOutput(resolved!.target, agentRemoteFilesScript({ root, request }), 30_000))
|
||||
: await runAgentFileTool(root, request)
|
||||
payload = response.data
|
||||
result.truncated = response.truncated
|
||||
} else if (request.name === 'logs.read') {
|
||||
const response = remote
|
||||
? await readRemoteWorkspaceModuleLogs(remote, request.moduleName!, folder)
|
||||
: await readWorkspaceModuleLogs(request.moduleName!, workspaceRoot, folder)
|
||||
payload = { ...response, logs: response.logs.slice(-20_000) }
|
||||
if (!response.ok) result.status = 'error'
|
||||
result.truncated = response.logs.length > 20_000
|
||||
} else {
|
||||
const host = (executable: string, args: string[]): Promise<string> => remote
|
||||
? runUnqueuedSshOutput(resolved!.target, [executable, ...args].map(quoteShellSingle).join(' '), 12_000)
|
||||
: runCommandOutput(executable, args, undefined, undefined, 12_000, 'stdout')
|
||||
const container = async (command: string): Promise<string> => {
|
||||
const runtime = remote ? await resolveRemoteAgentContainer(resolved!.target, root, folder) : await resolveLocalAgentContainer(root, folder)
|
||||
const args = agentContainerCommandArgs(runtime, command, false)
|
||||
// Unlike free-form commands, these fixed probes always have a container-side deadline.
|
||||
args.splice(4, 0, 'timeout', '--signal=INT', '--kill-after=2s', '10s')
|
||||
return host('docker', args)
|
||||
}
|
||||
payload = await inspectAgentRuntime(request, host, container)
|
||||
if (request.name === 'network.inspect' && (payload as { container?: { error?: string } }).container?.error) result.truncated = true
|
||||
if (request.name === 'ros.inspect') result.target += ':module-container'
|
||||
}
|
||||
result.data = JSON.stringify(payload)
|
||||
if (result.data.length > 60_000) {
|
||||
result.data = JSON.stringify({ message: '조회 결과가 용량 한도를 초과했습니다. 파일 범위나 검색어를 좁혀주세요.', excerpt: result.data.slice(0, 20_000) })
|
||||
result.truncated = true
|
||||
}
|
||||
} catch (error) {
|
||||
result.status = 'error'
|
||||
result.data = JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
let agentFileApplyPending = false
|
||||
async function applyWorkspaceAgentFiles(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
||||
if (agentFileApplyPending) throw new Error('다른 파일 변경안을 적용 중입니다.')
|
||||
agentFileApplyPending = true
|
||||
try { return await applyWorkspaceAgentFilesOnce(changes, workspaceRoot, remote) }
|
||||
finally { agentFileApplyPending = false }
|
||||
}
|
||||
|
||||
async function applyWorkspaceAgentFilesOnce(changes: AgentCheckedChange[], workspaceRoot?: string, remote?: RemoteWorkspaceTarget): Promise<{ ok: boolean }> {
|
||||
if (!Array.isArray(changes) || !changes.length || changes.length > 24) throw new Error('변경 파일 수를 확인하세요.')
|
||||
const resolved: Array<AgentCheckedChange & { root: string }> = []
|
||||
for (const change of changes) {
|
||||
if (!change || typeof change.content !== 'string' || change.content.length > 200_000 || (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash))) throw new Error('검증된 파일 변경안이 아닙니다.')
|
||||
validateAgentTool({ id: 'apply-check', name: 'files.read', moduleName: change.moduleName, args: { path: change.path } })
|
||||
const folder = agentFolder(change.folderName)
|
||||
const root = remote ? (await resolveRemoteGitModuleRoot(remote, change.moduleName, folder)).moduleRoot : await findWorkspaceModuleRoot(change.moduleName, workspaceRoot, folder)
|
||||
if (!root) throw new Error(`변경 대상 모듈이 설치되지 않았습니다: ${change.moduleName}`)
|
||||
resolved.push({ ...change, root })
|
||||
}
|
||||
if (remote) await runUnqueuedSshOutput(remote, 'bash --noprofile --norc -s', 30_000, agentRemoteFilesScript({ changes: resolved }))
|
||||
else await applyAgentFiles(resolved)
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function collectModuleFiles(moduleName: string, workspaceRoot?: string | null, folderName?: string | null): Promise<CachedModuleFiles> {
|
||||
const rootPath = await findModuleRoot(moduleName, workspaceRoot, folderName)
|
||||
if (!rootPath) return { root: null, absoluteRoot: null, files: {}, folders: [] }
|
||||
@@ -6681,6 +6798,25 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
||||
ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName))
|
||||
ipcMain.handle('local-workspace:agent-command:run', (_event, moduleName: string, command: string, workspaceRoot?: string | null, folderName?: string | null) => runLocalAgentCommand(moduleName, command, workspaceRoot, folderName))
|
||||
const checkAgentSender = (event: Electron.IpcMainInvokeEvent): void => {
|
||||
if (event.sender !== mainWindow?.webContents || event.senderFrame !== event.sender.mainFrame) throw new Error('허용되지 않은 에이전트 요청입니다.')
|
||||
}
|
||||
ipcMain.handle('local-workspace:agent-tool:read', (event, request: AgentToolRequest, workspaceRoot?: string, folderName?: string) => {
|
||||
checkAgentSender(event)
|
||||
return runWorkspaceAgentTool(request, workspaceRoot, folderName)
|
||||
})
|
||||
ipcMain.handle('remote-workspace:agent-tool:read', (event, target: RemoteWorkspaceTarget, request: AgentToolRequest, folderName?: string) => {
|
||||
checkAgentSender(event)
|
||||
return runWorkspaceAgentTool(request, undefined, folderName, target)
|
||||
})
|
||||
ipcMain.handle('local-workspace:agent-files:apply', (event, changes: AgentCheckedChange[], workspaceRoot?: string) => {
|
||||
checkAgentSender(event)
|
||||
return applyWorkspaceAgentFiles(changes, workspaceRoot)
|
||||
})
|
||||
ipcMain.handle('remote-workspace:agent-files:apply', (event, target: RemoteWorkspaceTarget, changes: AgentCheckedChange[]) => {
|
||||
checkAgentSender(event)
|
||||
return applyWorkspaceAgentFiles(changes, undefined, target)
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
|
||||
ipcMain.handle('local-workspace:nav2:set-simulator-pose', (_event, pose?: { x?: number; y?: number; yaw?: number } | null) => setNav2SimulatorPose(pose))
|
||||
ipcMain.handle('local-workspace:nav2:add-runtime-obstacle', (_event, obstacle?: Nav2RuntimeObstacle | null) => addNav2RuntimeObstacle(obstacle))
|
||||
@@ -6927,6 +7063,9 @@ app.whenReady().then(() => {
|
||||
return { opened: true }
|
||||
})
|
||||
ipcMain.handle('app-diagnostics:read', () => readAppDiagnostics())
|
||||
ipcMain.handle('chat-text:context-menu', showChatTextMenu)
|
||||
ipcMain.handle('chat-text:clipboard-state', readChatClipboardState)
|
||||
ipcMain.handle('chat-text:clipboard-action', performChatClipboardAction)
|
||||
ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close())
|
||||
ipcMain.on('app-quit', () => app.quit())
|
||||
ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input))
|
||||
|
||||
@@ -7,7 +7,7 @@ import base64, json, os, stat, sys
|
||||
args = json.loads(base64.b64decode(sys.argv[1]))
|
||||
root = os.path.realpath(args['root'])
|
||||
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.'
|
||||
def content(relative):
|
||||
if os.path.isabs(relative) or '..' in relative.split('/'):
|
||||
|
||||
@@ -117,6 +117,11 @@ console.info('[lms-preload] launcher auth resolved', {
|
||||
})
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||
contextBridge.exposeInMainWorld('chatTextMenu', {
|
||||
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options),
|
||||
readClipboardState: () => ipcRenderer.invoke('chat-text:clipboard-state'),
|
||||
perform: (action: import('../shared/chatTextMenu').ChatClipboardAction) => ipcRenderer.invoke('chat-text:clipboard-action', action)
|
||||
})
|
||||
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
||||
contextBridge.exposeInMainWorld('lmsBootSplash', {
|
||||
status: (message: string) => ipcRenderer.send('lms-boot-status', message),
|
||||
@@ -216,6 +221,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
|
||||
},
|
||||
agent: {
|
||||
readTool: (request: import('../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-tool:read', request, workspaceRoot, folderName),
|
||||
applyFiles: (changes: import('../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:agent-files:apply', changes, workspaceRoot),
|
||||
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-command:run', moduleName, command, workspaceRoot, folderName)
|
||||
},
|
||||
modules: {
|
||||
@@ -304,6 +311,8 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
||||
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
|
||||
},
|
||||
agent: {
|
||||
readTool: (target: RemoteWorkspaceTarget, request: import('../shared/agentTools').AgentToolRequest, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-tool:read', target, request, folderName),
|
||||
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../shared/agentTools').AgentCheckedChange[]) => ipcRenderer.invoke('remote-workspace:agent-files:apply', target, changes),
|
||||
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-command:run', target, moduleName, command, folderName)
|
||||
},
|
||||
maps: {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -24,11 +24,12 @@
|
||||
#miniMapToggle {
|
||||
position:absolute;
|
||||
right:16px;
|
||||
bottom:240px;
|
||||
bottom:calc(16px + 220px + 2px);
|
||||
z-index:11;
|
||||
min-height:28px;
|
||||
padding:0 10px;
|
||||
border:1px solid rgba(255,255,255,.22);
|
||||
border-radius:6px 6px 0 0;
|
||||
background:rgba(17,24,39,.42);
|
||||
color:rgba(255,255,255,.88);
|
||||
font:12px sans-serif;
|
||||
@@ -60,14 +61,29 @@
|
||||
#cameraPreviewWrap.is-visible { display:block; }
|
||||
#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; }
|
||||
#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;
|
||||
width:100%;
|
||||
aspect-ratio:16 / 9;
|
||||
object-fit:contain;
|
||||
background:#111520;
|
||||
}
|
||||
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
|
||||
#cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; }
|
||||
#cameraPreviewToggle {
|
||||
position:absolute;
|
||||
left:0;
|
||||
@@ -78,6 +94,7 @@
|
||||
place-items:center;
|
||||
padding:0;
|
||||
border:1px solid rgba(255,255,255,.22);
|
||||
border-radius:0 0 6px 6px;
|
||||
background:rgba(17,24,39,.42);
|
||||
color:rgba(255,255,255,.9);
|
||||
font:11px sans-serif;
|
||||
@@ -109,6 +126,7 @@
|
||||
display:none;
|
||||
}
|
||||
#keyGuide {
|
||||
display:none;
|
||||
position:absolute;
|
||||
left:16px;
|
||||
bottom:16px;
|
||||
@@ -127,7 +145,7 @@
|
||||
#rosTopicStatusWrap {
|
||||
position:absolute;
|
||||
left:16px;
|
||||
bottom:54px;
|
||||
bottom:16px;
|
||||
z-index:10;
|
||||
width:178px;
|
||||
}
|
||||
@@ -150,6 +168,7 @@
|
||||
place-items:center;
|
||||
padding:0;
|
||||
border:1px solid rgba(255,255,255,.22);
|
||||
border-radius:6px 6px 0 0;
|
||||
background:rgba(17,24,39,.42);
|
||||
color:rgba(255,255,255,.9);
|
||||
font:11px sans-serif;
|
||||
@@ -252,6 +271,7 @@
|
||||
#cameraPresetSelect {
|
||||
min-height:32px;
|
||||
border:1px solid rgba(255,255,255,.22);
|
||||
border-radius:6px;
|
||||
background:rgba(17,24,39,.42);
|
||||
color:rgba(255,255,255,.88);
|
||||
font:12px sans-serif;
|
||||
@@ -261,29 +281,43 @@
|
||||
box-shadow:0 10px 26px rgba(0,0,0,.16);
|
||||
transition:background .15s ease, border-color .15s ease, color .15s ease;
|
||||
}
|
||||
#downloadMap,
|
||||
#pathToggle,
|
||||
#sensorToggle,
|
||||
#poseToggle,
|
||||
#goalToggle,
|
||||
#obstacleToggle {
|
||||
padding:0 11px;
|
||||
}
|
||||
#downloadMap {
|
||||
display:inline-flex;
|
||||
align-items:center;
|
||||
gap:6px;
|
||||
}
|
||||
.viewer-icon-button {
|
||||
display:grid;
|
||||
place-items:center;
|
||||
width:34px;
|
||||
height:32px;
|
||||
flex:0 0 34px;
|
||||
padding:0;
|
||||
}
|
||||
.camera-preset-wrap {
|
||||
position:relative;
|
||||
width:34px;
|
||||
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 {
|
||||
min-width:86px;
|
||||
padding:0 24px 0 9px;
|
||||
outline:none;
|
||||
width:100%;
|
||||
height:100%;
|
||||
padding:0;
|
||||
color:transparent;
|
||||
appearance:none;
|
||||
background-image:linear-gradient(45deg, transparent 50%, rgba(255,255,255,.7) 50%), linear-gradient(135deg, rgba(255,255,255,.7) 50%, transparent 50%);
|
||||
background-position:calc(100% - 13px) 14px, calc(100% - 8px) 14px;
|
||||
background-size:5px 5px, 5px 5px;
|
||||
background-repeat:no-repeat;
|
||||
}
|
||||
#cameraPresetSelect option {
|
||||
color:rgba(255,255,255,.88);
|
||||
background:#202020;
|
||||
}
|
||||
.viewer-icon-button:focus-visible,
|
||||
#cameraPresetSelect:focus-visible {
|
||||
outline:2px solid #93c5fd;
|
||||
outline-offset:2px;
|
||||
}
|
||||
#downloadMap:hover,
|
||||
#pathToggle:hover,
|
||||
#sensorToggle:hover,
|
||||
@@ -296,6 +330,7 @@
|
||||
border-color:rgba(255,255,255,.34);
|
||||
color:#fff;
|
||||
}
|
||||
#cameraPresetSelect:hover { color:transparent; }
|
||||
#downloadMap:disabled {
|
||||
opacity:.45;
|
||||
cursor:not-allowed;
|
||||
@@ -374,41 +409,39 @@
|
||||
.nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; }
|
||||
.nav2-navigation #downloadWrap { display:none; }
|
||||
.nav2-navigation #topLeftControls { display:block; }
|
||||
.nav2-navigation #keyGuide { display:none; }
|
||||
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
|
||||
.real-runtime #topLeftControls { display:none; }
|
||||
.real-runtime #keyGuide { display:none; }
|
||||
.real-teleop:not(.editor-mode) #keyGuide { display:block; }
|
||||
.real-teleop #rosTopicStatusWrap { bottom:50px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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 id="topRightControls">
|
||||
<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>
|
||||
<button id="pathToggle" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시">Path</button>
|
||||
<button id="sensorToggle" title="센서 표시" data-i18n-title="센서 표시" aria-label="센서 표시" data-i18n-aria-label="센서 표시" aria-pressed="false">Sensor</button>
|
||||
<button id="cameraToggle" type="button" title="카메라 영상 표시" data-i18n-title="카메라 영상 표시" aria-label="카메라 영상 표시" data-i18n-aria-label="카메라 영상 표시" aria-pressed="true" aria-controls="cameraPreviewWrap">
|
||||
<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" 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" 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. -->
|
||||
<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 id="poseToggle" title="초기 위치 지정" data-i18n-title="초기 위치 지정" aria-label="초기 위치 지정" data-i18n-aria-label="초기 위치 지정">Pose</button>
|
||||
<button id="goalToggle" title="목표 위치 지정" data-i18n-title="목표 위치 지정" aria-label="목표 위치 지정" data-i18n-aria-label="목표 위치 지정">Goal</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" 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="카메라 시점">
|
||||
<option value="default">Default</option>
|
||||
<option value="top">Top</option>
|
||||
<option value="follow">Follow</option>
|
||||
</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>
|
||||
<canvas id="miniMap" width="260" height="220"></canvas>
|
||||
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
|
||||
<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>
|
||||
<img id="cameraPreview" alt="실시간 카메라 화면" data-i18n-alt="실시간 카메라 화면" />
|
||||
<div id="cameraPreviewList"></div>
|
||||
</div>
|
||||
<div id="keyGuide">
|
||||
<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">
|
||||
import { t, setText } from './locale.js';
|
||||
import { createCameraPreview } from './camera-preview.js';
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||
@@ -484,7 +518,7 @@ const miniMap = document.getElementById('miniMap');
|
||||
const miniCtx = miniMap.getContext('2d');
|
||||
const miniMapToggle = document.getElementById('miniMapToggle');
|
||||
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
|
||||
const cameraPreview = document.getElementById('cameraPreview');
|
||||
const cameraPreviewList = document.getElementById('cameraPreviewList');
|
||||
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
|
||||
const cameraToggleButton = document.getElementById('cameraToggle');
|
||||
const topRightControls = document.getElementById('topRightControls');
|
||||
@@ -919,12 +953,7 @@ let pointCloudRefreshTimer = null;
|
||||
let savedMapPointCloudLoaded = false;
|
||||
let sensorPointCloudRequestInFlight = false;
|
||||
let sensorPointCloudRefreshTimer = null;
|
||||
let cameraFrameRequestInFlight = false;
|
||||
let cameraFrameRetryAt = 0;
|
||||
let cameraFrameRefreshTimer = null;
|
||||
let cameraFrameObjectUrl = null;
|
||||
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
|
||||
let cameraFrameAbortController = null;
|
||||
let pathVisible = false;
|
||||
// SLAM always renders its live scan, including the forward beam. Navigation
|
||||
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
||||
@@ -2069,84 +2098,27 @@ async function refreshSensorPointCloud() {
|
||||
}
|
||||
}
|
||||
|
||||
function setCameraPreviewAvailable(available) {
|
||||
available = available && cameraPreviewEnabled;
|
||||
cameraPreviewWrap?.classList.toggle('is-visible', available);
|
||||
if (available) return;
|
||||
if (cameraFrameObjectUrl) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
const cameraPreviewController = createCameraPreview({
|
||||
root: cameraPreviewList,
|
||||
baseUrl: BACKEND_URL,
|
||||
getState: () => ({ state: latestSlamState, receivedAt: latestSlamStateReceivedAt }),
|
||||
onAvailable: available => cameraPreviewWrap.classList.toggle('is-visible', available),
|
||||
});
|
||||
|
||||
function setCameraPreviewEnabled(enabled) {
|
||||
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
|
||||
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
|
||||
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
|
||||
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
||||
cameraFrameRefreshTimer = null;
|
||||
cameraPreviewController.setEnabled(cameraPreviewEnabled);
|
||||
if (!cameraPreviewEnabled) {
|
||||
cameraFrameAbortController?.abort();
|
||||
setCameraPreviewAvailable(false);
|
||||
return;
|
||||
}
|
||||
cameraFrameRetryAt = 0;
|
||||
cameraPreviewWrap.classList.remove('is-collapsed');
|
||||
cameraPreviewToggle.setAttribute('aria-expanded', 'true');
|
||||
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
|
||||
void refreshCameraFrame();
|
||||
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
|
||||
}
|
||||
|
||||
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
|
||||
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
|
||||
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
|
||||
|
||||
function drawMiniMap(state) {
|
||||
pendingMiniMapState = state;
|
||||
@@ -3281,9 +3253,7 @@ window.addEventListener('beforeunload', () => {
|
||||
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
|
||||
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
|
||||
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
|
||||
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
||||
cameraFrameAbortController?.abort();
|
||||
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
|
||||
cameraPreviewController.dispose();
|
||||
});
|
||||
|
||||
function connectControlSocket() {
|
||||
|
||||
+338
-99
@@ -12,7 +12,7 @@ import type { Socket } from 'socket.io-client'
|
||||
import Sidebar from './components/Sidebar'
|
||||
import DockerSetupNotice from './components/DockerSetupNotice'
|
||||
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
|
||||
import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import { ConfirmStatusModal, ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import TitleBar from './components/TitleBar'
|
||||
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
|
||||
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
|
||||
@@ -58,7 +58,11 @@ import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminal
|
||||
import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel'
|
||||
import { useQaChat } from './features/workspace/useQaChat'
|
||||
import { useAiAgent } from './features/workspace/useAiAgent'
|
||||
import type { AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
|
||||
import { validateAgentOperation, validateAgentTool, type AgentToolRequest, type AgentToolResult } from '../../shared/agentTools'
|
||||
import { AGENT_PRACTICE_PROFILES, inspectAgentPractice, prepareAgentPractice } from './features/workspace/agentPractice'
|
||||
import type { AgentCodeReference, AgentCommand, AgentFileChange, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './features/workspace/useAiAgent'
|
||||
import { revealAgentCodeReference, validateCodeReference } from './features/workspace/agentCodeNavigation'
|
||||
import type { ChatAttachment } from './components/chatAttachments'
|
||||
import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime'
|
||||
import { useSourceControl } from './features/workspace/useSourceControl'
|
||||
import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus'
|
||||
@@ -565,6 +569,7 @@ function LocalPcWorkspaceMockup({
|
||||
accessToken,
|
||||
courseId: courseKey,
|
||||
weekNumber,
|
||||
revealInView: true,
|
||||
apiBaseUrl: LMS_API_BASE_URL,
|
||||
socketBaseUrl: LMS_SOCKET_ORIGIN,
|
||||
socketPath: LMS_SOCKET_PATH,
|
||||
@@ -623,13 +628,22 @@ function LocalPcWorkspaceMockup({
|
||||
const [selectedModule, setSelectedModule] = useState<ModuleRow | null>(null)
|
||||
const {
|
||||
enabled: agentEnabled,
|
||||
provider: agentProvider,
|
||||
imagesEnabled: agentImagesEnabled,
|
||||
models: agentModels,
|
||||
selectProvider: selectAgentProvider,
|
||||
statusLoading: agentStatusLoading,
|
||||
pending: agentPending,
|
||||
activity: agentActivity,
|
||||
progressSteps: agentProgressSteps,
|
||||
requestStartedAt: agentRequestStartedAt,
|
||||
messages: agentMessages,
|
||||
persistenceError: agentPersistenceError,
|
||||
requestPlan: requestAgentPlan,
|
||||
applyPlan: applyAgentPlan,
|
||||
cancelPlan: cancelAgentPlan,
|
||||
updateCommandExecution: updateAgentCommandExecution,
|
||||
openCodeReference: openAgentCodeReference,
|
||||
stop: stopAgent,
|
||||
clear: clearAgentConversation
|
||||
} = useAiAgent({
|
||||
@@ -641,6 +655,10 @@ function LocalPcWorkspaceMockup({
|
||||
getFreshAccessToken,
|
||||
onResponse: () => onTitlebarNotice(t("AI 에이전트 답변이 도착했습니다."), 'info', true)
|
||||
})
|
||||
const [agentActionBusy, setAgentActionBusy] = useState(false)
|
||||
const agentApplying = agentMessages.some(message => message.applyState === 'applying')
|
||||
const agentActionControllerRef = useRef<AbortController | null>(null)
|
||||
const [agentPracticeApproval, setAgentPracticeApproval] = useState<{ messageId: string; command: AgentCommand; starts: string[]; modules: string[]; parameters: Record<string, string[]>; runtimeSettings: string } | null>(null)
|
||||
const [moduleTabs, setModuleTabs] = useState<ModuleRow[]>([])
|
||||
const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false)
|
||||
const [teamGitSyncing, setTeamGitSyncing] = useState(false)
|
||||
@@ -703,6 +721,8 @@ function LocalPcWorkspaceMockup({
|
||||
finishTabDrop,
|
||||
resetTabDrag
|
||||
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
|
||||
const agentEditorFilesRef = useRef(editorFiles)
|
||||
agentEditorFilesRef.current = editorFiles
|
||||
const [agentDiffPreviews, setAgentDiffPreviews] = useState<AgentDiffPreview[]>([])
|
||||
const [activeAgentDiffPreviewId, setActiveAgentDiffPreviewId] = useState<string | null>(null)
|
||||
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
|
||||
@@ -755,6 +775,11 @@ function LocalPcWorkspaceMockup({
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent): void => {
|
||||
if (event.key !== 'Escape') return
|
||||
if (agentPracticeApproval) {
|
||||
event.preventDefault()
|
||||
setAgentPracticeApproval(null)
|
||||
return
|
||||
}
|
||||
if (pendingEditorCloseRequest) {
|
||||
event.preventDefault()
|
||||
setPendingEditorCloseRequest(null)
|
||||
@@ -788,7 +813,7 @@ function LocalPcWorkspaceMockup({
|
||||
|
||||
window.addEventListener('keydown', handleEscape)
|
||||
return () => window.removeEventListener('keydown', handleEscape)
|
||||
}, [pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
|
||||
}, [agentPracticeApproval, pendingEditorCloseRequest, pendingSourceSettingsSave, powerActionModalOpen, previewOpen, submitConfirmOpen, treeDialog])
|
||||
useEffect(() => {
|
||||
if (!controlEndsAt) return
|
||||
setControlCountdownNow(Date.now())
|
||||
@@ -836,6 +861,7 @@ function LocalPcWorkspaceMockup({
|
||||
const editorPaneRef = useRef<HTMLDivElement>(null)
|
||||
const editorRef = useRef<Parameters<OnMount>[0] | null>(null)
|
||||
const decorationIdsRef = useRef<string[]>([])
|
||||
const agentCodeDecorationsRef = useRef<ReturnType<Parameters<OnMount>[0]['createDecorationsCollection']> | null>(null)
|
||||
const [leftPanelWidth, setLeftPanelWidth] = useState(310)
|
||||
const [rightPanelWidth, setRightPanelWidth] = useState(310)
|
||||
const [projectTreeWidth, setProjectTreeWidth] = useState(280)
|
||||
@@ -1432,6 +1458,10 @@ function LocalPcWorkspaceMockup({
|
||||
return () => window.removeEventListener('message', handleViewerMessage)
|
||||
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, localVisualizationWorkspaceRoot, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, visualizationBridgeLocation, weekNumber])
|
||||
const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
|
||||
useEffect(() => {
|
||||
setAgentPracticeApproval(null)
|
||||
return () => { agentActionControllerRef.current?.abort() }
|
||||
}, [courseKey, weekNumber, platformId, remoteWorkspaceTargetKey])
|
||||
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
|
||||
const hasRestorableWorkspaceUi = Boolean(
|
||||
window.localStorage.getItem(workspaceUiSessionKey)
|
||||
@@ -2423,6 +2453,10 @@ function LocalPcWorkspaceMockup({
|
||||
setActiveEditorFile(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> => {
|
||||
try {
|
||||
const token = accessToken ? await getFreshAccessToken() : ''
|
||||
@@ -3494,16 +3528,23 @@ function LocalPcWorkspaceMockup({
|
||||
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
|
||||
await createCustomModuleRecord(input)
|
||||
}
|
||||
const collectAgentWorkspace = async (requestedModuleNames: string[] = []): Promise<AgentWorkspaceModule[]> => {
|
||||
const workspaceModules: AgentWorkspaceModule[] = availableModules.map((module) => ({
|
||||
const collectAgentWorkspace = async (requestedModuleNames: string[] = [], moduleSnapshot = availableModules): Promise<AgentWorkspaceModule[]> => {
|
||||
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
|
||||
const workspaceModules: AgentWorkspaceModule[] = moduleSnapshot.map((module) => ({
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
category: module.category,
|
||||
state: module.state,
|
||||
files: []
|
||||
parameters: module.parameters,
|
||||
files: [],
|
||||
...(requestedNames.has(module.name.toLowerCase()) ? {
|
||||
fileContext: {
|
||||
status: module.state === 'uninstalled' ? 'uninstalled' as const : 'limited' as const,
|
||||
message: module.state === 'uninstalled' ? '설치되지 않아 소스를 읽을 수 없습니다.' : '파일 수·크기 제한으로 읽지 못했습니다.'
|
||||
}
|
||||
} : {})
|
||||
}))
|
||||
const requestedNames = new Set(requestedModuleNames.map((name) => name.toLowerCase()))
|
||||
const targetModules = availableModules.filter((module) => (
|
||||
const targetModules = moduleSnapshot.filter((module) => (
|
||||
module.state !== 'uninstalled' && requestedNames.has(module.name.toLowerCase())
|
||||
))
|
||||
if (!targetModules.length) return workspaceModules
|
||||
@@ -3513,53 +3554,63 @@ function LocalPcWorkspaceMockup({
|
||||
return /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|zsh|launch|urdf|xacro|rviz|proto|cmake)$/i.test(name)
|
||||
|| ['dockerfile', 'cmakelists.txt', 'package.xml', 'setup.py', 'setup.cfg'].includes(name)
|
||||
}
|
||||
let totalCharacters = 0
|
||||
let totalFiles = 0
|
||||
|
||||
for (const module of targetModules) {
|
||||
if (totalFiles >= 24 || totalCharacters >= 140_000) break
|
||||
const modulePrefix = `modules/${module.name}`
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
||||
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
|
||||
const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null
|
||||
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
|
||||
const sourcePriority = (path: string): number => {
|
||||
if (path === preferredPath) return -100
|
||||
if (/(^|\/)src(\/|$)/.test(path)) return -50
|
||||
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
|
||||
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -30
|
||||
return 0
|
||||
}
|
||||
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
|
||||
.filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path))
|
||||
.sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right))
|
||||
const files: AgentWorkspaceModule['files'] = []
|
||||
|
||||
for (const rendererPath of paths) {
|
||||
if (totalFiles >= 24 || totalCharacters >= 140_000) break
|
||||
let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath)
|
||||
if (content === undefined && remoteWorkspace) {
|
||||
content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content
|
||||
let totalCharacters = 0
|
||||
let totalFiles = 0
|
||||
try {
|
||||
const modulePrefix = `modules/${module.name}`
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
||||
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const toRendererPath = (path: string): string => path.startsWith('modules/') ? path : `${modulePrefix}/${path.replace(/^\/+/, '')}`
|
||||
const preferredPath = currentActiveEditorFile?.startsWith(`${modulePrefix}/`) ? currentActiveEditorFile : null
|
||||
const normalizedFileContents = new Map(Object.entries(result.files).map(([path, content]) => [toRendererPath(path), content]))
|
||||
const sourcePriority = (path: string): number => {
|
||||
if (path === preferredPath) return -100
|
||||
if (/(^|\/)(launch|config)(\/|$)/.test(path)) return -60
|
||||
if (/(^|\/)src(\/|$)/.test(path)) return -50
|
||||
if (/(^|\/)(package\.xml|setup\.(py|cfg)|cmakelists\.txt|build\.sh|run\.sh)$/i.test(path)) return -40
|
||||
return 0
|
||||
}
|
||||
if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue
|
||||
if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue
|
||||
totalCharacters += content.length
|
||||
totalFiles += 1
|
||||
files.push({ path: rendererPath.slice(modulePrefix.length + 1), content })
|
||||
}
|
||||
const paths = Array.from(new Set((result.filePaths ?? Object.keys(result.files)).map(toRendererPath)))
|
||||
.filter((path) => isTextSource(path) && !/(^|\/)(\.git|node_modules|build|install|log)(\/|$)/.test(path))
|
||||
.sort((left, right) => sourcePriority(left) - sourcePriority(right) || left.localeCompare(right))
|
||||
const files: AgentWorkspaceModule['files'] = []
|
||||
|
||||
const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name)
|
||||
const workspaceModule = {
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
category: module.category,
|
||||
state: module.state,
|
||||
files
|
||||
for (const rendererPath of paths) {
|
||||
if (totalFiles >= 24 || totalCharacters >= 140_000) break
|
||||
let content = editorFiles[rendererPath] ?? normalizedFileContents.get(rendererPath)
|
||||
if (content === undefined && remoteWorkspace) {
|
||||
content = (await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, rendererPath, module.folderName)).content
|
||||
}
|
||||
if (typeof content !== 'string' || content.includes('\0') || content === 'Binary or large file preview is not available.') continue
|
||||
if (content.length > 60_000 || totalCharacters + content.length > 140_000) continue
|
||||
totalCharacters += content.length
|
||||
totalFiles += 1
|
||||
files.push({ path: rendererPath.slice(modulePrefix.length + 1), content })
|
||||
}
|
||||
|
||||
const moduleIndex = workspaceModules.findIndex((item) => item.name === module.name)
|
||||
const workspaceModule = {
|
||||
name: module.name,
|
||||
folderName: module.folderName,
|
||||
category: module.category,
|
||||
state: module.state,
|
||||
files,
|
||||
fileContext: {
|
||||
status: files.length ? 'loaded' as const : 'empty' as const,
|
||||
message: files.length ? '텍스트 소스를 조회했습니다. 파일 수·크기·형식 제한으로 일부 파일이 제외될 수 있습니다.' : '읽을 수 있는 텍스트 파일이 없습니다. 파일 수·크기·형식 제한 또는 빈 소스일 수 있습니다.'
|
||||
}
|
||||
}
|
||||
if (moduleIndex < 0) workspaceModules.push(workspaceModule)
|
||||
else workspaceModules[moduleIndex] = workspaceModule
|
||||
} catch (error) {
|
||||
const workspaceModule = workspaceModules.find((item) => item.name === module.name)
|
||||
if (workspaceModule) workspaceModule.fileContext = {
|
||||
status: 'error',
|
||||
message: (error instanceof Error ? error.message : '모듈 소스를 읽지 못했습니다.').slice(0, 500)
|
||||
}
|
||||
}
|
||||
if (moduleIndex < 0) workspaceModules.push(workspaceModule)
|
||||
else workspaceModules[moduleIndex] = workspaceModule
|
||||
}
|
||||
|
||||
return workspaceModules
|
||||
@@ -3590,20 +3641,89 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
}))
|
||||
}
|
||||
const submitAgentRequest = async (content: string): Promise<void> => {
|
||||
const loadAgentRequestedContext = async (request: { modules: string[]; logs: string[] }): Promise<{
|
||||
modules: AgentWorkspaceModule[]
|
||||
runtimeLogs: AgentRuntimeLog[]
|
||||
}> => ({
|
||||
modules: await collectAgentWorkspace(request.modules),
|
||||
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
|
||||
})
|
||||
const readAgentModules = async (): Promise<ModuleRow[]> => {
|
||||
const response = remoteWorkspace
|
||||
? await window.remoteWorkspace.modules.list(getRemoteWorkspaceTarget(), runtimeModules ?? [])
|
||||
: await window.localWorkspace.modules.list(clonePathPreview, runtimeModules ?? [])
|
||||
return applyModuleParameterOverrides(response.modules)
|
||||
}
|
||||
const executeWorkspaceAgentTool = async (input: AgentToolRequest): Promise<AgentToolResult> => {
|
||||
const request = validateAgentTool(input)
|
||||
const target = remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host'
|
||||
const result: AgentToolResult = { id: request.id, name: request.name, moduleName: request.moduleName, status: 'ok', observedAt: new Date().toISOString(), target, data: '', truncated: false }
|
||||
try {
|
||||
const modules = await collectAgentWorkspace()
|
||||
await requestAgentPlan(content, modules, [], {
|
||||
loadRequestedContext: async (request) => ({
|
||||
modules: await collectAgentWorkspace(request.modules),
|
||||
runtimeLogs: await collectAgentRuntimeLogs(request.logs)
|
||||
})
|
||||
if (request.name === 'modules.list' || request.name === 'practice.inspect') {
|
||||
const modules = await readAgentModules()
|
||||
const payload = request.name === 'modules.list'
|
||||
? modules.map(module => ({ name: module.name, state: module.state, category: module.category, parameters: module.parameters }))
|
||||
: (request.args?.profile ? [request.args.profile] : AGENT_PRACTICE_PROFILES.map(profile => profile.id)).map(profile => inspectAgentPractice(profile, modules, remoteWorkspace ? 'remote' : 'local'))
|
||||
return { ...result, data: JSON.stringify(payload) }
|
||||
}
|
||||
const matches = availableModules.filter(module => module.name === request.moduleName)
|
||||
if (matches.length !== 1) throw new Error('대상 모듈이 없거나 같은 이름이 여러 개입니다.')
|
||||
const module = matches[0]
|
||||
return remoteWorkspace
|
||||
? await window.remoteWorkspace.agent.readTool(getRemoteWorkspaceTarget(), request, module.folderName)
|
||||
: await window.localWorkspace.agent.readTool(request, clonePathPreview, module.folderName)
|
||||
} catch (error) {
|
||||
return { ...result, status: 'error', data: JSON.stringify({ error: (error instanceof Error ? error.message : String(error)).slice(-2000) }) }
|
||||
}
|
||||
}
|
||||
const submitAgentRequest = async (content: string, attachments: ChatAttachment[] = []): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentApplying) return
|
||||
try {
|
||||
await requestAgentPlan(content, [], [], {
|
||||
attachments,
|
||||
loadInitialContext: async (signal) => {
|
||||
const modules = await readAgentModules()
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
return { modules: await collectAgentWorkspace([], modules), runtimeLogs: [] }
|
||||
},
|
||||
loadRequestedContext: loadAgentRequestedContext,
|
||||
executeTool: executeWorkspaceAgentTool,
|
||||
environment: remoteWorkspace ? 'remote' : 'local',
|
||||
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[agent] workspace collection failed', error)
|
||||
onTitlebarNotice(error instanceof Error ? error.message : '모듈 파일을 읽지 못했습니다.', 'error')
|
||||
}
|
||||
}
|
||||
const openWorkspaceAgentCodeReference = async (reference: AgentCodeReference, conversationCurrent: () => boolean): Promise<void> => {
|
||||
if (agentApplying) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 열어주세요.')
|
||||
validateCodeReference(reference)
|
||||
const scope = agentNavigationScope
|
||||
const isCurrent = (): boolean => conversationCurrent() && agentNavigationRef.current.scope === scope && agentNavigationRef.current.visible
|
||||
const module = availableModules.find(item => item.name === reference.moduleName)
|
||||
if (!module) throw new Error('코드 위치의 모듈을 찾을 수 없습니다. 다시 분석을 요청해주세요.')
|
||||
const file = `modules/${module.name}/${reference.path}`
|
||||
if (!isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.')
|
||||
const snapshot = agentEditorFilesRef.current[file]
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.content(getRemoteWorkspaceTarget(), module.name, file, module.folderName)
|
||||
: { content: (await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)).files[file] }
|
||||
if (!isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.')
|
||||
if (typeof result.content !== 'string') throw new Error('코드 파일을 읽지 못했습니다. 다시 분석을 요청해주세요.')
|
||||
setEditorFiles(files => {
|
||||
// Preserve any editor buffer already open, including edits made during the read.
|
||||
if (files[file] !== undefined || files[file] !== snapshot) return files
|
||||
return { ...files, [file]: result.content }
|
||||
})
|
||||
setSavedEditorFiles(files => ({ ...files, [file]: result.content }))
|
||||
if (selectedModule?.name !== module.name) openModuleSource(module)
|
||||
else setActiveIdeActivity('workspace')
|
||||
openEditorFile(file)
|
||||
agentCodeDecorationsRef.current?.clear()
|
||||
agentCodeDecorationsRef.current = await revealAgentCodeReference({ file, reference, isCurrent, getEditor: () => editorRef.current, getActiveFile: () => agentNavigationRef.current.file, isReady: () => agentNavigationRef.current.ready })
|
||||
}
|
||||
const openAgentChangePreview = async (messageId: string, change: AgentFileChange): Promise<void> => {
|
||||
const module = availableModules.find((item) => item.name === change.moduleName)
|
||||
?? (selectedModule?.name === change.moduleName ? selectedModule : null)
|
||||
@@ -3613,32 +3733,16 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
|
||||
const rendererPath = `modules/${module.name}/${change.path.replace(/^\/+/, '')}`
|
||||
let originalContent = editorFiles[rendererPath]
|
||||
|
||||
if (originalContent === undefined) {
|
||||
try {
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
||||
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
|
||||
const normalizePath = (path: string): string => path.startsWith('modules/')
|
||||
? path
|
||||
: `modules/${module.name}/${path.replace(/^\/+/, '')}`
|
||||
const fileContent = Object.entries(result.files).find(([path]) => normalizePath(path) === rendererPath)?.[1]
|
||||
const fileExists = [...(result.filePaths ?? []), ...Object.keys(result.files)]
|
||||
.some((path) => normalizePath(path) === rendererPath)
|
||||
|
||||
originalContent = fileContent
|
||||
if (originalContent === undefined && fileExists && remoteWorkspace) {
|
||||
originalContent = (await window.remoteWorkspace.moduleFiles.content(
|
||||
getRemoteWorkspaceTarget(),
|
||||
module.name,
|
||||
rendererPath,
|
||||
module.folderName
|
||||
)).content
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[agent] failed to read original file for diff preview', error)
|
||||
}
|
||||
let originalContent: string | undefined
|
||||
try {
|
||||
const result = await executeWorkspaceAgentTool({ id: `preview-${Date.now()}`, name: 'files.read', moduleName: module.name, args: { path: change.path } })
|
||||
const file = JSON.parse(result.data) as { content?: string; hash?: string | null; complete?: boolean }
|
||||
if (result.status !== 'ok' || !file.complete || typeof file.content !== 'string') throw new Error('비교할 원본 파일 전체를 읽지 못했습니다. 다시 분석해주세요.')
|
||||
if (change.expectedHash !== undefined && change.expectedHash !== file.hash) throw new Error('분석 이후 원본 파일이 변경되었습니다. 다시 분석해주세요.')
|
||||
originalContent = file.content
|
||||
} catch (error) {
|
||||
onTitlebarNotice(error instanceof Error ? error.message : '비교할 원본 파일을 읽지 못했습니다.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedModule?.name !== module.name) openModuleSource(module)
|
||||
@@ -3668,15 +3772,93 @@ function LocalPcWorkspaceMockup({
|
||||
return previews.filter((preview) => preview.messageId !== messageId)
|
||||
})
|
||||
}
|
||||
const runStructuredAgentCommand = async (messageId: string, command: AgentCommand, approval?: { starts: string[]; parameters: Record<string, string[]>; runtimeSettings: string }): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentPending || agentApplying) return
|
||||
const message = agentMessages.find(item => item.id === messageId)
|
||||
if (!message?.plan || message.applyState === 'cancelled' || !command.operation) return
|
||||
const controller = new AbortController()
|
||||
agentActionControllerRef.current = controller
|
||||
setAgentActionBusy(true)
|
||||
const assertActive = (): void => { if (controller.signal.aborted) throw new DOMException('Aborted', 'AbortError') }
|
||||
try {
|
||||
const operation = validateAgentOperation(command.operation)
|
||||
const runtimeSettings = JSON.stringify([runtimeSourceSettings, moduleRuntimeImages, vehicleRuntimeProfiles])
|
||||
if (approval && approval.runtimeSettings !== runtimeSettings) throw new Error('승인 이후 ROS Domain 또는 실행 환경 설정이 바뀌었습니다. 다시 승인해주세요.')
|
||||
const freshModules = await readAgentModules()
|
||||
assertActive()
|
||||
const candidates = freshModules.filter(item => item.name === command.moduleName)
|
||||
if (candidates.length !== 1 || candidates[0].state === 'uninstalled') throw new Error('대상 모듈의 설치 상태를 확인해주세요.')
|
||||
const module = candidates[0]
|
||||
if (getDirtyModuleFiles(module.name).length) throw new Error('저장하지 않은 변경 사항이 있습니다. 저장 후 다시 승인해주세요.')
|
||||
const environment = remoteWorkspace ? 'remote' as const : 'local' as const
|
||||
if (operation.name === 'practice.prepare' && !approval) {
|
||||
const inspection = inspectAgentPractice(operation.profile!, freshModules, environment)
|
||||
if (inspection.blockers.length) throw new Error(inspection.blockers.join('\n'))
|
||||
if (command.moduleName !== inspection.profile.modules.at(-1)) throw new Error('실습 구성표의 SLAM 모듈을 대상으로 지정해야 합니다.')
|
||||
setAgentPracticeApproval({ messageId, command, starts: inspection.starts, modules: [...inspection.profile.modules], parameters: Object.fromEntries(inspection.required.map(item => [item.name, item.parameters])), runtimeSettings })
|
||||
return
|
||||
}
|
||||
updateAgentCommandExecution(messageId, command.id, { status: 'running', output: '실행 전 상태 확인 중' })
|
||||
let output: unknown
|
||||
let ok: boolean
|
||||
if (operation.name === 'practice.prepare') {
|
||||
output = await prepareAgentPractice({
|
||||
profileId: operation.profile!, environment, approvedStarts: approval!.starts, approvedParameters: approval!.parameters, signal: controller.signal,
|
||||
list: readAgentModules,
|
||||
start: async target => {
|
||||
if (getDirtyModuleFiles(target.name).length) throw new Error(`${target.name}: 저장하지 않은 파일이 있습니다.`)
|
||||
let failure: string | undefined
|
||||
const ok = await runModuleControl(target, 'start', { background: true, freshSlam: true, onResult: result => { if (!result.ok) failure = result.message?.slice(-6000) } })
|
||||
if (!ok && failure) throw new Error(`${target.name}: ${failure}`)
|
||||
return ok
|
||||
},
|
||||
probe: (target, topics) => executeWorkspaceAgentTool({ id: `verify-${Date.now()}`, name: 'ros.inspect', moduleName: target.name, args: { topics } }),
|
||||
progress: (text, steps) => updateAgentCommandExecution(messageId, command.id, { status: 'running', output: JSON.stringify({ phase: text, steps }, null, 2) })
|
||||
})
|
||||
ok = (output as { ready: boolean }).ready
|
||||
} else {
|
||||
let detail: string | undefined
|
||||
ok = operation.name === 'modules.start' && module.state === 'running'
|
||||
? true
|
||||
: await runModuleControl(module, operation.name === 'modules.start' ? 'start' : 'build', { background: true, onResult: result => { detail = result.message?.slice(-6000) } })
|
||||
output = { ok, operation: operation.name, moduleName: module.name, message: detail, note: operation.name === 'modules.start' ? '프로세스 시작 결과입니다. 센서·TF 정상 여부는 별도 검증이 필요합니다.' : '빌드 결과입니다. 전체 빌드 로그를 수집한 것은 아니며 경고 해소·기능 정상 여부는 별도 검증이 필요합니다.' }
|
||||
}
|
||||
assertActive()
|
||||
const serialized = JSON.stringify(output, null, 2)
|
||||
updateAgentCommandExecution(messageId, command.id, { status: ok ? 'completed' : 'failed', output: serialized, exitCode: ok ? 0 : 1 })
|
||||
onTitlebarNotice(operation.name === 'practice.prepare' ? ok ? 'SLAM 실습 준비와 데이터 검증이 완료되었습니다.' : 'SLAM 준비 검증에 실패했습니다. 실행 결과를 확인해주세요.' : ok ? '에이전트 승인 작업이 완료되었습니다.' : '에이전트 승인 작업에 실패했습니다.', ok ? 'info' : 'error', true)
|
||||
const messageIndex = agentMessages.findIndex(item => item.id === messageId)
|
||||
const originalRequest = [...agentMessages.slice(0, messageIndex)].reverse().find(item => item.role === 'user')?.content ?? message.plan.summary
|
||||
const currentModules = await readAgentModules()
|
||||
assertActive()
|
||||
await requestAgentPlan(`학생의 원래 요청: ${originalRequest.slice(0, 2600)}\n승인 작업 ${command.command}의 실행 결과가 workspace.toolResults에 있습니다. 결과를 분석하고 확인된 성공과 실패를 설명하세요. 이미 승인·실행한 작업을 반복하지 마세요.`, currentModules.map(item => ({ name: item.name, state: item.state, category: item.category, parameters: item.parameters, files: [] })), [], {
|
||||
showUserMessage: false, environment, workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview,
|
||||
executeTool: executeWorkspaceAgentTool, loadRequestedContext: loadAgentRequestedContext,
|
||||
toolResults: [{ id: `action-${Date.now()}`, name: 'action.result', moduleName: module.name, observedAt: new Date().toISOString(), target: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host', status: ok ? 'ok' : 'error', data: JSON.stringify({ approvedAction: operation, result: output }), truncated: false }]
|
||||
})
|
||||
} catch (error) {
|
||||
const reason = controller.signal.aborted ? '다음 단계 진행을 중단했습니다. 이미 시작된 모듈과 진행 중인 단일 작업은 자동 종료하지 않았습니다.' : error instanceof Error ? error.message : '승인 작업을 처리하지 못했습니다.'
|
||||
updateAgentCommandExecution(messageId, command.id, { status: 'failed', error: reason, exitCode: null })
|
||||
onTitlebarNotice(reason, 'error', true)
|
||||
} finally {
|
||||
if (agentActionControllerRef.current === controller) { agentActionControllerRef.current = null; setAgentActionBusy(false) }
|
||||
}
|
||||
}
|
||||
const runWorkspaceAgentCommand = async (messageId: string, command: AgentCommand): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentPending) return
|
||||
const message = agentMessages.find((item) => item.id === messageId)
|
||||
const currentExecution = message?.commandExecutions?.[command.id]
|
||||
if (!message?.plan || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
|
||||
if (!message?.plan || message.applyState === 'cancelled' || (currentExecution && !['pending', 'failed'].includes(currentExecution.status))) return
|
||||
const requiresAppliedChanges = message.plan.changes.length > 0 || message.plan.createModule !== null
|
||||
if (requiresAppliedChanges && message.applyState !== 'applied') {
|
||||
onTitlebarNotice('코드 변경을 먼저 적용한 뒤 명령을 실행하세요.', 'error')
|
||||
return
|
||||
}
|
||||
if (command.operation) {
|
||||
await runStructuredAgentCommand(messageId, command)
|
||||
return
|
||||
}
|
||||
|
||||
const module = availableModules.find((item) => item.name === command.moduleName)
|
||||
?? (selectedModule?.name === command.moduleName ? selectedModule : null)
|
||||
@@ -3730,7 +3912,14 @@ function LocalPcWorkspaceMockup({
|
||||
`명령 출력:\n${result.output.slice(-2400) || '(출력 없음)'}`,
|
||||
'이미 실행한 명령을 그대로 반복하지 말고, 결과상 추가 작업이 필요할 때만 다음 승인 명령 또는 파일 변경안을 제안하세요.'
|
||||
].filter(Boolean).join('\n')
|
||||
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], { showUserMessage: false })
|
||||
await requestAgentPlan(followUpRequest.slice(0, 4000), nextWorkspace, [], {
|
||||
showUserMessage: false,
|
||||
loadRequestedContext: loadAgentRequestedContext,
|
||||
executeTool: executeWorkspaceAgentTool,
|
||||
environment: remoteWorkspace ? 'remote' : 'local',
|
||||
workspaceTarget: remoteWorkspace ? `ssh:${getRemoteWorkspaceTarget().host}` : 'local-host',
|
||||
workspaceRoot: remoteWorkspace ? JSON.stringify([getRemoteWorkspaceTarget().user, getRemoteWorkspaceTarget().port, getRemoteWorkspaceTarget().workspaceRoot]) : clonePathPreview
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = (error instanceof Error ? error.message : '명령을 실행하지 못했습니다.')
|
||||
.replace(/^Error invoking remote method '[^']+': Error:\s*/, '')
|
||||
@@ -3748,6 +3937,13 @@ function LocalPcWorkspaceMockup({
|
||||
updateAgentCommandExecution(messageId, command.id, { status: 'rejected' })
|
||||
}
|
||||
const applyWorkspaceAgentPlan = async (plan: AgentPlan): Promise<void> => {
|
||||
if (agentActionControllerRef.current || agentPending) throw new Error('진행 중인 에이전트 작업이 끝난 뒤 적용해주세요.')
|
||||
const editorSnapshot = agentEditorFilesRef.current
|
||||
for (const change of plan.changes) {
|
||||
if (plan.createModule && change.moduleName !== plan.createModule.name) throw new Error('새 모듈 생성과 기존 모듈 수정은 별도로 진행해주세요.')
|
||||
if (getDirtyModuleFiles(change.moduleName).includes(`modules/${change.moduleName}/${change.path}`)) throw new Error(`저장하지 않은 파일입니다: ${change.moduleName}/${change.path}. 먼저 저장하거나 변경을 취소해주세요.`)
|
||||
if (change.expectedHash === undefined && !plan.createModule) throw new Error('원본 파일 검증 정보가 없습니다. 다시 분석한 변경안을 적용해주세요.')
|
||||
}
|
||||
let createdModule: ModuleRow | null = null
|
||||
if (plan.createModule) {
|
||||
if (availableModules.some((module) => module.name === plan.createModule?.name)) {
|
||||
@@ -3783,23 +3979,34 @@ function LocalPcWorkspaceMockup({
|
||||
if (createdModule?.name === moduleName) return createdModule
|
||||
return availableModules.find((module) => module.name === moduleName) ?? (selectedModule?.name === moduleName ? selectedModule : null)
|
||||
}
|
||||
if (!createdModule && plan.changes.length) {
|
||||
const changes = plan.changes.map(change => {
|
||||
const module = resolveModule(change.moduleName)
|
||||
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
|
||||
return { ...change, folderName: module.folderName, expectedHash: change.expectedHash! }
|
||||
})
|
||||
if (remoteWorkspace) await window.remoteWorkspace.agent.applyFiles(getRemoteWorkspaceTarget(), changes)
|
||||
else await window.localWorkspace.agent.applyFiles(changes, clonePathPreview)
|
||||
}
|
||||
let firstChangedFile: { module: ModuleRow; path: string } | null = null
|
||||
for (const change of plan.changes) {
|
||||
const module = resolveModule(change.moduleName)
|
||||
if (!module) throw new Error(`변경 대상 모듈을 찾을 수 없습니다: ${change.moduleName}`)
|
||||
const rendererPath = `modules/${module.name}/${change.path}`
|
||||
if (remoteWorkspace) {
|
||||
if (createdModule && remoteWorkspace) {
|
||||
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), module.name, rendererPath, 'file', module.folderName)
|
||||
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), module.name, rendererPath, change.content, module.folderName)
|
||||
} else {
|
||||
} else if (createdModule) {
|
||||
await window.localWorkspace.moduleFiles.create(module.name, rendererPath, 'file', clonePathPreview, module.folderName)
|
||||
await window.localWorkspace.moduleFiles.write(module.name, rendererPath, change.content, clonePathPreview, module.folderName)
|
||||
}
|
||||
setEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
|
||||
const editedDuringApply = agentEditorFilesRef.current[rendererPath] !== editorSnapshot[rendererPath]
|
||||
setEditorFiles((files) => ({ ...files, [rendererPath]: files[rendererPath] !== editorSnapshot[rendererPath] ? files[rendererPath] : change.content }))
|
||||
setSavedEditorFiles((files) => ({ ...files, [rendererPath]: change.content }))
|
||||
setDirtyEditorFiles((files) => {
|
||||
const next = { ...files }
|
||||
delete next[rendererPath]
|
||||
if (editedDuringApply) next[rendererPath] = true
|
||||
else delete next[rendererPath]
|
||||
return next
|
||||
})
|
||||
if (!firstChangedFile) firstChangedFile = { module, path: rendererPath }
|
||||
@@ -4383,6 +4590,7 @@ function LocalPcWorkspaceMockup({
|
||||
/>
|
||||
|
||||
<WorkspaceEditorPane
|
||||
editingLocked={agentApplying}
|
||||
fileLoading={remoteEditorFile.loading}
|
||||
fileError={remoteEditorFile.error}
|
||||
onRetryFile={remoteEditorFile.retry}
|
||||
@@ -4403,7 +4611,7 @@ function LocalPcWorkspaceMockup({
|
||||
beforeMount={configureMonacoTheme}
|
||||
onMount={handleEditorMount}
|
||||
onChange={(value) => {
|
||||
if (!currentActiveEditorFile) return
|
||||
if (!currentActiveEditorFile || agentApplying) return
|
||||
updateEditorFileContent(currentActiveEditorFile, value ?? '', exampleActive)
|
||||
}}
|
||||
/>
|
||||
@@ -4554,13 +4762,21 @@ function LocalPcWorkspaceMockup({
|
||||
visibleTeacherMessages={visibleTeacherMessages}
|
||||
agentMessages={agentMessages}
|
||||
agentEnabled={agentEnabled}
|
||||
agentProvider={agentProvider}
|
||||
agentImagesEnabled={Boolean(agentImagesEnabled)}
|
||||
agentModels={agentModels}
|
||||
onAgentProviderChange={selectAgentProvider}
|
||||
agentStatusLoading={agentStatusLoading}
|
||||
agentPending={agentPending}
|
||||
agentActivity={agentActivity}
|
||||
agentProgressSteps={agentProgressSteps}
|
||||
agentRequestStartedAt={agentRequestStartedAt}
|
||||
agentActionBusy={agentActionBusy || agentApplying}
|
||||
agentPersistenceError={agentPersistenceError}
|
||||
aiPending={aiPending}
|
||||
teacherThreadStatus={teacherThreadStatus}
|
||||
qaCollapseSignal={qaCollapseSignal}
|
||||
inputBusy={qaMode === 'agent' ? agentPending : aiPending || Boolean(aiTypingMessageId)}
|
||||
inputBusy={qaMode === 'agent' ? agentPending || agentActionBusy || agentApplying : aiPending || Boolean(aiTypingMessageId)}
|
||||
inputDisabled={!validChatWeek || (qaMode === 'agent' && (!agentEnabled || agentStatusLoading))}
|
||||
workspaceChatScrollRef={workspaceChatScrollRef}
|
||||
onMinimize={() => setRightPanelMinimized(true)}
|
||||
@@ -4568,9 +4784,9 @@ function LocalPcWorkspaceMockup({
|
||||
setQaMode(mode)
|
||||
if (mode === 'teacher') setTeacherChatFlash(false)
|
||||
}}
|
||||
onStop={qaMode === 'agent' ? stopAgent : stopAiResponse}
|
||||
onSubmit={(content) => {
|
||||
if (qaMode === 'agent') void submitAgentRequest(content)
|
||||
onStop={qaMode === 'agent' ? () => { agentActionControllerRef.current?.abort(); stopAgent() } : stopAiResponse}
|
||||
onSubmit={(content, attachments) => {
|
||||
if (qaMode === 'agent') void submitAgentRequest(content, attachments)
|
||||
else void sendQaMessage(content, { contextWeek: curriculumWeek.week })
|
||||
}}
|
||||
onClearConversation={() => {
|
||||
@@ -4584,12 +4800,35 @@ function LocalPcWorkspaceMockup({
|
||||
onOpenAgentChange={(messageId, change) => void openAgentChangePreview(messageId, change)}
|
||||
onRunAgentCommand={(messageId, command) => void runWorkspaceAgentCommand(messageId, command)}
|
||||
onRejectAgentCommand={rejectWorkspaceAgentCommand}
|
||||
onCancelAgentPlan={messageId => {
|
||||
if (agentActionControllerRef.current || agentPending) return
|
||||
if (cancelAgentPlan(messageId)) closeAgentDiffPreviewsForMessage(messageId)
|
||||
}}
|
||||
onOpenAgentCodeReference={(messageId, referenceId) => void openAgentCodeReference(messageId, referenceId, openWorkspaceAgentCodeReference)}
|
||||
onApplyAgentPlan={(messageId, plan) => void applyAgentPlan(messageId, async () => {
|
||||
await applyWorkspaceAgentPlan(plan)
|
||||
closeAgentDiffPreviewsForMessage(messageId)
|
||||
})}
|
||||
/>
|
||||
|
||||
{agentPracticeApproval && (
|
||||
<ConfirmStatusModal
|
||||
dark={dark}
|
||||
overlayClassName="fixed inset-0 z-[200] grid place-items-center bg-black/65 p-5"
|
||||
icon={<Monitor size={18} className="text-[#2563eb]" />}
|
||||
title="SLAM 실습 준비"
|
||||
message={`시작할 모듈: ${agentPracticeApproval.starts.join(', ') || '없음 (이미 실행 중)'}`}
|
||||
detail={`검증 대상: ${agentPracticeApproval.modules.join(', ')}. 순서대로 시작한 뒤 센서·지도·TF를 확인합니다. 차량 이동 명령은 보내지 않습니다. 실패해도 이미 시작된 모듈은 자동 종료하지 않습니다.`}
|
||||
confirmLabel="승인하고 준비"
|
||||
onCancel={() => setAgentPracticeApproval(null)}
|
||||
onConfirm={() => {
|
||||
const approval = agentPracticeApproval
|
||||
setAgentPracticeApproval(null)
|
||||
void runStructuredAgentCommand(approval.messageId, approval.command, approval)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{workspaceRestoreLoading && (
|
||||
<PlatformLoadingOverlay
|
||||
title={t("{{v0}} 화면 불러오는 중", { v0: platformKey === 'futuredrive-local-pc' ? t('로컬 PC') : platformName })}
|
||||
@@ -5359,14 +5598,14 @@ export default function App(): React.JSX.Element {
|
||||
)
|
||||
const filteredPlatformOptions = platformOptions.filter((platform) => {
|
||||
const pending = isRemotePlatformStatusPending(platform)
|
||||
if (platformFilter === 'available') return platform.state === 'ready' && !pending
|
||||
if (platformFilter === 'unavailable') return platform.state !== 'ready' && !pending
|
||||
if (platformFilter === 'available') return platform.state === 'ready' && !isPlatformUnavailable(platform) && !pending
|
||||
if (platformFilter === 'unavailable') return (platform.state !== 'ready' || isPlatformUnavailable(platform)) && !pending
|
||||
return true
|
||||
})
|
||||
const platformFilterCounts = {
|
||||
all: platformOptions.length,
|
||||
available: platformOptions.filter((platform) => platform.state === 'ready' && !isRemotePlatformStatusPending(platform)).length,
|
||||
unavailable: 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' || isPlatformUnavailable(platform)) && !isRemotePlatformStatusPending(platform)).length
|
||||
}
|
||||
const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc')
|
||||
const openPlatformSettings = (platform: PlatformOption): void => {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { FileCode2, Loader2 } from 'lucide-react'
|
||||
import { t, useLocaleRender } from '../i18n'
|
||||
import type { AgentCodeReference, AgentCodeReferenceState } from '../features/workspace/useAiAgent'
|
||||
|
||||
export function AgentCodeReferences({ references, states, dark, busy, onOpen }: {
|
||||
references: AgentCodeReference[]
|
||||
states?: Record<string, AgentCodeReferenceState>
|
||||
dark: boolean
|
||||
busy?: boolean
|
||||
onOpen?: (id: string) => void
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
return <div className="grid min-w-0 gap-3">
|
||||
{references.map(reference => {
|
||||
const state = states?.[reference.id]
|
||||
const opening = state?.status === 'opening'
|
||||
return <div key={reference.id} data-agent-code-reference={reference.id} className={['min-w-0 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<FileCode2 size={14} className="mt-0.5 shrink-0 text-[#8b5cf6]" />
|
||||
<div className="min-w-0">
|
||||
<p className="break-all font-bold">{reference.moduleName}/{reference.path}</p>
|
||||
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{t('{{start}}~{{end}}줄', { start: reference.startLine, end: reference.endLine })}</p>
|
||||
<p className="mt-1 break-words">{reference.reason}</p>
|
||||
</div>
|
||||
</div>
|
||||
{state?.error && <p role="alert" className="mt-2 break-words text-[11px] text-[#ef4444]">{t(state.error)}</p>}
|
||||
<div className="mt-2 flex min-w-0 items-center justify-end gap-2">
|
||||
<button type="button" disabled={busy || opening || !onOpen} onClick={() => onOpen?.(reference.id)} className="inline-flex h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-bold leading-none text-white hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:opacity-40">{opening ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <FileCode2 size={13} className="shrink-0" />}<span className="relative top-px leading-none">{opening ? t('파일 여는 중...') : t('코드에서 보기')}</span></button>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Bot, Check } from 'lucide-react'
|
||||
import { t } from '../i18n'
|
||||
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
|
||||
|
||||
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
|
||||
models: AgentModelOption[]
|
||||
provider: string | null
|
||||
disabled: boolean
|
||||
dark: boolean
|
||||
onChange: (provider: string) => void
|
||||
}): React.JSX.Element {
|
||||
const selected = models.find(model => model.provider === provider)
|
||||
const blocked = disabled || models.length === 0
|
||||
const [open, setOpen] = useState(false)
|
||||
const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 })
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const menuId = useId()
|
||||
const visible = open && !blocked
|
||||
const close = (restoreFocus = false): void => {
|
||||
setOpen(false)
|
||||
if (restoreFocus) buttonRef.current?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (blocked) setOpen(false)
|
||||
}, [blocked])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!visible || !buttonRef.current) return
|
||||
const bounds = buttonRef.current.getBoundingClientRect()
|
||||
const width = Math.min(320, window.innerWidth - 16)
|
||||
setPosition({
|
||||
left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)),
|
||||
bottom: window.innerHeight - bounds.top + 6,
|
||||
width,
|
||||
maxHeight: Math.max(0, Math.min(320, bounds.top - 14))
|
||||
})
|
||||
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
|
||||
const index = Math.max(0, models.findIndex(model => model.provider === provider))
|
||||
items?.[index]?.focus({ preventScroll: true })
|
||||
}, [visible, models, provider])
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
const outside = (event: Event): void => {
|
||||
const target = event.target as Node
|
||||
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
|
||||
}
|
||||
const dismiss = (): void => setOpen(false)
|
||||
window.addEventListener('pointerdown', outside, true)
|
||||
window.addEventListener('contextmenu', outside)
|
||||
window.addEventListener('scroll', outside, true)
|
||||
window.addEventListener('resize', dismiss)
|
||||
window.addEventListener('blur', dismiss)
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', outside, true)
|
||||
window.removeEventListener('contextmenu', outside)
|
||||
window.removeEventListener('scroll', outside, true)
|
||||
window.removeEventListener('resize', dismiss)
|
||||
window.removeEventListener('blur', dismiss)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
aria-label={t('에이전트 모델 선택')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={visible}
|
||||
aria-controls={visible ? menuId : undefined}
|
||||
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
|
||||
disabled={blocked}
|
||||
onClick={() => setOpen(current => !current)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) }
|
||||
else if (event.key === 'Escape') close()
|
||||
}}
|
||||
className={['grid size-7 shrink-0 cursor-pointer place-items-center rounded outline-none transition focus-visible:ring-1 focus-visible:ring-[#8b5cf6] disabled:cursor-default disabled:opacity-35',
|
||||
dark ? 'text-white/60 hover:bg-white/10 hover:text-white/90' : 'text-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]',
|
||||
visible ? dark ? 'bg-white/10 text-white/90' : 'bg-[#eef1f7] text-[#111520]' : ''].join(' ')}
|
||||
>
|
||||
<Bot size={15} aria-hidden="true" />
|
||||
</button>
|
||||
{visible && createPortal(
|
||||
<div ref={menuRef} id={menuId} role="menu" aria-label={t('에이전트 모델 선택')}
|
||||
className={['fixed z-[160] overflow-y-auto overscroll-contain rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
|
||||
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'].join(' ')}
|
||||
style={position}
|
||||
onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); return }
|
||||
if (event.key === 'Tab') { close(true); return }
|
||||
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
|
||||
event.preventDefault()
|
||||
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
|
||||
const current = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
|
||||
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
|
||||
items[next]?.focus()
|
||||
}}
|
||||
>
|
||||
{models.map(model => <button key={model.provider} type="button" role="menuitemradio" aria-checked={model.provider === provider}
|
||||
title={agentModelLabel(model)} tabIndex={-1}
|
||||
onClick={() => { if (!blocked) { onChange(model.provider); close(true) } }}
|
||||
className={['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left outline-none transition',
|
||||
dark ? 'hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520] focus:bg-[#eef1f7] focus:text-[#111520]'].join(' ')}>
|
||||
<Check size={13} className={['shrink-0', model.provider === provider ? '' : 'opacity-0'].join(' ')} aria-hidden="true" />
|
||||
<span className="min-w-0 truncate">{agentModelLabel(model)}</span>
|
||||
</button>)}
|
||||
</div>, document.body
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { CheckCircle2, CircleAlert, XCircle } from 'lucide-react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { t, useLocaleRender } from '../i18n'
|
||||
import type { AgentProgressStep } from '../features/workspace/agentContext'
|
||||
import { AgentWorkDuration } from './AgentWorkDuration'
|
||||
|
||||
function stepLabel(step: AgentProgressStep): string {
|
||||
if (step.kind === 'analysis') return [
|
||||
t('{{round}}차 분석 완료', { round: step.round ?? 1 }),
|
||||
step.requests ? t('추가 조회 {{count}}건', { count: step.requests }) : ''
|
||||
].filter(Boolean).join(' · ')
|
||||
if (step.kind === 'image') return `${t('이미지 업로드 완료')} · ${step.name}`
|
||||
const tools: Record<string, string> = {
|
||||
'modules.list': t('모듈 목록'),
|
||||
'files.list': t('파일 목록'),
|
||||
'files.search': t('파일 검색'),
|
||||
'files.read': t('파일 읽기'),
|
||||
'logs.read': t('로그 조회'),
|
||||
'network.inspect': t('네트워크 조회'),
|
||||
'ros.inspect': t('ROS 상태 조회'),
|
||||
'practice.inspect': t('실습 구성 조회')
|
||||
}
|
||||
const status = step.status === 'error' ? t('조회 실패') : step.status === 'partial' ? t('일부 결과') : step.status === 'empty' ? t('조회 결과 없음') : step.status === 'missing' ? t('대상 없음') : t('조회 완료')
|
||||
return [
|
||||
step.moduleName,
|
||||
step.kind === 'files' ? t('소스 파일') : step.kind === 'logs' ? t('로그 조회') : tools[step.name ?? ''] ?? step.name,
|
||||
step.path,
|
||||
status,
|
||||
step.count !== undefined ? t('{{count}}건', { count: step.count }) : ''
|
||||
].filter(Boolean).join(' · ')
|
||||
}
|
||||
|
||||
export function AgentProgressBubble({ dark, steps, activity, startedAt }: { dark: boolean; steps: AgentProgressStep[]; activity?: string; startedAt?: number | null }): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const listRef = useRef<HTMLOListElement>(null)
|
||||
useEffect(() => {
|
||||
const list = listRef.current
|
||||
if (list) list.scrollTop = list.scrollHeight
|
||||
}, [steps.length])
|
||||
return (
|
||||
<div data-agent-progress="true" className={['min-w-0 flex-1 overflow-hidden rounded-lg border px-3 py-3 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
<AgentWorkDuration dark={dark} startedAt={startedAt} />
|
||||
<p className="font-bold">{t('진행 상황')}</p>
|
||||
{steps.length > 0 && <ol ref={listRef} role="log" aria-label={t('진행 상황')} aria-live="polite" aria-relevant="additions" className="thin-scrollbar mt-2 grid max-h-64 gap-2 overflow-y-auto">
|
||||
{steps.map(step => <li key={step.id} data-agent-progress-step={step.kind} className="agent-response-fade flex min-w-0 items-start gap-2">
|
||||
{step.status === 'error' ? <XCircle size={13} className="mt-0.5 shrink-0 text-[#ef4444]" /> : step.status !== 'ok' ? <CircleAlert size={13} className="mt-0.5 shrink-0 text-[#d97706]" /> : <CheckCircle2 size={13} className="mt-0.5 shrink-0 text-[#22c55e]" />}
|
||||
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{stepLabel(step)}</span>
|
||||
</li>)}
|
||||
</ol>}
|
||||
<p role="status" className={['mt-2 break-words text-[11px]', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>{activity ? t(activity) : t('요청 분석 중')}</p>
|
||||
<div className="mt-3 grid w-[220px] max-w-full animate-pulse gap-2 motion-reduce:animate-none" aria-hidden="true">
|
||||
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
|
||||
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
|
||||
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { t, useLocaleRender } from '../i18n'
|
||||
|
||||
export function AgentWorkDuration({ startedAt, durationMs, dark }: { startedAt?: number | null; durationMs?: number; dark: boolean }): React.JSX.Element | null {
|
||||
useLocaleRender()
|
||||
const [now, setNow] = useState(Date.now)
|
||||
const completed = typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0
|
||||
const running = !completed && typeof startedAt === 'number' && Number.isFinite(startedAt)
|
||||
useEffect(() => {
|
||||
if (!running) return
|
||||
const update = (): void => setNow(Date.now())
|
||||
update()
|
||||
const timer = window.setInterval(update, 1000)
|
||||
document.addEventListener('visibilitychange', update)
|
||||
window.addEventListener('focus', update)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', update)
|
||||
window.removeEventListener('focus', update)
|
||||
}
|
||||
}, [running, startedAt])
|
||||
if (!running && !completed) return null
|
||||
const seconds = Math.floor(Math.max(0, completed ? durationMs : now - startedAt!) / 1000)
|
||||
return <p data-agent-work-duration={completed ? 'completed' : 'running'} aria-live="off" className={['mb-2 text-left text-[11px] font-normal leading-relaxed tabular-nums', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>
|
||||
{completed ? t('{{seconds}}초 동안 작업함', { seconds }) : t('{{seconds}}초 동안 작업 중입니다', { seconds })}
|
||||
</p>
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export function StatusNoticeModal({
|
||||
onConfirm: () => void
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const hasAction = Boolean(actionLabel && onAction)
|
||||
return (
|
||||
<div className={overlayClassName}>
|
||||
<section className={modalClass(dark)}>
|
||||
@@ -91,12 +92,12 @@ export function StatusNoticeModal({
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button type="button" onClick={onConfirm} className={hasAction ? cancelButtonClass(dark) : confirmButtonClass('primary')}>{t("확인")}</button>
|
||||
{actionLabel && onAction && (
|
||||
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}>
|
||||
<button type="button" onClick={onAction} className={confirmButtonClass('primary')}>
|
||||
{t(actionLabel)}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>{t("확인")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -276,7 +277,7 @@ export function ControlExtensionConfirmModal({
|
||||
)
|
||||
}
|
||||
|
||||
function ConfirmStatusModal({
|
||||
export function ConfirmStatusModal({
|
||||
overlayClassName,
|
||||
dark,
|
||||
icon,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Check, Copy, FileCode2, X } from 'lucide-react'
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
import type React from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { t, useLocaleRender } from '../i18n'
|
||||
|
||||
const highlighter = createLowlight(common)
|
||||
const languageLabels: Record<string, string> = {
|
||||
python: 'Python', py: 'Python', yaml: 'YAML', yml: 'YAML', json: 'JSON',
|
||||
cpp: 'C++', 'c++': 'C++', c: 'C', javascript: 'JavaScript', js: 'JavaScript',
|
||||
typescript: 'TypeScript', ts: 'TypeScript', bash: 'Bash', sh: 'Bash', shell: 'Shell',
|
||||
xml: 'XML', html: 'HTML', css: 'CSS', sql: 'SQL', markdown: 'Markdown', md: 'Markdown',
|
||||
}
|
||||
type HighlightNode = ReturnType<typeof highlighter.highlight>['children'][number]
|
||||
|
||||
function renderToken(node: HighlightNode, key: string): React.ReactNode {
|
||||
if (node.type === 'text') return node.value
|
||||
if (node.type !== 'element') return null
|
||||
const classes = node.properties.className
|
||||
return <span key={key} className={Array.isArray(classes) ? classes.join(' ') : undefined}>
|
||||
{node.children.map((child, index) => renderToken(child, `${key}-${index}`))}
|
||||
</span>
|
||||
}
|
||||
|
||||
export function ChatCodeBlock({ source, language = '', dark, fadeClass = '' }: {
|
||||
source: string
|
||||
language?: string
|
||||
dark: boolean
|
||||
fadeClass?: string
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle')
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
useEffect(() => () => clearTimeout(timer.current), [])
|
||||
useEffect(() => { setCopyState('idle'); clearTimeout(timer.current) }, [source])
|
||||
const normalizedLanguage = language.toLowerCase()
|
||||
const tokens = useMemo(() => {
|
||||
if (source.length > 50_000 || !highlighter.registered(normalizedLanguage)) return source
|
||||
try { return highlighter.highlight(normalizedLanguage, source).children.map((node, index) => renderToken(node, `${index}`)) }
|
||||
catch { return source }
|
||||
}, [source, normalizedLanguage])
|
||||
const copy = async (): Promise<void> => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(source)
|
||||
setCopyState('copied')
|
||||
} catch {
|
||||
setCopyState('error')
|
||||
}
|
||||
clearTimeout(timer.current)
|
||||
timer.current = setTimeout(() => setCopyState('idle'), 1800)
|
||||
}
|
||||
const copyLabel = copyState === 'copied' ? t('복사됨') : copyState === 'error' ? t('코드를 복사하지 못했습니다.') : t('코드 복사')
|
||||
return <div data-chat-code-block data-code-theme={dark ? 'dark' : 'light'} className={['chat-code-block my-2 min-w-0 max-w-full overflow-hidden rounded-md border', dark ? 'border-white/12 bg-[#191919] text-[#dedede]' : 'border-[#d7dfeb] bg-[#f8fafc] text-[#253247]', fadeClass].join(' ')}>
|
||||
<div className={['flex h-9 min-w-0 items-center justify-between gap-2 border-b px-2.5', dark ? 'border-white/10 bg-[#262626]' : 'border-[#e2e8f0] bg-[#eef2f7]'].join(' ')}>
|
||||
<span title={language || undefined} className="flex min-w-0 items-center gap-1.5 text-[11px] font-bold"><FileCode2 size={13} className="shrink-0" /><span className="truncate">{languageLabels[normalizedLanguage] ?? (language ? language.slice(0, 40) : t('일반 텍스트'))}</span></span>
|
||||
<button type="button" title={copyLabel} aria-label={copyLabel} onClick={event => { event.stopPropagation(); void copy() }} className={['grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded transition focus-visible:outline-2 focus-visible:outline-[#60a5fa]', copyState === 'error' ? 'text-[#ef4444]' : dark ? 'text-white/65 hover:bg-white/10 hover:text-white' : 'text-[#526079] hover:bg-[#dbe3ee] hover:text-[#182337]'].join(' ')}>{copyState === 'copied' ? <Check size={14} /> : copyState === 'error' ? <X size={14} /> : <Copy size={14} />}</button>
|
||||
<span role="status" className="sr-only">{copyState === 'idle' ? '' : copyLabel}</span>
|
||||
</div>
|
||||
<pre tabIndex={0} aria-label={t('코드')} className="thin-scrollbar m-0 max-h-80 max-w-full overflow-auto p-3 text-[11px] leading-relaxed outline-offset-[-2px] [overflow-wrap:normal] [tab-size:4] [word-break:normal]">
|
||||
<code className="block w-max min-w-full whitespace-pre font-mono font-normal [overflow-wrap:normal] [word-break:normal]">{tokens}</code>
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X } from 'lucide-react'
|
||||
import { t, useLocaleRender } from '../i18n'
|
||||
import { displayAttachmentName } from './chatAttachments'
|
||||
|
||||
type ImageAttachment = { name: string; url: string }
|
||||
|
||||
export function ChatImageAttachments({ images, variant = 'draft', dark = true, onRemove, removeDisabled = false }: {
|
||||
images: ImageAttachment[]
|
||||
variant?: 'draft' | 'sent'
|
||||
dark?: boolean
|
||||
onRemove?: (image: ImageAttachment) => void
|
||||
removeDisabled?: boolean
|
||||
}): React.JSX.Element | null {
|
||||
useLocaleRender()
|
||||
const [preview, setPreview] = useState<ImageAttachment | null>(null)
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
useEffect(() => {
|
||||
if (preview) dialogRef.current?.showModal()
|
||||
else dialogRef.current?.close()
|
||||
}, [preview])
|
||||
if (!images.length) return null
|
||||
return <>
|
||||
<div data-chat-image-attachments={variant} className={['flex min-w-0 flex-wrap gap-2', variant === 'sent' ? 'justify-end' : 'mt-2'].join(' ')}>
|
||||
{images.map((source, index) => {
|
||||
const image = { ...source, name: displayAttachmentName(source.name) }
|
||||
return variant === 'draft' ? <div key={`${image.url}-${index}`} data-chat-image-draft="true" className={['flex h-16 w-56 min-w-0 max-w-full items-center gap-1 overflow-hidden rounded-md border p-1', dark ? 'border-white/10 bg-white/[0.03] text-white/70' : 'border-[#d8dee9] bg-[#f8f9fc] text-[#475569]'].join(' ')}>
|
||||
<button type="button" title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="flex h-full min-w-0 flex-1 cursor-zoom-in items-center gap-2 overflow-hidden text-left">
|
||||
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-14 shrink-0 rounded bg-black/10 object-contain" />
|
||||
<span className="min-w-0 truncate text-[11px]">{image.name}</span>
|
||||
</button>
|
||||
{onRemove && <button type="button" disabled={removeDisabled} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${image.name}`} onClick={() => onRemove(image)} className={['grid size-6 shrink-0 cursor-pointer place-items-center rounded disabled:cursor-default disabled:opacity-40', dark ? 'hover:bg-white/10' : 'hover:bg-black/5'].join(' ')}><X size={12} /></button>}
|
||||
</div> : <button type="button" key={`${image.url}-${index}`} title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="h-16 w-20 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-current/20 bg-black/10">
|
||||
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-contain" />
|
||||
</button>
|
||||
})}
|
||||
</div>
|
||||
{preview && createPortal(<dialog ref={dialogRef} aria-label={preview.name} onCancel={() => setPreview(null)} onClick={event => { if (event.target === event.currentTarget) setPreview(null) }} className="fixed inset-0 m-auto max-h-[90vh] max-w-[94vw] overflow-auto rounded-lg border border-white/15 bg-[#202020] p-3 text-white shadow-xl backdrop:bg-black/75">
|
||||
<div className="mb-2 flex min-w-0 items-center justify-between gap-3"><span className="min-w-0 break-all text-[12px]">{preview.name}</span><button type="button" title={t('닫기')} aria-label={t('닫기')} onClick={() => setPreview(null)} className="grid size-8 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10"><X size={16} /></button></div>
|
||||
<img src={preview.url} alt={preview.name} referrerPolicy="no-referrer" className="max-h-[78vh] max-w-full object-contain" />
|
||||
</dialog>, document.body)}
|
||||
</>
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ClipboardPaste, Copy, Trash2 } from 'lucide-react'
|
||||
import { t, useLocaleRender } from '../i18n'
|
||||
|
||||
export type ChatTextContextMenuState = {
|
||||
kind: 'message' | 'input'
|
||||
x: number
|
||||
y: number
|
||||
text: string
|
||||
input?: HTMLTextAreaElement
|
||||
selectionStart?: number
|
||||
selectionEnd?: number
|
||||
}
|
||||
|
||||
export function ChatTextContextMenu({ dark, menu, canClear = false, onClose, onClear, onPasteText }: {
|
||||
dark: boolean
|
||||
menu: ChatTextContextMenuState | null
|
||||
canClear?: boolean
|
||||
onClose: () => void
|
||||
onClear?: () => void
|
||||
onPasteText?: (text: string, menu: ChatTextContextMenuState) => void
|
||||
}): React.JSX.Element | null {
|
||||
useLocaleRender()
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const [clipboardState, setClipboardState] = useState<{ menu: ChatTextContextMenuState; hasText: boolean } | null>(null)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const closeRef = useRef(onClose)
|
||||
closeRef.current = onClose
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!menu || !menuRef.current) return
|
||||
const bounds = menuRef.current.getBoundingClientRect()
|
||||
setPosition({ x: Math.max(8, Math.min(menu.x, window.innerWidth - bounds.width - 8)), y: Math.max(8, Math.min(menu.y, window.innerHeight - bounds.height - 8)) })
|
||||
}, [menu])
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return
|
||||
let disposed = false
|
||||
if (menu.kind === 'input') {
|
||||
const read = Promise.resolve().then(() => window.chatTextMenu?.readClipboardState
|
||||
? window.chatTextMenu.readClipboardState()
|
||||
: navigator.clipboard.readText().then(text => ({ hasText: text.length > 0 })))
|
||||
void read.then(state => { if (!disposed) setClipboardState({ menu, hasText: state.hasText }) })
|
||||
.catch(() => { if (!disposed) setClipboardState({ menu, hasText: false }) })
|
||||
}
|
||||
const outside = (event: Event): void => {
|
||||
if (!menuRef.current?.contains(event.target as Node)) closeRef.current()
|
||||
}
|
||||
const keydown = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); closeRef.current() }
|
||||
}
|
||||
const close = (): void => closeRef.current()
|
||||
window.addEventListener('pointerdown', outside, true)
|
||||
window.addEventListener('contextmenu', outside)
|
||||
window.addEventListener('scroll', outside, true)
|
||||
window.addEventListener('resize', close)
|
||||
window.addEventListener('blur', close)
|
||||
window.addEventListener('keydown', keydown)
|
||||
return () => {
|
||||
disposed = true
|
||||
window.removeEventListener('pointerdown', outside, true)
|
||||
window.removeEventListener('contextmenu', outside)
|
||||
window.removeEventListener('scroll', outside, true)
|
||||
window.removeEventListener('resize', close)
|
||||
window.removeEventListener('blur', close)
|
||||
window.removeEventListener('keydown', keydown)
|
||||
}
|
||||
}, [menu])
|
||||
|
||||
if (!menu) return null
|
||||
const pasteEnabled = menu.kind === 'input' && !menu.input?.disabled && !menu.input?.readOnly && clipboardState?.menu === menu && clipboardState.hasText
|
||||
const itemClass = ['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition disabled:cursor-default disabled:opacity-35', dark ? 'enabled:hover:bg-[#37373d] enabled:hover:text-white' : 'enabled:hover:bg-[#eef1f7] enabled:hover:text-[#111520]'].join(' ')
|
||||
const copy = async (): Promise<void> => {
|
||||
if (!menu.text) return
|
||||
onClose()
|
||||
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'copy', text: menu.text })
|
||||
else await navigator.clipboard.writeText(menu.text)
|
||||
}
|
||||
const paste = async (): Promise<void> => {
|
||||
if (!pasteEnabled || !menu.input?.isConnected || menu.input.disabled || menu.input.readOnly) return
|
||||
const input = menu.input
|
||||
input.focus({ preventScroll: true })
|
||||
input.setSelectionRange(menu.selectionStart ?? input.selectionStart, menu.selectionEnd ?? input.selectionEnd)
|
||||
onClose()
|
||||
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'paste' })
|
||||
else {
|
||||
const text = await navigator.clipboard.readText()
|
||||
if (text) onPasteText?.(text, menu)
|
||||
}
|
||||
}
|
||||
return createPortal(
|
||||
<div ref={menuRef} role="menu" aria-label={t('질의응답')} className={[
|
||||
'fixed z-[160] w-[160px] overflow-hidden rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
|
||||
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'
|
||||
].join(' ')} style={{ left: position.x, top: position.y }} onMouseDown={event => event.preventDefault()} onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}>
|
||||
<button type="button" role="menuitem" disabled={!menu.text} onClick={() => void copy().catch(error => console.error('[chat] copy failed', error))} className={itemClass}><Copy size={13} />{t('복사')}</button>
|
||||
{menu.kind === 'input' ? (
|
||||
<button type="button" role="menuitem" disabled={!pasteEnabled} onClick={() => void paste().catch(error => console.error('[chat] paste failed', error))} className={itemClass}><ClipboardPaste size={13} />{t('붙여넣기')}</button>
|
||||
) : onClear ? <>
|
||||
<div role="separator" className={['my-1 border-t', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')} />
|
||||
<button type="button" role="menuitem" disabled={!canClear} onClick={() => { onClose(); onClear() }} className={itemClass}><Trash2 size={13} />{t('대화내역 삭제')}</button>
|
||||
</> : null}
|
||||
</div>, document.body
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { t, useLocaleRender } from '../i18n'
|
||||
import { ChevronDown, Send, Square } from 'lucide-react'
|
||||
import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { CHAT_ATTACHMENT_ACCEPT, CHAT_IMAGE_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments'
|
||||
import { ChatImageAttachments } from './ChatImageAttachments'
|
||||
import { ChatCodeBlock } from './ChatCodeBlock'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu'
|
||||
|
||||
export function ChatAvatar({
|
||||
accessToken,
|
||||
@@ -58,23 +62,23 @@ export function ChatAvatar({
|
||||
)
|
||||
}
|
||||
|
||||
export function MarkdownMessage({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
|
||||
export function MarkdownMessage({ content, dark, fadeFrom }: { content: string; dark: boolean; fadeFrom?: number }): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const textColor = dark ? 'text-white/76' : 'text-[#4f596b]'
|
||||
const headingColor = dark ? 'text-white/92' : 'text-[#111827]'
|
||||
const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]'
|
||||
const codeBg = dark ? 'bg-[#171717] text-white/82' : 'bg-[#f1f5f9] text-[#334155]'
|
||||
const fadeClass = (offset?: number): string => fadeFrom !== undefined && offset !== undefined && offset >= fadeFrom ? ' agent-response-fade' : ''
|
||||
|
||||
return (
|
||||
<div className={`min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [overflow-wrap:anywhere] [word-break:break-word] ${textColor}`}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h1>,
|
||||
h2: ({ children }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h3>,
|
||||
h4: ({ children }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h4>,
|
||||
p: ({ children }) => <p className="my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]">{children}</p>,
|
||||
h1: ({ children, node }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h1>,
|
||||
h2: ({ children, node }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h2>,
|
||||
h3: ({ children, node }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h3>,
|
||||
h4: ({ children, node }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h4>,
|
||||
p: ({ children, node }) => <p className={`my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</p>,
|
||||
strong: ({ children }) => <strong className={`font-black ${headingColor}`}>{children}</strong>,
|
||||
em: ({ children }) => <em className="italic">{children}</em>,
|
||||
a: ({ children, href }) => (
|
||||
@@ -88,30 +92,37 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
ul: ({ children }) => <ul className="my-2 grid gap-1 pl-4">{children}</ul>,
|
||||
ol: ({ children }) => <ol className="my-2 grid gap-1 pl-4">{children}</ol>,
|
||||
li: ({ children }) => <li className="max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}`}>
|
||||
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, start }) => <ol start={start} className="my-2 grid list-decimal gap-1 pl-5">{children}</ol>,
|
||||
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 className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}${fadeClass(node?.position?.start.offset)}`}>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => <hr className={`my-3 ${mutedBorder}`} />,
|
||||
pre: ({ children }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}`}>{children}</pre>,
|
||||
hr: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />,
|
||||
pre: ({ children, node }) => {
|
||||
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 }) => {
|
||||
if (className) {
|
||||
return <code className="whitespace-pre-wrap break-words font-mono [overflow-wrap:anywhere]">{children}</code>
|
||||
}
|
||||
return <code className={`rounded px-1 py-0.5 font-mono text-[11px] ${dark ? 'bg-white/10 text-[#dbeafe]' : 'bg-[#eef2ff] text-[#1d4ed8]'}`}>{children}</code>
|
||||
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 }) => (
|
||||
<div className="my-2 max-w-full overflow-hidden">
|
||||
<table className={`w-full table-fixed border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
|
||||
table: ({ children, node }) => (
|
||||
<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-auto border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
|
||||
</div>
|
||||
),
|
||||
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>,
|
||||
td: ({ children }) => <td className={`break-words border px-2 py-1.5 align-top [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</td>
|
||||
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, 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}
|
||||
@@ -120,6 +131,33 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatEmptyState({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<div className={['relative mx-4 min-w-0 max-w-full break-words rounded-lg border px-8 py-5', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')}>
|
||||
<span>{children}</span>
|
||||
<span aria-hidden="true" className={['absolute -bottom-[5px] left-7 size-2 rotate-45 border-b border-r', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatResponseSkeleton({ dark, label }: { dark: boolean; label: string }): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-label={label}
|
||||
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
|
||||
>
|
||||
<div className="grid animate-pulse gap-2 motion-reduce:animate-none" aria-hidden="true">
|
||||
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
|
||||
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
|
||||
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExpandableMarkdownMessage({
|
||||
content,
|
||||
dark,
|
||||
@@ -166,6 +204,7 @@ export function ExpandableMarkdownMessage({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={expanded ? t("접기") : t("자세히 보기")}
|
||||
aria-expanded={expanded}
|
||||
title={expanded ? t("접기") : t("자세히 보기")}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
className={['grid h-7 w-full cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white/80' : 'text-[#64748b] hover:bg-[#e2e8f0]/70 hover:text-[#2563eb]'].join(' ')}
|
||||
@@ -234,11 +273,32 @@ export function ExpandablePlainMessage({
|
||||
)
|
||||
}
|
||||
|
||||
function resizeQaInput(input: HTMLTextAreaElement): void {
|
||||
const styles = window.getComputedStyle(input)
|
||||
const lineHeight = Number.parseFloat(styles.lineHeight)
|
||||
const padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom)
|
||||
const border = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth)
|
||||
const minHeight = Number.parseFloat(styles.minHeight)
|
||||
const maxHeight = Math.ceil(lineHeight * 8 + padding + border)
|
||||
|
||||
input.style.height = 'auto'
|
||||
input.style.overflowY = 'hidden'
|
||||
const contentHeight = input.scrollHeight + border
|
||||
input.style.maxHeight = `${maxHeight}px`
|
||||
input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px`
|
||||
input.style.overflowY = contentHeight > maxHeight ? 'auto' : 'hidden'
|
||||
}
|
||||
|
||||
export const QaInputBox = memo(function QaInputBox({
|
||||
dark,
|
||||
busy,
|
||||
disabled,
|
||||
accent = 'blue',
|
||||
attachmentsEnabled = false,
|
||||
imagesEnabled = false,
|
||||
onClearConversation,
|
||||
clearDisabled = false,
|
||||
footerAction,
|
||||
onStop,
|
||||
onSubmit
|
||||
}: {
|
||||
@@ -246,46 +306,127 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
busy: boolean
|
||||
disabled: boolean
|
||||
accent?: 'blue' | 'cyan'
|
||||
attachmentsEnabled?: boolean
|
||||
imagesEnabled?: boolean
|
||||
onClearConversation?: () => void
|
||||
clearDisabled?: boolean
|
||||
footerAction?: React.ReactNode
|
||||
onStop: () => void
|
||||
onSubmit: (content: string) => void
|
||||
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
|
||||
}): React.JSX.Element {
|
||||
const [draft, setDraft] = useState('')
|
||||
const canSubmit = busy || (Boolean(draft.trim()) && !disabled)
|
||||
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null)
|
||||
const [readingAttachments, setReadingAttachments] = useState(false)
|
||||
const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const attachmentReadRef = useRef(0)
|
||||
const attachmentUrlsRef = useRef<string[]>([])
|
||||
const canSubmit = busy || ((Boolean(draft.trim()) || attachments.some(file => file.kind === 'image')) && !disabled && !readingAttachments)
|
||||
|
||||
useEffect(() => {
|
||||
const urls = attachments.flatMap(file => file.kind === 'image' ? [file.previewUrl] : [])
|
||||
attachmentUrlsRef.current.filter(url => !urls.includes(url)).forEach(url => URL.revokeObjectURL(url))
|
||||
attachmentUrlsRef.current = urls
|
||||
}, [attachments])
|
||||
useEffect(() => () => attachmentUrlsRef.current.forEach(url => URL.revokeObjectURL(url)), [])
|
||||
|
||||
const attachFiles = (files: File[]): void => {
|
||||
if (!attachmentsEnabled || disabled || busy || readingAttachments || !files.length) return
|
||||
const readId = ++attachmentReadRef.current
|
||||
setReadingAttachments(true)
|
||||
setAttachmentError(null)
|
||||
void readChatAttachments(files, attachments, { imagesEnabled }).then(files => {
|
||||
if (attachmentReadRef.current === readId) setAttachments(files)
|
||||
else files.forEach(file => { if (file.kind === 'image' && !attachments.includes(file)) URL.revokeObjectURL(file.previewUrl) })
|
||||
}).catch(error => {
|
||||
if (attachmentReadRef.current === readId) setAttachmentError(error instanceof Error ? error.message : '파일을 읽지 못했습니다.')
|
||||
}).finally(() => { if (attachmentReadRef.current === readId) setReadingAttachments(false) })
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (textareaRef.current) resizeQaInput(textareaRef.current)
|
||||
}, [draft])
|
||||
|
||||
useEffect(() => {
|
||||
const input = textareaRef.current
|
||||
if (!input) return
|
||||
let width = input.getBoundingClientRect().width
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (!entry || entry.contentRect.width === width) return
|
||||
width = entry.contentRect.width
|
||||
resizeQaInput(input)
|
||||
})
|
||||
observer.observe(input)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
attachmentReadRef.current += 1
|
||||
setReadingAttachments(false)
|
||||
if (!attachmentsEnabled) {
|
||||
setAttachments([])
|
||||
setAttachmentError(null)
|
||||
setReadingAttachments(false)
|
||||
}
|
||||
else if (!imagesEnabled) setAttachments(current => current.filter(file => file.kind !== 'image'))
|
||||
return () => { attachmentReadRef.current += 1 }
|
||||
}, [attachmentsEnabled, imagesEnabled])
|
||||
|
||||
const submit = (): void => {
|
||||
const content = draft.trim()
|
||||
const content = draft.trim() || (attachments.some(file => file.kind === 'image') ? t('첨부 이미지를 분석해주세요.') : '')
|
||||
if (busy) {
|
||||
onStop()
|
||||
return
|
||||
}
|
||||
if (!content || disabled) return
|
||||
if (!content || disabled || readingAttachments) return
|
||||
setDraft('')
|
||||
onSubmit(content)
|
||||
onSubmit(content, attachmentsEnabled ? attachments : undefined)
|
||||
setAttachments([])
|
||||
setAttachmentError(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={['m-3 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
||||
<div className={['m-3 shrink-0 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')} onDragOver={event => { if (attachmentsEnabled && event.dataTransfer.types.includes('Files')) event.preventDefault() }} onDrop={event => { if (!attachmentsEnabled) return; event.preventDefault(); attachFiles(Array.from(event.dataTransfer.files)) }}>
|
||||
<div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={2}
|
||||
spellCheck={false}
|
||||
autoCorrect="off"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
onPaste={event => { if (attachmentsEnabled && event.clipboardData.files.length) { event.preventDefault(); attachFiles(Array.from(event.clipboardData.files)) } }}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const input = event.currentTarget
|
||||
setContextMenu({
|
||||
kind: 'input',
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
text: input.value.slice(input.selectionStart, input.selectionEnd),
|
||||
input,
|
||||
selectionStart: input.selectionStart,
|
||||
selectionEnd: input.selectionEnd
|
||||
})
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}}
|
||||
placeholder={t("질문하세요...")}
|
||||
className={[
|
||||
'min-h-[56px] min-w-0 flex-1 resize-none bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium',
|
||||
'box-border min-h-[56px] min-w-0 flex-1 self-start resize-none overflow-y-hidden bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium',
|
||||
dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]'
|
||||
].join(' ')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={busy ? t('중지') : t('전송')}
|
||||
disabled={!canSubmit}
|
||||
onClick={busy ? onStop : submit}
|
||||
className={[
|
||||
@@ -298,6 +439,43 @@ export const QaInputBox = memo(function QaInputBox({
|
||||
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<ChatTextContextMenu dark={dark} menu={contextMenu} onClose={() => setContextMenu(null)} onPasteText={(text, menu) => {
|
||||
const start = menu.selectionStart ?? draft.length
|
||||
const end = menu.selectionEnd ?? start
|
||||
setDraft(current => current.slice(0, start) + text + current.slice(end))
|
||||
window.requestAnimationFrame(() => textareaRef.current?.setSelectionRange(start + text.length, start + text.length))
|
||||
}} />
|
||||
{attachmentsEnabled && attachments.some(file => file.kind !== 'image') && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-2">
|
||||
{attachments.map((file, index) => file.kind === 'image' ? null : (
|
||||
<span key={`${file.name}-${index}`} className={['inline-flex min-w-0 max-w-full items-center gap-1 rounded border px-2 py-1 text-[11px]', dark ? 'border-white/10 text-white/70' : 'border-[#d8dee9] text-[#475569]'].join(' ')}>
|
||||
<Paperclip size={12} className="shrink-0" />
|
||||
<span className="min-w-0 truncate" title={file.name}>{file.name}</span>
|
||||
<button type="button" disabled={busy || readingAttachments} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${file.name}`} onClick={() => setAttachments((current) => current.filter((_, item) => item !== index))} className="grid size-5 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10 disabled:cursor-default disabled:opacity-40"><X size={12} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
|
||||
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
|
||||
{(attachmentsEnabled || onClearConversation || footerAction) && (
|
||||
<div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
|
||||
{footerAction}
|
||||
{attachmentsEnabled && (
|
||||
<>
|
||||
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
|
||||
const files = Array.from(event.currentTarget.files ?? [])
|
||||
event.currentTarget.value = ''
|
||||
attachFiles(files)
|
||||
}} />
|
||||
<button type="button" title={t('파일 첨부')} aria-label={t('파일 첨부')} disabled={disabled || busy || readingAttachments} onClick={() => fileInputRef.current?.click()} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 disabled:cursor-default disabled:opacity-35"><Paperclip size={15} className={readingAttachments ? 'animate-pulse' : ''} /></button>
|
||||
</>
|
||||
)}
|
||||
{onClearConversation && (
|
||||
<button type="button" title={t('대화내역 삭제')} aria-label={t('대화내역 삭제')} disabled={clearDisabled || busy || readingAttachments} onClick={onClearConversation} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 hover:text-[#ef4444] disabled:cursor-default disabled:opacity-35"><Trash2 size={15} /></button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ExpandableMarkdownMessage, MarkdownMessage } from './ChatWidgets'
|
||||
import { AGENT_REVEAL_INTERVAL, agentRevealFrame, createAgentRevealDocument } from './agentResponseReveal'
|
||||
|
||||
type ResponseMessage = {
|
||||
content: string
|
||||
revealStartedAt?: number
|
||||
plan?: { summary: string; explanation: string }
|
||||
provider?: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export function ProgressiveChatResponse({ message, dark, children, footerAction, collapseOnSignal = false, collapseSignal = 0, defaultCollapsed = message.revealStartedAt === undefined }: {
|
||||
message: ResponseMessage
|
||||
dark: boolean
|
||||
children?: React.ReactNode
|
||||
footerAction?: React.ReactNode
|
||||
collapseOnSignal?: boolean
|
||||
collapseSignal?: number
|
||||
defaultCollapsed?: boolean
|
||||
}): React.JSX.Element {
|
||||
const content = message.plan?.explanation ?? message.content
|
||||
const modelLabel = message.provider === 'A_COPILOT' ? 'A-Copilot' : [message.provider, message.model].filter(Boolean).join(' · ')
|
||||
const document = useMemo(() => createAgentRevealDocument(content), [content])
|
||||
const [now, setNow] = useState(Date.now)
|
||||
const [reducedMotion, setReducedMotion] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
||||
const frame = agentRevealFrame(document, message.revealStartedAt, now, reducedMotion)
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const update = (): void => setReducedMotion(media.matches)
|
||||
media.addEventListener('change', update)
|
||||
return () => media.removeEventListener('change', update)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (message.revealStartedAt === undefined || reducedMotion) return
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const startedAt = message.revealStartedAt
|
||||
const tick = (): void => {
|
||||
const current = Date.now()
|
||||
setNow(current)
|
||||
const remaining = frame.duration - (current - startedAt)
|
||||
if (remaining > 0) timer = setTimeout(tick, Math.min(AGENT_REVEAL_INTERVAL, remaining))
|
||||
}
|
||||
tick()
|
||||
return () => clearTimeout(timer)
|
||||
}, [frame.duration, message.revealStartedAt, reducedMotion])
|
||||
|
||||
return (
|
||||
<div className="grid min-w-0 max-w-full gap-3" aria-busy={!frame.complete}>
|
||||
<div className="min-w-0">
|
||||
{message.plan && (
|
||||
<div className={['font-black', frame.fadeFrom === 0 ? 'agent-response-fade' : ''].join(' ')}>
|
||||
<MarkdownMessage content={message.plan.summary} dark={dark} />
|
||||
</div>
|
||||
)}
|
||||
<div className={message.plan ? 'mt-1 min-w-0' : 'min-w-0'}>
|
||||
{frame.complete && frame.fadeFrom === undefined ? (
|
||||
<ExpandableMarkdownMessage
|
||||
content={content}
|
||||
dark={dark}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
collapseOnSignal={collapseOnSignal}
|
||||
collapseSignal={collapseSignal}
|
||||
/>
|
||||
) : <MarkdownMessage content={frame.content} dark={dark} fadeFrom={frame.fadeFrom} />}
|
||||
</div>
|
||||
</div>
|
||||
{frame.complete ? children : null}
|
||||
{frame.complete && (modelLabel || footerAction) ? (
|
||||
<div className="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2">
|
||||
<p title={modelLabel || undefined} className={['min-w-0 flex-1 truncate text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{modelLabel}</p>
|
||||
{footerAction}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { unified } from 'unified'
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
const parser = unified().use(remarkParse).use(remarkGfm)
|
||||
export const AGENT_REVEAL_INTERVAL = 240
|
||||
export const AGENT_REVEAL_FADE_DURATION = 220
|
||||
|
||||
export function createAgentRevealDocument(content: string) {
|
||||
const tree = parser.parse(content)
|
||||
const offsets: number[] = []
|
||||
const definitions: string[] = []
|
||||
for (const node of tree.children) {
|
||||
if (node.type === 'definition' || node.type === 'footnoteDefinition') {
|
||||
definitions.push(content.slice(node.position?.start.offset, node.position?.end.offset))
|
||||
continue
|
||||
}
|
||||
const blocks = node.type === 'list' ? node.children : [node]
|
||||
for (const block of blocks) {
|
||||
if (block.position?.end.offset !== undefined) offsets.push(block.position.end.offset)
|
||||
}
|
||||
}
|
||||
// Group unusually long answers without splitting Markdown blocks or list items.
|
||||
const groupSize = Math.max(1, Math.ceil(offsets.length / 20))
|
||||
const boundaries = offsets.filter((_, index) => (index + 1) % groupSize === 0 || index === offsets.length - 1)
|
||||
if (boundaries.length) boundaries[boundaries.length - 1] = content.length
|
||||
else boundaries.push(content.length)
|
||||
return { content, boundaries, definitions: definitions.join('\n\n') }
|
||||
}
|
||||
|
||||
export function agentRevealFrame(document: ReturnType<typeof createAgentRevealDocument>, startedAt: number | undefined, now: number, reducedMotion = false) {
|
||||
const elapsed = startedAt === undefined ? Infinity : Math.max(0, now - startedAt)
|
||||
const visible = reducedMotion ? document.boundaries.length : Math.min(document.boundaries.length, 1 + Math.floor(elapsed / AGENT_REVEAL_INTERVAL))
|
||||
const complete = visible === document.boundaries.length
|
||||
const end = document.boundaries[visible - 1]
|
||||
const fadeFrom = !reducedMotion && elapsed < (visible - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
|
||||
? (visible > 1 ? document.boundaries[visible - 2] : 0)
|
||||
: undefined
|
||||
return {
|
||||
content: complete ? document.content : document.content.slice(0, end) + (document.definitions ? `\n\n${document.definitions}` : ''),
|
||||
fadeFrom,
|
||||
complete,
|
||||
duration: (document.boundaries.length - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
export type ChatTextAttachment = { name: string; content: string; kind?: 'text' }
|
||||
export type ChatImageDraft = { kind: 'image'; name: string; file: File; previewUrl: string }
|
||||
export type ChatAttachment = ChatTextAttachment | ChatImageDraft
|
||||
export type AgentImageReference = { token: string; name: string; mimeType: string; size: number; url: string }
|
||||
|
||||
export const CHAT_IMAGE_ACCEPT = '.png,.jpg,.jpeg,.webp'
|
||||
|
||||
export function displayAttachmentName(name: string): string {
|
||||
// Older multipart uploads can leave Latin-1-decoded UTF-8 in cached image references.
|
||||
if (!/^[\u0000-\u00ff]*$/.test(name)) return name
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(name, character => character.charCodeAt(0)))
|
||||
} catch {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
export const CHAT_ATTACHMENT_ACCEPT = '.txt,.log,.md,.json,.yaml,.yml,.xml,.py,.js,.jsx,.ts,.tsx,.c,.cc,.cpp,.h,.hpp,.sh,.bash,.cfg,.ini,.toml,.csv,.urdf,.xacro,.launch,.rviz,.cmake'
|
||||
|
||||
export async function readChatAttachments(files: File[], existing: ChatAttachment[], options: { imagesEnabled?: boolean } = {}): Promise<ChatAttachment[]> {
|
||||
if (files.length + existing.length > 5) throw new Error('파일은 최대 5개까지 첨부할 수 있습니다.')
|
||||
const result = [...existing]
|
||||
const createdUrls: string[] = []
|
||||
try { for (const file of files) {
|
||||
const name = file.name
|
||||
const extension = `.${name.split('.').pop()?.toLowerCase()}`
|
||||
if (CHAT_IMAGE_ACCEPT.split(',').includes(extension)) {
|
||||
if (!options.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다. Qwen 3.7 Plus·Flash 또는 3.8 Max·Flash를 선택해주세요.')
|
||||
if (result.filter(item => item.kind === 'image').length >= 3) throw new Error('이미지는 최대 3장까지 첨부할 수 있습니다.')
|
||||
if (name.length > 255 || !file.size || file.size > 5 * 1024 * 1024) throw new Error('이미지는 장당 5MB 이하여야 합니다.')
|
||||
let bitmap: ImageBitmap
|
||||
try { bitmap = await createImageBitmap(file) } catch { throw new Error('이미지 파일을 읽지 못했습니다.') }
|
||||
const pixels = bitmap.width * bitmap.height
|
||||
bitmap.close()
|
||||
if (!pixels || pixels > 16_000_000) throw new Error('이미지는 1600만 픽셀 이하여야 합니다.')
|
||||
const previewUrl = URL.createObjectURL(file)
|
||||
createdUrls.push(previewUrl)
|
||||
result.push({ kind: 'image', name, file, previewUrl })
|
||||
continue
|
||||
}
|
||||
if (!CHAT_ATTACHMENT_ACCEPT.split(',').includes(extension) && !['dockerfile', 'cmakelists.txt'].includes(name.toLowerCase())) {
|
||||
throw new Error('코드·설정·로그 또는 PNG·JPEG·WebP 이미지를 첨부해주세요. PDF는 지원하지 않습니다.')
|
||||
}
|
||||
if (name.length > 255 || file.size > 100_000) throw new Error('파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.')
|
||||
let content: string
|
||||
try {
|
||||
content = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer())
|
||||
} catch {
|
||||
throw new Error('UTF-8 텍스트 파일만 첨부할 수 있습니다.')
|
||||
}
|
||||
if (content.includes('\0')) throw new Error('바이너리 파일은 첨부할 수 없습니다.')
|
||||
if (content.length > 50_000) throw new Error('파일 하나의 내용은 5만 자 이하여야 합니다.')
|
||||
result.push({ name, content })
|
||||
}
|
||||
if (result.reduce((sum, file) => sum + (file.kind === 'image' ? 0 : file.name.length + file.content.length), 0) > 100_000) {
|
||||
throw new Error('첨부 파일의 전체 내용은 10만 자 이하여야 합니다.')
|
||||
}
|
||||
return result
|
||||
} catch (error) {
|
||||
createdUrls.forEach(url => URL.revokeObjectURL(url))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
Vendored
+9
@@ -67,6 +67,11 @@ interface Window {
|
||||
openDevTools: () => Promise<{ opened: boolean; message?: string }>
|
||||
close: () => void
|
||||
}
|
||||
chatTextMenu?: {
|
||||
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
|
||||
readClipboardState: () => Promise<{ hasText: boolean }>
|
||||
perform: (action: import('../../shared/chatTextMenu').ChatClipboardAction) => Promise<boolean>
|
||||
}
|
||||
appDiagnostics?: {
|
||||
read: () => Promise<{ path: string; content: string }>
|
||||
}
|
||||
@@ -92,6 +97,8 @@ interface Window {
|
||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||
}
|
||||
agent: {
|
||||
readTool: (request: import('../../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
|
||||
applyFiles: (changes: import('../../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => Promise<{ ok: boolean }>
|
||||
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => Promise<{
|
||||
ok: boolean
|
||||
output: string
|
||||
@@ -320,6 +327,8 @@ interface Window {
|
||||
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
|
||||
}
|
||||
agent: {
|
||||
readTool: (target: RemoteWorkspaceTarget, request: import('../../shared/agentTools').AgentToolRequest, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
|
||||
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../../shared/agentTools').AgentCheckedChange[]) => Promise<{ ok: boolean }>
|
||||
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ReturnType<Window['agent']['runCommand']>
|
||||
}
|
||||
maps: {
|
||||
|
||||
@@ -105,7 +105,11 @@ export function PlatformOptionCard({
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-[15px] font-bold text-[#111520]">{platform.platformKey === 'futuredrive-local-pc' ? t('시뮬레이션') : platform.title}</h3>
|
||||
</div>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p>
|
||||
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">
|
||||
{platform.platformKey === 'futuredrive-local-pc' && platform.description === '내 PC에서 직접 개발 환경을 실행합니다.'
|
||||
? t('내 PC에서 직접 개발 환경을 실행합니다.')
|
||||
: platform.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]">
|
||||
{statusPending
|
||||
? <LoaderCircle size={13} className="animate-spin text-[#7a879a]" />
|
||||
|
||||
@@ -275,7 +275,7 @@ export default function ModuleListView({
|
||||
)}
|
||||
</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(' ')}>
|
||||
<ModuleListToolbar
|
||||
dark={dark}
|
||||
|
||||
@@ -3,6 +3,7 @@ import Editor, { DiffEditor, type BeforeMount, type OnMount } from '@monaco-edit
|
||||
import { FileText, Loader2, RotateCw } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
editingLocked?: boolean
|
||||
fileLoading?: boolean
|
||||
fileError?: string
|
||||
onRetryFile?: () => void
|
||||
@@ -30,6 +31,7 @@ type Props = {
|
||||
}
|
||||
|
||||
function WorkspaceEditorPane({
|
||||
editingLocked = false,
|
||||
fileLoading,
|
||||
fileError,
|
||||
onRetryFile,
|
||||
@@ -66,6 +68,10 @@ function WorkspaceEditorPane({
|
||||
language={editorLanguage}
|
||||
theme={dark ? 'ads-ide-dark' : 'ads-ide-light'}
|
||||
beforeMount={beforeMount}
|
||||
onMount={(editor) => {
|
||||
editor.getOriginalEditor().getModel()?.updateOptions({ tabSize })
|
||||
editor.getModifiedEditor().getModel()?.updateOptions({ tabSize })
|
||||
}}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace',
|
||||
@@ -75,7 +81,6 @@ function WorkspaceEditorPane({
|
||||
readOnly: true,
|
||||
renderSideBySide: true,
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize,
|
||||
wordWrap: wordWrap ? 'on' : 'off'
|
||||
}}
|
||||
/>
|
||||
@@ -136,7 +141,7 @@ function WorkspaceEditorPane({
|
||||
scrollBeyondLastLine: false,
|
||||
tabSize,
|
||||
wordWrap: wordWrap ? 'on' : 'off',
|
||||
readOnly: exampleActive || editorValue === 'Binary or large file preview is not available.'
|
||||
readOnly: editingLocked || exampleActive || editorValue === 'Binary or large file preview is not available.'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { BookOpen, ChevronDown, Download, FileText, Minus, Play } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { useId } from 'react'
|
||||
|
||||
export type WorkspaceGuideResourceMode = 'material' | 'reference'
|
||||
|
||||
@@ -97,6 +98,7 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
onDownloadResource
|
||||
}: WorkspaceGuidePanelProps<Resource>): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const panelId = useId()
|
||||
const textDim = dark ? inheritedTextDim : 'text-[#475569]'
|
||||
return (
|
||||
<aside
|
||||
@@ -147,36 +149,35 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
const canToggle = allowFutureMaterialSteps || previous || active
|
||||
const youtubeEmbedUrl = getYoutubeEmbedUrl(item.videoUrl)
|
||||
const youtubeVideoId = getYoutubeVideoId(item.videoUrl)
|
||||
const contentId = `${panelId}-material-${index}`
|
||||
|
||||
return (
|
||||
<section
|
||||
key={`${item.title}-${index}`}
|
||||
role={canToggle ? 'button' : undefined}
|
||||
tabIndex={canToggle ? 0 : undefined}
|
||||
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
|
||||
onKeyDown={canToggle ? (event) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return
|
||||
event.preventDefault()
|
||||
onToggleMaterial(index)
|
||||
} : undefined}
|
||||
className={[
|
||||
'rounded-lg border transition',
|
||||
expanded ? 'px-2.5 pb-4 pt-2.5' : 'p-3',
|
||||
canToggle ? 'cursor-pointer' : '',
|
||||
'rounded-lg border px-3 pt-3 transition',
|
||||
expanded ? 'pb-4' : 'pb-3',
|
||||
active
|
||||
? dark ? 'border-[#60a5fa]/42 bg-[#303030]' : 'border-[#8aade4] bg-white'
|
||||
: canToggle
|
||||
? dark ? 'border-white/12 bg-[#292929] opacity-92 hover:border-white/28 hover:bg-[#303030] hover:opacity-100' : 'border-[#c5cfdd] bg-white hover:border-[#9cacc2] hover:bg-[#f8fafc]'
|
||||
? dark ? 'border-white/12 bg-[#292929] opacity-92' : 'border-[#c5cfdd] bg-white'
|
||||
: dark ? 'border-white/9 bg-[#292929] opacity-55' : 'border-[#cbd4e0] bg-white'
|
||||
].join(' ')}
|
||||
>
|
||||
<div className={['flex gap-2.5', expanded ? 'items-start' : 'items-center'].join(' ')}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={contentId}
|
||||
onClick={canToggle ? () => onToggleMaterial(index) : undefined}
|
||||
className={['flex h-6 w-full min-w-0 items-center gap-2.5 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-[#60a5fa] disabled:cursor-default', canToggle ? 'cursor-pointer' : ''].join(' ')}
|
||||
>
|
||||
<span className={[
|
||||
'grid h-6 w-6 shrink-0 place-items-center rounded-md text-[11px] font-bold',
|
||||
active ? dark ? 'bg-[#2563eb]/18 text-[#9dbdff]' : 'bg-[#ddd0fa] text-[#5b21b6]' : (previous || allowFutureMaterialSteps) ? dark ? 'bg-white/12 text-white/70' : 'bg-[#cbd5e1] text-[#334155]' : dark ? 'bg-white/8 text-white/38' : 'bg-[#d5dbe5] text-[#64748b]'
|
||||
].join(' ')}><span className="relative top-px leading-none">{index + 1}</span></span>
|
||||
<div className={['min-w-0 flex-1', expanded ? 'pt-0.5' : ''].join(' ')}>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<span className="block min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center justify-between gap-2">
|
||||
<strong title={item.title} className={['block min-w-0 truncate text-[13px] font-bold', active || previous || allowFutureMaterialSteps ? textStrong : textDim].join(' ')}>{item.title}</strong>
|
||||
<span className="flex shrink-0 items-center gap-1.5">
|
||||
{active && (
|
||||
@@ -193,55 +194,55 @@ export default function WorkspaceGuidePanel<Resource extends WorkspaceGuideResou
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div className="mt-2.5 grid gap-2.5">
|
||||
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
|
||||
{youtubeEmbedUrl && youtubeVideoId && (
|
||||
<button
|
||||
type="button"
|
||||
className={['group relative aspect-video w-full cursor-pointer overflow-hidden rounded-md border bg-black text-left', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title }))
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
|
||||
alt=""
|
||||
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
|
||||
<span className="absolute inset-0 grid place-items-center">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-full bg-white/92 text-[#2563eb] shadow-[0_14px_34px_rgba(0,0,0,0.32)] transition group-hover:scale-105">
|
||||
<Play size={22} fill="currentColor" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span>
|
||||
</button>
|
||||
)}
|
||||
<section>
|
||||
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
|
||||
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
{item.description || topicDetails[index] || overview}
|
||||
</p>
|
||||
</section>
|
||||
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
|
||||
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("핵심 개념")}</span>
|
||||
<div className="mt-2 grid gap-1.5">
|
||||
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
|
||||
<div key={concept} className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#2563eb]" />
|
||||
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{concept}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div id={contentId} className="ml-[34px] mt-2.5 grid select-text gap-2.5">
|
||||
<div className={['h-px', dark ? 'bg-white/10' : 'bg-[#e5e8ef]'].join(' ')} />
|
||||
{youtubeEmbedUrl && youtubeVideoId && (
|
||||
<button
|
||||
type="button"
|
||||
className={['group relative aspect-video w-full cursor-pointer overflow-hidden rounded-md border bg-black text-left', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void window.learningVideo.open(youtubeEmbedUrl, t("{{v0}} 동영상강의", { v0: item.title }))
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`https://img.youtube.com/vi/${youtubeVideoId}/hqdefault.jpg`}
|
||||
alt=""
|
||||
className="h-full w-full object-cover opacity-90 transition group-hover:scale-[1.02] group-hover:opacity-100"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="absolute inset-0 bg-black/28 transition group-hover:bg-black/18" />
|
||||
<span className="absolute inset-0 grid place-items-center">
|
||||
<span className="grid h-11 w-11 place-items-center rounded-full bg-white/92 text-[#2563eb] shadow-[0_14px_34px_rgba(0,0,0,0.32)] transition group-hover:scale-105">
|
||||
<Play size={22} fill="currentColor" />
|
||||
</span>
|
||||
</span>
|
||||
<span className="absolute bottom-2 left-2 rounded bg-black/65 px-2 py-1 text-[11px] font-bold text-white/88">{t("동영상강의 보기")}</span>
|
||||
</button>
|
||||
)}
|
||||
<section>
|
||||
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("학습 내용")}</span>
|
||||
<p className={['mt-1 text-[12px] font-medium leading-relaxed', textDim].join(' ')}>
|
||||
{item.description || topicDetails[index] || overview}
|
||||
</p>
|
||||
</section>
|
||||
<section className={['border-b pb-2.5', borderSoft].join(' ')}>
|
||||
<span className={['block text-[11px] font-bold', textMid].join(' ')}>{t("핵심 개념")}</span>
|
||||
<div className="mt-2 grid gap-1.5">
|
||||
{(item.concepts?.length ? item.concepts : [topics[index]].filter(Boolean)).map((concept) => (
|
||||
<div key={concept} className="flex items-center gap-2">
|
||||
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-[#2563eb]" />
|
||||
<span className={['text-[12px] font-medium leading-snug', textDim].join(' ')}>{concept}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}) : (
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { t, useLocaleRender } from '../../i18n'
|
||||
import { Bot, Check, CheckCircle2, Circle, Copy, FileCode2, ListChecks, Loader2, MessageCircle, Minus, Play, TerminalSquare, User, WandSparkles, X } from 'lucide-react'
|
||||
import { Bot, Check, CheckCircle2, Circle, Copy, FileCode2, ListChecks, Loader2, MessageCircle, Minus, Play, TerminalSquare, Trash2, User, WandSparkles, X } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ConfirmStatusModal } from '../../components/AppStatusModals'
|
||||
import { ProgressiveChatResponse } from '../../components/ProgressiveChatResponse'
|
||||
import { ChatTextContextMenu, type ChatTextContextMenuState } from '../../components/ChatTextContextMenu'
|
||||
import { ChatImageAttachments } from '../../components/ChatImageAttachments'
|
||||
import { AgentProgressBubble } from '../../components/AgentProgressBubble'
|
||||
import { AgentWorkDuration } from '../../components/AgentWorkDuration'
|
||||
import { AgentCodeReferences } from '../../components/AgentCodeReferences'
|
||||
import { AgentModelSelector } from '../../components/AgentModelSelector'
|
||||
import type { AgentModelOption } from './agentModels'
|
||||
import type { AgentProgressStep } from './agentContext'
|
||||
import { ChatAvatar, ChatEmptyState, ChatResponseSkeleton, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from '../../components/ChatWidgets'
|
||||
import type { AgentCommand, AgentConversationMessage, AgentFileChange, AgentPlan } from './useAiAgent'
|
||||
import type { ChatAttachment } from '../../components/chatAttachments'
|
||||
|
||||
type QaMode = 'ai' | 'agent' | 'teacher'
|
||||
|
||||
@@ -13,6 +25,7 @@ type WorkspaceQaMessage = {
|
||||
content: string
|
||||
senderAvatarUrl?: string | null
|
||||
isTyping?: boolean
|
||||
revealStartedAt?: number
|
||||
keepExpanded?: boolean
|
||||
}
|
||||
|
||||
@@ -34,8 +47,16 @@ type WorkspaceQaPanelProps = {
|
||||
visibleTeacherMessages: WorkspaceQaMessage[]
|
||||
agentMessages: AgentConversationMessage[]
|
||||
agentEnabled: boolean
|
||||
agentProvider?: string | null
|
||||
agentImagesEnabled?: boolean
|
||||
agentModels?: AgentModelOption[]
|
||||
onAgentProviderChange?: (provider: string) => void
|
||||
agentStatusLoading: boolean
|
||||
agentPending: boolean
|
||||
agentActivity?: string
|
||||
agentProgressSteps?: AgentProgressStep[]
|
||||
agentRequestStartedAt?: number | null
|
||||
agentActionBusy?: boolean
|
||||
agentPersistenceError?: string | null
|
||||
aiPending: boolean
|
||||
teacherThreadStatus: string | null
|
||||
@@ -46,12 +67,14 @@ type WorkspaceQaPanelProps = {
|
||||
onMinimize: () => void
|
||||
onModeChange: (mode: QaMode) => void
|
||||
onStop: () => void
|
||||
onSubmit: (content: string) => void
|
||||
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
|
||||
onClearConversation: () => void
|
||||
onApplyAgentPlan: (messageId: string, plan: AgentPlan) => void
|
||||
onCancelAgentPlan: (messageId: string) => void
|
||||
onOpenAgentChange: (messageId: string, change: AgentFileChange) => void
|
||||
onRunAgentCommand: (messageId: string, command: AgentCommand) => void
|
||||
onRejectAgentCommand: (messageId: string, command: AgentCommand) => void
|
||||
onOpenAgentCodeReference?: (messageId: string, referenceId: string) => void
|
||||
}
|
||||
|
||||
function MessageCopyButton({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
|
||||
@@ -71,7 +94,7 @@ function MessageCopyButton({ content, dark }: { content: string; dark: boolean }
|
||||
aria-label={copied ? t("복사됨") : t("복사")}
|
||||
onClick={copy}
|
||||
className={[
|
||||
'absolute bottom-2 right-2 z-10 grid h-6 w-6 place-items-center rounded opacity-0 transition group-hover:opacity-100 focus:opacity-100',
|
||||
'grid h-6 w-6 shrink-0 cursor-pointer place-items-center rounded opacity-0 transition group-hover:opacity-100 focus-visible:opacity-100',
|
||||
dark ? 'text-white/55 hover:bg-white/10 hover:text-white' : 'text-[#64748b] hover:bg-[#ede9fe] hover:text-[#1e293b]'
|
||||
].join(' ')}
|
||||
>
|
||||
@@ -103,8 +126,16 @@ export default function WorkspaceQaPanel({
|
||||
visibleTeacherMessages,
|
||||
agentMessages,
|
||||
agentEnabled,
|
||||
agentProvider,
|
||||
agentImagesEnabled = false,
|
||||
agentModels = [],
|
||||
onAgentProviderChange,
|
||||
agentStatusLoading,
|
||||
agentPending,
|
||||
agentActivity,
|
||||
agentProgressSteps = [],
|
||||
agentRequestStartedAt,
|
||||
agentActionBusy,
|
||||
agentPersistenceError,
|
||||
aiPending,
|
||||
teacherThreadStatus,
|
||||
@@ -118,29 +149,34 @@ export default function WorkspaceQaPanel({
|
||||
onSubmit,
|
||||
onClearConversation,
|
||||
onApplyAgentPlan,
|
||||
onCancelAgentPlan,
|
||||
onOpenAgentCodeReference,
|
||||
onOpenAgentChange,
|
||||
onRunAgentCommand,
|
||||
onRejectAgentCommand
|
||||
}: WorkspaceQaPanelProps): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
|
||||
const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
|
||||
const [clearConfirmationOpen, setClearConfirmationOpen] = useState(false)
|
||||
const activeMessages = qaMode === 'ai' ? visibleAiMessages : qaMode === 'agent' ? agentMessages : visibleTeacherMessages
|
||||
const lastMessage = activeMessages[activeMessages.length - 1]
|
||||
const clearDisabled = inputBusy || qaMessagesLoading || (agentStatusLoading && qaMode === 'agent') || activeMessages.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai')
|
||||
}, [onModeChange, qaMode, teacherQaDisabled])
|
||||
setClearConfirmationOpen(false)
|
||||
setContextMenu(null)
|
||||
}, [qaMode, workspaceActive, minimized])
|
||||
|
||||
const requestClearConversation = (): void => {
|
||||
setContextMenu(null)
|
||||
if (!clearDisabled) setClearConfirmationOpen(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu) return
|
||||
const closeContextMenu = (): void => setContextMenu(null)
|
||||
window.addEventListener('click', closeContextMenu)
|
||||
window.addEventListener('contextmenu', closeContextMenu)
|
||||
return () => {
|
||||
window.removeEventListener('click', closeContextMenu)
|
||||
window.removeEventListener('contextmenu', closeContextMenu)
|
||||
if ((teacherQaDisabled && qaMode === 'teacher') || (!agentStatusLoading && !agentEnabled && qaMode === 'agent')) {
|
||||
onModeChange('ai')
|
||||
}
|
||||
}, [contextMenu])
|
||||
}, [agentEnabled, agentStatusLoading, onModeChange, qaMode, teacherQaDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (minimized || qaMessagesLoading) return
|
||||
@@ -177,6 +213,8 @@ export default function WorkspaceQaPanel({
|
||||
}
|
||||
}, [
|
||||
agentMessages.length,
|
||||
agentProgressSteps.length,
|
||||
agentActivity,
|
||||
agentPending,
|
||||
aiPending,
|
||||
lastMessage?.content,
|
||||
@@ -191,10 +229,13 @@ export default function WorkspaceQaPanel({
|
||||
])
|
||||
|
||||
const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const target = event.target as HTMLElement | null
|
||||
if (target?.closest('[data-qa-message="true"]')) return
|
||||
event.preventDefault()
|
||||
setContextMenu({ x: event.clientX, y: event.clientY })
|
||||
event.stopPropagation()
|
||||
const selection = window.getSelection()
|
||||
const text = selection?.anchorNode && selection.focusNode && event.currentTarget.contains(selection.anchorNode) && event.currentTarget.contains(selection.focusNode)
|
||||
? selection.toString()
|
||||
: ''
|
||||
setContextMenu({ kind: 'message', x: event.clientX, y: event.clientY, text })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -214,12 +255,12 @@ export default function WorkspaceQaPanel({
|
||||
<span className={['pointer-events-none absolute right-0 top-8 z-50 hidden whitespace-nowrap rounded-md border px-2 py-1 text-[11px] font-semibold shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block', dark ? 'border-white/10 bg-[#303030] text-white/78' : 'border-[#d8dee9] bg-white text-[#4f596b]'].join(' ')}>{t("최소화")}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className={['mt-3 grid grid-cols-3 overflow-hidden rounded-md border', borderSoft].join(' ')}>
|
||||
<div className={['mt-3 grid overflow-hidden rounded-md border', agentEnabled ? 'grid-cols-3' : 'grid-cols-2', borderSoft].join(' ')}>
|
||||
{[
|
||||
{ id: 'ai' as const, label: 'AI', Icon: Bot },
|
||||
{ id: 'agent' as const, label: t("에이전트"), Icon: WandSparkles },
|
||||
{ id: 'teacher' as const, label: t("강사"), Icon: User }
|
||||
].map(({ id, label, Icon }) => {
|
||||
].filter(({ id }) => id !== 'agent' || agentEnabled).map(({ id, label, Icon }) => {
|
||||
const disabled = id === 'teacher' && teacherQaDisabled
|
||||
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
|
||||
|
||||
@@ -280,8 +321,14 @@ export default function WorkspaceQaPanel({
|
||||
enabled={agentEnabled}
|
||||
statusLoading={agentStatusLoading}
|
||||
pending={agentPending}
|
||||
activity={agentActivity}
|
||||
progressSteps={agentProgressSteps}
|
||||
requestStartedAt={agentRequestStartedAt}
|
||||
busy={agentPending || agentActionBusy}
|
||||
messages={agentMessages}
|
||||
onApply={onApplyAgentPlan}
|
||||
onCancel={onCancelAgentPlan}
|
||||
onOpenCodeReference={onOpenAgentCodeReference}
|
||||
onOpenChange={onOpenAgentChange}
|
||||
onRunCommand={onRunAgentCommand}
|
||||
onRejectCommand={onRejectAgentCommand}
|
||||
@@ -296,29 +343,36 @@ export default function WorkspaceQaPanel({
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<div
|
||||
className={['fixed z-[140] min-w-[150px] overflow-hidden rounded-md border py-1 shadow-[0_18px_42px_rgba(0,0,0,0.28)]', dark ? 'border-white/10 bg-[#2b2b2b]' : 'border-[#d5dbe7] bg-white'].join(' ')}
|
||||
style={{ left: contextMenu.x, top: contextMenu.y }}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={['block w-full px-3 py-2 text-left text-[12px] font-semibold transition', dark ? 'text-white/78 hover:bg-white/10' : 'text-[#334155] hover:bg-[#f1f5f9]'].join(' ')}
|
||||
onClick={() => {
|
||||
setContextMenu(null)
|
||||
onClearConversation()
|
||||
}}
|
||||
>{t("대화내역 삭제")}</button>
|
||||
</div>
|
||||
)}
|
||||
<ChatTextContextMenu dark={dark} menu={contextMenu} canClear={!clearDisabled} onClose={() => setContextMenu(null)} onClear={requestClearConversation} />
|
||||
<QaInputBox
|
||||
dark={dark}
|
||||
busy={inputBusy}
|
||||
disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
|
||||
onStop={onStop}
|
||||
onSubmit={onSubmit}
|
||||
attachmentsEnabled={qaMode === 'agent' && agentProvider !== 'A_COPILOT'}
|
||||
imagesEnabled={qaMode === 'agent' && agentImagesEnabled}
|
||||
onClearConversation={requestClearConversation}
|
||||
clearDisabled={clearDisabled}
|
||||
footerAction={qaMode === 'agent' && onAgentProviderChange ? <AgentModelSelector models={agentModels} provider={agentProvider ?? null} dark={dark} disabled={inputBusy || agentStatusLoading || inputDisabled} onChange={onAgentProviderChange} /> : undefined}
|
||||
/>
|
||||
{clearConfirmationOpen && createPortal(
|
||||
<ConfirmStatusModal
|
||||
overlayClassName="fixed bottom-0 left-0 right-0 top-[38px] z-[180] grid place-items-center bg-black/36"
|
||||
dark={dark}
|
||||
icon={<Trash2 size={15} className="text-[#e87979]" />}
|
||||
title={t('대화내역 삭제')}
|
||||
message={t('대화내역을 삭제할까요?')}
|
||||
detail={t('현재 탭의 대화내역을 삭제합니다.')}
|
||||
confirmLabel={t('삭제')}
|
||||
onCancel={() => setClearConfirmationOpen(false)}
|
||||
onConfirm={() => {
|
||||
setClearConfirmationOpen(false)
|
||||
if (!clearDisabled) onClearConversation()
|
||||
}}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -328,21 +382,33 @@ function AgentMessages({
|
||||
enabled,
|
||||
statusLoading,
|
||||
pending,
|
||||
activity,
|
||||
progressSteps = [],
|
||||
requestStartedAt,
|
||||
busy,
|
||||
messages,
|
||||
onApply,
|
||||
onCancel,
|
||||
onOpenChange,
|
||||
onRunCommand,
|
||||
onRejectCommand
|
||||
onRejectCommand,
|
||||
onOpenCodeReference
|
||||
}: {
|
||||
dark: boolean
|
||||
enabled: boolean
|
||||
statusLoading: boolean
|
||||
pending: boolean
|
||||
activity?: string
|
||||
progressSteps?: AgentProgressStep[]
|
||||
requestStartedAt?: number | null
|
||||
busy?: boolean
|
||||
messages: AgentConversationMessage[]
|
||||
onApply: (messageId: string, plan: AgentPlan) => void
|
||||
onCancel: (messageId: string) => void
|
||||
onOpenChange: (messageId: string, change: AgentFileChange) => void
|
||||
onRunCommand: (messageId: string, command: AgentCommand) => void
|
||||
onRejectCommand: (messageId: string, command: AgentCommand) => void
|
||||
onOpenCodeReference?: (messageId: string, referenceId: string) => void
|
||||
}): React.JSX.Element {
|
||||
useLocaleRender()
|
||||
if (statusLoading) {
|
||||
@@ -356,42 +422,53 @@ function AgentMessages({
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<ChatEmptyState dark={dark}>
|
||||
{t("강사가 이 강좌의 AI 에이전트를 설정하면 사용할 수 있습니다.")}
|
||||
</div>
|
||||
</ChatEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
if (!messages.length && !pending) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center px-5 text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<ChatEmptyState dark={dark}>
|
||||
{t("현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요.")}<br />{t("변경 내용은 확인 후 직접 적용합니다.")}
|
||||
</div>
|
||||
</ChatEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
const codeNavigationBusy = messages.some(message => message.applyState === 'applying')
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{messages.map((message) => message.role === 'user' ? (
|
||||
{messages.map((message, index) => message.role === 'user' ? (
|
||||
<div key={message.id} data-qa-message="true" className="flex justify-end">
|
||||
<div className="max-w-[82%] break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
|
||||
<div className="flex min-w-0 max-w-[82%] flex-col items-end gap-2">
|
||||
<div data-agent-user-bubble="true" className="min-w-0 max-w-full whitespace-pre-wrap break-words rounded-lg bg-[#2563eb] px-3 py-2 text-[12px] font-bold leading-relaxed text-white">{message.content}</div>
|
||||
<ChatImageAttachments images={message.imageAttachments ?? []} variant="sent" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={message.id} data-qa-message="true" className="flex min-w-0 gap-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
|
||||
<div className="group relative min-w-0 max-w-[calc(100%-38px)]">
|
||||
<div className={['overflow-hidden rounded-lg border px-3 pb-9 pt-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
{message.plan ? (
|
||||
<div className="grid gap-3">
|
||||
<div>
|
||||
<div className="font-black">
|
||||
<MarkdownMessage content={message.plan.summary} dark={dark} />
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<MarkdownMessage content={message.plan.explanation} dark={dark} />
|
||||
</div>
|
||||
{message.provider && message.model ? <p className={['mt-1 text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{message.provider} · {message.model}</p> : null}
|
||||
</div>
|
||||
<div className={['overflow-hidden rounded-lg border px-3 py-2 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
<AgentWorkDuration dark={dark} durationMs={message.durationMs} />
|
||||
<ProgressiveChatResponse message={message} dark={dark} collapseOnSignal={index < messages.length - 1} collapseSignal={messages.length} footerAction={<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />}>
|
||||
{message.plan && (
|
||||
<>
|
||||
{!!message.plan.codeReferences?.length && <AgentCodeReferences references={message.plan.codeReferences} states={message.codeReferenceStates} dark={dark} busy={codeNavigationBusy} onOpen={onOpenCodeReference ? id => onOpenCodeReference(message.id, id) : undefined} />}
|
||||
{!!message.plan.evidence?.length && (
|
||||
<details className={['border-t pt-2 text-[11px]', dark ? 'border-white/10 text-white/55' : 'border-[#ddd6fe] text-[#64748b]'].join(' ')}>
|
||||
<summary className="cursor-pointer select-none">{t("조회 근거")} · {message.plan.evidence.length}</summary>
|
||||
{message.plan.evidence.map(result => (
|
||||
<details key={result.id} className="mt-2 min-w-0 border-t border-current/15 pt-2">
|
||||
<summary className="cursor-pointer break-words">{result.moduleName ? `${result.moduleName} · ` : ''}{result.name} · {result.status === 'ok' ? t("조회 완료") : t("조회 실패")}{result.truncated ? ` · ${t("일부 결과")}` : ''}</summary>
|
||||
<p className="mt-1 break-all">{result.target} · {new Date(result.observedAt).toLocaleTimeString()}</p>
|
||||
<pre className="thin-scrollbar mt-1 max-h-40 overflow-auto whitespace-pre-wrap break-all font-mono text-[10px]">{result.data}</pre>
|
||||
</details>
|
||||
))}
|
||||
</details>
|
||||
)}
|
||||
{message.plan.actionPlan.length > 0 ? (
|
||||
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
{(() => {
|
||||
@@ -423,12 +500,12 @@ function AgentMessages({
|
||||
</div>
|
||||
) : null}
|
||||
{message.plan.commands.length > 0 ? (
|
||||
<div className={['order-5 grid gap-3 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
<fieldset disabled={busy} className={['order-5 m-0 grid min-w-0 gap-3 border-0 border-t p-0 pt-2 disabled:cursor-wait', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
{message.plan.commands.map((command) => {
|
||||
const execution = message.commandExecutions?.[command.id] ?? { status: 'pending' as const }
|
||||
const moduleStartAction = command.command.trim() === 'lms module start'
|
||||
const moduleStartAction = command.command.trim() === 'lms module start' || command.operation?.name === 'modules.start'
|
||||
const finished = execution.status === 'completed' || execution.status === 'failed'
|
||||
const policyRejected = execution.status === 'rejected'
|
||||
const policyRejected = message.applyState === 'cancelled' || execution.status === 'rejected'
|
||||
|| (execution.status === 'failed' && /허용되지|실행할 수 없습니다/.test(execution.error ?? ''))
|
||||
const waitingForChanges = (message.plan!.changes.length > 0 || message.plan!.createModule !== null) && message.applyState !== 'applied'
|
||||
return (
|
||||
@@ -450,7 +527,9 @@ function AgentMessages({
|
||||
) : null}
|
||||
{execution.error ? <p className="mt-2 text-[11px] font-bold text-[#ef4444]">{execution.error}</p> : null}
|
||||
<div className="mt-2 flex items-center justify-end gap-2">
|
||||
{execution.status === 'pending' ? <>
|
||||
{message.applyState === 'cancelled' ? (
|
||||
<span className={['text-[11px] font-bold', dark ? 'text-white/40' : 'text-[#64748b]'].join(' ')}>{execution.status === 'completed' ? t("실행 완료") : t("취소됨")}</span>
|
||||
) : execution.status === 'pending' ? <>
|
||||
<button type="button" onClick={() => onRejectCommand(message.id, command)} className={['inline-flex h-7 items-center gap-1 rounded px-2 text-[11px] font-bold transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white' : 'text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}><X size={12} />{t("거부")}</button>
|
||||
<button type="button" disabled={waitingForChanges} title={waitingForChanges ? t("코드 변경을 먼저 적용하세요.") : undefined} onClick={() => onRunCommand(message.id, command)} className="inline-flex h-7 items-center gap-1 rounded bg-[#7c3aed] px-2.5 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"><Play size={12} />{waitingForChanges ? t("변경 적용 후 실행") : t("승인")}</button>
|
||||
</> : execution.status === 'running' ? (
|
||||
@@ -473,7 +552,7 @@ function AgentMessages({
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</fieldset>
|
||||
) : null}
|
||||
{message.plan.createModule ? (
|
||||
<div className={['border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||
@@ -505,37 +584,39 @@ function AgentMessages({
|
||||
) : null}
|
||||
{message.applyError ? <p className="text-[11px] font-bold text-[#ef4444]">{message.applyError}</p> : null}
|
||||
{message.plan.changes.length > 0 || message.plan.createModule ? (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{message.applyState !== 'applied' && message.applyState !== 'cancelled' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy || message.applyState === 'applying' || Object.values(message.commandExecutions ?? {}).some(execution => execution.status === 'running')}
|
||||
onClick={() => onCancel(message.id)}
|
||||
className={['inline-flex h-8 shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md border px-3 text-[11px] font-bold transition disabled:cursor-not-allowed disabled:opacity-40', dark ? 'border-white/15 text-white/65 hover:bg-white/8' : 'border-[#d8dee9] text-[#64748b] hover:bg-[#e2e8f0]'].join(' ')}
|
||||
>
|
||||
<X size={13} className="shrink-0" /><span className="relative top-px leading-none">{t("취소")}</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={message.applyState === 'applying' || message.applyState === 'applied' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
|
||||
disabled={busy || message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled' || Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')}
|
||||
onClick={() => onApply(message.id, message.plan!)}
|
||||
className="inline-flex h-8 items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
|
||||
className="inline-flex h-8 min-w-0 flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-black text-white transition hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:bg-[#64748b]"
|
||||
>
|
||||
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />}
|
||||
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
|
||||
{message.applyState === 'applying' ? <Loader2 size={13} className="animate-spin" /> : message.applyState === 'cancelled' ? <X size={13} /> : message.applyState === 'applied' ? <Check size={13} /> : <FileCode2 size={13} />}
|
||||
{message.applyState === 'applying' ? t("적용 중") : message.applyState === 'applied' ? t("적용 완료") : message.applyState === 'cancelled' ? t("취소됨") : message.applyState === 'error' ? t("다시 적용") : t("변경 적용")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : <p className="whitespace-pre-wrap">{message.content}</p>}
|
||||
</>
|
||||
)}
|
||||
</ProgressiveChatResponse>
|
||||
</div>
|
||||
<MessageCopyButton content={agentMessageCopyText(message)} dark={dark} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{pending ? (
|
||||
<div data-qa-message="true" className="flex gap-2.5">
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#7c3aed] text-white"><WandSparkles size={14} /></span>
|
||||
<div
|
||||
role="status"
|
||||
aria-label={t("코드 변경안을 만드는 중입니다.")}
|
||||
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
|
||||
>
|
||||
<div className="grid animate-pulse gap-2" aria-hidden="true">
|
||||
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
|
||||
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
|
||||
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
|
||||
</div>
|
||||
</div>
|
||||
<AgentProgressBubble dark={dark} steps={progressSteps} activity={activity} startedAt={requestStartedAt} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -556,9 +637,9 @@ function AiMessages({
|
||||
useLocaleRender()
|
||||
if (!messages.length && !pending) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<span>{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}</span>
|
||||
</div>
|
||||
<ChatEmptyState dark={dark}>
|
||||
{t("AI에게 질문할")}<br />{t("내용을 입력하세요")}
|
||||
</ChatEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -581,16 +662,13 @@ function AiMessages({
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<div className={['min-w-0 max-w-[calc(100%-38px)] overflow-hidden rounded-lg border px-3 py-2 text-[12px] font-medium leading-relaxed', dark ? 'border-transparent bg-[#303030] text-white/75' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||
<ExpandableMarkdownMessage
|
||||
<ProgressiveChatResponse
|
||||
collapseOnSignal={index < messages.length - 1}
|
||||
collapseSignal={collapseSignal}
|
||||
content={message.content}
|
||||
message={message}
|
||||
dark={dark}
|
||||
defaultCollapsed={!message.isTyping && !message.keepExpanded}
|
||||
defaultCollapsed={!message.keepExpanded}
|
||||
/>
|
||||
{message.isTyping && (
|
||||
<span className={['mt-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full', dark ? 'bg-white/65' : 'bg-[#2563eb]'].join(' ')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -600,10 +678,7 @@ function AiMessages({
|
||||
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-full bg-[#2563eb] text-white">
|
||||
<Bot size={15} />
|
||||
</span>
|
||||
<div className={['inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-[12px] font-medium', dark ? 'border-transparent bg-[#303030] text-white/55' : 'border-[#b9a5e8] bg-[#faf7ff] text-[#475569]'].join(' ')}>
|
||||
<span>{t("답변을 작성 중입니다...")}</span>
|
||||
<span className={['h-3 w-3 shrink-0 animate-spin rounded-full border-2 border-t-transparent', dark ? 'border-white/45' : 'border-[#94a3b8]'].join(' ')} />
|
||||
</div>
|
||||
<ChatResponseSkeleton dark={dark} label={t("답변을 작성 중입니다...")} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -626,9 +701,9 @@ function TeacherMessages({
|
||||
useLocaleRender()
|
||||
if (!messages.length) {
|
||||
return (
|
||||
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||
<span>{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}</span>
|
||||
</div>
|
||||
<ChatEmptyState dark={dark}>
|
||||
{t("강사에게 문의할")}<br />{t("내용을 입력하세요")}
|
||||
</ChatEmptyState>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { editor } from 'monaco-editor'
|
||||
import { safeAgentPath } from '../../../../shared/agentTools'
|
||||
import type { AgentCodeReference } from './useAiAgent'
|
||||
|
||||
export function validateCodeReference(reference: AgentCodeReference): void {
|
||||
safeAgentPath(reference.path)
|
||||
if (!Number.isSafeInteger(reference.startLine) || !Number.isSafeInteger(reference.endLine) || reference.startLine < 1 || reference.endLine < reference.startLine || reference.endLine > 100_000 || reference.endLine - reference.startLine >= 200
|
||||
|| !reference.expectedHash || !/^[a-f0-9]{64}$/.test(reference.expectedHash) || !reference.snippet?.trim() || reference.snippet.length > 12_000 || reference.snippet.includes('[REDACTED]')) {
|
||||
throw new Error('코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.')
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCodeReferenceRange(content: string, reference: AgentCodeReference): Promise<{ startLine: number; endLine: number }> {
|
||||
validateCodeReference(reference)
|
||||
const lines = content.replace(/\r\n/g, '\n').split('\n')
|
||||
const snippet = reference.snippet!.replace(/\r\n/g, '\n')
|
||||
const expectedLines = reference.endLine - reference.startLine + 1
|
||||
if (snippet.split('\n').length !== expectedLines) throw new Error('코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.')
|
||||
const bytes = globalThis.crypto?.subtle ? await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(content)) : null
|
||||
const hash = bytes ? Array.from(new Uint8Array(bytes), value => value.toString(16).padStart(2, '0')).join('') : null
|
||||
if (hash === reference.expectedHash && lines.slice(reference.startLine - 1, reference.endLine).join('\n') === snippet) return { startLine: reference.startLine, endLine: reference.endLine }
|
||||
|
||||
// Unsaved edits can shift lines. Only a unique, exact match is safe to reveal.
|
||||
const matches: number[] = []
|
||||
for (let index = 0; index <= lines.length - expectedLines; index++) {
|
||||
if (lines.slice(index, index + expectedLines).join('\n') === snippet) matches.push(index + 1)
|
||||
if (matches.length > 1) break
|
||||
}
|
||||
if (matches.length !== 1) throw new Error('코드가 변경되어 위치를 확정할 수 없습니다. 다시 분석을 요청해주세요.')
|
||||
return { startLine: matches[0], endLine: matches[0] + expectedLines - 1 }
|
||||
}
|
||||
|
||||
export async function revealAgentCodeReference(options: {
|
||||
file: string
|
||||
reference: AgentCodeReference
|
||||
isCurrent: () => boolean
|
||||
getEditor: () => editor.IStandaloneCodeEditor | null
|
||||
getActiveFile: () => string | null
|
||||
isReady: () => boolean
|
||||
attempts?: number
|
||||
}): Promise<editor.IEditorDecorationsCollection> {
|
||||
for (let attempt = 0; attempt < (options.attempts ?? 100); attempt++) {
|
||||
if (!options.isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.')
|
||||
const instance = options.getEditor()
|
||||
const model = instance?.getModel()
|
||||
const element = instance?.getDomNode()
|
||||
if (options.isReady() && options.getActiveFile() === options.file && instance && element && element.clientWidth > 0 && element.clientHeight > 0 && model && !model.isDisposed() && model.uri.path === `/${options.file}`) {
|
||||
const content = model.getValue()
|
||||
const range = await resolveCodeReferenceRange(content, options.reference)
|
||||
if (!options.isCurrent()) throw new Error('코드 위치 열기가 중단되었습니다.')
|
||||
if (instance !== options.getEditor() || model !== instance.getModel() || model.getValue() !== content || !options.isReady() || options.getActiveFile() !== options.file) continue
|
||||
instance.layout()
|
||||
const selection = { startLineNumber: range.startLine, startColumn: 1, endLineNumber: range.endLine, endColumn: model.getLineMaxColumn(range.endLine) }
|
||||
const decorations = instance.createDecorationsCollection([{ range: selection, options: { isWholeLine: true, className: 'monaco-agent-code-highlight' } }])
|
||||
instance.setPosition({ lineNumber: range.startLine, column: 1 })
|
||||
instance.revealRangeInCenter(selection)
|
||||
instance.focus()
|
||||
return decorations
|
||||
}
|
||||
await new Promise(resolve => window.setTimeout(resolve, 100))
|
||||
}
|
||||
throw new Error('코드 편집기를 준비하지 못했습니다. 다시 시도해주세요.')
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
import type { AgentConversationMessage, AgentPlan, AgentRuntimeLog, AgentWorkspaceModule } from './useAiAgent'
|
||||
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||
import { compactAgentEvidence, describeAgentEvidence, missingAgentRead, readFileEvidence } from './agentEvidence'
|
||||
|
||||
export type AgentContext = { modules: AgentWorkspaceModule[]; runtimeLogs: AgentRuntimeLog[]; toolResults?: AgentToolResult[]; contextNotes?: string[] }
|
||||
export type AgentContextRequest = { modules: string[]; logs: string[] }
|
||||
export type AgentProgressStep = {
|
||||
id: string
|
||||
kind: 'analysis' | 'tool' | 'files' | 'logs' | 'image'
|
||||
status: 'ok' | 'error' | 'partial' | 'empty' | 'missing'
|
||||
moduleName?: string
|
||||
name?: string
|
||||
path?: string
|
||||
count?: number
|
||||
round?: number
|
||||
requests?: number
|
||||
}
|
||||
|
||||
function toolProgressStep(tool: AgentToolRequest, result: AgentToolResult, id: string): AgentProgressStep {
|
||||
const step: AgentProgressStep = { id, kind: 'tool', name: tool.name, moduleName: tool.moduleName, path: tool.args?.path, status: result.status === 'error' ? 'error' : result.truncated ? 'partial' : 'ok' }
|
||||
if (result.status !== 'ok') return step
|
||||
try {
|
||||
const data = JSON.parse(result.data)
|
||||
if (tool.name === 'files.read' && data?.exists === false) step.status = 'missing'
|
||||
else if (tool.name === 'files.read' && data?.complete === false) step.status = 'partial'
|
||||
const items = tool.name === 'files.list' ? data?.paths : tool.name === 'files.search' ? data?.matches : undefined
|
||||
if (Array.isArray(items)) step.count = items.length
|
||||
} catch { /* Progress never exposes raw tool output or treats it as instructions. */ }
|
||||
return step
|
||||
}
|
||||
export type AgentPlanResponse = {
|
||||
provider: string
|
||||
model: string
|
||||
usage?: AgentConversationMessage['usage']
|
||||
plan: AgentPlan
|
||||
}
|
||||
|
||||
export function recoverInterruptedAgentMessages(messages: AgentConversationMessage[]): AgentConversationMessage[] {
|
||||
let changed = false
|
||||
const recovered = messages.map(message => {
|
||||
const interrupted = Object.entries(message.commandExecutions ?? {}).some(([, execution]) => execution.status === 'running')
|
||||
const interruptedNavigation = Object.values(message.codeReferenceStates ?? {}).some(state => state.status === 'opening')
|
||||
if (!interrupted && !interruptedNavigation && message.applyState !== 'applying') return message
|
||||
changed = true
|
||||
const error = '이전 작업의 완료 결과를 확인할 수 없습니다. 현재 모듈·파일 상태를 다시 조회한 뒤 진행해주세요.'
|
||||
return { ...message, ...(message.applyState === 'applying' ? { applyState: 'error' as const, applyError: error } : {}), ...(interruptedNavigation ? { codeReferenceStates: Object.fromEntries(Object.entries(message.codeReferenceStates ?? {}).map(([id, state]) => [id, state.status === 'opening' ? { status: 'error' as const, error: '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' } : state])) } : {}), commandExecutions: Object.fromEntries(Object.entries(message.commandExecutions ?? {}).map(([id, execution]) => [id, execution.status === 'running' ? { ...execution, status: 'failed' as const, error } : execution])) }
|
||||
})
|
||||
return changed ? recovered : messages
|
||||
}
|
||||
|
||||
export function agentPlanRequestText(request: string, finalize = false): string {
|
||||
if (!finalize) return request
|
||||
const instruction = '자료 조회는 종료되었습니다. 제공된 workspace의 파일·조회 상태·로그·toolResults만 근거로 아래 원래 질문에 최종 답변하세요. toolRequests, requestedModules, requestedLogs, requestedDocs, actionPlan, commands, changes는 모두 빈 배열, createModule은 null로 반환하세요. 같은 자료를 다시 요청하거나 확인하겠다는 예고로 끝내지 마세요. 코드 위치와 역할 설명은 전체 파일이 아닌 확인된 줄 범위만으로 답할 수 있습니다. 확인된 내용은 codeReferences와 함께 답하고 미확인 항목만 별도로 구분하세요. 확인한 파일 경로와 설정 항목을 구체적으로 설명하되, 자료가 없거나 제외됐다면 확정할 수 없는 부분과 추가로 필요한 정확한 자료를 명시하세요. 확인하지 않은 내용이나 경로를 추측하지 마세요.\n\n원래 학생 질문:\n'
|
||||
return instruction + request.slice(0, 4000 - instruction.length)
|
||||
}
|
||||
|
||||
function fitAgentContext(context: AgentContext, preferredModules: string[] = [], preferredLogs: string[] = [], preferredReads: AgentToolRequest[] = []): AgentContext {
|
||||
const orderedModules = [...context.modules].sort((left, right) => Number(preferredModules.includes(right.name)) - Number(preferredModules.includes(left.name)))
|
||||
const selected = new Map<string, AgentWorkspaceModule['files']>()
|
||||
const candidates = orderedModules.map((module) => ({ module, files: [...module.files], index: 0 }))
|
||||
let fileCount = 0
|
||||
let characters = 0
|
||||
|
||||
// Interleave complete files so a large module cannot consume every file slot.
|
||||
while (fileCount < 24) {
|
||||
let added = false
|
||||
for (const candidate of candidates) {
|
||||
while (candidate.index < candidate.files.length && fileCount < 24) {
|
||||
const file = candidate.files[candidate.index++]
|
||||
const size = file.path.length + file.content.length
|
||||
if (file.path.length > 500 || file.content.length > 100_000 || characters + size > 160_000) continue
|
||||
const files = selected.get(candidate.module.name) ?? []
|
||||
files.push(file)
|
||||
selected.set(candidate.module.name, files)
|
||||
characters += size
|
||||
fileCount += 1
|
||||
added = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!added) break
|
||||
}
|
||||
|
||||
const modules = context.modules.map((module) => {
|
||||
const files = selected.get(module.name) ?? []
|
||||
if (files.length === module.files.length) return module
|
||||
const note = `요청 용량 제한으로 전체 ${module.files.length}개 중 ${files.length}개 파일만 제공했습니다. 제외된 파일은 확인한 것으로 간주하지 말고 제공된 근거로 답하거나 필요한 자료를 구체적으로 안내하세요.`
|
||||
return {
|
||||
...module,
|
||||
files,
|
||||
fileContext: {
|
||||
status: 'limited' as const,
|
||||
message: `${note} ${module.fileContext?.message ?? ''}`.trim().slice(0, 500)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const logOrder = [...new Set([...preferredLogs, ...context.runtimeLogs.map((log) => log.moduleName).reverse()])]
|
||||
const logMap = new Map(context.runtimeLogs.map((log) => [log.moduleName, log]))
|
||||
const retainedLogs = logOrder.flatMap((name) => logMap.has(name) ? [logMap.get(name)!] : []).slice(0, 3)
|
||||
const omitted = context.runtimeLogs.filter((log) => !retainedLogs.includes(log)).map((log) => log.moduleName)
|
||||
const runtimeLogs = retainedLogs.map((log, index) => {
|
||||
let notes = [
|
||||
log.message,
|
||||
index === 0 && omitted.length ? `로그 모듈 한도로 이전 로그 제외: ${omitted.join(', ')}. 제외된 로그를 확인했다고 주장하지 마세요.` : ''
|
||||
].filter(Boolean).join(' ').slice(0, 500)
|
||||
if (log.content.length + notes.length > 20_000) {
|
||||
notes = `로그 용량 제한으로 최근 구간만 제공합니다. ${notes}`.trim().slice(0, 500)
|
||||
}
|
||||
// The server counts both log content and messages against its 60,000-character cap.
|
||||
const content = log.content.slice(-(20_000 - notes.length))
|
||||
return { ...log, content, ...(notes ? { message: notes } : {}) }
|
||||
})
|
||||
let toolSize = 0
|
||||
const evidence = compactAgentEvidence(context.toolResults ?? [])
|
||||
const priority = (result: AgentToolResult): number => {
|
||||
const file = readFileEvidence(result)
|
||||
if (file && preferredReads.some(tool => tool.name === 'files.read' && tool.moduleName === result.moduleName && tool.args?.path === file.path)) return 6
|
||||
return result.name === 'action.result' ? 5 : !result.name.startsWith('files.') ? 4
|
||||
: file || result.status === 'error' ? 3 : 1
|
||||
}
|
||||
const candidatesByPriority = evidence.map((result, index) => ({ result, index }))
|
||||
.sort((a, b) => priority(b.result) - priority(a.result) || b.index - a.index)
|
||||
const retained = new Set<AgentToolResult>()
|
||||
for (const { result } of candidatesByPriority) {
|
||||
if (retained.size >= 12 || result.data.length > 60_000 || toolSize + result.data.length > 180_000) continue
|
||||
retained.add(result)
|
||||
toolSize += result.data.length
|
||||
}
|
||||
const toolResults = evidence.filter(result => retained.has(result))
|
||||
const omittedTools = evidence.filter(result => !retained.has(result))
|
||||
const contextNotes = omittedTools.length
|
||||
? [`용량·개수 한도로 조회 근거 ${omittedTools.length}개를 제외했습니다. 제외된 내용을 확인했다고 주장하지 마세요.`,
|
||||
...omittedTools.slice(-10).map(result => describeAgentEvidence(result).slice(0, 500))]
|
||||
: []
|
||||
return { modules, runtimeLogs, ...(context.toolResults ? { toolResults, contextNotes } : {}) }
|
||||
}
|
||||
|
||||
export async function resolveAgentContext({
|
||||
initialContext,
|
||||
requestPlan,
|
||||
loadRequestedContext,
|
||||
executeTool,
|
||||
onProgress,
|
||||
onStep,
|
||||
signal
|
||||
}: {
|
||||
initialContext: AgentContext
|
||||
requestPlan: (context: AgentContext, finalize?: boolean) => Promise<AgentPlanResponse>
|
||||
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
||||
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
||||
onProgress?: (label: string) => void
|
||||
onStep?: (step: AgentProgressStep) => void
|
||||
signal: AbortSignal
|
||||
}): Promise<AgentPlanResponse> {
|
||||
let cachedContext = initialContext
|
||||
let context = fitAgentContext(cachedContext)
|
||||
const requestedModules = new Set(initialContext.modules.filter((module) => module.files.length || module.fileContext).map((module) => module.name))
|
||||
const requestedLogs = new Set(initialContext.runtimeLogs.map((log) => log.moduleName))
|
||||
const queriedTools = new Set<string>()
|
||||
let toolCount = 0
|
||||
let confirmedAnswer: AgentPlanResponse | undefined
|
||||
const fingerprint = (tool: AgentToolRequest): string => JSON.stringify([tool.name, tool.moduleName ?? '', Object.entries(tool.args ?? {}).sort(([a], [b]) => a.localeCompare(b))])
|
||||
const usage: NonNullable<AgentConversationMessage['usage']> = {}
|
||||
const checkAborted = (): void => {
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError')
|
||||
}
|
||||
const addUsage = (response: AgentPlanResponse): void => {
|
||||
for (const key of ['promptTokens', 'completionTokens', 'totalTokens'] as const) {
|
||||
if (response.usage?.[key] !== undefined) usage[key] = (usage[key] ?? 0) + response.usage[key]
|
||||
}
|
||||
}
|
||||
const finalize = async (): Promise<AgentPlanResponse> => {
|
||||
checkAborted()
|
||||
onProgress?.('최종 답변 정리 중')
|
||||
const response = await requestPlan(context, true)
|
||||
checkAborted()
|
||||
addUsage(response)
|
||||
const stillRequesting = Boolean(response.plan.requestedModules?.length || response.plan.requestedLogs?.length || response.plan.toolRequests?.length)
|
||||
const sourceStatus = context.modules.filter((module) => requestedModules.has(module.name)).map((module) => (
|
||||
`- ${module.name}: 제공된 파일 ${module.files.length}개${module.fileContext?.message ? ` (${module.fileContext.message})` : ''}\n${module.files.slice(0, 4).map((file) => ` ${file.path}`).join('\n')}`
|
||||
)).join('\n').slice(0, 8000)
|
||||
const toolStatus = (context.toolResults ?? []).map(result => `- ${describeAgentEvidence(result)}`).join('\n').slice(0, 8000)
|
||||
const candidate = response.plan.codeReferences?.length ? response : confirmedAnswer
|
||||
const references = candidate?.plan.codeReferences?.filter(reference => {
|
||||
const latest = (cachedContext.toolResults ?? []).filter(result => result.moduleName === reference.moduleName)
|
||||
.map(readFileEvidence).filter(file => file?.path === reference.path).at(-1)
|
||||
return !latest || latest.exists && latest.hash === reference.expectedHash
|
||||
})
|
||||
const confirmed = references?.length && candidate ? { ...candidate, plan: { ...candidate.plan, codeReferences: references } } : undefined
|
||||
const confirmedReferences = Boolean(confirmed?.plan.codeReferences?.length)
|
||||
const explanation = stillRequesting
|
||||
? `${confirmedReferences ? `${confirmed!.plan.explanation}\n\n추가 분석은 완료하지 못했지만 확인된 코드 위치는 유지했습니다.` : 'AI가 최종 분석 요청 후에도 같은 자료를 재요청하여 결론을 확정하지 못했습니다. 파일 조회 실패나 설치 문제로 단정할 수는 없습니다.'}\n\n현재 AI에 제공된 자료:\n${sourceStatus || (toolStatus ? '- 모듈 묶음 조회 대신 아래 도구 결과를 제공했습니다.' : '- 소스 파일 없음')}\n\n도구 조회 근거:\n${toolStatus || '- 없음'}\n\n제공된 로그: ${context.runtimeLogs.map((log) => log.moduleName).join(', ') || '없음'}\n\n반복 조회는 중단했으며, 확인되지 않은 설정값이나 코드 변경은 제안하지 않았습니다.`.slice(0, 12_000)
|
||||
: response.plan.explanation
|
||||
return {
|
||||
...response,
|
||||
usage,
|
||||
// Recovery is analysis-only, even if the model proposes actions despite the instruction.
|
||||
plan: {
|
||||
...response.plan,
|
||||
summary: stillRequesting ? confirmedReferences ? '추가 분석은 미완료이며 확인된 코드 위치를 안내합니다.' : '확보한 자료만으로는 답변을 확정하지 못했습니다.' : response.plan.summary,
|
||||
...(stillRequesting ? { codeReferences: confirmed?.plan.codeReferences ?? [] } : {}),
|
||||
explanation,
|
||||
requestedModules: [], requestedLogs: [], toolRequests: [], evidence: response.plan.evidence ?? context.toolResults, actionPlan: [], commands: [], changes: [], createModule: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const maxRounds = executeTool ? 8 : 5
|
||||
for (let round = 0; round <= maxRounds; round += 1) {
|
||||
checkAborted()
|
||||
onProgress?.(round === 0 ? '요청 분석 중' : '조회 결과 분석 중')
|
||||
const response = await requestPlan(context)
|
||||
checkAborted()
|
||||
addUsage(response)
|
||||
if (response.plan.codeReferences?.length) confirmedAnswer = response
|
||||
const modules = [...new Set(response.plan.requestedModules ?? [])]
|
||||
const logs = [...new Set(response.plan.requestedLogs ?? [])]
|
||||
const tools = response.plan.toolRequests ?? []
|
||||
onStep?.({ id: `analysis-${round}`, kind: 'analysis', status: 'ok', round: round + 1, requests: modules.length + logs.length + tools.length })
|
||||
if (!modules.length && !logs.length && !tools.length) return { ...response, usage, plan: { ...response.plan, ...(executeTool ? { evidence: response.plan.evidence ?? context.toolResults ?? [] } : {}) } }
|
||||
if (round === maxRounds) {
|
||||
context = fitAgentContext(cachedContext, [], [], tools)
|
||||
return finalize()
|
||||
}
|
||||
if (tools.length) {
|
||||
if (!executeTool || tools.length > 6) return finalize()
|
||||
const fresh = tools.filter(tool => {
|
||||
const missing = missingAgentRead(tool, cachedContext.toolResults ?? [])
|
||||
return missing && !queriedTools.has(fingerprint(missing))
|
||||
})
|
||||
if (!fresh.length || toolCount + fresh.length > 24) {
|
||||
context = fitAgentContext(cachedContext, [], [], tools)
|
||||
return finalize()
|
||||
}
|
||||
const results = [...(cachedContext.toolResults ?? [])]
|
||||
for (const [index, proposedTool] of fresh.entries()) {
|
||||
checkAborted()
|
||||
const tool = missingAgentRead(proposedTool, results)
|
||||
if (!tool) continue
|
||||
const key = fingerprint(tool)
|
||||
if (queriedTools.has(key)) continue
|
||||
queriedTools.add(key)
|
||||
onProgress?.(`${tool.moduleName ? `${tool.moduleName} · ` : ''}${tool.name} 조회 중`)
|
||||
const result = await executeTool({ ...tool, id: `query-${round}-${index}-${tool.id}`.slice(0, 80) })
|
||||
checkAborted()
|
||||
onStep?.(toolProgressStep(tool, result, `tool-${round}-${index}`))
|
||||
toolCount++
|
||||
results.push(result)
|
||||
}
|
||||
cachedContext = { ...cachedContext, toolResults: compactAgentEvidence(results) }
|
||||
context = fitAgentContext(cachedContext, [], [], tools)
|
||||
onProgress?.('조회 결과 분석 중')
|
||||
// A model may also use legacy source requests; process those on the next pass.
|
||||
continue
|
||||
}
|
||||
if (!loadRequestedContext) return finalize()
|
||||
|
||||
const nextRequest = {
|
||||
modules: modules.filter((name) => !requestedModules.has(name)),
|
||||
logs: logs.filter((name) => !requestedLogs.has(name))
|
||||
}
|
||||
if (!nextRequest.modules.length && !nextRequest.logs.length) {
|
||||
return finalize()
|
||||
}
|
||||
nextRequest.modules.forEach((name) => requestedModules.add(name))
|
||||
nextRequest.logs.forEach((name) => requestedLogs.add(name))
|
||||
onProgress?.('추가 자료 조회 중')
|
||||
const loaded = await loadRequestedContext(nextRequest)
|
||||
checkAborted()
|
||||
for (const name of nextRequest.modules) {
|
||||
const module = loaded.modules.find(module => module.name === name)
|
||||
const state = module?.fileContext?.status
|
||||
const status = state === 'error' ? 'error' : state === 'uninstalled' ? 'missing' : state === 'limited' ? 'partial' : module?.files.length ? 'ok' : 'empty'
|
||||
onStep?.({ id: `files-${round}-${name}`, kind: 'files', moduleName: name, status, count: module?.files.length ?? 0 })
|
||||
}
|
||||
for (const name of nextRequest.logs) {
|
||||
const log = loaded.runtimeLogs.find(log => log.moduleName === name)
|
||||
onStep?.({ id: `logs-${round}-${name}`, kind: 'logs', moduleName: name, status: log?.content ? 'ok' : 'empty' })
|
||||
}
|
||||
const loadedModules = new Map(loaded.modules.map((module) => [module.name, module]))
|
||||
const mergedModules = cachedContext.modules.map((module) => {
|
||||
const updated = loadedModules.get(module.name)
|
||||
loadedModules.delete(module.name)
|
||||
// Collectors also return metadata-only entries for other modules. Keep earlier sources.
|
||||
return updated && nextRequest.modules.includes(module.name)
|
||||
? { ...module, files: updated.files, fileContext: updated.fileContext }
|
||||
: module
|
||||
})
|
||||
mergedModules.push(...loadedModules.values())
|
||||
const mergedLogs = new Map(cachedContext.runtimeLogs.map((log) => [log.moduleName, log]))
|
||||
loaded.runtimeLogs.forEach((log) => {
|
||||
mergedLogs.delete(log.moduleName)
|
||||
mergedLogs.set(log.moduleName, log)
|
||||
})
|
||||
cachedContext = { ...cachedContext, modules: mergedModules, runtimeLogs: [...mergedLogs.values()] }
|
||||
context = fitAgentContext(cachedContext, nextRequest.modules, nextRequest.logs)
|
||||
}
|
||||
throw new Error('AI 분석을 완료하지 못했습니다.')
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { AgentConversationMessage } from './useAiAgent'
|
||||
|
||||
export type AgentConversationScope = { workspace: string; createdAt: number }
|
||||
export type AgentConversationContext = {
|
||||
environment: 'local' | 'remote'
|
||||
workspaceTarget: string
|
||||
summary: string
|
||||
recentMessages: Array<{ role: 'user' | 'assistant'; content: string }>
|
||||
}
|
||||
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000
|
||||
const RECENT_TURNS = 2
|
||||
const MESSAGE_LIMIT = 700
|
||||
const SUMMARY_LIMIT = 1000
|
||||
|
||||
const clip = (text: string, limit: number): string => text.length > limit ? `${text.slice(0, limit - 3)}...` : text
|
||||
|
||||
export function agentConversationWorkspace(environment?: 'local' | 'remote', target?: string, root?: string): string | undefined {
|
||||
if (!environment || !target || !root) return undefined
|
||||
const workspace = JSON.stringify([environment, target, root])
|
||||
return workspace.length <= 1000 ? workspace : undefined
|
||||
}
|
||||
|
||||
function responseContext(message: AgentConversationMessage, limit: number): string {
|
||||
const plan = message.plan!
|
||||
// Keep identities and outcomes, never replay source contents, logs, or executable payloads.
|
||||
const details = [
|
||||
...(plan.codeReferences ?? []).slice(0, 3).map(item => `${item.moduleName}/${item.path}:${item.startLine}-${item.endLine}`),
|
||||
...plan.changes.slice(0, 2).map(item => `${item.moduleName}/${item.path} (file change: ${message.applyState ?? 'ready'})`),
|
||||
...plan.commands.slice(0, 2).map(item => `${item.moduleName} (${clip(item.purpose, 60)}: ${message.commandExecutions?.[item.id]?.status ?? 'pending'})`)
|
||||
].join('; ')
|
||||
const targets = details ? `\nHistorical references/outcomes: ${clip(details, Math.min(240, Math.floor(limit / 2)))}` : ''
|
||||
const answer = (limit > 300 ? `${plan.summary}\n${plan.explanation}` : plan.summary).replace(/```[^\n]*\n[\s\S]*?(?:```|$)/g, '[code block omitted]')
|
||||
return `${clip(answer.trim(), limit - targets.length)}${targets}`
|
||||
}
|
||||
|
||||
export function buildAgentConversationContext(
|
||||
messages: AgentConversationMessage[],
|
||||
workspace: string | undefined,
|
||||
now = Date.now()
|
||||
): AgentConversationContext | undefined {
|
||||
if (!workspace) return undefined
|
||||
const [environment, workspaceTarget] = JSON.parse(workspace) as ['local' | 'remote', string, string]
|
||||
const turns: Array<{ user: AgentConversationMessage; assistant: AgentConversationMessage }> = []
|
||||
let summary = ''
|
||||
let user: AgentConversationMessage | undefined
|
||||
const inScope = (message: AgentConversationMessage): boolean => message.conversationScope?.workspace === workspace
|
||||
&& Number.isFinite(message.conversationScope.createdAt)
|
||||
&& message.conversationScope.createdAt <= now
|
||||
&& now - message.conversationScope.createdAt < MAX_AGE_MS
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.role === 'user') {
|
||||
user = inScope(message) ? message : undefined
|
||||
continue
|
||||
}
|
||||
if (!user || !inScope(message) || !message.plan || message.plan.toolRequests?.length
|
||||
|| message.plan.requestedModules.length || message.plan.requestedLogs.length) continue
|
||||
const previous = turns.at(-1)
|
||||
if (previous?.user === user) previous.assistant = message
|
||||
else turns.push({ user, assistant: message })
|
||||
if (turns.length > RECENT_TURNS) turns.shift()
|
||||
if (message.plan.conversationSummary?.trim()) summary = clip(message.plan.conversationSummary.trim(), SUMMARY_LIMIT)
|
||||
}
|
||||
if (!turns.length) return undefined
|
||||
const recent = turns.slice(-RECENT_TURNS)
|
||||
return {
|
||||
environment, workspaceTarget, summary,
|
||||
recentMessages: recent.flatMap(turn => [
|
||||
{ role: 'user' as const, content: clip(turn.user.content, MESSAGE_LIMIT) },
|
||||
{ role: 'assistant' as const, content: responseContext(turn.assistant, MESSAGE_LIMIT) }
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||
|
||||
type FileEvidence = {
|
||||
path: string; exists: boolean; content: string; hash: string | null
|
||||
complete: boolean; startLine: number; endLine: number; totalLines: number
|
||||
contentTruncated?: boolean
|
||||
}
|
||||
|
||||
export function readFileEvidence(result: AgentToolResult): FileEvidence | undefined {
|
||||
if (result.name !== 'files.read' || result.status !== 'ok') return
|
||||
try {
|
||||
const data = JSON.parse(result.data)
|
||||
if (typeof data.path !== 'string' || typeof data.content !== 'string' || typeof data.complete !== 'boolean'
|
||||
|| data.contentTruncated !== undefined && typeof data.contentTruncated !== 'boolean') return
|
||||
if (data.exists === false && data.complete === true && data.hash === null && data.content === '') {
|
||||
return { ...data, startLine: 1, endLine: 0, totalLines: 0 }
|
||||
}
|
||||
if (data.exists !== true || typeof data.hash !== 'string' || !/^[a-f0-9]{64}$/.test(data.hash)) return
|
||||
const startLine = data.startLine ?? 1
|
||||
const last = startLine + data.content.split('\n').length - 1
|
||||
// Older clients cannot distinguish a ranged read from a cut final line.
|
||||
const endLine = data.endLine ?? last - Number(result.truncated)
|
||||
if (!Number.isSafeInteger(startLine) || startLine < 1 || !Number.isSafeInteger(endLine)
|
||||
|| endLine < startLine - 1 || endLine > last || !Number.isSafeInteger(data.totalLines) || data.totalLines < 1 || endLine > data.totalLines
|
||||
|| data.contentTruncated === true && endLine >= last) return
|
||||
return { ...data, startLine, endLine }
|
||||
} catch { return }
|
||||
}
|
||||
|
||||
const fileKey = (result: AgentToolResult, file: FileEvidence): string => JSON.stringify([result.target, result.moduleName, file.path])
|
||||
|
||||
// Only combine observations of the same file revision on the same host.
|
||||
export function compactAgentEvidence(results: AgentToolResult[]): AgentToolResult[] {
|
||||
const compacted: AgentToolResult[] = []
|
||||
for (const result of results) {
|
||||
const file = readFileEvidence(result)
|
||||
if (!file) { compacted.push(result); continue }
|
||||
const key = fileKey(result, file)
|
||||
let current = result
|
||||
let currentFile = file
|
||||
for (let index = compacted.length - 1; index >= 0; index--) {
|
||||
const previous = compacted[index]
|
||||
const prior = readFileEvidence(previous)
|
||||
if (!prior || fileKey(previous, prior) !== key) continue
|
||||
if (prior.hash !== file.hash || prior.exists !== file.exists) {
|
||||
compacted.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
if (currentFile.complete) { compacted.splice(index, 1); continue }
|
||||
if (prior.complete) { current = previous; currentFile = prior; compacted.splice(index, 1); continue }
|
||||
if (prior.contentTruncated !== false || currentFile.contentTruncated !== false
|
||||
|| prior.totalLines !== currentFile.totalLines
|
||||
|| prior.endLine + 1 < currentFile.startLine || currentFile.endLine + 1 < prior.startLine) continue
|
||||
const lines = new Map<number, string>()
|
||||
let conflict = false
|
||||
for (const part of [prior, currentFile]) {
|
||||
for (const [offset, line] of part.content.split('\n').entries()) {
|
||||
const number = part.startLine + offset
|
||||
if (number > part.endLine) break
|
||||
if (lines.has(number) && lines.get(number) !== line) conflict = true
|
||||
lines.set(number, line)
|
||||
}
|
||||
}
|
||||
if (conflict) continue
|
||||
const startLine = Math.min(prior.startLine, currentFile.startLine)
|
||||
const endLine = Math.max(prior.endLine, currentFile.endLine)
|
||||
const content = Array.from({ length: endLine - startLine + 1 }, (_, offset) => lines.get(startLine + offset)!).join('\n')
|
||||
// Merged ranges remain read-only evidence: no unverified full-file hash promotion.
|
||||
const merged = { ...currentFile, startLine, endLine, content, complete: false, contentTruncated: false }
|
||||
const data = JSON.stringify(merged)
|
||||
if (content.length > 40_000 || data.length > 60_000) continue
|
||||
currentFile = merged
|
||||
current = { ...result, data, truncated: true }
|
||||
compacted.splice(index, 1)
|
||||
}
|
||||
compacted.push(current)
|
||||
}
|
||||
return compacted
|
||||
}
|
||||
|
||||
export function missingAgentRead(tool: AgentToolRequest, results: AgentToolResult[]): AgentToolRequest | undefined {
|
||||
if (tool.name !== 'files.read') return tool
|
||||
const observations = results.filter(result => result.moduleName === tool.moduleName)
|
||||
.flatMap(result => { const file = readFileEvidence(result); return file && file.path === tool.args?.path ? [{ file, target: result.target }] : [] })
|
||||
const latest = observations.at(-1)
|
||||
const files = observations.filter(item => item.target === latest?.target && item.file.hash === latest?.file.hash).map(item => item.file)
|
||||
if (files.some(file => file.complete)) return
|
||||
// An unbounded read may be needed for an approved edit; partial ranges do not replace it.
|
||||
if (tool.args?.startLine === undefined && tool.args?.lineCount === undefined) return tool
|
||||
const start = tool.args?.startLine ?? 1
|
||||
const total = files[0]?.totalLines
|
||||
const end = Math.min(start + (tool.args?.lineCount ?? 200) - 1, total ?? Infinity)
|
||||
let next = start
|
||||
const ranges = files.map(file => [file.startLine, file.endLine]).sort((a, b) => a[0] - b[0])
|
||||
for (const [from, to] of ranges) {
|
||||
if (from > next) return { ...tool, args: { ...tool.args, startLine: next, lineCount: Math.min(end, from - 1) - next + 1 } }
|
||||
if (to >= next) next = to + 1
|
||||
if (next > end) return
|
||||
}
|
||||
if (next > end) return
|
||||
return { ...tool, args: { ...tool.args, startLine: next, lineCount: end - next + 1 } }
|
||||
}
|
||||
|
||||
export function describeAgentEvidence(result: AgentToolResult): string {
|
||||
const file = readFileEvidence(result)
|
||||
return `${result.moduleName ? `${result.moduleName} · ` : ''}${result.name}: ${result.status}${file ? ` · ${file.path}${file.exists ? ` · ${file.startLine}–${file.endLine}/${file.totalLines}줄` : ' · 파일 없음'}` : ''}${result.truncated ? ' (일부 결과)' : ''} · ${result.target} · ${result.observedAt}`
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export const AGENT_PROVIDERS = ['A_COPILOT', 'OPENAI', 'ANTHROPIC', 'GEMINI', 'QWEN'] as const
|
||||
export type AgentProvider = typeof AGENT_PROVIDERS[number]
|
||||
export type AgentModelOption = { provider: AgentProvider; model: string; imagesEnabled: boolean }
|
||||
export type AgentStatus = {
|
||||
enabled: boolean
|
||||
provider: string | null
|
||||
model: string | null
|
||||
imagesEnabled?: boolean
|
||||
models: AgentModelOption[]
|
||||
selectionSupported: boolean
|
||||
}
|
||||
|
||||
export const AGENT_PROVIDER_LABELS: Record<AgentProvider, string> = {
|
||||
A_COPILOT: 'A-Copilot', OPENAI: 'OpenAI', ANTHROPIC: 'Claude', GEMINI: 'Gemini', QWEN: 'Qwen'
|
||||
}
|
||||
|
||||
export function normalizeAgentStatus(value: Partial<AgentStatus>): AgentStatus {
|
||||
const selectionSupported = Array.isArray(value.models)
|
||||
const candidates = value.enabled ? selectionSupported ? value.models! : [value] : []
|
||||
const models = AGENT_PROVIDERS.flatMap(provider => {
|
||||
const item = candidates.find(item => item.provider === provider && typeof item.model === 'string' && item.model.trim() && item.model.length <= 120)
|
||||
return item ? [{ provider, model: item.model!.trim(), imagesEnabled: provider === 'QWEN' && item.imagesEnabled === true }] : []
|
||||
})
|
||||
return { enabled: models.length > 0, provider: models[0]?.provider ?? null, model: models[0]?.model ?? null,
|
||||
imagesEnabled: models[0]?.imagesEnabled ?? false, models, selectionSupported }
|
||||
}
|
||||
|
||||
export function selectedAgentModel(models: AgentModelOption[], preference: string | null): AgentModelOption | null {
|
||||
return models.find(model => model.provider === preference) ?? models[0] ?? null
|
||||
}
|
||||
|
||||
export function agentModelLabel(model: AgentModelOption): string {
|
||||
return model.provider === 'A_COPILOT' ? 'A-Copilot' : `${AGENT_PROVIDER_LABELS[model.provider]} · ${model.model}`
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { AgentToolResult } from '../../../../shared/agentTools'
|
||||
import type { ModuleRow } from './types'
|
||||
|
||||
// The same module sequences are documented in the workspace module guide.
|
||||
export const AGENT_PRACTICE_PROFILES = [
|
||||
{ id: 'local-2d', environment: 'local', platform: 'turtlebot3', level: '2d', modules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
|
||||
{ id: 'mkmini-2d', environment: 'remote', platform: 'mkmini', level: '2d', modules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
|
||||
{ id: 'mkmini-3d', environment: 'remote', platform: 'mkmini', level: '3d', modules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'], topics: ['/lidar_filtered', '/odom', '/cloud_map'], frames: [['map', 'odom'], ['odom', 'base_link']] },
|
||||
{ id: 'yahboom-2d', environment: 'remote', platform: 'yahboom', level: '2d', modules: ['NAV2-Yahboom-Agent', 'NAV2-Yahboom', 'NAV2-Yahboom-Slam'], topics: ['/scan', '/odom', '/map'], frames: [['map', 'odom'], ['odom', 'base_link']] }
|
||||
] as const
|
||||
|
||||
export function inspectAgentPractice(profileId: string, modules: ModuleRow[], environment: 'local' | 'remote') {
|
||||
const profile = AGENT_PRACTICE_PROFILES.find(item => item.id === profileId)
|
||||
if (!profile) throw new Error('지원하지 않는 실습 구성입니다.')
|
||||
const blockers: string[] = []
|
||||
if (profile.environment !== environment) blockers.push(`현재 ${environment} 환경에서는 ${profile.id} 구성을 실행할 수 없습니다.`)
|
||||
const required = profile.modules.map(name => {
|
||||
const candidates = modules.filter(item => item.name === name)
|
||||
const module = candidates[0]
|
||||
if (candidates.length > 1) blockers.push(`${name}: 같은 이름의 모듈이 여러 개입니다.`)
|
||||
if (!module || module.state === 'uninstalled') blockers.push(`${name}: 필수 모듈이 설치되지 않았습니다.`)
|
||||
if (module) {
|
||||
const params = new Map(module.parameters.map(parameter => {
|
||||
const separator = parameter.indexOf(':=')
|
||||
return [parameter.slice(0, separator), parameter.slice(separator + 2)]
|
||||
}))
|
||||
const expectedMode = environment === 'local' ? 'true' : 'false'
|
||||
for (const [key, expected] of [['sim_mode', expectedMode], ['platform', profile.platform], ['robot', profile.platform], ['level', profile.level]]) {
|
||||
if (params.has(key) && params.get(key) !== expected) blockers.push(`${name}: ${key}:=${params.get(key)} (필요 값: ${expected})`)
|
||||
}
|
||||
// Never guess hardware/simulation mode or start localization on an existing map.
|
||||
if ((module.category.toLowerCase() === 'vehicle' || name === 'NAV2-MKMini' || name === 'NAV2-Yahboom') && !params.has('sim_mode')) blockers.push(`${name}: sim_mode 설정을 확인해야 합니다.`)
|
||||
if (params.get('localization') === 'true') blockers.push(`${name}: 기존 지도 위치추정 모드입니다. 새 지도 SLAM 모드인지 확인해주세요.`)
|
||||
}
|
||||
return { name, state: module?.state ?? 'missing', parameters: module?.parameters ?? [] }
|
||||
})
|
||||
const conflicting = modules.filter(module => module.state === 'running' && !profile.modules.some(name => name === module.name)
|
||||
&& module.name !== 'NAV2-Camera'
|
||||
&& ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control'].includes(module.category.toLowerCase()))
|
||||
if (conflicting.length) blockers.push(`다른 실습 모듈이 실행 중입니다: ${conflicting.map(module => module.name).join(', ')}. 자동 종료하지 않습니다.`)
|
||||
return { profile, required, blockers, canPrepare: !blockers.length, starts: required.filter(module => module.state === 'stopped').map(module => module.name) }
|
||||
}
|
||||
|
||||
export function verifyAgentPractice(profileId: string, probe: AgentToolResult): { ready: boolean; failures: string[]; evidence: unknown } {
|
||||
const profile = AGENT_PRACTICE_PROFILES.find(item => item.id === profileId)
|
||||
if (!profile || probe.status !== 'ok' || probe.truncated) return { ready: false, failures: ['ROS 상태를 확인하지 못했습니다.'], evidence: probe }
|
||||
let data: { topics?: Array<{ name: string; publishers: number; samples: number; validSamples: number; frameIds?: string[]; error?: string }>; tfEdges?: Array<{ parent: string; child: string }> }
|
||||
try { data = JSON.parse(probe.data) } catch { return { ready: false, failures: ['ROS 조회 결과 형식이 올바르지 않습니다.'], evidence: probe } }
|
||||
if (!data || !Array.isArray(data.topics) || !Array.isArray(data.tfEdges)) return { ready: false, failures: ['ROS 조회 결과 형식이 올바르지 않습니다.'], evidence: probe }
|
||||
const failures: string[] = []
|
||||
for (const name of profile.topics) {
|
||||
const topic = data.topics?.find(topic => topic.name === name)
|
||||
if (!topic || ![topic.publishers, topic.samples, topic.validSamples].every(value => Number.isFinite(value) && value > 0) || topic.error) failures.push(`${name}: 관찰 구간에서 정상 수신을 확인하지 못했습니다.`)
|
||||
}
|
||||
const edges = data.tfEdges.filter(edge => edge && typeof edge.parent === 'string' && typeof edge.child === 'string')
|
||||
const reaches = (source: string, target: string): boolean => {
|
||||
const queue = [source]
|
||||
const seen = new Set<string>()
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!
|
||||
if (current === target) return true
|
||||
if (seen.has(current)) continue
|
||||
seen.add(current)
|
||||
edges.filter(edge => edge.parent.replace(/^\//, '') === current).forEach(edge => queue.push(edge.child.replace(/^\//, '')))
|
||||
}
|
||||
return false
|
||||
}
|
||||
for (const [source, target] of profile.frames) if (!reaches(source, target)) failures.push(`TF ${source} → ${target}: 연결을 확인하지 못했습니다.`)
|
||||
const sensor = data.topics?.find(topic => topic.name === profile.topics[0])
|
||||
if (!Array.isArray(sensor?.frameIds) || !sensor.frameIds.some(frame => typeof frame === 'string' && reaches('base_link', frame.replace(/^\//, '')))) failures.push('센서 프레임과 base_link의 TF 연결을 확인하지 못했습니다.')
|
||||
return { ready: !failures.length, failures, evidence: probe }
|
||||
}
|
||||
|
||||
export async function prepareAgentPractice(options: {
|
||||
profileId: string
|
||||
environment: 'local' | 'remote'
|
||||
approvedStarts: string[]
|
||||
approvedParameters?: Record<string, string[]>
|
||||
list: () => Promise<ModuleRow[]>
|
||||
start: (module: ModuleRow) => Promise<boolean>
|
||||
probe: (module: ModuleRow, topics: string[]) => Promise<AgentToolResult>
|
||||
progress: (text: string, steps?: Array<{ moduleName: string; status: string }>) => void
|
||||
signal: AbortSignal
|
||||
pause?: () => Promise<void>
|
||||
}) {
|
||||
const assertActive = (): void => { if (options.signal.aborted) throw new DOMException('Aborted', 'AbortError') }
|
||||
assertActive()
|
||||
let modules = await options.list()
|
||||
assertActive()
|
||||
const inspection = inspectAgentPractice(options.profileId, modules, options.environment)
|
||||
if (inspection.blockers.length) throw new Error(inspection.blockers.join('\n'))
|
||||
if (inspection.starts.some(name => !options.approvedStarts.includes(name))) throw new Error('승인 이후 시작 대상이 달라졌습니다. 다시 승인해주세요.')
|
||||
const steps: Array<{ moduleName: string; status: string }> = []
|
||||
const assertParameters = (modules: ModuleRow[]): void => {
|
||||
for (const name of inspection.profile.modules) {
|
||||
if (options.approvedParameters && JSON.stringify([...(modules.find(module => module.name === name)?.parameters ?? [])].sort()) !== JSON.stringify([...(options.approvedParameters[name] ?? [])].sort())) throw new Error(`${name}: 승인 이후 실행 파라미터가 바뀌었습니다. 다시 승인해주세요.`)
|
||||
}
|
||||
}
|
||||
assertParameters(modules)
|
||||
for (const name of inspection.profile.modules) {
|
||||
assertActive()
|
||||
modules = await options.list()
|
||||
assertActive()
|
||||
const latest = inspectAgentPractice(options.profileId, modules, options.environment)
|
||||
assertParameters(modules)
|
||||
if (latest.blockers.length) throw new Error(latest.blockers.join('\n'))
|
||||
const module = modules.find(module => module.name === name)!
|
||||
if (module.state === 'running') { steps.push({ moduleName: name, status: 'already-running' }); continue }
|
||||
if (!options.approvedStarts.includes(name)) throw new Error(`승인받지 않은 시작 작업입니다: ${name}`)
|
||||
options.progress(`${name} 시작 중`, steps)
|
||||
if (!await options.start(module)) return { ready: false, steps: [...steps, { moduleName: name, status: 'failed' }], failures: [`${name} 시작에 실패했습니다. 이미 시작된 모듈은 자동 종료하지 않았습니다.`] }
|
||||
steps.push({ moduleName: name, status: 'started' })
|
||||
options.progress(`${name} 시작 완료`, steps)
|
||||
if (module.category.toLowerCase() === 'simulator') await (options.pause?.() ?? new Promise(resolve => setTimeout(resolve, 4000)))
|
||||
}
|
||||
const topics = [...inspection.profile.topics, '/tf', '/tf_static']
|
||||
let verification: ReturnType<typeof verifyAgentPractice> | undefined
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
assertActive()
|
||||
options.progress(`SLAM 데이터·TF 확인 중 (${attempt + 1}/3)`, steps)
|
||||
modules = await options.list()
|
||||
assertActive()
|
||||
const latest = inspectAgentPractice(options.profileId, modules, options.environment)
|
||||
assertParameters(modules)
|
||||
if (latest.blockers.length || latest.required.some(module => module.state !== 'running')) return { ready: false, steps, failures: [...latest.blockers, '필수 모듈의 실행 상태를 확인해주세요.'] }
|
||||
verification = verifyAgentPractice(options.profileId, await options.probe(modules.find(module => module.name === inspection.profile.modules.at(-1))!, topics))
|
||||
assertActive()
|
||||
if (verification.ready) break
|
||||
if (attempt < 2) await (options.pause?.() ?? new Promise(resolve => setTimeout(resolve, 2000)))
|
||||
}
|
||||
return { ...verification!, steps }
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'
|
||||
import { createAgentSessionSync } from './agentSessionSync'
|
||||
import { agentPlanRequestText, recoverInterruptedAgentMessages, resolveAgentContext, type AgentContext, type AgentContextRequest, type AgentPlanResponse, type AgentProgressStep } from './agentContext'
|
||||
import type { AgentImageReference, ChatAttachment } from '../../components/chatAttachments'
|
||||
import type { AgentOperation, AgentToolRequest, AgentToolResult } from '../../../../shared/agentTools'
|
||||
import { normalizeAgentStatus, selectedAgentModel, type AgentStatus } from './agentModels'
|
||||
import { agentConversationWorkspace, buildAgentConversationContext, type AgentConversationScope } from './agentConversationContext'
|
||||
|
||||
export type AgentFileChange = {
|
||||
expectedHash?: string | null
|
||||
moduleName: string
|
||||
path: string
|
||||
content: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
export type AgentCodeReference = {
|
||||
id: string
|
||||
moduleName: string
|
||||
path: string
|
||||
startLine: number
|
||||
endLine: number
|
||||
reason: string
|
||||
expectedHash?: string
|
||||
snippet?: string
|
||||
}
|
||||
|
||||
export type AgentCodeReferenceState = {
|
||||
status: 'pending' | 'opening' | 'opened' | 'cancelled' | 'error'
|
||||
error?: string
|
||||
}
|
||||
|
||||
export type AgentModulePlan = {
|
||||
name: string
|
||||
category: string
|
||||
@@ -18,6 +40,7 @@ export type AgentModulePlan = {
|
||||
}
|
||||
|
||||
export type AgentCommand = {
|
||||
operation?: AgentOperation
|
||||
id: string
|
||||
moduleName: string
|
||||
command: string
|
||||
@@ -32,6 +55,10 @@ export type AgentCommandExecution = {
|
||||
}
|
||||
|
||||
export type AgentPlan = {
|
||||
conversationSummary?: string
|
||||
codeReferences?: AgentCodeReference[]
|
||||
toolRequests?: AgentToolRequest[]
|
||||
evidence?: AgentToolResult[]
|
||||
summary: string
|
||||
explanation: string
|
||||
requestedModules: string[]
|
||||
@@ -43,10 +70,15 @@ export type AgentPlan = {
|
||||
}
|
||||
|
||||
export type AgentWorkspaceModule = {
|
||||
parameters?: string[]
|
||||
name: string
|
||||
folderName?: string
|
||||
category?: string
|
||||
state?: string
|
||||
fileContext?: {
|
||||
status: 'loaded' | 'empty' | 'error' | 'uninstalled' | 'limited'
|
||||
message?: string
|
||||
}
|
||||
files: Array<{ path: string; content: string }>
|
||||
}
|
||||
|
||||
@@ -58,15 +90,20 @@ export type AgentRuntimeLog = {
|
||||
}
|
||||
|
||||
export type AgentConversationMessage = {
|
||||
conversationScope?: AgentConversationScope
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
provider?: string
|
||||
model?: string
|
||||
revealStartedAt?: number
|
||||
durationMs?: number
|
||||
imageAttachments?: AgentImageReference[]
|
||||
plan?: AgentPlan
|
||||
applyState?: 'ready' | 'applying' | 'applied' | 'error'
|
||||
applyState?: 'ready' | 'applying' | 'applied' | 'error' | 'cancelled'
|
||||
applyError?: string
|
||||
commandExecutions?: Record<string, AgentCommandExecution>
|
||||
codeReferenceStates?: Record<string, AgentCodeReferenceState>
|
||||
usage?: {
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
@@ -84,12 +121,6 @@ type UseAiAgentOptions = {
|
||||
onResponse?: () => void
|
||||
}
|
||||
|
||||
type AgentStatus = {
|
||||
enabled: boolean
|
||||
provider: string | null
|
||||
model: string | null
|
||||
}
|
||||
|
||||
type AgentSessionResponse = {
|
||||
sessionId: string | null
|
||||
messages: AgentConversationMessage[]
|
||||
@@ -98,26 +129,47 @@ type AgentSessionResponse = {
|
||||
const sessionSyncs = new Map<string, ReturnType<typeof createAgentSessionSync>>()
|
||||
|
||||
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 [historyLoading, setHistoryLoading] = useState(Boolean(accessToken && courseId))
|
||||
const [historyError, setHistoryError] = useState<string | null>(null)
|
||||
const [pending, setPending] = useState(false)
|
||||
const [activity, setActivity] = useState('')
|
||||
const [progressSteps, setProgressSteps] = useState<AgentProgressStep[]>([])
|
||||
const [requestStartedAt, setRequestStartedAt] = useState<number | null>(null)
|
||||
const [messages, setMessages] = useState<AgentConversationMessage[]>([])
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const applyingRef = useRef(false)
|
||||
const navigatingRef = useRef(false)
|
||||
const tokenReaderRef = useRef(getFreshAccessToken)
|
||||
tokenReaderRef.current = getFreshAccessToken
|
||||
const onResponseRef = useRef(onResponse)
|
||||
onResponseRef.current = onResponse
|
||||
const authenticated = Boolean(accessToken && courseId && studentEmail)
|
||||
|
||||
const selectProvider = (provider: string): void => {
|
||||
if (pending || applyingRef.current || abortControllerRef.current || !status.models.some(model => model.provider === provider)) return
|
||||
setModelPreference({ scope: modelPreferenceKey, provider })
|
||||
try { window.localStorage.setItem(modelPreferenceKey, provider) } catch { /* Selection still works without persistent storage. */ }
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let provider: string | null = null
|
||||
try { provider = window.localStorage.getItem(modelPreferenceKey) } catch { /* Use the first available model. */ }
|
||||
setModelPreference({ scope: modelPreferenceKey, provider })
|
||||
}, [modelPreferenceKey])
|
||||
|
||||
const requestApi = useCallback(async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
||||
const authorization = new Headers(options?.headers).get('Authorization')
|
||||
?? `Bearer ${await tokenReaderRef.current()}`
|
||||
const response = await fetch(`${apiBaseUrl}${path}`, {
|
||||
...options,
|
||||
headers: {
|
||||
...(options?.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(options?.body && !(options.body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
|
||||
Authorization: authorization,
|
||||
...options?.headers
|
||||
}
|
||||
@@ -125,6 +177,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { message?: string | string[] } | null
|
||||
const message = Array.isArray(payload?.message) ? payload.message.join('\n') : payload?.message
|
||||
if (response.status === 400 && message?.split('\n').includes('property attachments should not exist')) {
|
||||
throw new Error('연결된 서버가 에이전트 파일 첨부를 지원하지 않습니다. 백엔드를 업데이트한 뒤 다시 시도하거나 첨부 없이 질문해 주세요.')
|
||||
}
|
||||
if (response.status === 400 && /property (?:provider|toolProtocol|toolResults|environment|workspaceTarget|contextNotes|imageTokens|conversationContext) should not exist/.test(message ?? '')) throw new Error('연결된 서버가 에이전트 요청 형식을 지원하지 않습니다. 백엔드를 먼저 업데이트해주세요.')
|
||||
throw new Error(message ?? 'AI 에이전트 요청을 처리하지 못했습니다.')
|
||||
}
|
||||
return response.json() as Promise<T>
|
||||
@@ -138,7 +194,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
body: JSON.stringify({
|
||||
courseId,
|
||||
week: Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : undefined,
|
||||
messages: batch
|
||||
messages: batch.map(({ revealStartedAt: _revealStartedAt, ...message }) => message)
|
||||
})
|
||||
})
|
||||
let sync = sessionSyncs.get(scope)
|
||||
@@ -162,32 +218,51 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !courseId) {
|
||||
setStatus({ enabled: false, provider: null, model: null })
|
||||
setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
|
||||
setStatusLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
setStatusLoading(true)
|
||||
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
|
||||
.then((nextStatus) => {
|
||||
if (!cancelled) setStatus(nextStatus)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setStatus({ enabled: false, provider: null, model: null })
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setStatusLoading(false)
|
||||
})
|
||||
let revision = 0
|
||||
let initialLoad = true
|
||||
const refresh = (): void => {
|
||||
const requestRevision = ++revision
|
||||
if (initialLoad) setStatusLoading(true)
|
||||
requestApi<AgentStatus>(`/chat/agent/status?courseId=${encodeURIComponent(courseId)}`)
|
||||
.then(nextStatus => {
|
||||
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus(nextStatus) })
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled && requestRevision === revision) setStatusSnapshot({ scope: modelPreferenceKey, status: normalizeAgentStatus({}) })
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled && requestRevision === revision) {
|
||||
initialLoad = false
|
||||
setStatusLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
const refreshVisible = (): void => { if (!document.hidden) refresh() }
|
||||
refresh()
|
||||
window.addEventListener('focus', refresh)
|
||||
document.addEventListener('visibilitychange', refreshVisible)
|
||||
const timer = window.setInterval(refreshVisible, 30_000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.removeEventListener('focus', refresh)
|
||||
document.removeEventListener('visibilitychange', refreshVisible)
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [accessToken, courseId, requestApi, weekNumber])
|
||||
}, [accessToken, courseId, modelPreferenceKey, requestApi, weekNumber])
|
||||
|
||||
// The workspace stays mounted while other app pages or Q&A tabs are shown.
|
||||
// Request cancellation follows the conversation lifetime, not its visibility.
|
||||
useEffect(() => {
|
||||
setPending(false)
|
||||
setRequestStartedAt(null)
|
||||
setProgressSteps([])
|
||||
setActivity('')
|
||||
return () => {
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
@@ -195,7 +270,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
}, [authenticated, session])
|
||||
|
||||
useEffect(() => {
|
||||
const cached = authenticated ? session.restore([]) : []
|
||||
const cached = authenticated ? recoverInterruptedAgentMessages(session.restore([])) : []
|
||||
messageStateRef.current = { session, messages: cached }
|
||||
setMessages(cached)
|
||||
setHistoryError(null)
|
||||
@@ -210,7 +285,7 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
requestApi<AgentSessionResponse>(`/chat/agent/session?${query.toString()}`)
|
||||
.then((response) => {
|
||||
if (!cancelled) {
|
||||
const restored = session.restore(Array.isArray(response.messages) ? response.messages : [])
|
||||
const restored = recoverInterruptedAgentMessages(session.restore(Array.isArray(response.messages) ? response.messages : []))
|
||||
messageStateRef.current = { session, messages: restored }
|
||||
setMessages(restored)
|
||||
}
|
||||
@@ -243,47 +318,83 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
runtimeLogs: AgentRuntimeLog[] = [],
|
||||
options: {
|
||||
showUserMessage?: boolean
|
||||
loadRequestedContext?: (request: { modules: string[]; logs: string[] }) => Promise<{
|
||||
modules: AgentWorkspaceModule[]
|
||||
runtimeLogs: AgentRuntimeLog[]
|
||||
}>
|
||||
attachments?: ChatAttachment[]
|
||||
loadInitialContext?: (signal: AbortSignal) => Promise<AgentContext>
|
||||
loadRequestedContext?: (request: AgentContextRequest) => Promise<AgentContext>
|
||||
executeTool?: (request: AgentToolRequest) => Promise<AgentToolResult>
|
||||
environment?: 'local' | 'remote'
|
||||
workspaceTarget?: string
|
||||
workspaceRoot?: string
|
||||
toolResults?: AgentToolResult[]
|
||||
} = {}
|
||||
): Promise<void> => {
|
||||
const request = rawRequest.trim()
|
||||
if (!request || pending || historyLoading || 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 = {
|
||||
id: `agent-user-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: request
|
||||
content: request,
|
||||
...(conversationScope ? { conversationScope } : {})
|
||||
}
|
||||
if (options.showUserMessage !== false) updateMessages((current) => [...current, userMessage])
|
||||
setPending(true)
|
||||
setRequestStartedAt(startedAt)
|
||||
setProgressSteps([])
|
||||
setActivity('요청 분석 중')
|
||||
const abortController = new AbortController()
|
||||
abortControllerRef.current = abortController
|
||||
const currentRequest = (): boolean => !abortController.signal.aborted && abortControllerRef.current === abortController && messageStateRef.current.session === session
|
||||
const appendStep = (step: AgentProgressStep): void => {
|
||||
if (currentRequest()) setProgressSteps(current => [...current, step])
|
||||
}
|
||||
|
||||
try {
|
||||
let response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', {
|
||||
method: 'POST',
|
||||
let initialContext: AgentContext = { modules, runtimeLogs, ...(options.toolResults?.length ? { toolResults: options.toolResults } : {}) }
|
||||
if (options.loadInitialContext) {
|
||||
setActivity('모듈 상태 조회 중')
|
||||
const loaded = await options.loadInitialContext(abortController.signal)
|
||||
if (!currentRequest()) return
|
||||
initialContext = { ...initialContext, ...loaded }
|
||||
}
|
||||
const textAttachments = (options.attachments ?? []).flatMap(file => file.kind === 'image' ? [] : [{ name: file.name, content: file.content }])
|
||||
const imageAttachments: AgentImageReference[] = []
|
||||
for (const file of options.attachments ?? []) {
|
||||
if (file.kind !== 'image') continue
|
||||
if (!requestModel.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다.')
|
||||
setActivity(`이미지 업로드 중: ${file.name}`)
|
||||
const body = new FormData()
|
||||
body.append('courseId', courseId)
|
||||
if (selection.provider) body.append('provider', selection.provider)
|
||||
if (Number.isInteger(weekNumber) && weekNumber >= 1) body.append('week', String(weekNumber))
|
||||
body.append('file', file.file, file.name)
|
||||
const uploaded = await requestApi<AgentImageReference>('/chat/agent/attachments/images', { method: 'POST', body, signal: abortController.signal })
|
||||
if (!currentRequest()) return
|
||||
imageAttachments.push(uploaded)
|
||||
if (options.showUserMessage !== false) updateMessages(current => current.map(item => item.id === userMessage.id ? { ...item, imageAttachments: [...imageAttachments] } : item))
|
||||
appendStep({ id: `image-${imageAttachments.length}`, kind: 'image', status: 'ok', name: file.name })
|
||||
}
|
||||
const imageTokens = imageAttachments.map(image => image.token)
|
||||
setActivity('요청 분석 중')
|
||||
const response = await resolveAgentContext({
|
||||
initialContext,
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({ courseId, week: weekNumber, request, modules, runtimeLogs })
|
||||
})
|
||||
const requestedModules = response.plan.requestedModules ?? []
|
||||
const requestedLogs = response.plan.requestedLogs ?? []
|
||||
if ((requestedModules.length || requestedLogs.length) && options.loadRequestedContext) {
|
||||
const requestedContext = await options.loadRequestedContext({ modules: requestedModules, logs: requestedLogs })
|
||||
response = await requestApi<{ provider: string; model: string; usage?: AgentConversationMessage['usage']; plan: AgentPlan }>('/chat/agent/plan', {
|
||||
loadRequestedContext: options.loadRequestedContext,
|
||||
executeTool: options.executeTool,
|
||||
onProgress: label => { if (currentRequest()) setActivity(label) },
|
||||
onStep: appendStep,
|
||||
requestPlan: (context, finalize) => requestApi<AgentPlanResponse>('/chat/agent/plan', {
|
||||
method: 'POST',
|
||||
signal: abortController.signal,
|
||||
body: JSON.stringify({
|
||||
courseId,
|
||||
week: weekNumber,
|
||||
request,
|
||||
modules: requestedContext.modules,
|
||||
runtimeLogs: requestedContext.runtimeLogs
|
||||
})
|
||||
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 = {
|
||||
...response.plan,
|
||||
requestedModules: response.plan.requestedModules ?? [],
|
||||
@@ -295,7 +406,10 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
updateMessages((current) => [...current, {
|
||||
id: `agent-plan-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
revealStartedAt: Date.now(),
|
||||
durationMs: Math.max(0, Date.now() - startedAt),
|
||||
content: plan.summary,
|
||||
...(conversationScope ? { conversationScope } : {}),
|
||||
provider: response.provider,
|
||||
model: response.model,
|
||||
usage: response.usage,
|
||||
@@ -305,24 +419,32 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
}])
|
||||
onResponseRef.current?.()
|
||||
} catch (error) {
|
||||
if (abortController.signal.aborted) return
|
||||
if (!currentRequest()) return
|
||||
updateMessages((current) => [...current, {
|
||||
id: `agent-error-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
revealStartedAt: Date.now(),
|
||||
durationMs: Math.max(0, Date.now() - startedAt),
|
||||
content: error instanceof Error ? error.message : 'AI 변경안을 만들지 못했습니다.'
|
||||
}])
|
||||
onResponseRef.current?.()
|
||||
} finally {
|
||||
if (abortControllerRef.current === abortController) {
|
||||
abortControllerRef.current = null
|
||||
setPending(false)
|
||||
setRequestStartedAt(null)
|
||||
setActivity('')
|
||||
setProgressSteps([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const applyPlan = async (messageId: string, apply: (plan: AgentPlan) => Promise<void>): Promise<void> => {
|
||||
const message = messages.find((item) => item.id === messageId)
|
||||
if (!message?.plan || message.applyState === 'applying' || message.applyState === 'applied') return
|
||||
if (messageStateRef.current.session !== session) return
|
||||
const message = messageStateRef.current.messages.find((item) => item.id === messageId)
|
||||
if (applyingRef.current || !message?.plan || message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled') return
|
||||
if (Object.values(message.commandExecutions ?? {}).some((execution) => execution.status === 'running')) return
|
||||
applyingRef.current = true
|
||||
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'applying', applyError: undefined } : item))
|
||||
try {
|
||||
await apply(message.plan)
|
||||
@@ -340,13 +462,36 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
} catch (error) {
|
||||
const applyError = error instanceof Error ? error.message : '변경안을 적용하지 못했습니다.'
|
||||
updateMessages((current) => current.map((item) => item.id === messageId ? { ...item, applyState: 'error', applyError } : item))
|
||||
} finally {
|
||||
applyingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelPlan = (messageId: string): boolean => {
|
||||
if (messageStateRef.current.session !== session || applyingRef.current || abortControllerRef.current) return false
|
||||
const message = messageStateRef.current.messages.find((item) => item.id === messageId)
|
||||
if (!message?.plan || (!message.plan.changes.length && !message.plan.createModule)
|
||||
|| message.applyState === 'applying' || message.applyState === 'applied' || message.applyState === 'cancelled'
|
||||
|| Object.values(message.commandExecutions ?? {}).some(execution => execution.status === 'running')) return false
|
||||
updateMessages(current => current.map(item => item.id === messageId ? {
|
||||
...item,
|
||||
applyState: 'cancelled',
|
||||
applyError: undefined,
|
||||
commandExecutions: Object.fromEntries(item.plan!.commands.map(command => {
|
||||
const execution = item.commandExecutions?.[command.id]
|
||||
return [command.id, execution?.status === 'completed' ? execution : { ...execution, status: 'rejected' as const, error: undefined }]
|
||||
}))
|
||||
} : item))
|
||||
return true
|
||||
}
|
||||
|
||||
const stop = (): void => {
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
setPending(false)
|
||||
setRequestStartedAt(null)
|
||||
setActivity('')
|
||||
setProgressSteps([])
|
||||
}
|
||||
|
||||
const updateCommandExecution = (messageId: string, commandId: string, execution: AgentCommandExecution): void => {
|
||||
@@ -355,12 +500,34 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
...message,
|
||||
commandExecutions: {
|
||||
...message.commandExecutions,
|
||||
[commandId]: execution
|
||||
[commandId]: { ...message.commandExecutions?.[commandId], ...(execution.status === 'running' ? { error: undefined } : {}), ...execution }
|
||||
}
|
||||
}
|
||||
: message))
|
||||
}
|
||||
|
||||
const openCodeReference = async (messageId: string, referenceId: string, open: (reference: AgentCodeReference, isCurrent: () => boolean) => Promise<void>): Promise<void> => {
|
||||
if (navigatingRef.current || applyingRef.current || messageStateRef.current.session !== session) return
|
||||
const message = messageStateRef.current.messages.find(message => message.id === messageId)
|
||||
const reference = message?.plan?.codeReferences?.find(reference => reference.id === referenceId)
|
||||
if (!reference) return
|
||||
const latestUserMessageId = messageStateRef.current.messages.findLast(message => message.role === 'user')?.id
|
||||
const isCurrent = (): boolean => messageStateRef.current.session === session && messageStateRef.current.messages.some(message => message.id === messageId)
|
||||
const update = (state: AgentCodeReferenceState): void => {
|
||||
if (isCurrent()) updateMessages(current => current.map(message => message.id === messageId ? { ...message, codeReferenceStates: { ...message.codeReferenceStates, [referenceId]: state } } : message))
|
||||
}
|
||||
navigatingRef.current = true
|
||||
update({ status: 'opening' })
|
||||
try {
|
||||
await open(reference, () => isCurrent() && messageStateRef.current.messages.findLast(message => message.role === 'user')?.id === latestUserMessageId && !applyingRef.current)
|
||||
update({ status: 'opened' })
|
||||
} catch (error) {
|
||||
update({ status: 'error', error: error instanceof Error ? error.message : '코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.' })
|
||||
} finally {
|
||||
navigatingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
const clear = (): void => {
|
||||
if (!authenticated || historyLoading) return
|
||||
stop()
|
||||
@@ -384,13 +551,22 @@ export function useAiAgent({ accessToken, studentEmail, courseId, weekNumber, ap
|
||||
|
||||
return {
|
||||
...status,
|
||||
provider: selectedModel?.provider ?? null,
|
||||
model: selectedModel?.model ?? null,
|
||||
imagesEnabled: selectedModel?.imagesEnabled ?? false,
|
||||
selectProvider,
|
||||
statusLoading: statusLoading || historyLoading,
|
||||
pending,
|
||||
activity,
|
||||
progressSteps,
|
||||
requestStartedAt,
|
||||
messages,
|
||||
persistenceError: persistenceError ?? historyError,
|
||||
requestPlan,
|
||||
applyPlan,
|
||||
cancelPlan,
|
||||
updateCommandExecution,
|
||||
openCodeReference,
|
||||
stop,
|
||||
clear
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export type QaChatMessage = {
|
||||
createdAt?: string
|
||||
senderAvatarUrl?: string | null
|
||||
isTyping?: boolean
|
||||
revealStartedAt?: number
|
||||
keepExpanded?: boolean
|
||||
status?: 'ACTIVE' | 'CANCELED'
|
||||
}
|
||||
@@ -55,6 +56,7 @@ type UseQaChatOptions = {
|
||||
courseId: string
|
||||
weekNumber: number
|
||||
globalScope?: boolean
|
||||
revealInView?: boolean
|
||||
apiBaseUrl: string
|
||||
socketBaseUrl: string
|
||||
socketPath: string
|
||||
@@ -77,6 +79,7 @@ export function useQaChat({
|
||||
courseId,
|
||||
weekNumber,
|
||||
globalScope = false,
|
||||
revealInView = false,
|
||||
apiBaseUrl,
|
||||
socketBaseUrl,
|
||||
socketPath,
|
||||
@@ -369,6 +372,17 @@ export function useQaChat({
|
||||
})
|
||||
expandedAiMessageIdsRef.current.add(response.message.id)
|
||||
setAiPending(false)
|
||||
if (revealInView) {
|
||||
setAiMessages((messages) => [...messages, {
|
||||
id: response.message.id,
|
||||
role: 'assistant',
|
||||
content: fullContent,
|
||||
createdAt: response.message.createdAt,
|
||||
revealStartedAt: Date.now(),
|
||||
keepExpanded: true
|
||||
}])
|
||||
return
|
||||
}
|
||||
setAiTypingMessageId(response.message.id)
|
||||
setAiMessages((messages) => [
|
||||
...messages,
|
||||
|
||||
@@ -11,8 +11,10 @@ type ModuleActionResult = {
|
||||
ok: boolean
|
||||
message?: string
|
||||
}
|
||||
type ModuleControlOptions = { background?: boolean; freshSlam?: boolean; onResult?: (result: ModuleActionResult) => void }
|
||||
|
||||
type SavedMapPathOverrides = {
|
||||
freshSlam?: boolean
|
||||
savedMapYamlPath?: string | null
|
||||
savedMapDatabasePath?: string | null
|
||||
savedMapWorldLayoutPath?: string | null
|
||||
@@ -70,7 +72,7 @@ type UseWorkspaceModuleControlResult = {
|
||||
startAllModules: () => Promise<void>
|
||||
stopAllModules: () => Promise<void>
|
||||
restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean>
|
||||
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<boolean>
|
||||
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options?: ModuleControlOptions) => Promise<boolean>
|
||||
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
|
||||
}
|
||||
|
||||
@@ -108,10 +110,12 @@ export function useWorkspaceModuleControl({
|
||||
const moduleActionIdRef = useRef(0)
|
||||
const controlGenerationRef = useRef(0)
|
||||
const workspaceActiveRef = useRef(workspaceActive)
|
||||
const backgroundControlsRef = useRef(0)
|
||||
useEffect(() => () => { controlGenerationRef.current += 1 }, [courseKey, weekNumber, clonePathPreview, remoteWorkspace])
|
||||
|
||||
useEffect(() => {
|
||||
workspaceActiveRef.current = workspaceActive
|
||||
if (workspaceActive) return
|
||||
if (workspaceActive || backgroundControlsRef.current > 0) return
|
||||
|
||||
// Workspace pages remain mounted while another LMS page is shown. Do not
|
||||
// leave their control buttons locked behind a detached IPC request.
|
||||
@@ -126,6 +130,7 @@ export function useWorkspaceModuleControl({
|
||||
), [bulkExcludedModuleKeys])
|
||||
|
||||
const getModuleRosParams = useCallback((module: ModuleRow, overrides?: {
|
||||
freshSlam?: boolean
|
||||
simulatorWorldFile?: string | null
|
||||
savedMapYamlPath?: string | null
|
||||
savedMapDatabasePath?: string | null
|
||||
@@ -133,7 +138,7 @@ export function useWorkspaceModuleControl({
|
||||
}): string => {
|
||||
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
|
||||
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
|
||||
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
|
||||
const mapDatabasePath = overrides?.freshSlam ? null : overrides?.savedMapDatabasePath ?? savedMapDatabasePath
|
||||
const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath
|
||||
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
|
||||
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
|
||||
@@ -175,12 +180,13 @@ export function useWorkspaceModuleControl({
|
||||
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
|
||||
}
|
||||
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '')
|
||||
const databasePath = mapDatabasePath
|
||||
const databasePath = overrides?.freshSlam ? null : mapDatabasePath
|
||||
?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
|
||||
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
|
||||
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
|
||||
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true')
|
||||
}
|
||||
if (overrides?.freshSlam && /slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=false')
|
||||
return parameters.join(' ')
|
||||
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
|
||||
|
||||
@@ -473,8 +479,8 @@ export function useWorkspaceModuleControl({
|
||||
}
|
||||
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
|
||||
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<boolean> => {
|
||||
if (!workspaceActiveRef.current) return false
|
||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build', options: ModuleControlOptions = {}): Promise<boolean> => {
|
||||
if (!workspaceActiveRef.current && !options.background) return false
|
||||
// The parking area only opts modules out of bulk actions; those modules
|
||||
// must remain individually controllable while the rest are stopping.
|
||||
const canControlExcludedModuleDuringBulkStop = (
|
||||
@@ -489,17 +495,18 @@ export function useWorkspaceModuleControl({
|
||||
const actionId = ++moduleActionIdRef.current
|
||||
const controlGeneration = controlGenerationRef.current
|
||||
queuedModuleActionsRef.current.set(module.name, actionId)
|
||||
if (options.background) backgroundControlsRef.current += 1
|
||||
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
||||
setModuleInstallError(null)
|
||||
const execute = async (): Promise<boolean> => {
|
||||
const isCurrentControl = (): boolean => (
|
||||
workspaceActiveRef.current
|
||||
(workspaceActiveRef.current || options.background === true)
|
||||
&& controlGenerationRef.current === controlGeneration
|
||||
&& queuedModuleActionsRef.current.get(module.name) === actionId
|
||||
)
|
||||
try {
|
||||
if (!isCurrentControl()) return false
|
||||
const savedMapOverrides = action === 'start' || action === 'restart'
|
||||
const savedMapOverrides = options.freshSlam ? { freshSlam: true } : action === 'start' || action === 'restart'
|
||||
? await prepareSavedMap?.()
|
||||
: undefined
|
||||
const result = action === 'stop'
|
||||
@@ -517,6 +524,7 @@ export function useWorkspaceModuleControl({
|
||||
: remoteWorkspace
|
||||
? await remoteModuleStart(module, savedMapOverrides)
|
||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
|
||||
options.onResult?.(result)
|
||||
if (!result.ok) throw new Error(result.message)
|
||||
if (!isCurrentControl()) return false
|
||||
|
||||
@@ -551,6 +559,7 @@ export function useWorkspaceModuleControl({
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`[modules] ${action} failed`, error)
|
||||
options.onResult?.({ ok: false, message: error instanceof Error ? error.message : String(error) })
|
||||
if (!isCurrentControl()) return false
|
||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
||||
const actionLabel = action === 'stop'
|
||||
@@ -564,6 +573,7 @@ export function useWorkspaceModuleControl({
|
||||
await loadAvailableModules({ silent: true })
|
||||
return false
|
||||
} finally {
|
||||
if (options.background) backgroundControlsRef.current -= 1
|
||||
if (queuedModuleActionsRef.current.get(module.name) === actionId) {
|
||||
queuedModuleActionsRef.current.delete(module.name)
|
||||
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
|
||||
|
||||
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
|
||||
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
|
||||
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
|
||||
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('.css')) return 'css'
|
||||
if (file.endsWith('.py')) return 'python'
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
{
|
||||
"에이전트 모델 선택": "Agent model",
|
||||
"사용 가능한 모델 없음": "No models available",
|
||||
"전송": "Send",
|
||||
"파일 첨부": "Attach files",
|
||||
"첨부 제거": "Remove attachment",
|
||||
"대화내역을 삭제할까요?": "Clear this conversation?",
|
||||
"현재 탭의 대화내역을 삭제합니다.": "The conversation in the current tab will be deleted.",
|
||||
"파일은 최대 5개까지 첨부할 수 있습니다.": "You can attach up to 5 files.",
|
||||
"코드·설정·로그 등 텍스트 파일만 첨부할 수 있습니다. 이미지와 PDF는 지원하지 않습니다.": "Only text files such as source code, configuration, and logs are supported. Images and PDFs are not supported.",
|
||||
"파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.": "File names must be at most 255 characters and files at most 100KB.",
|
||||
"UTF-8 텍스트 파일만 첨부할 수 있습니다.": "Only UTF-8 text files are supported.",
|
||||
"바이너리 파일은 첨부할 수 없습니다.": "Binary files cannot be attached.",
|
||||
"파일 하나의 내용은 5만 자 이하여야 합니다.": "Each attachment must contain at most 50,000 characters.",
|
||||
"첨부 파일의 전체 내용은 10만 자 이하여야 합니다.": "All attachments together must contain at most 100,000 characters.",
|
||||
"로컬 모듈 Git 가져오기": "Import local module from Git",
|
||||
"로컬 사용자 모듈": "Local custom module",
|
||||
"현재 강좌·주차의 로컬 사용자 모듈이 없습니다.": "No local custom modules for this course and week.",
|
||||
@@ -589,6 +603,15 @@
|
||||
"추가 중": "Adding",
|
||||
"추가할 ROS2 모듈의 기본 정보를 입력합니다.": "Enter details for the new ROS2 module.",
|
||||
"취소": "Cancel",
|
||||
"취소됨": "Cancelled",
|
||||
"이미지 확대": "Enlarge image",
|
||||
"첨부 이미지를 분석해주세요.": "Please analyze the attached images.",
|
||||
"현재 모델은 이미지 첨부를 지원하지 않습니다. Qwen 3.7 Plus·Flash 또는 3.8 Max·Flash를 선택해주세요.": "This model does not support image attachments. Select Qwen 3.7 Plus/Flash or 3.8 Max/Flash.",
|
||||
"이미지는 최대 3장까지 첨부할 수 있습니다.": "You can attach up to 3 images.",
|
||||
"이미지는 장당 5MB 이하여야 합니다.": "Each image must be 5 MB or smaller.",
|
||||
"이미지 파일을 읽지 못했습니다.": "Could not read the image file.",
|
||||
"이미지는 1600만 픽셀 이하여야 합니다.": "Images must be 16 megapixels or smaller.",
|
||||
"코드·설정·로그 또는 PNG·JPEG·WebP 이미지를 첨부해주세요. PDF는 지원하지 않습니다.": "Attach code, configuration, logs, or PNG/JPEG/WebP images. PDF is not supported.",
|
||||
"카테고리": "Category",
|
||||
"칸": " spaces",
|
||||
"커리큘럼": "Curriculum",
|
||||
@@ -691,5 +714,51 @@
|
||||
"실행 실패": "Failed",
|
||||
"다시 실행": "Run again",
|
||||
"명령 출력이 없습니다.": "The command produced no output.",
|
||||
"코드 변경안을 만드는 중입니다.": "Preparing code changes."
|
||||
"코드 변경안을 만드는 중입니다.": "Preparing code changes.",
|
||||
"조회 근거": "Evidence",
|
||||
"조회 완료": "Retrieved",
|
||||
"조회 실패": "Retrieval failed",
|
||||
"일부 결과": "Partial results",
|
||||
"SLAM 실습 준비": "Prepare SLAM practice",
|
||||
"승인하고 준비": "Approve and prepare",
|
||||
"내 PC에서 직접 개발 환경을 실행합니다.": "Run the development environment directly on this PC.",
|
||||
"진행 상황": "Progress",
|
||||
"{{seconds}}초 동안 작업 중입니다": "Working for {{seconds}} seconds",
|
||||
"{{seconds}}초 동안 작업함": "Worked for {{seconds}} seconds",
|
||||
"모듈 목록": "Module list",
|
||||
"{{round}}차 분석 완료": "Analysis round {{round}} completed",
|
||||
"추가 조회 {{count}}건": "{{count}} additional lookups requested",
|
||||
"이미지 업로드 완료": "Image uploaded",
|
||||
"파일 목록": "File list",
|
||||
"파일 검색": "File search",
|
||||
"파일 읽기": "File read",
|
||||
"로그 조회": "Log lookup",
|
||||
"네트워크 조회": "Network inspection",
|
||||
"ROS 상태 조회": "ROS inspection",
|
||||
"실습 구성 조회": "Practice configuration",
|
||||
"조회 결과 없음": "No results",
|
||||
"대상 없음": "Not found",
|
||||
"소스 파일": "Source files",
|
||||
"{{count}}건": "{{count}} items",
|
||||
"요청 분석 중": "Analyzing request",
|
||||
"모듈 상태 조회 중": "Checking module status",
|
||||
"조회 결과 분석 중": "Analyzing retrieved results",
|
||||
"추가 자료 조회 중": "Retrieving additional context",
|
||||
"최종 답변 정리 중": "Preparing the final answer",
|
||||
"코드에서 보기": "View in editor",
|
||||
"파일 여는 중...": "Opening file...",
|
||||
"{{start}}~{{end}}줄": "Lines {{start}}-{{end}}",
|
||||
"코드 위치를 열지 못했습니다. 다시 분석을 요청해주세요.": "Could not open the code location. Ask the agent to analyze it again.",
|
||||
"코드 위치의 조회 근거가 없습니다. 다시 분석을 요청해주세요.": "The code location has no verified source. Ask the agent to analyze it again.",
|
||||
"코드가 변경되어 위치를 확정할 수 없습니다. 다시 분석을 요청해주세요.": "The code has changed and its location is no longer certain. Ask the agent to analyze it again.",
|
||||
"코드 위치 열기가 중단되었습니다.": "Opening the code location was cancelled.",
|
||||
"코드 편집기를 준비하지 못했습니다. 다시 시도해주세요.": "The editor could not be prepared. Please try again.",
|
||||
"진행 중인 에이전트 작업이 끝난 뒤 열어주세요.": "Wait for the current agent task before opening the file.",
|
||||
"코드 위치의 모듈을 찾을 수 없습니다. 다시 분석을 요청해주세요.": "The referenced module was not found. Ask the agent to analyze it again.",
|
||||
"코드 파일을 읽지 못했습니다. 다시 분석을 요청해주세요.": "The code file could not be read. Ask the agent to analyze it again.",
|
||||
"코드 복사": "Copy code",
|
||||
"코드를 복사하지 못했습니다.": "Could not copy the code.",
|
||||
"일반 텍스트": "Plain text",
|
||||
"코드": "Code",
|
||||
"표": "Table"
|
||||
}
|
||||
|
||||
+28
-30
@@ -1,36 +1,11 @@
|
||||
@import "tailwindcss";
|
||||
@import "driver.js/dist/driver.css";
|
||||
@import "./styles/nanumSquare.css";
|
||||
@import "./styles/chatCode.css";
|
||||
|
||||
@font-face {
|
||||
font-family: 'NanumSquare';
|
||||
src: url('/assets/fonts/NanumSquareL.woff2') format('woff2');
|
||||
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;
|
||||
.monaco-agent-code-highlight {
|
||||
background: rgba(124, 58, 237, 0.16);
|
||||
border-left: 2px solid #8b5cf6;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
@@ -248,6 +223,21 @@
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@keyframes agent-response-fade {
|
||||
from { opacity: 0; transform: translateY(3px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.agent-response-fade {
|
||||
animation: agent-response-fade 220ms ease-out both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.agent-response-fade {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.monaco-search-line-highlight {
|
||||
background: rgba(47, 141, 255, 0.12);
|
||||
}
|
||||
@@ -304,4 +294,12 @@
|
||||
background: rgba(148, 163, 184, 0.95);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
export const AGENT_READ_TOOLS = ['modules.list', 'files.list', 'files.search', 'files.read', 'logs.read', 'network.inspect', 'ros.inspect', 'practice.inspect'] as const
|
||||
export type AgentReadToolName = typeof AGENT_READ_TOOLS[number]
|
||||
export const AGENT_RESULT_NAMES = [...AGENT_READ_TOOLS, 'action.result'] as const
|
||||
export type AgentToolRequest = {
|
||||
id: string
|
||||
name: AgentReadToolName
|
||||
moduleName?: string
|
||||
args?: { path?: string; query?: string; startLine?: number; lineCount?: number; topics?: string[]; profile?: string }
|
||||
}
|
||||
export type AgentToolResult = {
|
||||
id: string
|
||||
name: typeof AGENT_RESULT_NAMES[number]
|
||||
moduleName?: string
|
||||
status: 'ok' | 'error'
|
||||
observedAt: string
|
||||
target: string
|
||||
data: string
|
||||
truncated: boolean
|
||||
}
|
||||
export type AgentOperation = { name: 'modules.start' | 'modules.build' | 'practice.prepare'; profile?: string }
|
||||
export type AgentCheckedChange = { moduleName: string; folderName?: string; path: string; content: string; expectedHash: string | null }
|
||||
|
||||
const blockedParts = new Set(['node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'])
|
||||
export function safeAgentPath(value: unknown, allowEmpty = false): string {
|
||||
if (allowEmpty && (value === undefined || value === '')) return ''
|
||||
if (typeof value !== 'string' || value.length > 500 || /[\\\0\r\n]/.test(value) || value.startsWith('/')) throw new Error('안전하지 않은 파일 경로입니다.')
|
||||
const parts = value.split('/')
|
||||
if (parts.some(part => !part || part.startsWith('.') || blockedParts.has(part.toLowerCase()) || /\.(pem|key|p12|pfx)$/i.test(part))) throw new Error('시스템·비밀 파일 또는 제외된 경로에는 접근할 수 없습니다.')
|
||||
return value
|
||||
}
|
||||
export function validateAgentTool(input: AgentToolRequest): AgentToolRequest {
|
||||
if (!input || typeof input !== 'object' || !/^[A-Za-z0-9._-]{1,80}$/.test(input.id) || !AGENT_READ_TOOLS.includes(input.name)) throw new Error('등록되지 않은 에이전트 도구입니다.')
|
||||
if (input.moduleName !== undefined && (typeof input.moduleName !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9 ._-]{0,119}$/.test(input.moduleName))) throw new Error('모듈명이 올바르지 않습니다.')
|
||||
if (!['modules.list', 'practice.inspect'].includes(input.name) && !input.moduleName) throw new Error('대상 모듈이 필요합니다.')
|
||||
const args = input.args ?? {}
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('도구 인자가 올바르지 않습니다.')
|
||||
const allowed = input.name === 'files.read' ? ['path', 'startLine', 'lineCount'] : input.name === 'files.search' ? ['path', 'query'] : input.name === 'files.list' ? ['path'] : input.name === 'ros.inspect' ? ['topics'] : input.name === 'practice.inspect' ? ['profile'] : []
|
||||
if (Object.keys(args).some(key => !allowed.includes(key))) throw new Error('허용되지 않은 도구 인자입니다.')
|
||||
if (input.name.startsWith('files.')) safeAgentPath(args.path, input.name !== 'files.read')
|
||||
if (input.name === 'files.search' && (typeof args.query !== 'string' || args.query.length < 2 || args.query.length > 200 || /[\0\r\n]/.test(args.query))) throw new Error('검색어는 2~200자의 한 줄 텍스트여야 합니다.')
|
||||
for (const [key, max] of [['startLine', 100_000], ['lineCount', 400]] as const) {
|
||||
if (args[key] !== undefined && (!Number.isInteger(args[key]) || args[key]! < 1 || args[key]! > max)) throw new Error('파일 조회 범위를 확인하세요.')
|
||||
}
|
||||
if (args.topics !== undefined && (!Array.isArray(args.topics) || args.topics.length > 12 || args.topics.some(topic => typeof topic !== 'string' || topic.length > 200 || !/^\/[A-Za-z0-9_/]+$/.test(topic)))) throw new Error('ROS 토픽 형식이 올바르지 않습니다.')
|
||||
if (args.profile !== undefined && !['local-2d', 'mkmini-2d', 'mkmini-3d', 'yahboom-2d'].includes(args.profile)) throw new Error('지원하지 않는 실습 구성입니다.')
|
||||
return { id: input.id, name: input.name, ...(input.moduleName ? { moduleName: input.moduleName } : {}), args }
|
||||
}
|
||||
export function validateAgentOperation(value: AgentOperation): AgentOperation {
|
||||
if (!value || !['modules.start', 'modules.build', 'practice.prepare'].includes(value.name) || Object.keys(value).some(key => !['name', 'profile'].includes(key))) throw new Error('허용되지 않은 승인 작업입니다.')
|
||||
if (value.name === 'practice.prepare' && !['local-2d', 'mkmini-2d', 'mkmini-3d', 'yahboom-2d'].includes(value.profile ?? '')) throw new Error('실습 구성을 선택해야 합니다.')
|
||||
if (value.name !== 'practice.prepare' && value.profile !== undefined) throw new Error('허용되지 않은 승인 작업 인자입니다.')
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export type ChatTextMenuOptions = {
|
||||
kind: 'message' | 'input'
|
||||
canCopy: boolean
|
||||
canPaste?: boolean
|
||||
canClear?: boolean
|
||||
labels: { copy: string; paste?: string; clear?: string }
|
||||
}
|
||||
|
||||
export type ChatTextMenuAction = 'clear' | null
|
||||
|
||||
export type ChatClipboardAction = { action: 'copy'; text: string } | { action: 'paste' }
|
||||
@@ -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})}
|
||||
})
|
||||
@@ -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(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, /--device "\$capture_device:\$capture_device"/);
|
||||
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
|
||||
|
||||
@@ -1,70 +1,103 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
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 html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
|
||||
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'"));
|
||||
let now = 10000;
|
||||
let requests = 0;
|
||||
let ok = true;
|
||||
let visible = false;
|
||||
let afterBlob = () => {};
|
||||
const context = vm.createContext({
|
||||
ENABLE_CAMERA_PREVIEW: true,
|
||||
cameraFrameRequestInFlight: false,
|
||||
cameraFrameRetryAt: 0,
|
||||
cameraFrameObjectUrl: null,
|
||||
cameraPreview: {},
|
||||
latestSlamState: null,
|
||||
latestSlamStateReceivedAt: now,
|
||||
document: { hidden: false },
|
||||
Date: { now: () => now },
|
||||
BACKEND_URL: 'http://localhost:8765',
|
||||
AbortSignal,
|
||||
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} },
|
||||
setCameraPreviewAvailable(value) { visible = value; },
|
||||
async fetch() {
|
||||
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;
|
||||
};
|
||||
function harness() {
|
||||
const exports = {}, requests = [], revoked = [], pending = new Map()
|
||||
let now = 10000, state = null, receivedAt = now, visible = false, enabledFetch = true
|
||||
let sequence = 0
|
||||
function element() {
|
||||
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() {} }
|
||||
}
|
||||
const root = element(), document = { hidden: false, createElement: element, addEventListener() {}, removeEventListener() {} }
|
||||
const globals = { exports, document, AbortController, Date: { now: () => now },
|
||||
window: { setTimeout: () => 1, clearTimeout() {}, setInterval: () => 1, clearInterval() {} },
|
||||
URL: { createObjectURL: () => `blob:${++sequence}`, revokeObjectURL: url => revoked.push(url) },
|
||||
fetch: async (url, options) => {
|
||||
requests.push({ url, signal: options.signal })
|
||||
if (pending.has(url)) await pending.get(url)
|
||||
return { ok: enabledFetch, blob: async () => ({}) }
|
||||
}
|
||||
}
|
||||
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)
|
||||
const controller = exports.createCameraPreview({ root, baseUrl: 'http://camera.test', getState: () => ({ state, receivedAt }), onAvailable: value => { visible = value } })
|
||||
return { root, requests, revoked, pending, document, controller,
|
||||
state(value) { state = value; receivedAt = now }, advance(ms) { now += ms }, fail(value) { enabledFetch = !value }, visible: () => visible,
|
||||
settle: () => new Promise(resolve => setImmediate(resolve)) }
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await refresh();
|
||||
available(false);
|
||||
await refresh();
|
||||
assert.equal(requests, 0, 'Absent camera must not fetch frames');
|
||||
available(true);
|
||||
await refresh();
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(context.cameraPreview.src, 'blob:frame');
|
||||
now += 3001;
|
||||
await refresh();
|
||||
assert.equal(requests, 1, 'Stale websocket state must stop polling');
|
||||
assert.equal(visible, false);
|
||||
available(true);
|
||||
ok = false;
|
||||
await refresh();
|
||||
await refresh();
|
||||
assert.equal(requests, 2, 'HTTP errors must back off');
|
||||
now += 5000;
|
||||
available(true);
|
||||
ok = true;
|
||||
afterBlob = () => available(false);
|
||||
context.cameraPreview.src = '';
|
||||
await refresh();
|
||||
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera');
|
||||
assert.equal(context.cameraFrameRequestInFlight, false);
|
||||
afterBlob = () => {};
|
||||
available(true);
|
||||
await refresh();
|
||||
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames');
|
||||
console.log('camera preview polling tests passed');
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
test('legacy camera polling stays compatible and skips absent, hidden and stale cameras', async () => {
|
||||
const h = harness()
|
||||
h.controller.setEnabled(true)
|
||||
assert.equal(h.requests.length, 0)
|
||||
h.state({ topic_status: { camera_image: { available: true } } })
|
||||
h.controller.refresh(); await h.settle()
|
||||
assert.equal(h.requests[0].url, 'http://camera.test/camera-frame')
|
||||
assert.equal(h.root.children[0].children[1].src, 'blob:1')
|
||||
h.document.hidden = true
|
||||
h.controller.refresh(); await h.settle()
|
||||
assert.equal(h.requests.length, 1)
|
||||
h.advance(3001)
|
||||
h.controller.refresh()
|
||||
assert.equal(h.visible(), false)
|
||||
assert.equal(h.root.children.length, 0)
|
||||
assert.deepEqual(h.revoked, ['blob:1'])
|
||||
})
|
||||
|
||||
test('multiple camera frames are isolated and one unavailable camera cannot hide another', async () => {
|
||||
const h = harness()
|
||||
h.state({ cameras: [{ id: 'video0', name: '/dev/video0', available: true }, { id: 'video2', name: '/dev/video2', available: true }] })
|
||||
h.controller.setEnabled(true); await h.settle()
|
||||
assert.equal(h.root.children.length, 2)
|
||||
assert.equal(h.requests.length, 2)
|
||||
assert.deepEqual(h.requests.map(request => request.url), ['http://camera.test/camera-frame?camera_id=video0', 'http://camera.test/camera-frame?camera_id=video2'])
|
||||
const first = h.root.children[0].children[1].src
|
||||
h.state({ cameras: [{ id: 'video2', name: '/dev/video2', available: true }] })
|
||||
h.controller.refresh(); await h.settle()
|
||||
assert.equal(h.root.children.length, 1)
|
||||
assert.equal(h.root.children[0].dataset.cameraId, 'video2')
|
||||
assert.ok(h.revoked.includes(first))
|
||||
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>')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user