Compare commits

...

6 Commits

Author SHA1 Message Date
web-admin 4873020bfa add.. 2026-09-01 18:19:11 +09:00
web-admin c847e321a7 fix(lms-web): load modules from platform environment profiles 2026-09-01 17:27:54 +09:00
web-admin 7c0bdf02a2 patch curriculum.. 2026-08-07 10:26:26 +09:00
web-admin b98339d299 remove topics.. 2026-08-04 11:20:33 +09:00
web-admin 99cfef1004 patch.. 2026-08-03 14:06:06 +09:00
web-admin 107ccd151e feat(lms-web): improve AI guide workspace 2026-07-20 16:39:26 +09:00
43 changed files with 3601 additions and 496 deletions
+65
View File
@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/three": "^0.181.0",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
@@ -23,6 +24,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"three": "^0.181.2",
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"devDependencies": { "devDependencies": {
@@ -295,6 +297,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -1424,6 +1432,12 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1542,6 +1556,27 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.181.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz",
"integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.22.0"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1555,6 +1590,12 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@ungap/structured-clone": { "node_modules/@ungap/structured-clone": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
@@ -1581,6 +1622,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@webgpu/types": {
"version": "0.1.71",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
"license": "BSD-3-Clause"
},
"node_modules/@xterm/addon-fit": { "node_modules/@xterm/addon-fit": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
@@ -1968,6 +2015,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2738,6 +2791,12 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/meshoptimizer": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
"license": "MIT"
},
"node_modules/micromark": { "node_modules/micromark": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -3755,6 +3814,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/three": {
"version": "0.181.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
"integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+2
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/three": "^0.181.0",
"@vitejs/plugin-react": "^4.4.1", "@vitejs/plugin-react": "^4.4.1",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
@@ -24,6 +25,7 @@
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"three": "^0.181.2",
"vite": "^6.3.5" "vite": "^6.3.5"
}, },
"devDependencies": { "devDependencies": {
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
<asset>
<contributor>
<author>Cole</author>
<authoring_tool>OpenCOLLADA for 3ds Max; Version: 1.3.1; Revision: 847M; Platform: x64; Configuration: Release_Max2012_static</authoring_tool>
<source_data>file:///C:/Users/Cole/Desktop/Houses.max</source_data>
</contributor>
<created>2012-12-21T07:14:54</created>
<modified>2012-12-21T07:14:54</modified>
<unit name="inch" meter="0.0254"/>
<up_axis>Z_UP</up_axis>
</asset>
<library_effects>
<effect id="_1_-_Default">
<profile_COMMON>
<technique sid="common">
<blinn>
<emission>
<color>0 0 0 1</color>
</emission>
<ambient>
<color>1 1 1 1</color>
</ambient>
<diffuse>
<color>1 1 1 1</color>
</diffuse>
<specular>
<color>0.9 0.9 0.9 1</color>
</specular>
<shininess>
<float>9.999999</float>
</shininess>
<reflective>
<color>0 0 0 1</color>
</reflective>
<transparent opaque="A_ONE">
<color>1 1 1 1</color>
</transparent>
<transparency>
<float>1</float>
</transparency>
</blinn>
</technique>
</profile_COMMON>
<extra>
<technique profile="OpenCOLLADA3dsMax">
<extended_shader>
<apply_reflection_dimming>0</apply_reflection_dimming>
<dim_level>0</dim_level>
<falloff_type>0</falloff_type>
<index_of_refraction>1.5</index_of_refraction>
<opacity_type>0</opacity_type>
<reflection_level>3</reflection_level>
<wire_size>1</wire_size>
<wire_units>0</wire_units>
</extended_shader>
<shader>
<ambient_diffuse_lock>1</ambient_diffuse_lock>
<ambient_diffuse_texture_lock>1</ambient_diffuse_texture_lock>
<diffuse_specular_lock>0</diffuse_specular_lock>
<soften>0.1</soften>
<use_self_illum_color>0</use_self_illum_color>
</shader>
</technique>
</extra>
</effect>
</library_effects>
<library_materials>
<material id="_1_-_Default-material" name="_1_-_Default">
<instance_effect url="#_1_-_Default"/>
</material>
</library_materials>
<library_geometries>
<geometry id="geom-Cylinder001" name="Cylinder001">
<mesh>
<source id="geom-Cylinder001-positions">
<float_array id="geom-Cylinder001-positions-array" count="279">63.77863 0.3839193 -94.24056 55.23391 31.88931 -94.24056 31.88931 55.23391 -94.24056 1.33153e-6 63.77862 -94.24056 -31.88931 55.23391 -94.24056 -55.2339 31.88931 -94.24056 -63.77862 0.3839193 -94.24056 55.23391 31.88931 101.6574 31.88931 55.23391 101.6574 -2.78785e-6 63.77862 101.6574 -31.88931 55.23391 101.6574 -55.23391 31.88931 101.6574 -63.77862 0.3839242 101.6574 63.77862 0.3839242 101.6574 63.77863 -135.6524 -94.24055 63.77862 -135.6524 101.6575 -63.77863 -135.6524 101.6575 -63.77862 -135.6524 -94.24055 69.09421 0.3839242 101.6574 60.08233 34.68855 101.6574 34.68855 60.08234 101.6574 -3.13332e-6 69.3771 101.6574 -34.68855 60.08233 101.6574 -60.08233 34.68855 101.6574 -69.18634 0.3839242 101.6574 -69.18634 -141.0601 101.6575 69.18633 -141.0602 101.6575 60.08233 34.68855 108.9806 34.68855 60.08234 108.9806 -3.13258e-6 69.3771 108.9806 -34.68855 60.08233 108.9806 -60.08233 34.68855 108.9806 -69.18634 0.3839244 108.9806 -69.18634 -141.0601 108.9806 69.18633 -141.0602 108.9806 69.09421 0.3839244 108.9806 -23.34707 -979.6222 17.01899 23.34707 -979.6222 17.01899 -23.34707 -979.6222 -9.602058 23.34707 -979.6222 -9.602058 -23.34707 -121.6467 17.01897 23.34707 -121.6467 17.01897 -23.34707 -121.6467 -9.602079 23.34707 -121.6467 -9.602079 -65.29713 -90.30914 34.35137 -65.29715 -90.30914 60.09743 -65.29715 -39.25652 63.45726 -73.57328 -90.30915 34.35137 -73.57329 -84.41591 63.45725 -73.57329 -39.25653 39.67269 -73.5733 -39.25652 63.45725 -73.57329 -48.59029 34.35137 -65.29715 -93.29141 67.81843 -65.29715 -39.25652 67.81852 -65.29713 -99.18414 34.35137 -65.29715 -84.41591 63.45726 -73.57329 -90.30914 60.09743 -65.29715 -99.1846 64.45859 -5.55696 73.37229 122.3759 5.556962 70.81001 120.9151 -5.556961 70.81001 109.6903 5.556962 70.81001 109.6903 -5.55696 85.82349 122.3759 5.556962 85.82349 122.3759 -5.55696 70.81001 120.9151 5.556962 73.37229 122.3759 -5.556959 66.63747 122.3759 5.556964 69.19975 120.9151 -5.55696 69.19975 109.6903 5.556963 69.19975 109.6903 -5.556959 54.18628 122.3759 5.556964 54.18628 122.3759 -5.556959 69.19975 120.9151 5.556964 66.63747 122.3759 -66.63045 -79.03947 42.09508 -66.63046 -79.03947 57.59226 -66.63046 162.3586 42.09508 -66.63046 99.09541 42.09508 -66.63046 162.3586 55.67602 -66.63046 148.0616 12.03616 -66.63045 102.9279 10.30568 -5.556961 70.81001 100.9286 5.556961 70.81001 100.9286 -5.556961 58.29042 100.9286 5.556961 58.29042 100.9286 -5.556961 70.81001 109.6903 5.556962 70.81001 109.6903 -5.55696 46.97038 109.6903 5.556962 46.97038 109.6903 -66.63046 101.0116 40.17884 -66.63046 160.4424 57.59225 -66.63046 145.3223 10.30569 -66.63045 101.0116 12.22192</float_array>
<technique_common>
<accessor source="#geom-Cylinder001-positions-array" count="93" stride="3">
<param name="X" type="float"/>
<param name="Y" type="float"/>
<param name="Z" type="float"/>
</accessor>
</technique_common>
</source>
<source id="geom-Cylinder001-normals">
<float_array id="geom-Cylinder001-normals-array" count="783">0.9912451 0.132035 2.4983e-8 0.8652635 0.5013175 1.68492e-8 0.8652634 0.5013174 1.38252e-8 0.9912451 0.132035 1.60082e-8 0.5000001 0.8660254 9.92266e-9 0.5 0.8660254 9.73644e-9 -5.64708e-8 1 -9.79561e-10 -5.89261e-8 1 -1.8623e-10 -0.5 0.8660254 -1.14197e-8 -0.5 0.8660254 -1.36251e-8 -0.8652635 0.5013175 -1.98732e-8 -0.8652635 0.5013175 -2.00329e-8 -0.9912451 0.132035 -2.25966e-8 -0.9912451 0.132035 -2.56756e-8 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 1 -1.72061e-8 2.69974e-8 1 2.18421e-15 3.89457e-8 0 5.39393e-8 1 0 5.39393e-8 1 0 5.39393e-8 1 5.94806e-15 5.39393e-8 1 5.94806e-15 5.39393e-8 1 5.94806e-15 5.39393e-8 1 -1 1.08357e-8 -2.69974e-8 -1 2.80417e-8 -3.89457e-8 0 -5.60835e-8 -1 0 -5.60835e-8 -1 0 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 5.60835e-8 -5.60835e-8 -1 5.60835e-8 -5.60835e-8 -1 5.60835e-8 -5.60835e-8 -1 -2.97263e-16 -5.39393e-8 -1 -2.97263e-16 -5.39393e-8 -1 -2.97263e-16 -5.39393e-8 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 -3.14437e-16 -5.60835e-8 -1 -3.14437e-16 -5.60835e-8 -1 -3.14437e-16 -5.60835e-8 -1 -5.39745e-8 -5.39745e-8 -1 -5.39745e-8 -5.39745e-8 -1 -5.39745e-8 -5.39745e-8 -1 0.99172 0.1284185 -3.18533e-9 0.8672485 0.4978755 0 0.8672485 0.4978756 -4.85969e-10 0.99172 0.1284184 -3.65799e-9 0.5000001 0.8660254 -1.74247e-12 0.5 0.8660254 0 -1.08031e-7 1 -1.74247e-12 -1.37494e-7 1 1.74249e-12 -0.5 0.8660254 0 -0.5000001 0.8660254 1.74248e-12 -0.8666227 0.498964 -4.20111e-10 -0.8666227 0.498964 0 -0.9916003 0.1293404 -3.1582e-9 -0.9916003 0.1293404 -2.7491e-9 -1 0 0 -1 0 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 0.9999998 6.51317e-4 -6.11024e-13 0.9999998 6.51317e-4 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 2.44539e-8 1 0 2.44539e-8 1 0 2.44539e-8 1 0 2.44539e-8 1 1 0 0 1 0 0 1 0 0 1 0 0 0 -2.44539e-8 -1 0 -2.44539e-8 -1 0 -2.44539e-8 -1 0 -2.44539e-8 -1 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 -1.30567e-7 -2.28558e-7 -1 1.68944e-7 -2.96333e-7 -1 -5.90983e-8 -2.5016e-7 -1 -1.06023e-7 -2.34713e-7 -1 1.82876e-7 -3.20771e-7 -1 -7.93843e-8 -2.36668e-7 7.39325e-7 -1 2.37658e-7 0 -1 0 1.82529e-7 -1 5.86744e-8 9.21853e-7 -1 2.96333e-7 -4.60927e-7 -6.88853e-14 1 -4.60927e-7 0 1 -4.60927e-7 -8.98548e-15 1 -4.60927e-7 -7.78708e-14 1 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 -5.92665e-7 -1 0 -5.92665e-7 -1 0 -5.92665e-7 -1 -2.49037e-7 -5.06819e-7 -1 -2.49037e-7 -5.06819e-7 -1 -2.49037e-7 -5.06819e-7 -4.00423e-7 -0.4952793 0.8687338 -4.00423e-7 -0.4952793 0.8687338 -4.00423e-7 -0.4952793 0.8687338 0 -0.4952789 0.868734 0 -0.4952789 0.8687341 0 -0.4952789 0.868734 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 0 1 0 0 1 0 0 1 0 0 1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 -0.4952782 -0.8687345 0 -0.4952782 -0.8687344 0 -0.4952781 -0.8687344 0 -0.4952782 -0.8687345 0 -0.4952782 -0.8687344 0 -0.4952781 -0.8687344 0 1 0 0 1 0 0 1 0 0 1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 -1 -2.99963e-8 -3.58181e-7 -1 -1.02221e-7 -4.58289e-8 -1 -3.99212e-8 -1.73411e-7 -1 -3.23093e-8 -4.85334e-7 -1 -3.18579e-8 -4.92309e-7 -1 -9.30545e-8 -1.27403e-7 -1 -5.76775e-8 -2.63987e-7 -1 -4.88709e-8 -1.95574e-7 -1 -3.36656e-8 -3.5904e-7 -1 -1.73765e-7 1.23235e-7 -1 -1.79962e-7 2.84877e-7</float_array>
<technique_common>
<accessor source="#geom-Cylinder001-normals-array" count="261" stride="3">
<param name="X" type="float"/>
<param name="Y" type="float"/>
<param name="Z" type="float"/>
</accessor>
</technique_common>
</source>
<source id="geom-Cylinder001-map1">
<float_array id="geom-Cylinder001-map1-array" count="450">0.1889011 0.2758261 0 0.2256371 0.2758261 0 0.2256371 0.6486552 0 0.188901 0.6486552 0 0.2623734 0.2758261 0 0.2623734 0.6486552 0 0.2991094 0.2758261 0 0.2991095 0.6486552 0 0.3358451 0.275826 0 0.3358452 0.6486552 0 0.3725807 0.275826 0 0.3725807 0.6486552 0 0.9257745 0.70301 0 0.8734617 0.7549164 0 0.802001 0.7739153 0 0.7305403 0.7549164 0 0.6782273 0.7030097 0 0.6594725 0.6335595 0 0.4093161 0.2758261 0 0.4093161 0.6486552 0 0.03795872 0.6486552 0 0.03795872 0.2758261 0 0.5602581 0.2758261 0 0.5602581 0.6486552 0 0.9443399 0.6335346 0 0.061661 0.8492339 0 0.09246806 0.8492339 0 0.09246806 0.8621775 0 0.061661 0.8621775 0 0.1245703 0.8492339 0 0.4158128 0.7749463 0 0.4288802 0.7265559 0 0.4645816 0.6911321 0 0.5133504 0.6781658 0 0.5621194 0.6911321 0 0.5978206 0.7265559 0 0.6108883 0.7749462 0 0.1245703 0.8621775 0 0.1566722 0.8492339 0 0.1566722 0.8621775 0 0.1887741 0.8492339 0 0.1887741 0.8621775 0 0.2208759 0.8492339 0 0.2208759 0.8621775 0 0.2523189 0.8492339 0 0.2523189 0.8621775 0 0.3790478 0.8492339 0 0.3790478 0.8621775 0 0.2097653 0.9498755 0 0.3783371 0.826761 0 0.6594725 0.3437682 0 0.06166129 0.8267609 0 0.9445293 0.3437679 0 0.3783363 0.702438 0 0.6108882 0.9807906 0 0.0836773 0.9498755 0 0.0836773 0.9369324 0 0.2097653 0.9369324 0 0.061661 0.7024379 0 0.4158128 0.9807907 0 0.637657 0.6559129 0 0.6597634 0.7377743 0 0.6472197 0.7449604 0 0.6234784 0.6557912 0 0.7201594 0.7977016 0 0.7129173 0.8101482 0 0.802662 0.8196367 0 0.802662 0.8340079 0 0.8851648 0.7977016 0 0.8924065 0.8101479 0 0.2192692 0.9691411 0 0.0836773 0.9691411 0 0.9455608 0.7377743 0 0.9581041 0.7449604 0 0.9676672 0.6559129 0 0.9816576 0.6577407 0 0.9676669 0.3076856 0 0.9816576 0.2938034 0 0.637657 0.3076856 0 0.0836773 0.9573665 0 0.2192692 0.9573665 0 0.6236663 0.2938034 0 0.637657 0.6559129 0 0.973251 0.1941192 0 0.945253 0.1941192 0 0.945253 0.1408443 0 0.973251 0.1408443 0 0.04288544 0.1408443 0 0.04288544 0.194119 0 0.945253 0.03266054 0 0.945253 0.08657608 0 0.04288544 0.08657608 0 0.04288544 0.03266054 0 0.945253 0.2533531 0 0.04288544 0.253353 0 0.8747699 0.9740806 0 0.8747699 0.9228441 0 0.886499 0.9161604 0 0.9763682 0.91616 0 0.8582996 0.9740796 0 0.8582998 0.9228438 0 0.878347 0.9018502 0 0.6516011 0.9556062 0 0.7323457 0.9556062 0 0.748215 0.9695692 0 0.8864987 0.8996912 0 0.9763682 0.8996903 0 0.9763678 0.9634923 0 0.6516011 0.9695697 0 0.7428828 0.9448491 0 0.7428828 0.8624189 0 0.7587513 0.8624189 0 0.9577932 0.9740818 0 0.7587519 0.9588121 0 0.6609319 0.8997087 0 0.6875241 0.8997087 0 0.6875241 0.8657955 0 0.6875241 0.8926538 0 0.6875241 0.9294978 0 0.6609319 0.9294978 0 0.6609319 0.8926538 0 0.701803 0.8955842 0 0.7283945 0.9294978 0 0.701803 0.9294978 0 0.701803 0.90264 0 0.701803 0.8657949 0 0.7283945 0.8657949 0 0.7283945 0.90264 0 0.7283945 0.8955842 0 0.304611 0.9144163 0 0.2467109 0.9144163 0 0.304611 0.8910367 0 0.0836773 0.9144163 0 0.0836773 0.8877379 0 0.291526 0.9661621 0 0.2502185 0.9691411 0 0.6609319 0.8657955 0 0.8666169 0.9085327 0 0.9434043 0.8599022 0 0.9434043 0.8377851 0 0.9683194 0.8377851 0 0.9683194 0.8599022 0 0.9259688 0.8377851 0 0.9259688 0.8599022 0 0.878527 0.8599022 0 0.878527 0.8377851 0 0.2484647 0.9177151 0 0.3028572 0.8877379 0 0.289019 0.9691411 0 0.2484647 0.9658423 0</float_array>
<technique_common>
<accessor source="#geom-Cylinder001-map1-array" count="150" stride="3">
<param name="S" type="float"/>
<param name="T" type="float"/>
<param name="P" type="float"/>
</accessor>
</technique_common>
</source>
<vertices id="geom-Cylinder001-vertices">
<input semantic="POSITION" source="#geom-Cylinder001-positions"/>
</vertices>
<triangles material="_1_-_Default_1" count="121">
<input semantic="VERTEX" source="#geom-Cylinder001-vertices" offset="0"/>
<input semantic="NORMAL" source="#geom-Cylinder001-normals" offset="1"/>
<input semantic="TEXCOORD" source="#geom-Cylinder001-map1" offset="2" set="0"/>
<p>0 0 0 1 1 1 7 2 2 7 2 2 13 3 3 0 0 0 1 1 1 2 4 4 8 5 5 8 5 5 7 2 2 1 1 1 2 4 4 3 6 6 9 7 7 9 7 7 8 5 5 2 4 4 3 6 6 4 8 8 10 9 9 10 9 9 9 7 7 3 6 6 4 8 8 5 10 10 11 11 11 11 11 11 10 9 9 4 8 8 5 10 10 6 12 18 12 13 19 12 13 19 11 11 11 5 10 10 27 14 12 28 15 13 29 16 14 29 17 14 30 18 15 31 19 16 27 20 12 29 21 14 31 22 16 27 23 12 31 24 16 32 25 17 27 26 12 32 27 17 35 28 24 5 29 31 4 30 32 3 31 33 3 32 33 2 33 34 1 34 35 3 35 33 1 36 35 0 37 36 5 38 31 3 39 33 0 40 36 6 41 30 5 42 31 0 43 36 0 0 0 13 3 3 15 44 20 15 44 20 14 45 21 0 0 0 35 46 24 32 47 17 33 48 50 33 49 50 34 50 52 35 51 24 12 13 19 6 12 18 17 52 22 17 52 22 16 53 23 12 13 19 6 54 30 0 55 36 14 56 54 14 57 54 17 58 59 6 59 30 14 60 49 15 61 51 17 62 53 15 63 51 16 64 58 17 65 53 13 66 60 7 67 61 19 68 62 19 69 62 18 70 63 13 71 60 7 72 61 8 73 64 20 74 65 20 75 65 19 76 62 7 77 61 8 78 64 9 79 66 21 80 67 21 81 67 20 82 65 8 83 64 9 84 66 10 85 68 22 86 69 22 87 69 21 88 67 9 89 66 10 90 68 11 91 72 23 92 73 23 93 73 22 94 69 10 95 68 11 96 72 12 97 74 24 98 75 24 99 75 23 100 73 11 101 72 12 102 74 16 103 76 25 104 77 25 105 77 24 106 75 12 107 74 16 108 76 15 109 78 26 110 81 26 111 81 25 112 77 16 113 76 15 114 78 13 115 82 18 116 63 18 117 63 26 118 81 15 119 78 18 120 25 19 121 26 27 122 27 27 122 27 35 123 28 18 120 25 19 121 26 20 124 29 28 125 37 28 125 37 27 122 27 19 121 26 20 124 29 21 126 38 29 127 39 29 127 39 28 125 37 20 124 29 21 126 38 22 128 40 30 129 41 30 129 41 29 127 39 21 126 38 22 128 40 23 130 42 31 131 43 31 131 43 30 129 41 22 128 40 23 130 42 24 132 44 32 133 45 32 133 45 31 131 43 23 130 42 24 132 44 25 134 46 33 135 47 33 135 47 32 133 45 24 132 44 25 136 70 26 137 71 34 138 79 34 139 79 33 140 80 25 141 70 26 142 48 18 120 55 35 123 56 35 123 56 34 143 57 26 142 48 36 144 83 38 145 84 39 146 85 39 146 85 37 147 86 36 144 83 36 148 89 37 149 90 41 150 91 41 150 91 40 151 92 36 148 89 37 152 90 39 153 85 43 154 87 43 154 87 41 155 91 37 152 90 39 156 85 38 157 84 42 158 88 42 158 88 43 159 87 39 156 85 38 160 84 36 161 93 40 162 94 40 162 94 42 163 88 38 160 84 47 164 95 56 165 96 48 166 97 47 164 95 48 166 97 50 167 98 50 167 98 49 168 107 51 169 112 47 164 95 50 167 98 51 169 112 44 170 99 45 171 100 56 172 96 56 172 96 47 173 95 44 170 99 55 174 105 46 175 106 50 176 98 50 176 98 48 177 97 55 174 105 46 178 102 55 179 103 52 180 104 52 181 104 53 182 108 46 183 102 45 184 109 44 185 110 54 186 111 54 187 111 57 188 113 45 189 109 45 190 137 55 191 101 48 192 97 48 193 97 56 194 96 45 195 137 55 196 103 45 197 109 57 198 113 57 199 113 52 200 104 55 201 103 64 202 117 59 203 120 61 204 136 61 204 136 60 205 116 64 202 117 65 206 114 58 207 115 62 208 118 62 209 118 63 210 119 65 211 114 64 212 117 58 213 115 65 214 114 65 215 114 59 216 120 64 217 117 72 218 127 68 219 122 69 220 123 69 220 123 67 221 124 72 218 127 73 222 121 71 223 125 70 224 126 70 225 126 66 226 128 73 227 121 72 228 127 67 229 124 73 230 121 73 231 121 66 232 128 72 233 127 85 234 143 86 235 142 82 236 139 82 236 139 81 237 138 85 234 143 81 238 138 82 239 139 84 240 140 84 241 140 83 242 141 81 243 138 86 244 142 85 245 143 87 246 144 87 247 144 88 248 145 86 249 142 76 250 129 80 251 135 92 252 149 74 253 132 75 254 133 90 255 147 90 255 147 77 256 130 74 253 132 89 257 146 77 256 130 90 255 147 90 255 147 78 258 131 89 257 146 89 257 146 78 258 131 76 250 129 76 250 129 92 252 149 89 257 146 79 259 134 91 260 148 80 251 135 80 251 135 76 250 129 79 259 134</p>
</triangles>
</mesh>
</geometry>
</library_geometries>
<library_lights>
<light id="EnvironmentAmbientLight" name="EnvironmentAmbientLight">
<technique_common>
<ambient>
<color>0 0 0</color>
</ambient>
</technique_common>
</light>
</library_lights>
<library_visual_scenes>
<visual_scene id="MaxScene">
<node name="EnvironmentAmbientLight">
<instance_light url="#EnvironmentAmbientLight"/>
</node>
<node id="node-Cylinder001" name="Cylinder001">
<rotate>5.33851e-8 0.7071068 0.7071068 -180</rotate>
<scale>0.0367934 0.0367934 0.06453677</scale>
<node>
<matrix>1 0 0 0 0 1 0 979.6222 0 0 1 -3.708471 0 0 0 1</matrix>
<instance_geometry url="#geom-Cylinder001">
<bind_material>
<technique_common>
<instance_material symbol="_1_-_Default_1" target="#_1_-_Default-material"/>
</technique_common>
</bind_material>
</instance_geometry>
</node>
</node>
</visual_scene>
</library_visual_scenes>
<scene>
<instance_visual_scene url="#MaxScene"/>
</scene>
</COLLADA>
+61 -46
View File
@@ -1,6 +1,6 @@
import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react' import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ExpandableMarkdownMessage, QaInputBox } from '@lms-renderer/components/ChatWidgets' import { ExpandableMarkdownMessage, QaInputBox } from '@lms-renderer/components/ChatWidgets'
import type { QaChatMessage, QaChatThread } from '@lms-renderer/features/workspace/useQaChat' import type { QaChatMessage, QaChatThread } from '@lms-renderer/features/workspace/useQaChat'
@@ -73,6 +73,24 @@ export default function AiGuideView({
const [deleteConfirmThread, setDeleteConfirmThread] = useState<QaChatThread | null>(null) const [deleteConfirmThread, setDeleteConfirmThread] = useState<QaChatThread | null>(null)
const questionRefs = useRef<Record<string, HTMLElement | null>>({}) const questionRefs = useRef<Record<string, HTMLElement | null>>({})
const conversationScrollRef = useRef<HTMLDivElement | null>(null) const conversationScrollRef = useRef<HTMLDivElement | null>(null)
const latestMessageIdRef = useRef<string | null>(null)
const followConversationRef = useRef(true)
const latestMessage = messages.at(-1)
useEffect(() => {
const latestMessageId = latestMessage?.id ?? null
const hasNewMessage = latestMessageId !== latestMessageIdRef.current
latestMessageIdRef.current = latestMessageId
if (hasNewMessage) followConversationRef.current = true
if (!hasNewMessage && !followConversationRef.current) return
const frame = window.requestAnimationFrame(() => {
const target = conversationScrollRef.current
if (!target) return
target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
})
return () => window.cancelAnimationFrame(frame)
}, [busy, latestMessage?.content, latestMessage?.id, messages.length, typingMessageId])
const scrollToQuestion = (messageId: string): void => { const scrollToQuestion = (messageId: string): void => {
setSelectedQuestionId(messageId) setSelectedQuestionId(messageId)
@@ -86,15 +104,18 @@ export default function AiGuideView({
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
const target = conversationScrollRef.current const target = conversationScrollRef.current
if (target) target.scrollTo({ top: target.scrollHeight, behavior: 'auto' }) if (target) {
followConversationRef.current = true
target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
}
window.setTimeout(() => setSwitchingConversation(false), 1000) window.setTimeout(() => setSwitchingConversation(false), 1000)
}) })
}) })
} }
return ( return (
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#202020] text-white"> <section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#10131b] text-white">
<header className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#252525] px-3"> <header className="flex h-10 shrink-0 items-center justify-between border-b border-violet-200/15 bg-[#2a1d3f] px-3">
<strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong> <strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong>
<button <button
type="button" type="button"
@@ -108,11 +129,11 @@ export default function AiGuideView({
</header> </header>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#202228]"> <aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]">
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3"> <div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<> <>
<div className="rounded-lg border border-[#3b7ffa]/25 bg-[#3b7ffa]/[0.08] px-3 pb-3 pt-2"> <div className="rounded-lg border border-violet-300/20 bg-violet-400/[0.08] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-[#9bbdff]">현재 학습</span> <span className="text-[10px] font-bold tracking-[0.08em] text-violet-200">현재 학습</span>
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong> <strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p> <p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
</div> </div>
@@ -123,8 +144,8 @@ export default function AiGuideView({
onClick={onClear} onClick={onClear}
className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35" className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
> >
<Plus size={15} className="shrink-0 text-[#82abff]" /> <Plus size={15} className="shrink-0 text-emerald-300" />
<span className="min-w-0 flex-1">새 대화</span> <span className="min-w-0 flex-1">새 채팅</span>
<ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /> <ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" />
</button> </button>
@@ -139,7 +160,7 @@ export default function AiGuideView({
onClick={() => onQuickSubmit(prompt)} onClick={() => onQuickSubmit(prompt)}
className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35" className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
> >
<Icon size={15} className="shrink-0 text-[#82abff]" /> <Icon size={15} className="shrink-0 text-violet-300" />
<span className="min-w-0 flex-1">{label}</span> <span className="min-w-0 flex-1">{label}</span>
<ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /> <ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" />
</button> </button>
@@ -149,42 +170,33 @@ export default function AiGuideView({
<div className="mt-6 border-t border-white/[0.08] pt-4"> <div className="mt-6 border-t border-white/[0.08] pt-4">
<div className="flex items-center justify-between px-1"> <div className="flex items-center justify-between px-1">
<span className="text-[10px] font-bold tracking-[0.08em] text-white/38">최근 대화</span> <span className="text-[10px] font-bold tracking-[0.08em] text-white/38">최근 채팅</span>
</div> </div>
<div className="mt-2 grid gap-1"> <div className="mt-2 grid gap-1">
{activeConversation ? ( {activeConversation ? (
<button <button
type="button" type="button"
onClick={() => selectConversation(null)} onClick={() => selectConversation(null)}
className={['min-w-0 rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')} className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}
> >
<span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 대화'}</span> <span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span>
<span className="mt-0.5 block text-[10px] text-white/32">현재 대화</span> <span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span>
</button>
) : viewingHistory ? (
<button
type="button"
onClick={() => selectConversation(null)}
className="flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] font-semibold text-white/48 transition hover:bg-white/[0.06] hover:text-white/75"
>
<MessageSquareText size={13} className="shrink-0" />
<span className="truncate">새 대화로 돌아가기</span>
</button> </button>
) : null} ) : null}
{history.map((thread) => ( {history.map((thread) => (
<div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}> <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}>
<button <button
type="button" type="button"
onClick={() => selectConversation(thread.id)} onClick={() => selectConversation(thread.id)}
className="min-w-0 flex-1 px-2 py-2 text-left" className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"
> >
<span className="block truncate text-[11px] font-semibold">{thread.title || '이전 대화'}</span> <span className="block truncate text-[11px] font-semibold">{thread.title || '이전 채팅'}</span>
<span className="mt-0.5 block text-[10px] text-white/32">{formatConversationDate(thread.lastMessageAt)}</span> <span className="mt-0.5 block text-[10px] text-white/32">{formatConversationDate(thread.lastMessageAt)}</span>
</button> </button>
<button <button
type="button" type="button"
title="대화 삭제" title="채팅 삭제"
aria-label="대화 삭제" aria-label="채팅 삭제"
onClick={() => setDeleteConfirmThread(thread)} onClick={() => setDeleteConfirmThread(thread)}
className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100" className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"
> >
@@ -200,10 +212,13 @@ export default function AiGuideView({
</aside> </aside>
<main className="relative flex min-w-0 flex-1 flex-col bg-[#181a20]"> <main className="relative flex min-w-0 flex-1 flex-col bg-[#10131b]">
<div ref={conversationScrollRef} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-28 pt-7"> <div ref={conversationScrollRef} onScroll={(event) => {
const target = event.currentTarget
followConversationRef.current = target.scrollHeight - target.scrollTop - target.clientHeight < 96
}} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7">
{loading ? ( {loading ? (
<div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 대화를 불러오는 중</span></div> <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 채팅을 불러오는 중</span></div>
) : disabled ? ( ) : disabled ? (
<div className="grid h-full min-h-[300px] place-items-center text-center"> <div className="grid h-full min-h-[300px] place-items-center text-center">
<div> <div>
@@ -216,7 +231,7 @@ export default function AiGuideView({
<div className="mx-auto flex w-full max-w-[860px] flex-col gap-5"> <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
{messages.length === 0 && ( {messages.length === 0 && (
<div className="py-9 text-center"> <div className="py-9 text-center">
<span className="mx-auto grid size-11 place-items-center rounded-xl border border-[#3b7ffa]/25 bg-[#3b7ffa]/10 text-[#96b8ff]"><Sparkles size={20} /></span> <span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span>
<h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2> <h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2>
<p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p> <p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p>
</div> </div>
@@ -228,15 +243,15 @@ export default function AiGuideView({
className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}
> >
<div className={message.role === 'user' <div className={message.role === 'user'
? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#3b7ffa] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(32,91,204,0.24)]' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]'
: 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3'} : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}
> >
{message.role === 'user' ? ( {message.role === 'user' ? (
<p className="whitespace-pre-wrap">{message.content}</p> <p className="whitespace-pre-wrap">{message.content}</p>
) : ( ) : (
<> <>
{(message.isTyping || message.id === typingMessageId) && ( {(message.isTyping || message.id === typingMessageId) && (
<span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-[#8fb4ff]"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span> <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>
)} )}
<ExpandableMarkdownMessage <ExpandableMarkdownMessage
content={message.content || '응답을 작성하고 있습니다.'} content={message.content || '응답을 작성하고 있습니다.'}
@@ -251,7 +266,7 @@ export default function AiGuideView({
))} ))}
{busy && ( {busy && (
<article className="w-full"> <article className="w-full">
<div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3 text-[12px] font-semibold text-[#8fb4ff]"> <div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200">
<LoaderCircle size={15} className="animate-spin" /> 생각 중 <LoaderCircle size={15} className="animate-spin" /> 생각 중
</div> </div>
</article> </article>
@@ -264,15 +279,15 @@ export default function AiGuideView({
<div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div> <div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div>
</div> </div>
{switchingConversation && ( {switchingConversation && (
<div className="absolute inset-0 z-20 grid place-items-center bg-[#181a20]/78 backdrop-blur-[2px]"> <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]">
<span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252525] px-4 py-3 text-[12px] font-semibold text-white/76 shadow-[0_16px_32px_rgba(0,0,0,0.28)]"><LoaderCircle size={15} className="animate-spin text-[#8fb4ff]" /> 대화 불러오는 중</span> <span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76 shadow-[0_16px_32px_rgba(0,0,0,0.28)]"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span>
</div> </div>
)} )}
</main> </main>
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#202228]"> <aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]">
<div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"> <div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3">
<span className="text-[11px] font-bold tracking-[0.08em] text-white/44">질문 목록</span> <span className="text-[11px] font-bold text-white/44">질문 목록</span>
</div> </div>
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2"> <div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
{questionMessages.length ? ( {questionMessages.length ? (
@@ -282,7 +297,7 @@ export default function AiGuideView({
key={message.id} key={message.id}
type="button" type="button"
onClick={() => scrollToQuestion(message.id)} onClick={() => scrollToQuestion(message.id)}
className={['w-full min-w-0 overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}
> >
<span className="flex w-full min-w-0 items-start gap-2"> <span className="flex w-full min-w-0 items-start gap-2">
<span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span> <span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span>
@@ -299,15 +314,15 @@ export default function AiGuideView({
</div> </div>
{deleteConfirmThread && ( {deleteConfirmThread && (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36"> <div className="absolute inset-0 z-50 grid place-items-center bg-black/36">
<section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#252525] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"> <section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#1b2a42] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]">
<div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"> <div className="flex items-center gap-2 border-b border-white/10 px-4 py-3">
<Trash2 size={15} className="text-[#e87979]" /> <Trash2 size={15} className="text-[#e87979]" />
<strong className="text-[13px] font-bold">대화 삭제</strong> <strong className="text-[13px] font-bold">채팅 삭제</strong>
</div> </div>
<div className="p-4"> <div className="p-4">
<p className="text-[13px] font-semibold text-white">이 대화 내역을 삭제할까요?</p> <p className="text-[13px] font-semibold text-white">이 채팅 내역을 삭제할까요?</p>
<p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 대화'}</p> <p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 채팅'}</p>
<p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 대화와 답변은 복구할 수 없습니다.</p> <p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 채팅과 답변은 복구할 수 없습니다.</p>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button <button
type="button" type="button"
+413 -52
View File
@@ -2,12 +2,12 @@ import { type BeforeMount, type OnMount } from '@monaco-editor/react'
import { driver } from 'driver.js' import { driver } from 'driver.js'
import { CarFront, Laptop, Monitor } from 'lucide-react' import { CarFront, Laptop, Monitor } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { io } from 'socket.io-client' import { io } from 'socket.io-client'
import type { Socket } from 'socket.io-client' import type { Socket } from 'socket.io-client'
import Sidebar from './components/Sidebar' import Sidebar from './components/Sidebar'
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay' import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
import { RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals' import { ControlExtensionConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
import TitleBar from './components/TitleBar' import TitleBar from './components/TitleBar'
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig' import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession' import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
@@ -16,7 +16,7 @@ import CourseSelectionScreen from './features/curriculum/CourseSelectionScreen'
import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen' import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal' import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
import MaterialsModal from './features/curriculum/MaterialsModal' import MaterialsModal from './features/curriculum/MaterialsModal'
import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi' import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi'
import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types' import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
import { getContextHelpSteps } from './features/help/contextHelp' import { getContextHelpSteps } from './features/help/contextHelp'
import AiGuideView from '../features/AiGuideView' import AiGuideView from '../features/AiGuideView'
@@ -26,7 +26,7 @@ import PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel'
import PlatformSettingsPanel from './features/platforms/PlatformSettingsPanel' import PlatformSettingsPanel from './features/platforms/PlatformSettingsPanel'
import RecentSessionModal from './features/platforms/RecentSessionModal' import RecentSessionModal from './features/platforms/RecentSessionModal'
import type { RecentSessionItem } from './features/platforms/RecentSessionModal' import type { RecentSessionItem } from './features/platforms/RecentSessionModal'
import { connectPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl' import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl' import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl'
import type { EnvironmentProfile, PlatformControlActionResponse, PlatformOption, StudioSourceSettings } from './features/platforms/types' import type { EnvironmentProfile, PlatformControlActionResponse, PlatformOption, StudioSourceSettings } from './features/platforms/types'
import WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader' import WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader'
@@ -41,6 +41,8 @@ import type { WorkspaceGuideResourceMode } from './features/workspace/WorkspaceG
import WorkspaceLogView from './features/workspace/WorkspaceLogView' import WorkspaceLogView from './features/workspace/WorkspaceLogView'
import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu' import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu'
import ModuleListView from './features/workspace/ModuleListView' import ModuleListView from './features/workspace/ModuleListView'
import WorldEditorModal from './features/workspace/WorldEditorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel' import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView' import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView' import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
@@ -61,10 +63,11 @@ import { useWorkspaceTreeState } from './features/workspace/useWorkspaceTreeStat
import { useWorkspaceVisualization } from './features/workspace/useWorkspaceVisualization' import { useWorkspaceVisualization } from './features/workspace/useWorkspaceVisualization'
import { makeModuleParameterKey, readStoredModuleParameters, serializeModuleParameters } from './features/workspace/moduleParameters' import { makeModuleParameterKey, readStoredModuleParameters, serializeModuleParameters } from './features/workspace/moduleParameters'
import { parseHostFromUrl, readNestedString } from './features/workspace/remoteWorkspaceTarget' import { parseHostFromUrl, readNestedString } from './features/workspace/remoteWorkspaceTarget'
import { resolveRuntimeModules } from './features/workspace/runtimeModuleSet'
import { defaultStudioSourceSettings, makePlatformWorkspaceRoot, makeWeekWorkspaceRoot, normalizeStudioSourceSettings, readStudioSourceSettings, storeStudioSourceSettings } from './features/workspace/sourceSettings' import { defaultStudioSourceSettings, makePlatformWorkspaceRoot, makeWeekWorkspaceRoot, normalizeStudioSourceSettings, readStudioSourceSettings, storeStudioSourceSettings } from './features/workspace/sourceSettings'
import { makeWorkspaceUiSessionKey, readStudioRuntimeSessionState, storeStudioRuntimeSessionState } from './features/workspace/studioRuntimeSession' import { makeWorkspaceUiSessionKey, readStudioRuntimeSessionState, storeStudioRuntimeSessionState } from './features/workspace/studioRuntimeSession'
import type { StudioRuntimeSessionState } from './features/workspace/studioRuntimeSession' import type { StudioRuntimeSessionState } from './features/workspace/studioRuntimeSession'
import type { ModuleRow } from './features/workspace/types' import type { ModuleRow, RuntimeImageArtifact } from './features/workspace/types'
import { formatControlRemainingTime, formatSessionClock } from './features/workspace/workspaceFormatters' import { formatControlRemainingTime, formatSessionClock } from './features/workspace/workspaceFormatters'
import { buildModuleTreeItems, buildPathTreeItems, getEditorLanguage } from './features/workspace/workspaceFiles' import { buildModuleTreeItems, buildPathTreeItems, getEditorLanguage } from './features/workspace/workspaceFiles'
import type { EditorFilePath } from './features/workspace/workspaceFiles' import type { EditorFilePath } from './features/workspace/workspaceFiles'
@@ -81,6 +84,35 @@ type IdeActivity = WorkspaceActivity
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide' const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
function isRemoteEnvironmentProfile(profile: EnvironmentProfile): boolean {
const target = profile.config?.target
return target === 'remote-jetson' || target === 'platform'
}
function resolveModuleRuntimeImages(
profiles: EnvironmentProfile[],
remoteWorkspace: boolean,
activeRuntimeImages: RuntimeImageArtifact[],
): RuntimeImageArtifact[] {
if (!remoteWorkspace) return activeRuntimeImages
const images = profiles
.filter(isRemoteEnvironmentProfile)
.flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
.filter((image): image is RuntimeImageArtifact => (
typeof image === 'object'
&& image !== null
&& typeof (image as RuntimeImageArtifact).image === 'string'
&& Boolean((image as RuntimeImageArtifact).image?.trim())
// ROS bridges are started by the visualization flow, not module execution.
&& !/ros-bridge/i.test((image as RuntimeImageArtifact).image ?? '')
))
return images.filter((image, index) => (
images.findIndex((candidate) => candidate.image?.trim() === image.image?.trim()) === index
))
}
function makeStudentSlug(email?: string | null): string { function makeStudentSlug(email?: string | null): string {
return ((email ?? '').split('@')[0] || 'student').replace(/[^a-zA-Z0-9_-]+/g, '-').toLowerCase() return ((email ?? '').split('@')[0] || 'student').replace(/[^a-zA-Z0-9_-]+/g, '-').toLowerCase()
} }
@@ -343,7 +375,7 @@ const configureMonacoTheme: BeforeMount = (monaco) => {
}) })
} }
const imagePreviewSources: Partial<Record<string, string>> = { const initialImagePreviewSources: Partial<Record<string, string>> = {
'src/renderer/public/assets/icon.png': '/assets/icon.png', 'src/renderer/public/assets/icon.png': '/assets/icon.png',
'src/renderer/public/assets/splash.png': '/assets/splash.png', 'src/renderer/public/assets/splash.png': '/assets/splash.png',
'src/renderer/public/assets/images/cc_bg.png': '/assets/images/cc_bg.png', 'src/renderer/public/assets/images/cc_bg.png': '/assets/images/cc_bg.png',
@@ -361,9 +393,11 @@ function LocalPcWorkspaceMockup({
onClose, onClose,
onDisconnectSession, onDisconnectSession,
onReleaseControl, onReleaseControl,
onRequestControlExtension,
onTitlebarNotice, onTitlebarNotice,
onRegisterStopRunningModules, onRegisterStopRunningModules,
canReleaseControl, canReleaseControl,
canExtendControl,
controlEndsAt, controlEndsAt,
theme, theme,
title, title,
@@ -379,17 +413,21 @@ function LocalPcWorkspaceMockup({
weekNumber, weekNumber,
courseKey, courseKey,
curriculumWeek, curriculumWeek,
publicGuideCourse,
activeLearningStep, activeLearningStep,
allowFutureLearningSteps, allowFutureLearningSteps,
teacherQaDisabled,
onLearningStepSelect, onLearningStepSelect,
onSubmissionRecorded onSubmissionRecorded
}: { }: {
onClose: () => void onClose: () => void
onDisconnectSession: () => void onDisconnectSession: () => void
onReleaseControl: () => void onReleaseControl: () => void
onRequestControlExtension: () => void
onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void
onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void
canReleaseControl: boolean canReleaseControl: boolean
canExtendControl: boolean
controlEndsAt?: string | null controlEndsAt?: string | null
theme: 'dark' | 'light' theme: 'dark' | 'light'
title: string title: string
@@ -405,8 +443,10 @@ function LocalPcWorkspaceMockup({
weekNumber: number weekNumber: number
courseKey: string courseKey: string
curriculumWeek: CurriculumWeek curriculumWeek: CurriculumWeek
publicGuideCourse: boolean
activeLearningStep: string | null activeLearningStep: string | null
allowFutureLearningSteps: boolean allowFutureLearningSteps: boolean
teacherQaDisabled?: boolean
onLearningStepSelect?: (step: string) => void onLearningStepSelect?: (step: string) => void
onSubmissionRecorded: () => void onSubmissionRecorded: () => void
}): React.JSX.Element { }): React.JSX.Element {
@@ -453,6 +493,8 @@ function LocalPcWorkspaceMockup({
const [workspaceNavFlash, setWorkspaceNavFlash] = useState(false) const [workspaceNavFlash, setWorkspaceNavFlash] = useState(false)
const [moduleWorkspaceSnapshots, setModuleWorkspaceSnapshots] = useState<Record<string, ModuleWorkspaceSnapshot>>({}) const [moduleWorkspaceSnapshots, setModuleWorkspaceSnapshots] = useState<Record<string, ModuleWorkspaceSnapshot>>({})
const [previewOpen, setPreviewOpen] = useState(false) const [previewOpen, setPreviewOpen] = useState(false)
const [worldMapSelectorOpen, setWorldMapSelectorOpen] = useState(false)
const [worldEditorOpen, setWorldEditorOpen] = useState(false)
const [previewLarge, setPreviewLarge] = useState(false) const [previewLarge, setPreviewLarge] = useState(false)
const [previewPosition, setPreviewPosition] = useState({ x: 1040, y: 72 }) const [previewPosition, setPreviewPosition] = useState({ x: 1040, y: 72 })
const [previewSize, setPreviewSize] = useState({ width: 600, height: 600 }) const [previewSize, setPreviewSize] = useState({ width: 600, height: 600 })
@@ -494,6 +536,7 @@ function LocalPcWorkspaceMockup({
resetTabDrag resetTabDrag
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive) } = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null) const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
const [imagePreviewSources, setImagePreviewSources] = useState<Partial<Record<string, string>>>(initialImagePreviewSources)
const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null) const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null)
const [gitFileContextMenu, setGitFileContextMenu] = useState<{ moduleName: string; folderName?: string | null; filePath: string; status: GitChangedFile['status']; x: number; y: number } | null>(null) const [gitFileContextMenu, setGitFileContextMenu] = useState<{ moduleName: string; folderName?: string | null; filePath: string; status: GitChangedFile['status']; x: number; y: number } | null>(null)
const { const {
@@ -634,6 +677,27 @@ function LocalPcWorkspaceMockup({
const settingsActive = activeIdeActivity === 'settings' const settingsActive = activeIdeActivity === 'settings'
const terminalActive = activeIdeActivity === 'workspace' || activeIdeActivity === 'search' const terminalActive = activeIdeActivity === 'workspace' || activeIdeActivity === 'search'
const remoteWorkspace = platformKey !== 'futuredrive-local-pc' const remoteWorkspace = platformKey !== 'futuredrive-local-pc'
const worldMapStorageKey = `atnmy:lms:world-map:${courseKey}:${weekNumber}:${platformKey}`
const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => (
window.localStorage.getItem(worldMapStorageKey) ?? 'nav2-turtlebot3-world'
))
const [worldLayoutVersion, setWorldLayoutVersion] = useState(0)
const availableWorldMaps = useMemo(() => {
const configuredMaps = (curriculumWeek.maps ?? [])
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
if (!configuredMaps.length) return NAV2_MAP_OPTIONS
const localCustomMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')
return localCustomMap && !configuredMaps.some((map) => map.key === localCustomMap.key)
? [...configuredMaps, localCustomMap]
: configuredMaps
}, [curriculumWeek.maps])
const selectedWorldMap = availableWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? availableWorldMaps[0] ?? NAV2_MAP_OPTIONS[0]
useEffect(() => {
const storedMapKey = window.localStorage.getItem(worldMapStorageKey)
setSelectedWorldMapKey(availableWorldMaps.some((map) => map.key === storedMapKey)
? storedMapKey!
: availableWorldMaps[0]?.key ?? 'nav2-turtlebot3-world')
}, [availableWorldMaps, worldMapStorageKey])
const visualizationProfiles = [ const visualizationProfiles = [
...(platformEnvironmentProfiles?.filter((profile) => profile.week === weekNumber) ?? []), ...(platformEnvironmentProfiles?.filter((profile) => profile.week === weekNumber) ?? []),
...(curriculumWeek.environmentProfiles ?? []) ...(curriculumWeek.environmentProfiles ?? [])
@@ -641,10 +705,10 @@ function LocalPcWorkspaceMockup({
items.findIndex((item) => item.profileType.toLowerCase() === profile.profileType.toLowerCase()) === index items.findIndex((item) => item.profileType.toLowerCase() === profile.profileType.toLowerCase()) === index
)) ))
const configuredWorkspaceRoot = remoteWorkspace const configuredWorkspaceRoot = remoteWorkspace
? defaultStudioSourceSettings.workspaceRoot ? platformSourceSettingsDefaults.workspaceRoot
: sourceSettings.workspaceRoot : sourceSettings.workspaceRoot
const configuredDraftWorkspaceRoot = remoteWorkspace const configuredDraftWorkspaceRoot = remoteWorkspace
? defaultStudioSourceSettings.workspaceRoot ? platformSourceSettingsDefaults.workspaceRoot
: sourceSettingsDraft.workspaceRoot : sourceSettingsDraft.workspaceRoot
const currentSelectedTreePath = exampleActive ? selectedExampleTreePath : selectedTreePath const currentSelectedTreePath = exampleActive ? selectedExampleTreePath : selectedTreePath
const surface = dark ? 'bg-[#202020] text-[#e6e6e6]' : 'bg-[#eef1f6] text-[#151827]' const surface = dark ? 'bg-[#202020] text-[#e6e6e6]' : 'bg-[#eef1f6] text-[#151827]'
@@ -655,7 +719,6 @@ function LocalPcWorkspaceMockup({
const textDim = dark ? 'text-white/45' : 'text-[#727b8c]' const textDim = dark ? 'text-white/45' : 'text-[#727b8c]'
const studentSlug = makeStudentSlug(studentEmail) const studentSlug = makeStudentSlug(studentEmail)
const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, courseKey, weekNumber, platformKey) const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, courseKey, weekNumber, platformKey)
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platformId}-${studentSlug}-week-${weekNumber}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
const { const {
visualizationStacks, visualizationStacks,
visualizationStack, visualizationStack,
@@ -663,6 +726,8 @@ function LocalPcWorkspaceMockup({
visualizationRunning, visualizationRunning,
visualizationBusy, visualizationBusy,
visualizationViewerUrl, visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
activeEnvironmentProfile, activeEnvironmentProfile,
activeRuntimeImages, activeRuntimeImages,
refreshVisualizationStatus, refreshVisualizationStatus,
@@ -672,8 +737,21 @@ function LocalPcWorkspaceMockup({
profiles: visualizationProfiles, profiles: visualizationProfiles,
clonePathPreview, clonePathPreview,
rosDomainId: sourceSettings.rosDomainId, rosDomainId: sourceSettings.rosDomainId,
remoteWorkspace,
mapKey: selectedWorldMap.key,
worldLayoutVersion,
onTitlebarNotice onTitlebarNotice
}) })
const runtimeModules = useMemo(() => resolveRuntimeModules(
curriculumWeek.modules,
remoteWorkspace
? visualizationProfiles.filter(isRemoteEnvironmentProfile)
: activeEnvironmentProfile,
remoteWorkspace ? 'platform' : 'local'
), [activeEnvironmentProfile, curriculumWeek.modules, remoteWorkspace, visualizationProfiles])
const moduleRuntimeImages = useMemo(() => (
resolveModuleRuntimeImages(visualizationProfiles, remoteWorkspace, activeRuntimeImages)
), [activeRuntimeImages, remoteWorkspace, visualizationProfiles])
const profileSshUser = readNestedString(activeEnvironmentProfile?.config, ['agent', 'sshUser']) const profileSshUser = readNestedString(activeEnvironmentProfile?.config, ['agent', 'sshUser'])
const resolveRemoteWorkspaceTarget = (): RemoteWorkspaceTarget | null => { const resolveRemoteWorkspaceTarget = (): RemoteWorkspaceTarget | null => {
const profileAgentUrl = readNestedString(activeEnvironmentProfile?.config, ['agent', 'url']) const profileAgentUrl = readNestedString(activeEnvironmentProfile?.config, ['agent', 'url'])
@@ -688,7 +766,7 @@ function LocalPcWorkspaceMockup({
host, host,
user: sshUser ?? undefined, user: sshUser ?? undefined,
port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22, port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22,
workspaceRoot: remoteRuntimeRoot workspaceRoot: clonePathPreview
} }
} }
const getRemoteWorkspaceTarget = (): RemoteWorkspaceTarget => { const getRemoteWorkspaceTarget = (): RemoteWorkspaceTarget => {
@@ -709,15 +787,18 @@ function LocalPcWorkspaceMockup({
const storedParameters = readStoredModuleParameters() const storedParameters = readStoredModuleParameters()
return modules.map((module) => { return modules.map((module) => {
const override = storedParameters[getModuleParameterStorageKey(module)] const override = storedParameters[getModuleParameterStorageKey(module)]
if (!override) return module const defaultParameters = runtimeModules.find((item) => item.folderName === module.folderName)?.defaultParameters
?.filter((parameter): parameter is string => typeof parameter === 'string') ?? []
return { return {
...module, ...module,
parameters: serializeModuleParameters(override).split(/\s+/).filter(Boolean) parameters: override
? serializeModuleParameters(override).split(/\s+/).filter(Boolean)
: defaultParameters.length ? defaultParameters : module.parameters
} }
}) })
}, [courseKey, weekNumber]) }, [courseKey, runtimeModules, weekNumber])
const curriculumModuleKey = (curriculumWeek.modules ?? []) const curriculumModuleKey = runtimeModules
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}`) .map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`)
.join('|') .join('|')
const { const {
availableModules, availableModules,
@@ -727,7 +808,7 @@ function LocalPcWorkspaceMockup({
loadAvailableModules loadAvailableModules
} = useWorkspaceModuleList({ } = useWorkspaceModuleList({
clonePathPreview, clonePathPreview,
curriculumModules: curriculumWeek.modules, curriculumModules: runtimeModules,
curriculumModuleKey, curriculumModuleKey,
remoteWorkspace, remoteWorkspace,
remoteWorkspaceTargetKey, remoteWorkspaceTargetKey,
@@ -807,6 +888,8 @@ function LocalPcWorkspaceMockup({
} = useWorkspaceFileActions({ } = useWorkspaceFileActions({
selectedModule, selectedModule,
clonePathPreview, clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
selectedTreePath, selectedTreePath,
setSelectedTreePath, setSelectedTreePath,
setSelectedExampleTreePath, setSelectedExampleTreePath,
@@ -971,7 +1054,7 @@ function LocalPcWorkspaceMockup({
let statusMap = gitModuleStatuses let statusMap = gitModuleStatuses
try { try {
const entries = await Promise.all(installedGitModules.map(async (module) => { const entries = await Promise.all(installedGitModules.map(async (module) => {
const status = await window.localWorkspace.git.status(module.name, clonePathPreview, module.folderName, null) const status = await readGitStatus(module, null)
return [module.name, status] as const return [module.name, status] as const
})) }))
statusMap = Object.fromEntries(entries) statusMap = Object.fromEntries(entries)
@@ -997,14 +1080,24 @@ function LocalPcWorkspaceMockup({
try { try {
let submittedCommitCount = 0 let submittedCommitCount = 0
for (const module of changedModules) { for (const module of changedModules) {
const result = await window.localWorkspace.git.submit( const result = remoteWorkspace
? await window.remoteWorkspace.git.submit(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
`${weekNumber}주차 ${module.name} 제출`,
undefined,
undefined,
submissionId,
)
: await window.localWorkspace.git.submit(
module.name, module.name,
clonePathPreview, clonePathPreview,
module.folderName, module.folderName,
`${weekNumber}주차 ${module.name} 제출`, `${weekNumber}주차 ${module.name} 제출`,
undefined, undefined,
undefined, undefined,
submissionId submissionId,
) )
if (!result.ok) throw new Error(`[${module.name}] ${result.message}`) if (!result.ok) throw new Error(`[${module.name}] ${result.message}`)
if (result.commit) submittedCommitCount += 1 if (result.commit) submittedCommitCount += 1
@@ -1159,6 +1252,8 @@ function LocalPcWorkspaceMockup({
moduleName: selectedModule?.name, moduleName: selectedModule?.name,
moduleFolderName: selectedModule?.folderName, moduleFolderName: selectedModule?.folderName,
clonePathPreview, clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
dark, dark,
terminalActive, terminalActive,
resetKey: `${remoteWorkspace}:${remoteWorkspaceTargetKey}`, resetKey: `${remoteWorkspace}:${remoteWorkspaceTargetKey}`,
@@ -1293,6 +1388,24 @@ function LocalPcWorkspaceMockup({
const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext' const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext'
const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false
const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined
useEffect(() => {
const file = currentActiveEditorFile
if (!file || !activeFileIsImage || file.startsWith('examples/example-code/') || !selectedModule || imagePreviewSources[file]) return
let disposed = false
const loadImagePreview = async (): Promise<void> => {
try {
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.image(getRemoteWorkspaceTarget(), selectedModule.name, file, selectedModule.folderName)
: await window.localWorkspace.moduleFiles.image(selectedModule.name, file, clonePathPreview, selectedModule.folderName)
if (!disposed) setImagePreviewSources((sources) => ({ ...sources, [file]: result.dataUrl }))
} catch (error) {
console.warn('[module-files] image preview failed', error)
}
}
void loadImagePreview()
return () => { disposed = true }
}, [activeFileIsImage, clonePathPreview, currentActiveEditorFile, imagePreviewSources, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule])
const openEditorFile = (file: EditorFilePath): void => { const openEditorFile = (file: EditorFilePath): void => {
if (file.startsWith('examples/example-code/')) { if (file.startsWith('examples/example-code/')) {
setSelectedExampleTreePath(file) setSelectedExampleTreePath(file)
@@ -1378,9 +1491,14 @@ function LocalPcWorkspaceMockup({
setInstallingModuleName('__source_settings__') setInstallingModuleName('__source_settings__')
setModuleInstallError(null) setModuleInstallError(null)
try { try {
const result = await window.localWorkspace.modules.stopMany( const result = remoteWorkspace
? await window.remoteWorkspace.modules.stopMany(
getRemoteWorkspaceTarget(),
runningModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })), runningModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
clonePathPreview )
: await window.localWorkspace.modules.stopMany(
runningModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
clonePathPreview,
) )
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
} catch (error) { } catch (error) {
@@ -1437,7 +1555,17 @@ function LocalPcWorkspaceMockup({
if (!dirtyFiles.length) return if (!dirtyFiles.length) return
await Promise.all(dirtyFiles.map(async (file) => { await Promise.all(dirtyFiles.map(async (file) => {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.write(
getRemoteWorkspaceTarget(),
module.name,
file,
editorFiles[file] ?? '',
module.folderName,
)
} else {
await window.localWorkspace.moduleFiles.write(module.name, file, editorFiles[file] ?? '', clonePathPreview, module.folderName) await window.localWorkspace.moduleFiles.write(module.name, file, editorFiles[file] ?? '', clonePathPreview, module.folderName)
}
setSavedEditorFiles((files) => ({ ...files, [file]: editorFiles[file] ?? '' })) setSavedEditorFiles((files) => ({ ...files, [file]: editorFiles[file] ?? '' }))
})) }))
setDirtyEditorFiles((dirtyFilesState) => { setDirtyEditorFiles((dirtyFilesState) => {
@@ -1448,13 +1576,13 @@ function LocalPcWorkspaceMockup({
} }
const { prepareRemoteRuntimeModule } = useRemoteWorkspaceRuntime({ const { prepareRemoteRuntimeModule } = useRemoteWorkspaceRuntime({
availableModules, availableModules,
curriculumModules: curriculumWeek.modules, curriculumModules: runtimeModules,
courseKey, courseKey,
weekNumber, weekNumber,
clonePathPreview, clonePathPreview,
remoteWorkspace, remoteWorkspace,
remoteWorkspaceTargetKey, remoteWorkspaceTargetKey,
activeRuntimeImages, activeRuntimeImages: moduleRuntimeImages,
getFreshAccessToken, getFreshAccessToken,
getRemoteWorkspaceTarget, getRemoteWorkspaceTarget,
saveDirtyFilesForModule, saveDirtyFilesForModule,
@@ -1481,16 +1609,29 @@ function LocalPcWorkspaceMockup({
clonePathPreview, clonePathPreview,
remoteWorkspace, remoteWorkspace,
sourceSettings, sourceSettings,
activeRuntimeImages, activeRuntimeImages: moduleRuntimeImages,
getFreshAccessToken, getFreshAccessToken,
getRemoteWorkspaceTarget, getRemoteWorkspaceTarget,
prepareRemoteRuntimeModule, prepareRemoteRuntimeModule,
onTitlebarNotice onTitlebarNotice
}) })
const deleteModule = async (module: ModuleRow, options: { deleteRepository?: boolean } = {}): Promise<void> => { const deleteModule = async (module: ModuleRow, options: { deleteRepository?: boolean } = {}): Promise<void> => {
if (module.state !== 'stopped') {
onTitlebarNotice(`[${module.name}] 정지된 모듈만 삭제할 수 있습니다.`, 'warning')
return
}
setModuleInstallError(null) setModuleInstallError(null)
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.delete(
getRemoteWorkspaceTarget(),
module.name,
`modules/${module.name}`,
module.folderName,
)
} else {
await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName) await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName)
}
if (options.deleteRepository) { if (options.deleteRepository) {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/repositories`, { const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/repositories`, {
method: 'DELETE', method: 'DELETE',
@@ -2094,6 +2235,12 @@ function LocalPcWorkspaceMockup({
const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000) const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000)
const showControlCountdown = canReleaseControl && controlRemainingSeconds !== null const showControlCountdown = canReleaseControl && controlRemainingSeconds !== null
const controlCountdownDanger = showControlCountdown && controlRemainingSeconds < 5 * 60 const controlCountdownDanger = showControlCountdown && controlRemainingSeconds < 5 * 60
const applyWorldMapSelection = (map: WorkspaceMapOption): void => {
window.localStorage.setItem(worldMapStorageKey, map.key)
setSelectedWorldMapKey(map.key)
setWorldMapSelectorOpen(false)
onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info')
}
return ( return (
<div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}> <div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
@@ -2111,6 +2258,7 @@ function LocalPcWorkspaceMockup({
previewOpen={previewOpen} previewOpen={previewOpen}
showControlCountdown={showControlCountdown} showControlCountdown={showControlCountdown}
controlCountdownDanger={Boolean(controlCountdownDanger)} controlCountdownDanger={Boolean(controlCountdownDanger)}
canExtendControl={canExtendControl}
controlEndsAt={controlEndsAt ?? null} controlEndsAt={controlEndsAt ?? null}
submitVisible={curriculumWeek.state !== 'done'} submitVisible={curriculumWeek.state !== 'done'}
submitDisabled={gitSubmitting || ((curriculumWeek.detail.requiresSourceSubmission ?? true) && installedGitModules.length === 0)} submitDisabled={gitSubmitting || ((curriculumWeek.detail.requiresSourceSubmission ?? true) && installedGitModules.length === 0)}
@@ -2129,6 +2277,7 @@ function LocalPcWorkspaceMockup({
onTogglePreview={() => setPreviewOpen((value) => !value)} onTogglePreview={() => setPreviewOpen((value) => !value)}
onSubmit={() => setSubmitConfirmOpen(true)} onSubmit={() => setSubmitConfirmOpen(true)}
onOpenPowerActions={() => setPowerActionModalOpen(true)} onOpenPowerActions={() => setPowerActionModalOpen(true)}
onRequestControlExtension={onRequestControlExtension}
onClose={requestWorkspaceClose} onClose={requestWorkspaceClose}
/> />
@@ -2146,16 +2295,44 @@ function LocalPcWorkspaceMockup({
running={visualizationRunning} running={visualizationRunning}
busy={visualizationBusy} busy={visualizationBusy}
viewerUrl={visualizationViewerUrl} viewerUrl={visualizationViewerUrl}
nav2Mode={nav2VisualizationMode}
onDragStart={startPreviewDrag} onDragStart={startPreviewDrag}
onToggleLarge={() => setPreviewLarge((value) => !value)} onToggleLarge={() => setPreviewLarge((value) => !value)}
onClose={() => setPreviewOpen(false)} onClose={() => setPreviewOpen(false)}
onStackChange={setVisualizationStack} onStackChange={setVisualizationStack}
onStart={() => void startVisualizationBridge()} onStart={() => void startVisualizationBridge()}
onStartNav2={(mode) => {
setNav2VisualizationMode(mode)
void startVisualizationBridge()
}}
onStop={() => void stopVisualizationBridge()} onStop={() => void stopVisualizationBridge()}
onResizeStart={startPreviewResize} onResizeStart={startPreviewResize}
/> />
)} )}
{worldMapSelectorOpen && (
<WorldMapSelectorModal
platformKey={activeEnvironmentProfile?.profileType ?? 'NAV2'}
weekNumber={weekNumber}
maps={availableWorldMaps}
selectedMapKey={selectedWorldMap.key}
onClose={() => setWorldMapSelectorOpen(false)}
onCreate={() => {
setWorldMapSelectorOpen(false)
setWorldEditorOpen(true)
}}
onSelect={applyWorldMapSelection}
/>
)}
{worldEditorOpen && (
<WorldEditorModal
workspaceRoot={clonePathPreview}
onSaved={() => setWorldLayoutVersion((version) => version + 1)}
onClose={() => setWorldEditorOpen(false)}
/>
)}
{treeDialog && ( {treeDialog && (
<TreeTargetDialog <TreeTargetDialog
dark={dark} dark={dark}
@@ -2616,6 +2793,7 @@ function LocalPcWorkspaceMockup({
controllingModuleNames={controllingModuleNames} controllingModuleNames={controllingModuleNames}
listError={moduleListError} listError={moduleListError}
installError={moduleInstallError} installError={moduleInstallError}
allowRepositoryDeletion={!remoteWorkspace && !publicGuideCourse}
onAddModuleTab={addModuleTabOnly} onAddModuleTab={addModuleTabOnly}
onOpenSource={openModuleSource} onOpenSource={openModuleSource}
onInstallModule={(module) => void installModule(module)} onInstallModule={(module) => void installModule(module)}
@@ -2628,6 +2806,13 @@ function LocalPcWorkspaceMockup({
onInstallAll={() => void installAllModules()} onInstallAll={() => void installAllModules()}
onStartAll={() => void startAllModules()} onStartAll={() => void startAllModules()}
onStopAll={() => void stopAllModules()} onStopAll={() => void stopAllModules()}
onOpenWorldEditor={() => {
if (remoteWorkspace) {
onTitlebarNotice('맵 설정은 현재 로컬 플랫폼에서만 지원합니다.', 'warning')
return
}
setWorldMapSelectorOpen(true)
}}
/> />
</div> </div>
)} )}
@@ -2655,6 +2840,7 @@ function LocalPcWorkspaceMockup({
iconButtonClass={iconButtonClass} iconButtonClass={iconButtonClass}
accessToken={accessToken} accessToken={accessToken}
qaMode={qaMode} qaMode={qaMode}
teacherQaDisabled={teacherQaDisabled}
teacherChatFlash={teacherChatFlash} teacherChatFlash={teacherChatFlash}
qaMessagesLoading={qaMessagesLoading} qaMessagesLoading={qaMessagesLoading}
visibleAiMessages={visibleAiMessages} visibleAiMessages={visibleAiMessages}
@@ -2671,7 +2857,7 @@ function LocalPcWorkspaceMockup({
if (mode === 'teacher') setTeacherChatFlash(false) if (mode === 'teacher') setTeacherChatFlash(false)
}} }}
onStop={stopAiResponse} onStop={stopAiResponse}
onSubmit={(content) => void sendQaMessage(content)} onSubmit={(content) => void sendQaMessage(content, { contextWeek: selectedWeekState?.week })}
onClearConversation={() => { onClearConversation={() => {
void clearQaConversation().catch((error) => console.error('[chat] clear failed', error)) void clearQaConversation().catch((error) => console.error('[chat] clear failed', error))
}} }}
@@ -2708,6 +2894,7 @@ export default function App(): React.JSX.Element {
const [curriculumWeeks, setCurriculumWeeks] = useState<CurriculumWeek[]>([]) const [curriculumWeeks, setCurriculumWeeks] = useState<CurriculumWeek[]>([])
const [courseKey, setCourseKey] = useState('default-course') const [courseKey, setCourseKey] = useState('default-course')
const [studentCourses, setStudentCourses] = useState<StudentCourseOption[]>([]) const [studentCourses, setStudentCourses] = useState<StudentCourseOption[]>([])
const [trialCourse, setTrialCourse] = useState<StudentCourseOption | null>(null)
const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false) const [studentCoursesLoaded, setStudentCoursesLoaded] = useState(false)
const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '') const [selectedCourseId, setSelectedCourseId] = useState(() => window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) ?? '')
const [courseSelectionVisible, setCourseSelectionVisible] = useState(false) const [courseSelectionVisible, setCourseSelectionVisible] = useState(false)
@@ -2721,6 +2908,7 @@ export default function App(): React.JSX.Element {
const [authSession, setAuthSession] = useState<LmsAuthSession | null>(null) const [authSession, setAuthSession] = useState<LmsAuthSession | null>(null)
const authSessionRef = useRef<LmsAuthSession | null>(null) const authSessionRef = useRef<LmsAuthSession | null>(null)
const [authReady, setAuthReady] = useState(false) const [authReady, setAuthReady] = useState(false)
const [launcherSessionEnded, setLauncherSessionEnded] = useState(false)
const [studentCoursesLoading, setStudentCoursesLoading] = useState(false) const [studentCoursesLoading, setStudentCoursesLoading] = useState(false)
const [curriculumLoading, setCurriculumLoading] = useState(false) const [curriculumLoading, setCurriculumLoading] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
@@ -2754,9 +2942,13 @@ export default function App(): React.JSX.Element {
const [lastWorkspacePlatform, setLastWorkspacePlatform] = useState<PlatformOption | null>(null) const [lastWorkspacePlatform, setLastWorkspacePlatform] = useState<PlatformOption | null>(null)
const connectedPlatformRef = useRef<PlatformOption | null>(null) const connectedPlatformRef = useRef<PlatformOption | null>(null)
const runningPlatformsRef = useRef<PlatformOption[]>([]) const runningPlatformsRef = useRef<PlatformOption[]>([])
const platformStatusRequestSequenceRef = useRef(0)
const [checkedRemotePlatformStatusIds, setCheckedRemotePlatformStatusIds] = useState<Set<string>>(() => new Set())
const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined) const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined)
const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null) const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null)
const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null) const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null)
const [controlExtensionConfirmOpen, setControlExtensionConfirmOpen] = useState(false)
const [controlExtensionSubmitting, setControlExtensionSubmitting] = useState(false)
const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null) const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null)
const [titlebarNotices, setTitlebarNotices] = useState<Array<{ id: number; title: string; tone?: 'info' | 'warning' | 'error'; addToList?: boolean }>>([]) const [titlebarNotices, setTitlebarNotices] = useState<Array<{ id: number; title: string; tone?: 'info' | 'warning' | 'error'; addToList?: boolean }>>([])
const instructorAccount = isInstructorAccount(authSession?.user) const instructorAccount = isInstructorAccount(authSession?.user)
@@ -2787,7 +2979,8 @@ export default function App(): React.JSX.Element {
} }
const selectedWeek = selectedWeekState ?? EMPTY_WEEK const selectedWeek = selectedWeekState ?? EMPTY_WEEK
const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK const workspaceWeek = workspaceWeekState ?? selectedWeekState ?? EMPTY_WEEK
const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId) ?? null const selectedCourse = studentCourses.find((course) => course.id === selectedCourseId)
?? (trialCourse?.id === selectedCourseId ? trialCourse : null)
const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG const publicGuideCourseActive = selectedCourse?.slug === PUBLIC_GUIDE_COURSE_SLUG
const studioDisabled = !selectedWeekState const studioDisabled = !selectedWeekState
const connectedEnvironmentProfile = connectedPlatform const connectedEnvironmentProfile = connectedPlatform
@@ -2828,6 +3021,10 @@ export default function App(): React.JSX.Element {
clearStoredAuthSession() clearStoredAuthSession()
setAuthSession(null) setAuthSession(null)
}, []) }, [])
useEffect(() => window.launcherAuth?.onSessionExpired?.(() => {
clearAuthSession()
setLauncherSessionEnded(true)
}), [clearAuthSession])
useEffect(() => { useEffect(() => {
authSessionRef.current = authSession authSessionRef.current = authSession
}, [authSession]) }, [authSession])
@@ -2856,7 +3053,6 @@ export default function App(): React.JSX.Element {
?? null ?? null
const profileSshUser = readNestedString(profile?.config, ['agent', 'sshUser']) const profileSshUser = readNestedString(profile?.config, ['agent', 'sshUser'])
const studentSlug = makeStudentSlug(authSession?.user.email) const studentSlug = makeStudentSlug(authSession?.user.email)
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platform.id}-${studentSlug}-week-${workspaceWeek.week}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
const profileAgentUrl = readNestedString(profile?.config, ['agent', 'url']) const profileAgentUrl = readNestedString(profile?.config, ['agent', 'url'])
const host = parseHostFromUrl(profileAgentUrl) const host = parseHostFromUrl(profileAgentUrl)
?? parseHostFromUrl(readNestedString(profile?.config, ['bridge', 'env', 'BRIDGE_HTTP_URL'])) ?? parseHostFromUrl(readNestedString(profile?.config, ['bridge', 'env', 'BRIDGE_HTTP_URL']))
@@ -2868,7 +3064,13 @@ export default function App(): React.JSX.Element {
host, host,
user: profileSshUser ?? platformSettings.sshUser ?? platformDefaults.sshUser ?? undefined, user: profileSshUser ?? platformSettings.sshUser ?? platformDefaults.sshUser ?? undefined,
port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22, port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22,
workspaceRoot: remoteRuntimeRoot workspaceRoot: makePlatformWorkspaceRoot(
platformSettings.workspaceRoot,
studentSlug,
courseKey,
workspaceWeek.week,
platform.platformKey,
)
} }
} }
const stopRemotePlatformModules = async (platform: PlatformOption): Promise<void> => { const stopRemotePlatformModules = async (platform: PlatformOption): Promise<void> => {
@@ -2877,7 +3079,7 @@ export default function App(): React.JSX.Element {
const modules = (workspaceWeek.modules ?? []) const modules = (workspaceWeek.modules ?? [])
.slice() .slice()
.sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0)) .sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0))
.map((module) => ({ moduleName: module.name, folderName: module.folderName })) .map((module) => ({ moduleName: module.name, folderName: module.folderName, templateRepositoryUrl: module.templateRepositoryUrl }))
if (!modules.length) return if (!modules.length) return
await window.remoteWorkspace.modules.resetSession(target, modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' })) await window.remoteWorkspace.modules.resetSession(target, modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' }))
} }
@@ -3015,17 +3217,19 @@ export default function App(): React.JSX.Element {
setStudentCoursesLoading(true) setStudentCoursesLoading(true)
try { try {
const courses = await fetchStudentCourses(fetchWithFreshAuth) const { courses, guideCourse } = await fetchStudentCourses(fetchWithFreshAuth)
const selectableCourses = guideCourse ? [...courses, guideCourse] : courses
setStudentCourses(courses) setStudentCourses(courses)
setTrialCourse(guideCourse)
setStudentCoursesLoaded(true) setStudentCoursesLoaded(true)
setSelectedCourseId((current) => { setSelectedCourseId((current) => {
if (current && courses.some((course) => course.id === current)) { if (current && selectableCourses.some((course) => course.id === current)) {
if (options.autoSelectSingle) setCourseSelectionVisible(false) if (options.autoSelectSingle) setCourseSelectionVisible(false)
return current return current
} }
const stored = window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY) const stored = window.localStorage.getItem(SELECTED_COURSE_STORAGE_KEY)
if (stored && courses.some((course) => course.id === stored)) { if (stored && selectableCourses.some((course) => course.id === stored)) {
if (options.autoSelectSingle) setCourseSelectionVisible(false) if (options.autoSelectSingle) setCourseSelectionVisible(false)
return stored return stored
} }
@@ -3040,6 +3244,7 @@ export default function App(): React.JSX.Element {
}) })
} catch { } catch {
setStudentCourses([]) setStudentCourses([])
setTrialCourse(null)
setStudentCoursesLoaded(true) setStudentCoursesLoaded(true)
setCourseSelectionVisible(true) setCourseSelectionVisible(true)
} finally { } finally {
@@ -3060,6 +3265,7 @@ export default function App(): React.JSX.Element {
const loadCurriculum = useCallback(async (): Promise<void> => { const loadCurriculum = useCallback(async (): Promise<void> => {
if (!authReady || !studentCoursesLoaded || courseSelectionVisible || !selectedCourseId) return if (!authReady || !studentCoursesLoaded || courseSelectionVisible || !selectedCourseId) return
const curriculumLoadingStartedAt = Date.now()
setCurriculumLoading(true) setCurriculumLoading(true)
try { try {
const payload = await fetchCurriculum(fetchWithFreshAuth, selectedCourseId) const payload = await fetchCurriculum(fetchWithFreshAuth, selectedCourseId)
@@ -3103,6 +3309,8 @@ export default function App(): React.JSX.Element {
setPlatformOptions([]) setPlatformOptions([])
setSelectedWeekState(null) setSelectedWeekState(null)
} finally { } finally {
const remainingMs = Math.max(0, 500 - (Date.now() - curriculumLoadingStartedAt))
if (remainingMs) await new Promise<void>((resolve) => window.setTimeout(resolve, remainingMs))
setCurriculumLoading(false) setCurriculumLoading(false)
} }
}, [authReady, authSession?.accessToken, courseSelectionVisible, fetchWithFreshAuth, selectedCourseId, selectedWeekState?.week, studentCoursesLoaded]) }, [authReady, authSession?.accessToken, courseSelectionVisible, fetchWithFreshAuth, selectedCourseId, selectedWeekState?.week, studentCoursesLoaded])
@@ -3130,23 +3338,63 @@ export default function App(): React.JSX.Element {
let cancelled = false let cancelled = false
const refreshPlatformStatuses = async () => { const refreshPlatformStatuses = async () => {
const requestSequence = ++platformStatusRequestSequenceRef.current
try { try {
const platforms = await fetchPlatformStatuses(fetchWithFreshAuth, remotePlatformIds) const platforms = await fetchPlatformStatuses(fetchWithFreshAuth, remotePlatformIds)
if (cancelled || !platforms.length) return if (cancelled || requestSequence !== platformStatusRequestSequenceRef.current || !platforms.length) return
const statusById = new Map(platforms.map((platform) => [platform.id, platform])) const statusById = new Map(platforms.map((platform) => [platform.id, platform]))
const mergePlatformStatus = (platform: PlatformOption): PlatformOption => { const mergePlatformStatus = (platform: PlatformOption): PlatformOption => {
const status = statusById.get(platform.id) const status = statusById.get(platform.id)
return status ? { ...platform, ...status } : platform return status ? { ...platform, ...status } : platform
} }
setPlatformOptions((current) => current.map(mergePlatformStatus)) setPlatformOptions((current) => current.map(mergePlatformStatus))
setRunningPlatforms((current) => current setCheckedRemotePlatformStatusIds((current) => {
const next = new Set(current)
platforms.forEach((platform) => next.add(platform.id))
return next
})
const lostControlPlatformIds = new Set(
runningPlatformsRef.current
.filter((platform) => {
if (platform.platformKey === 'futuredrive-local-pc') return false
const nextControl = statusById.get(platform.id)?.control
if (!nextControl) return false
if (nextControl.status !== 'ACTIVE') return true
const currentSessionId = platform.control?.session?.id
return Boolean(
currentSessionId
&& nextControl.session?.id
&& nextControl.session.id !== currentSessionId,
)
})
.map((platform) => platform.id),
)
const remainingRunningPlatforms = runningPlatformsRef.current
.filter((platform) => !lostControlPlatformIds.has(platform.id))
.map(mergePlatformStatus) .map(mergePlatformStatus)
.filter((platform) => hasCurrentUserPlatformControl(platform)))
if (lostControlPlatformIds.size) {
setRunningPlatforms(remainingRunningPlatforms)
setConnectedPlatform((current) => {
if (current && !lostControlPlatformIds.has(current.id)) return mergePlatformStatus(current)
return remainingRunningPlatforms[0] ?? null
})
setLastWorkspacePlatform(remainingRunningPlatforms[0] ?? null)
if (!remainingRunningPlatforms.length) {
setLocalPcLayoutOpen(false)
setStudioEntered(false)
setSessionStatus('ended')
setSessionEndedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }))
setWorkspaceWeekState(null)
}
setCurriculumRefreshSignal((current) => current + 1)
} else {
setRunningPlatforms((current) => current.map(mergePlatformStatus))
setConnectedPlatform((current) => { setConnectedPlatform((current) => {
if (!current) return current if (!current) return current
const nextPlatform = mergePlatformStatus(current) return mergePlatformStatus(current)
return hasCurrentUserPlatformControl(nextPlatform) ? nextPlatform : null
}) })
}
} catch { } catch {
// Status polling is best-effort; keep the last known platform state. // Status polling is best-effort; keep the last known platform state.
} }
@@ -3164,10 +3412,15 @@ export default function App(): React.JSX.Element {
const restored = restoredStudioSessionRef.current const restored = restoredStudioSessionRef.current
if (!restored || !platformOptions.length) return if (!restored || !platformOptions.length) return
const restoredPlatformById = new Map(restored.runningPlatforms.map((platform) => [platform.id, platform]))
const nextRunningPlatforms = restored.runningPlatformIds const nextRunningPlatforms = restored.runningPlatformIds
.map((id) => platformOptions.find((platform) => platform.id === id)) .map((id) => {
.filter((platform): platform is PlatformOption => Boolean(platform)) const restoredPlatform = restoredPlatformById.get(id)
.filter((platform) => hasCurrentUserPlatformControl(platform)) const currentPlatform = platformOptions.find((platform) => platform.id === id)
if (restoredPlatform && currentPlatform) return { ...restoredPlatform, ...currentPlatform }
return currentPlatform ?? restoredPlatform ?? null
})
.filter((platform): platform is PlatformOption => Boolean(platform) && hasCurrentUserPlatformControl(platform))
const nextConnectedPlatform = restored.connectedPlatformId const nextConnectedPlatform = restored.connectedPlatformId
? nextRunningPlatforms.find((platform) => platform.id === restored.connectedPlatformId) ?? nextRunningPlatforms[0] ?? null ? nextRunningPlatforms.find((platform) => platform.id === restored.connectedPlatformId) ?? nextRunningPlatforms[0] ?? null
: nextRunningPlatforms[0] ?? null : nextRunningPlatforms[0] ?? null
@@ -3201,6 +3454,7 @@ export default function App(): React.JSX.Element {
sessionEndedAt, sessionEndedAt,
connectedPlatformId: connectedPlatform?.id ?? null, connectedPlatformId: connectedPlatform?.id ?? null,
runningPlatformIds: runningPlatforms.map((platform) => platform.id), runningPlatformIds: runningPlatforms.map((platform) => platform.id),
runningPlatforms,
workspaceWeekNumber: workspaceWeekState?.week ?? null workspaceWeekNumber: workspaceWeekState?.week ?? null
}) })
}, [connectedPlatform?.id, localPcLayoutOpen, runningPlatforms, sessionEndedAt, sessionStartedAt, sessionStatus, studioEntered, workspaceWeekState?.week]) }, [connectedPlatform?.id, localPcLayoutOpen, runningPlatforms, sessionEndedAt, sessionStartedAt, sessionStatus, studioEntered, workspaceWeekState?.week])
@@ -3269,15 +3523,19 @@ export default function App(): React.JSX.Element {
cancelled = true cancelled = true
} }
}, [authReady, authSession?.accessToken, fetchWithFreshAuth]) }, [authReady, authSession?.accessToken, fetchWithFreshAuth])
const isRemotePlatformStatusPending = (platform: PlatformOption): boolean => (
platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(platform.id)
)
const filteredPlatformOptions = platformOptions.filter((platform) => { const filteredPlatformOptions = platformOptions.filter((platform) => {
if (platformFilter === 'available') return platform.state === 'ready' const pending = isRemotePlatformStatusPending(platform)
if (platformFilter === 'unavailable') return platform.state !== 'ready' if (platformFilter === 'available') return platform.state === 'ready' && !pending
if (platformFilter === 'unavailable') return platform.state !== 'ready' && !pending
return true return true
}) })
const platformFilterCounts = { const platformFilterCounts = {
all: platformOptions.length, all: platformOptions.length,
available: platformOptions.filter((platform) => platform.state === 'ready').length, available: platformOptions.filter((platform) => platform.state === 'ready' && !isRemotePlatformStatusPending(platform)).length,
unavailable: platformOptions.filter((platform) => platform.state !== 'ready').length unavailable: platformOptions.filter((platform) => platform.state !== 'ready' && !isRemotePlatformStatusPending(platform)).length
} }
const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc') const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc')
const openPlatformSettings = (platform: PlatformOption): void => { const openPlatformSettings = (platform: PlatformOption): void => {
@@ -3390,6 +3648,15 @@ export default function App(): React.JSX.Element {
].join(' ') ].join(' ')
const fullAppModalOverlayClass = 'fixed bottom-0 left-0 right-0 top-[38px] z-[80] grid place-items-center bg-black/36' const fullAppModalOverlayClass = 'fixed bottom-0 left-0 right-0 top-[38px] z-[80] grid place-items-center bg-black/36'
const canReleaseConnectedPlatform = connectedPlatform?.control?.status === 'ACTIVE' const canReleaseConnectedPlatform = connectedPlatform?.control?.status === 'ACTIVE'
const controlWarningMatchesConnectedPlatform = Boolean(
controlWarningNotice
&& controlWarningPlatform?.id === connectedPlatform?.id,
)
const canExtendConnectedPlatform = Boolean(
canReleaseConnectedPlatform
&& (connectedPlatform?.control?.warning || controlWarningMatchesConnectedPlatform)
&& (connectedPlatform.control.session?.extensionCount ?? 0) < 1,
)
const connectedPlatformControlEndsAt = canReleaseConnectedPlatform const connectedPlatformControlEndsAt = canReleaseConnectedPlatform
? connectedPlatform?.control?.session?.endsAt ?? null ? connectedPlatform?.control?.session?.endsAt ?? null
: null : null
@@ -3424,6 +3691,14 @@ export default function App(): React.JSX.Element {
setLocalPcLayoutOpen(false) setLocalPcLayoutOpen(false)
setCurriculumRefreshSignal((current) => current + 1) setCurriculumRefreshSignal((current) => current + 1)
} }
const searchStudentCourseCatalog = useCallback(
(query: string) => searchCourseCatalog(fetchWithFreshAuth, query),
[fetchWithFreshAuth],
)
const enrollStudentInCourse = useCallback(async (courseId: string): Promise<void> => {
await enrollInCourse(fetchWithFreshAuth, courseId)
await loadStudentCourses({ autoSelectSingle: false })
}, [fetchWithFreshAuth, loadStudentCourses])
const closeLocalPcWorkspace = (): void => { const closeLocalPcWorkspace = (): void => {
setLocalPcLayoutOpen(false) setLocalPcLayoutOpen(false)
if (!userSidebarCollapsed) setSidebarCollapsed(false) if (!userSidebarCollapsed) setSidebarCollapsed(false)
@@ -3499,12 +3774,16 @@ export default function App(): React.JSX.Element {
window.sessionStorage.removeItem(workspaceKey) window.sessionStorage.removeItem(workspaceKey)
} }
const connectedPlatformId = connectedPlatformRef.current?.id ?? null
const closesConnectedWorkspace = connectedPlatformId === platformToRemove.id
setRunningPlatforms((platforms) => { setRunningPlatforms((platforms) => {
const nextPlatforms = platforms.filter((platform) => platform.id !== platformToRemove.id) const nextPlatforms = platforms.filter((platform) => platform.id !== platformToRemove.id)
const nextConnectedPlatform = nextPlatforms[0] ?? null const nextConnectedPlatform = !closesConnectedWorkspace && connectedPlatformId
? nextPlatforms.find((platform) => platform.id === connectedPlatformId) ?? nextPlatforms[0] ?? null
: nextPlatforms[0] ?? null
setConnectedPlatform(nextConnectedPlatform) setConnectedPlatform(nextConnectedPlatform)
setLastWorkspacePlatform(nextConnectedPlatform) setLastWorkspacePlatform(nextConnectedPlatform)
setLocalPcLayoutOpen(false) if (closesConnectedWorkspace || !nextPlatforms.length) setLocalPcLayoutOpen(false)
setSessionStatus(nextPlatforms.length ? 'running' : 'ended') setSessionStatus(nextPlatforms.length ? 'running' : 'ended')
if (!nextPlatforms.length) { if (!nextPlatforms.length) {
setLastWorkspacePlatform(null) setLastWorkspacePlatform(null)
@@ -3549,7 +3828,9 @@ export default function App(): React.JSX.Element {
} else { } else {
await stopRemotePlatformModules(platformToClose) await stopRemotePlatformModules(platformToClose)
} }
if (closeConnectedWorkspace) {
await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' })) await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' }))
}
} finally { } finally {
const remainingMs = closeConnectedWorkspace ? Math.max(0, 2000 - (Date.now() - loadingStartedAt)) : 0 const remainingMs = closeConnectedWorkspace ? Math.max(0, 2000 - (Date.now() - loadingStartedAt)) : 0
window.setTimeout(() => { window.setTimeout(() => {
@@ -3664,6 +3945,38 @@ export default function App(): React.JSX.Element {
}, remainingMs) }, remainingMs)
} }
} }
const handleExtendControl = async (): Promise<void> => {
if (!connectedPlatform || !canExtendConnectedPlatform || controlExtensionSubmitting) return
setControlExtensionSubmitting(true)
try {
const result = await extendPlatformControl(fetchWithFreshAuth, connectedPlatform.id)
if (!result.session) throw new Error('연장된 제어 시간을 확인하지 못했습니다.')
const updatePlatform = (platform: PlatformOption): PlatformOption => {
if (platform.id !== connectedPlatform.id || !platform.control) return platform
return {
...platform,
control: {
...platform.control,
warning: false,
session: result.session,
},
}
}
setPlatformOptions((platforms) => platforms.map(updatePlatform))
setRunningPlatforms((platforms) => platforms.map(updatePlatform))
setConnectedPlatform((platform) => platform ? updatePlatform(platform) : platform)
setControlWarningNotice(null)
setControlExtensionConfirmOpen(false)
showTitlebarNotice(`${connectedPlatform.title} 제어 시간이 연장되었습니다.`, 'info', true)
} catch (error) {
showTitlebarNotice(error instanceof Error ? error.message : '제어 시간 연장에 실패했습니다.', 'warning', true)
} finally {
setControlExtensionSubmitting(false)
}
}
const expireBackgroundPlatformControl = async (platform: PlatformOption): Promise<void> => { const expireBackgroundPlatformControl = async (platform: PlatformOption): Promise<void> => {
await stopRemotePlatformModules(platform) await stopRemotePlatformModules(platform)
if (authSession?.accessToken) { if (authSession?.accessToken) {
@@ -3715,6 +4028,8 @@ export default function App(): React.JSX.Element {
setLocalPcLayoutOpen(false) setLocalPcLayoutOpen(false)
setRecentSessionModalOpen(false) setRecentSessionModalOpen(false)
setPlatformModalMode('select') setPlatformModalMode('select')
platformStatusRequestSequenceRef.current += 1
setCheckedRemotePlatformStatusIds(new Set())
await loadCurriculum() await loadCurriculum()
setPlatformModalOpen(true) setPlatformModalOpen(true)
} }
@@ -3836,6 +4151,8 @@ export default function App(): React.JSX.Element {
} }
const applyPlatformControlResponse = (platformId: string, payload: PlatformControlActionResponse): void => { const applyPlatformControlResponse = (platformId: string, payload: PlatformControlActionResponse): void => {
// Do not let an older status poll overwrite this control action result.
platformStatusRequestSequenceRef.current += 1
const updatePlatform = (platform: PlatformOption): PlatformOption => { const updatePlatform = (platform: PlatformOption): PlatformOption => {
if (platform.id !== platformId) return platform if (platform.id !== platformId) return platform
if (payload.result === 'GRANTED' || payload.result === 'ALREADY_GRANTED') { if (payload.result === 'GRANTED' || payload.result === 'ALREADY_GRANTED') {
@@ -3876,7 +4193,7 @@ export default function App(): React.JSX.Element {
state: 'reserved', state: 'reserved',
control: { control: {
status: payload.session ? 'IN_USE' : 'AVAILABLE', status: payload.session ? 'IN_USE' : 'AVAILABLE',
action: payload.session ? '대기 예약' : '제어권 요청', action: payload.session ? '대기 예약' : '제어요청',
warning: false, warning: false,
session: payload.session ?? null, session: payload.session ?? null,
queue: null, queue: null,
@@ -3980,9 +4297,8 @@ export default function App(): React.JSX.Element {
const position = payload.queue?.position const position = payload.queue?.position
showTitlebarNotice(`${platform.title} 대기 예약되었습니다${position ? ` (${position}번째)` : ''}`, 'info', true) showTitlebarNotice(`${platform.title} 대기 예약되었습니다${position ? ` (${position}번째)` : ''}`, 'info', true)
} }
setCurriculumRefreshSignal((current) => current + 1)
} catch { } catch {
setCurriculumRefreshSignal((current) => current + 1) // Keep the existing curriculum visible when a control request fails.
} finally { } finally {
await waitForMinimumLoading(loadingStartedAt) await waitForMinimumLoading(loadingStartedAt)
if (shouldWaitForControlPaint) await waitForNextPaint() if (shouldWaitForControlPaint) await waitForNextPaint()
@@ -4066,7 +4382,12 @@ export default function App(): React.JSX.Element {
: runningPlatforms[0] : runningPlatforms[0]
if (platform) openRunningPlatformWorkspace(platform) if (platform) openRunningPlatformWorkspace(platform)
}} }}
onDisconnectSession={() => setSessionConfirmAction('disconnect')} onDisconnectSession={(sessionId) => {
const platform = sessionId
? runningPlatforms.find((current) => current.id === sessionId)
: connectedPlatform
if (platform) setRunningPlatformCloseTarget(platform)
}}
onReleaseControl={() => setSessionConfirmAction('release')} onReleaseControl={() => setSessionConfirmAction('release')}
/> />
<main <main
@@ -4089,18 +4410,23 @@ export default function App(): React.JSX.Element {
<div className={localPcLayoutOpen ? 'h-full' : 'hidden h-full'}> <div className={localPcLayoutOpen ? 'h-full' : 'hidden h-full'}>
<LocalPcWorkspaceMockup <LocalPcWorkspaceMockup
key={workspacePlatform?.id ?? 'local-pc-workspace'} key={workspacePlatform?.id ?? 'local-pc-workspace'}
onClose={closeLocalPcWorkspace} onClose={() => {
closeLocalPcWorkspace()
setOuterView('curriculum')
}}
onDisconnectSession={() => setSessionConfirmAction('disconnect')} onDisconnectSession={() => setSessionConfirmAction('disconnect')}
onReleaseControl={() => setSessionConfirmAction('release')} onReleaseControl={() => setSessionConfirmAction('release')}
onRequestControlExtension={() => setControlExtensionConfirmOpen(true)}
onTitlebarNotice={showTitlebarNotice} onTitlebarNotice={showTitlebarNotice}
onRegisterStopRunningModules={(handler) => { onRegisterStopRunningModules={(handler) => {
stopRunningWorkspaceModulesRef.current = handler stopRunningWorkspaceModulesRef.current = handler
}} }}
canReleaseControl={canReleaseConnectedPlatform} canReleaseControl={canReleaseConnectedPlatform}
canExtendControl={canExtendConnectedPlatform}
controlEndsAt={connectedPlatformControlEndsAt} controlEndsAt={connectedPlatformControlEndsAt}
theme={ideTheme} theme={ideTheme}
title={workspaceWeek.title} title={workspaceWeek.title}
platformName={activePlatformName} platformName={workspacePlatform?.title ?? activePlatformName}
platformId={workspacePlatform?.id ?? 'local-pc'} platformId={workspacePlatform?.id ?? 'local-pc'}
platformKey={workspacePlatform?.platformKey ?? 'futuredrive-local-pc'} platformKey={workspacePlatform?.platformKey ?? 'futuredrive-local-pc'}
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles} platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
@@ -4117,8 +4443,10 @@ export default function App(): React.JSX.Element {
weekNumber={workspaceWeek.week} weekNumber={workspaceWeek.week}
courseKey={courseKey} courseKey={courseKey}
curriculumWeek={workspaceWeek} curriculumWeek={workspaceWeek}
publicGuideCourse={publicGuideCourseActive}
activeLearningStep={activeLearningStep} activeLearningStep={activeLearningStep}
allowFutureLearningSteps={publicGuideCourseActive} allowFutureLearningSteps={publicGuideCourseActive}
teacherQaDisabled={publicGuideCourseActive}
onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined} onLearningStepSelect={publicGuideCourseActive ? setActiveLearningStep : undefined}
onSubmissionRecorded={() => undefined} onSubmissionRecorded={() => undefined}
/> />
@@ -4153,9 +4481,12 @@ export default function App(): React.JSX.Element {
) : courseSelectionVisible ? ( ) : courseSelectionVisible ? (
<CourseSelectionScreen <CourseSelectionScreen
courses={studentCourses} courses={studentCourses}
trialCourse={trialCourse}
selectedCourseId={selectedCourseId} selectedCourseId={selectedCourseId}
hasSelectedCourse={Boolean(selectedCourse)} hasSelectedCourse={Boolean(selectedCourse)}
onSelectCourse={selectStudentCourse} onSelectCourse={selectStudentCourse}
onSearchCourses={searchStudentCourseCatalog}
onEnrollCourse={enrollStudentInCourse}
onBack={() => setCourseSelectionVisible(false)} onBack={() => setCourseSelectionVisible(false)}
/> />
) : ( ) : (
@@ -4219,6 +4550,7 @@ export default function App(): React.JSX.Element {
counts={platformFilterCounts} counts={platformFilterCounts}
workspaceWeekTitle={workspaceWeek.title} workspaceWeekTitle={workspaceWeek.title}
platformLoading={platformLoading} platformLoading={platformLoading}
checkedRemotePlatformStatusIds={checkedRemotePlatformStatusIds}
isPlatformUnavailable={isPlatformUnavailable} isPlatformUnavailable={isPlatformUnavailable}
getPlatformDotClass={getPlatformDotClass} getPlatformDotClass={getPlatformDotClass}
onFilterChange={setPlatformFilter} onFilterChange={setPlatformFilter}
@@ -4285,6 +4617,11 @@ export default function App(): React.JSX.Element {
title="제어 시간 안내" title="제어 시간 안내"
message="제어 시간이 곧 종료됩니다." message="제어 시간이 곧 종료됩니다."
detail={`남은 시간 ${formatControlRemainingTime(controlWarningNotice.endsAt)}${formatSessionClock(controlWarningNotice.endsAt) ? ` · 종료 ${formatSessionClock(controlWarningNotice.endsAt)}` : ''}`} detail={`남은 시간 ${formatControlRemainingTime(controlWarningNotice.endsAt)}${formatSessionClock(controlWarningNotice.endsAt) ? ` · 종료 ${formatSessionClock(controlWarningNotice.endsAt)}` : ''}`}
actionLabel={canExtendConnectedPlatform ? '연장' : undefined}
onAction={canExtendConnectedPlatform ? () => {
setControlWarningNotice(null)
setControlExtensionConfirmOpen(true)
} : undefined}
onConfirm={() => setControlWarningNotice(null)} onConfirm={() => setControlWarningNotice(null)}
> >
{(controlWarningPlatform || controlWarningWeekLabel) && ( {(controlWarningPlatform || controlWarningWeekLabel) && (
@@ -4342,6 +4679,30 @@ export default function App(): React.JSX.Element {
}} }}
/> />
)} )}
{controlExtensionConfirmOpen && (
<ControlExtensionConfirmModal
overlayClassName={fullAppModalOverlayClass}
dark={appModalDark}
platformName={activePlatformName}
submitting={controlExtensionSubmitting}
onCancel={() => setControlExtensionConfirmOpen(false)}
onConfirm={() => void handleExtendControl()}
/>
)}
{launcherSessionEnded && (
<StatusNoticeModal
overlayClassName={fullAppModalOverlayClass}
dark={appModalDark}
tone="warning"
title="LMS 세션 종료"
message="런처에서 로그아웃되어 LMS 세션이 종료되었습니다."
detail="런처에 다시 로그인한 뒤 LMS를 실행해주세요."
onConfirm={() => {
window.close()
window.setTimeout(() => window.location.replace('about:blank'), 100)
}}
/>
)}
{platformLoading && !platformModalOpen && !recentSessionModalOpen && ( {platformLoading && !platformModalOpen && !recentSessionModalOpen && (
<PlatformLoadingOverlay <PlatformLoadingOverlay
title={platformLoadingCopy.title} title={platformLoadingCopy.title}
@@ -60,6 +60,8 @@ export function StatusNoticeModal({
message, message,
detail, detail,
children, children,
actionLabel,
onAction,
onConfirm onConfirm
}: AppModalTheme & { }: AppModalTheme & {
tone: ModalTone tone: ModalTone
@@ -67,6 +69,8 @@ export function StatusNoticeModal({
message: string message: string
detail?: string detail?: string
children?: React.ReactNode children?: React.ReactNode
actionLabel?: string
onAction?: () => void
onConfirm: () => void onConfirm: () => void
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
@@ -84,7 +88,12 @@ export function StatusNoticeModal({
{detail} {detail}
</p> </p>
)} )}
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end gap-2">
{actionLabel && onAction && (
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}>
{actionLabel}
</button>
)}
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}> <button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>
확인 확인
</button> </button>
@@ -195,6 +204,43 @@ export function SessionConfirmModal({
) )
} }
export function ControlExtensionConfirmModal({
overlayClassName,
dark,
platformName,
submitting = false,
onCancel,
onConfirm,
}: AppModalTheme & {
platformName: string
submitting?: boolean
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
<div className={overlayClassName}>
<section className={modalClass(dark)}>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
<Clock3 size={15} className="text-[#f59e0b]" />
<strong className="text-[13px] font-bold">제어 시간 연장</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName} 제어 시간을 연장할까요?</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.
</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
<button type="button" disabled={submitting} onClick={onConfirm} className={confirmButtonClass('primary')}>
{submitting ? '연장 중' : '연장'}
</button>
</div>
</div>
</section>
</div>
)
}
function ConfirmStatusModal({ function ConfirmStatusModal({
overlayClassName, overlayClassName,
dark, dark,
+10 -10
View File
@@ -33,7 +33,7 @@ export default function Sidebar({
runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }> runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }>
canReleaseControl?: boolean canReleaseControl?: boolean
onOpenSession?: (sessionId?: string) => void onOpenSession?: (sessionId?: string) => void
onDisconnectSession?: () => void onDisconnectSession?: (sessionId?: string) => void
onReleaseControl?: () => void onReleaseControl?: () => void
}): React.JSX.Element { }): React.JSX.Element {
const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false) const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false)
@@ -55,7 +55,7 @@ export default function Sidebar({
return ( return (
<aside <aside
className={[ className={[
'flex shrink-0 flex-col rounded-bl-[18px] bg-[#181b20] px-2.5 pb-3.5 pt-[18px] transition-[width] duration-200 ease-out', 'flex shrink-0 flex-col rounded-bl-[18px] bg-[#111520] px-2.5 pb-3.5 pt-[18px] transition-[width] duration-200 ease-out',
collapsed ? 'w-[68px]' : 'w-[240px]' collapsed ? 'w-[68px]' : 'w-[240px]'
].join(' ')} ].join(' ')}
> >
@@ -133,7 +133,7 @@ export default function Sidebar({
> >
{!collapsed && ( {!collapsed && (
<div className="session-scrollbar grid max-h-[286px] gap-2 overflow-y-auto pr-1"> <div className="session-scrollbar grid max-h-[286px] gap-2 overflow-y-auto pr-1">
{visibleRunningSessions.map((session, index) => ( {visibleRunningSessions.map((session) => (
<div key={session.id} className="relative"> <div key={session.id} className="relative">
<button <button
type="button" type="button"
@@ -152,13 +152,13 @@ export default function Sidebar({
)} )}
</span> </span>
</button> </button>
{sessionStatus === 'running' && index === 0 && ( {sessionStatus === 'running' && (
<button <button
type="button" type="button"
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white" className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
onDisconnectSession?.() onDisconnectSession?.(session.id)
}} }}
aria-label="세션 연결종료" aria-label="세션 연결종료"
title="연결종료" title="연결종료"
@@ -177,13 +177,13 @@ export default function Sidebar({
className="relative grid h-[34px] w-full cursor-pointer place-items-center rounded bg-transparent text-white/75 transition hover:bg-white/[0.06]" className="relative grid h-[34px] w-full cursor-pointer place-items-center rounded bg-transparent text-white/75 transition hover:bg-white/[0.06]"
> >
<Activity size={17} /> <Activity size={17} />
<span className={['absolute right-2 top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[#181b20]', visibleRunningSessions[0]?.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} /> <span className={['absolute right-2 top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[#111520]', visibleRunningSessions[0]?.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
</button> </button>
)} )}
{collapsed && sessionPopoverOpen && ( {collapsed && sessionPopoverOpen && (
<div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#181b20] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]"> <div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#111520] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]">
<div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1"> <div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1">
{visibleRunningSessions.map((session, index) => ( {visibleRunningSessions.map((session) => (
<div key={session.id} className="relative border-b border-white/10 last:border-b-0"> <div key={session.id} className="relative border-b border-white/10 last:border-b-0">
<button <button
type="button" type="button"
@@ -205,14 +205,14 @@ export default function Sidebar({
)} )}
</span> </span>
</button> </button>
{sessionStatus === 'running' && index === 0 && ( {sessionStatus === 'running' && (
<button <button
type="button" type="button"
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white" className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
setSessionPopoverOpen(false) setSessionPopoverOpen(false)
onDisconnectSession?.() onDisconnectSession?.(session.id)
}} }}
aria-label="세션 연결종료" aria-label="세션 연결종료"
title="연결종료" title="연결종료"
+38 -12
View File
@@ -36,6 +36,7 @@ interface Window {
appUrl: string | null appUrl: string | null
handoffToken: string | null handoffToken: string | null
audienceApp: string audienceApp: string
onSessionExpired?: (listener: () => void) => () => void
} }
lmsBootSplash?: { lmsBootSplash?: {
status: (message: string) => void status: (message: string) => void
@@ -56,6 +57,7 @@ interface Window {
files: Record<string, string> files: Record<string, string>
folders: string[] folders: string[]
}> }>
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }>
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
@@ -63,7 +65,7 @@ interface Window {
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
} }
modules: { modules: {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{ list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null; dockerImage?: string | null }>) => Promise<{
cacheRoot: string cacheRoot: string
workspaceRoot: string | null workspaceRoot: string | null
modules: Array<{ modules: Array<{
@@ -75,6 +77,7 @@ interface Window {
sourcePath: string sourcePath: string
workspacePath: string | null workspacePath: string | null
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
dockerImage?: string | null
parameters: string[] parameters: string[]
cpu: number | null cpu: number | null
memory: number | null memory: number | null
@@ -99,12 +102,12 @@ interface Window {
} }
}> }>
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null, rosParams?: string) => Promise<{ ok: boolean; message: string }>
stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }> stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
stopRunning: () => Promise<{ ok: boolean; message: string }> stopRunning: () => Promise<{ ok: boolean; message: string }>
restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }> updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }>
} }
@@ -208,6 +211,15 @@ interface Window {
localWorkspace: { localWorkspace: {
moduleFiles: Window['moduleFiles'] moduleFiles: Window['moduleFiles']
modules: Window['modules'] modules: Window['modules']
nav2: {
setSimulatorPose: (pose: { x: number; y: number; yaw: number }) => Promise<{ ok: boolean; message: string }>
addRuntimeObstacle: (obstacle: { id: string; x: number; y: number; yaw: number; length: number; depth: number; height: number }) => Promise<{ ok: boolean; message: string }>
clearRuntimeObstacles: () => Promise<{ ok: boolean; message: string }>
}
worldLayout: {
read: (workspaceRoot?: string) => Promise<WorldLayout>
write: (workspaceRoot: string | undefined, layout: WorldLayout) => Promise<WorldLayout>
}
visualization: Window['visualization'] visualization: Window['visualization']
git: Window['git'] git: Window['git']
terminal: Window['terminal'] terminal: Window['terminal']
@@ -215,6 +227,7 @@ interface Window {
remoteWorkspace: { remoteWorkspace: {
moduleFiles: { moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']> read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['image']>
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType<Window['moduleFiles']['create']> create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType<Window['moduleFiles']['create']>
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType<Window['moduleFiles']['write']> write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType<Window['moduleFiles']['write']>
copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ReturnType<Window['moduleFiles']['copy']> copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ReturnType<Window['moduleFiles']['copy']>
@@ -240,12 +253,12 @@ interface Window {
runtime: string runtime: string
} }
}> }>
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }> stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }> resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => Promise<{ ok: boolean; message: string }>
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
} }
git: { git: {
@@ -259,14 +272,27 @@ interface Window {
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise<boolean> start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise<boolean>
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise<boolean> resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean> changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
write: (data: string, sessionId?: string) => void write: (target: RemoteWorkspaceTarget, data: string, sessionId?: string) => void
stop: (sessionId?: string) => void stop: (target: RemoteWorkspaceTarget, sessionId?: string) => void
onData: (callback: (data: string) => void, sessionId?: string) => () => void onData: (target: RemoteWorkspaceTarget, callback: (data: string) => void, sessionId?: string) => () => void
onSuppressData: (callback: (data: string) => void, sessionId?: string) => () => void onSuppressData: (target: RemoteWorkspaceTarget, callback: (data: string) => void, sessionId?: string) => () => void
} }
} }
} }
type WorldLayoutObject = {
id: string
type: 'pillar' | 'wall' | 'box'
position: [number, number, number]
rotation: [number, number, number]
size: [number, number, number]
}
type WorldLayout = {
version: 1
objects: WorldLayoutObject[]
}
type RemoteWorkspaceTarget = { type RemoteWorkspaceTarget = {
host: string host: string
user?: string | null user?: string | null
@@ -1,6 +1,9 @@
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2, LoaderCircle, Search, Sparkles, UserPlus } from 'lucide-react'
import { useEffect, useState } from 'react'
import type React from 'react' import type React from 'react'
import type { CourseCatalogOption } from './curriculumApi'
type CourseOption = { type CourseOption = {
id: string id: string
title: string title: string
@@ -15,72 +18,131 @@ type CourseOption = {
type CourseSelectionScreenProps = { type CourseSelectionScreenProps = {
courses: CourseOption[] courses: CourseOption[]
trialCourse?: CourseOption | null
selectedCourseId: string | null selectedCourseId: string | null
hasSelectedCourse: boolean hasSelectedCourse: boolean
onSelectCourse: (courseId: string) => void onSelectCourse: (courseId: string) => void
onSearchCourses: (query: string) => Promise<CourseCatalogOption[]>
onEnrollCourse: (courseId: string) => Promise<void>
onBack: () => void onBack: () => void
} }
function teacherLabel(course: Pick<CourseOption, 'teacher' | 'organizationName'>): string {
return `${course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / ${course.teacher?.primaryOrganizationName ?? course.organizationName ?? '소속 미지정'}`
}
export default function CourseSelectionScreen({ export default function CourseSelectionScreen({
courses, courses,
trialCourse,
selectedCourseId, selectedCourseId,
hasSelectedCourse, hasSelectedCourse,
onSelectCourse, onSelectCourse,
onBack onSearchCourses,
onEnrollCourse,
onBack,
}: CourseSelectionScreenProps): React.JSX.Element { }: CourseSelectionScreenProps): React.JSX.Element {
const [query, setQuery] = useState('')
const [searchResults, setSearchResults] = useState<CourseCatalogOption[]>([])
const [searching, setSearching] = useState(false)
const [searchError, setSearchError] = useState('')
const [selectedCatalogCourseId, setSelectedCatalogCourseId] = useState<string | null>(null)
const [enrolling, setEnrolling] = useState(false)
useEffect(() => {
const keyword = query.trim()
if (!keyword) {
setSearchResults([])
setSelectedCatalogCourseId(null)
setSearchError('')
setSearching(false)
return
}
let cancelled = false
const timer = window.setTimeout(() => {
setSearching(true)
setSearchError('')
void onSearchCourses(keyword)
.then((nextResults) => {
if (cancelled) return
setSearchResults(nextResults)
setSelectedCatalogCourseId((current) => nextResults.some((course) => course.id === current) ? current : null)
})
.catch((error: unknown) => {
if (cancelled) return
setSearchResults([])
setSelectedCatalogCourseId(null)
setSearchError(error instanceof Error ? error.message : '강좌 검색에 실패했습니다.')
})
.finally(() => {
if (!cancelled) setSearching(false)
})
}, 250)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [onSearchCourses, query])
const selectedCatalogCourse = searchResults.find((course) => course.id === selectedCatalogCourseId) ?? null
const submitEnrollment = async (): Promise<void> => {
if (!selectedCatalogCourse || enrolling) return
setEnrolling(true)
setSearchError('')
try {
await onEnrollCourse(selectedCatalogCourse.id)
onSelectCourse(selectedCatalogCourse.id)
} catch (error) {
setSearchError(error instanceof Error ? error.message : '수강 신청에 실패했습니다.')
} finally {
setEnrolling(false)
}
}
return ( return (
<section className="mx-auto grid min-h-[calc(100vh-120px)] max-w-[900px] place-items-center px-4"> <section className="mx-auto grid min-h-[calc(100vh-120px)] max-w-[900px] place-items-center px-4">
<div className="w-full rounded-xl border border-[#d5dbea] bg-white p-6 shadow-[0_18px_42px_rgba(20,27,44,0.12)]"> <div className="w-full rounded-xl border border-[#d5dbea] bg-white p-6 shadow-[0_18px_42px_rgba(20,27,44,0.12)]">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-[24px] font-black text-[#111520]">강좌 선택</h1> <h1 className="text-[24px] font-black text-[#111520]">강좌 선택</h1>
<p className="mt-2 text-[13px] font-semibold text-[#64748b]">학습을 진행할 수강중인 강좌를 선택해주세요.</p> <p className="mt-2 text-[13px] font-semibold text-[#64748b]">수강 중인 강좌를 선택하거나 새 강좌를 검색해 수강 신청하세요.</p>
</div> </div>
{hasSelectedCourse ? ( {hasSelectedCourse ? (
<button <button type="button" onClick={onBack} className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]">뒤로가기</button>
type="button"
onClick={onBack}
className="shrink-0 cursor-pointer border-0 bg-transparent px-0 py-1 text-[13px] font-black text-[#6f4df6] transition hover:text-[#4f35c8]"
>
뒤로가기
</button>
) : null} ) : null}
</div> </div>
<div className="mt-5 grid gap-3">
<div className="mt-5">
<label className="relative block">
<Search size={18} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-[#738096]" />
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="강좌명 또는 강사명 검색" className="h-12 w-full rounded-lg border border-[#cfd8e6] bg-[#fbfcff] pl-11 pr-4 text-[14px] font-semibold text-[#172033] outline-none transition focus:border-[#6f4df6] focus:bg-white focus:ring-2 focus:ring-[#6f4df6]/15" />
</label>
{query.trim() ? (
<div className="mt-3">
{searching ? <div className="flex items-center justify-center gap-2 rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-[13px] font-bold text-[#64748b]"><LoaderCircle size={17} className="animate-spin" /> 검색 중입니다.</div>
: searchResults.length ? <div className="grid gap-2">{searchResults.map((course) => {
const selected = course.id === selectedCatalogCourseId
return <button key={course.id} type="button" disabled={course.enrolled || enrolling} onClick={() => setSelectedCatalogCourseId(course.id)} className={['rounded-lg border p-3.5 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', course.enrolled ? 'cursor-default border-[#dce3ee] bg-[#f8fafc] opacity-70' : selected ? 'cursor-pointer border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)]' : 'cursor-pointer border-[#dce3ee] bg-white hover:border-[#6f4df6] hover:bg-[#fbfaff]'].join(' ')}>
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="truncate text-[15px] font-black text-[#111520]">{course.title}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p></div>{course.enrolled ? <span className="shrink-0 rounded-md bg-[#edf7f2] px-2 py-1 text-[11px] font-black text-[#16845b]">수강 중</span> : selected ? <CheckCircle2 size={18} className="shrink-0 text-[#6f4df6]" /> : null}</div>
</button>
})}</div>
: <div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-8 text-center text-[13px] font-bold text-[#64748b]">검색 결과가 없습니다.</div>}
{searchError ? <p className="mt-2 text-[12px] font-bold text-[#d74d54]">{searchError}</p> : null}
{selectedCatalogCourse ? <div className="mt-3 flex justify-end"><button type="button" onClick={() => void submitEnrollment()} disabled={enrolling} className="inline-flex h-10 items-center gap-2 rounded-md bg-[#6f4df6] px-4 text-[13px] font-black text-white transition hover:bg-[#5f42d7] disabled:cursor-wait disabled:opacity-60">{enrolling ? <LoaderCircle size={16} className="animate-spin" /> : <UserPlus size={16} />} 수강 신청</button></div> : null}
</div>
) : null}
</div>
<div className="mt-6 border-t border-[#edf0f5] pt-5"><p className="text-[12px] font-black text-[#64748b]">수강 중인 강좌</p><div className="mt-3 grid gap-3">
{courses.length ? courses.map((course) => { {courses.length ? courses.map((course) => {
const selected = course.id === selectedCourseId const selected = course.id === selectedCourseId
return ( return <button key={course.id} type="button" className={['cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', selected ? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)] hover:border-[#5f42d7] hover:bg-[#f2efff] hover:shadow-[0_12px_28px_rgba(111,77,246,0.18)]' : 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'].join(' ')} onClick={() => onSelectCourse(course.id)}>
<button <div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]"><span className="min-w-0 truncate">{course.title}</span>{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{teacherLabel(course)}</p></div><span className="shrink-0 rounded-md bg-[#f0efff] px-2.5 py-1 text-[11px] font-black text-[#6f4df6]">{course.progress ?? 0}%</span></div>
key={course.id}
type="button"
className={[
'cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35',
selected
? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)] hover:border-[#5f42d7] hover:bg-[#f2efff] hover:shadow-[0_12px_28px_rgba(111,77,246,0.18)]'
: 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'
].join(' ')}
onClick={() => onSelectCourse(course.id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]">
<span className="min-w-0 truncate">{course.title}</span>
{selected ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}
</h2>
<p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">
{course.teacher?.name ?? course.teacher?.email ?? '강사 미지정'} / {course.teacher?.primaryOrganizationName ?? course.organizationName}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="rounded-md bg-[#f0efff] px-2.5 py-1 text-[11px] font-black text-[#6f4df6]">{course.progress ?? 0}%</span>
</div>
</div>
</button> </button>
) }) : <div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-10 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다. 위에서 강좌를 검색해 신청하세요.</div>}
}) : ( </div></div>
<div className="rounded-lg border border-dashed border-[#dce3ee] px-5 py-12 text-center text-[13px] font-bold text-[#64748b]">수강 중인 강좌가 없습니다.</div> {trialCourse ? <div className="mt-6 border-t border-[#edf0f5] pt-5"><p className="text-[12px] font-black text-[#64748b]">둘러보기</p><button type="button" onClick={() => onSelectCourse(trialCourse.id)} className={['mt-3 w-full cursor-pointer rounded-lg border p-4 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#6f4df6]/35', selectedCourseId === trialCourse.id ? 'border-[#6f4df6] bg-[#f7f5ff] shadow-[0_8px_18px_rgba(111,77,246,0.10)]' : 'border-[#dce3ee] bg-[#fbfcff] hover:border-[#6f4df6] hover:bg-white hover:shadow-[0_12px_26px_rgba(111,77,246,0.16)]'].join(' ')}><div className="flex items-start justify-between gap-3"><div className="min-w-0"><h2 className="flex min-w-0 items-center gap-1.5 text-[16px] font-black text-[#111520]"><span className="min-w-0 truncate">{trialCourse.title}</span>{selectedCourseId === trialCourse.id ? <CheckCircle2 size={16} className="shrink-0 text-[#6f4df6]" /> : null}</h2><p className="mt-1 truncate text-[12px] font-bold text-[#64748b]">{trialCourse.teacher?.primaryOrganizationName ?? trialCourse.organizationName ?? 'Autonomy Studio'}</p></div><span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-[#ded4ff] bg-[#f4f0ff] px-2.5 py-1 text-[11px] font-black text-[#6944d8]"><Sparkles size={12} strokeWidth={2.5} />체험</span></div></button></div> : null}
)}
</div>
</div> </div>
</section> </section>
) )
@@ -6,7 +6,6 @@ type LearningInfoWeek = {
title: string title: string
tags: string[] tags: string[]
progress: number progress: number
topics: string[]
duration: string duration: string
detail: { detail: {
requiresSourceSubmission?: boolean requiresSourceSubmission?: boolean
@@ -60,21 +59,7 @@ export default function LearningInfoCard({
</div> </div>
</div> </div>
<div className="my-[17px] overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff]"> <div className="mb-[17px] mt-4 overflow-hidden rounded-lg border border-[#dfe3ed] bg-[#fbfcff]">
<div className="border-b border-[#edf0f5] px-3.5 py-3">
<span className="flex items-center gap-2 text-[12px] font-bold text-[#151827]">
<BookOpen size={14} className="text-[#6d7788]" />
학습 주제
</span>
<div className="mt-2 grid gap-1.5">
{week.topics.map((topic, index) => (
<div key={topic} className="flex items-center gap-2 rounded-md px-1 py-0.5 text-[12px] font-medium text-[#4f596b]">
<span className="grid h-5 w-5 shrink-0 place-items-center rounded bg-[#ede9ff] text-[10px] font-bold text-[#714ef6]">{index + 1}</span>
<span className="min-w-0 truncate">{topic}</span>
</div>
))}
</div>
</div>
{[ {[
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 }, { label: '예상 학습 시간', value: week.duration, Icon: Clock3 },
...(week.detail.requiresSourceSubmission ...(week.detail.requiresSourceSubmission
@@ -3,10 +3,56 @@ import type { CurriculumResponse, DownloadResource, StudentCourseOption } from '
type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response> type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise<Response>
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseOption[]> { export type CourseCatalogOption = {
id: string
title: string
subtitle?: string | null
organizationName?: string | null
enrolled: boolean
teacher?: {
email?: string | null
name?: string | null
primaryOrganizationName?: string | null
} | null
}
export type StudentCourseList = {
courses: StudentCourseOption[]
guideCourse: StudentCourseOption | null
}
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseList> {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`) const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`)
const payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null const payload = response.ok ? await response.json() as Partial<StudentCourseList> : null
return payload?.courses ?? [] return {
courses: payload?.courses ?? [],
guideCourse: payload?.guideCourse ?? null,
}
}
async function responseMessage(response: Response, fallback: string): Promise<string> {
const payload = await response.json().catch(() => null) as { message?: string | string[] } | null
if (Array.isArray(payload?.message)) return payload.message.join(' ')
return payload?.message || fallback
}
export async function searchCourseCatalog(
fetchWithFreshAuth: AuthFetch,
query: string,
): Promise<CourseCatalogOption[]> {
const params = new URLSearchParams()
if (query.trim()) params.set('query', query.trim())
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/course-catalog?${params.toString()}`)
if (!response.ok) throw new Error(await responseMessage(response, '강좌 검색에 실패했습니다.'))
const payload = await response.json() as { courses?: CourseCatalogOption[] }
return payload.courses ?? []
}
export async function enrollInCourse(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<void> {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/course-catalog/${encodeURIComponent(courseId)}/enroll`, {
method: 'POST',
})
if (!response.ok) throw new Error(await responseMessage(response, '수강 신청에 실패했습니다.'))
} }
export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<CurriculumResponse | null> { export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise<CurriculumResponse | null> {
@@ -65,9 +65,20 @@ export type CurriculumWeek = {
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
sourceType?: string | null sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
defaultParameters?: string[] | null
dockerImage?: string | null dockerImage?: string | null
}> }>
environmentProfiles?: EnvironmentProfile[] environmentProfiles?: EnvironmentProfile[]
maps?: Array<{
key: string
platformKey: 'NAV2'
name: string
description: string
worldFile: string
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
custom?: boolean
layout?: unknown
}>
} }
export type CurriculumResponse = { export type CurriculumResponse = {
@@ -1,4 +1,4 @@
import { AlertCircle, CheckCircle2, Link2, Lock, Power, Settings } from 'lucide-react' import { AlertCircle, CheckCircle2, Link2, LoaderCircle, Lock, Power, Settings } from 'lucide-react'
import type { LucideIcon } from 'lucide-react' import type { LucideIcon } from 'lucide-react'
import type React from 'react' import type React from 'react'
import type { PlatformOption } from './types' import type { PlatformOption } from './types'
@@ -60,6 +60,7 @@ type PlatformOptionCardProps = {
Icon: LucideIcon Icon: LucideIcon
dotClassName: string dotClassName: string
unavailable: boolean unavailable: boolean
statusPending: boolean
displayStatus: string displayStatus: string
primaryAction: string primaryAction: string
actionDisabled: boolean actionDisabled: boolean
@@ -73,6 +74,7 @@ export function PlatformOptionCard({
Icon, Icon,
dotClassName, dotClassName,
unavailable, unavailable,
statusPending,
displayStatus, displayStatus,
primaryAction, primaryAction,
actionDisabled, actionDisabled,
@@ -106,7 +108,9 @@ export function PlatformOptionCard({
</div> </div>
<p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p> <p className="mt-1 text-[12px] font-medium text-[#5f6878]">{platform.description}</p>
<div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]"> <div className="mt-2 flex items-center gap-1.5 text-[11px] font-bold text-[#5f6878]">
{unavailable {statusPending
? <LoaderCircle size={13} className="animate-spin text-[#7a879a]" />
: unavailable
? <AlertCircle size={13} className="text-[#dc2626]" /> ? <AlertCircle size={13} className="text-[#dc2626]" />
: ready : ready
? <CheckCircle2 size={13} className="text-[#2fbda0]" /> ? <CheckCircle2 size={13} className="text-[#2fbda0]" />
@@ -25,7 +25,7 @@ export default function PlatformSelectModal({
}: PlatformSelectModalProps): React.JSX.Element { }: PlatformSelectModalProps): React.JSX.Element {
return ( return (
<div className="fixed bottom-0 left-0 right-0 top-[38px] z-50 grid place-items-center bg-[#080a10]/35 p-6 backdrop-blur-[2px]"> <div className="fixed bottom-0 left-0 right-0 top-[38px] z-50 grid place-items-center bg-[#080a10]/35 p-6 backdrop-blur-[2px]">
<section className="flex h-[660px] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]"> <section className="flex h-[min(660px,calc(100vh-86px))] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]">
<header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5"> <header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5">
<div> <div>
<span className="text-[12px] font-medium text-[#6f4df6]">{weekLabel}</span> <span className="text-[12px] font-medium text-[#6f4df6]">{weekLabel}</span>
@@ -19,6 +19,7 @@ type PlatformSelectionPanelProps = {
counts: Record<PlatformFilter, number> counts: Record<PlatformFilter, number>
workspaceWeekTitle: string workspaceWeekTitle: string
platformLoading: boolean platformLoading: boolean
checkedRemotePlatformStatusIds: ReadonlySet<string>
isPlatformUnavailable: (platform: PlatformOption) => boolean isPlatformUnavailable: (platform: PlatformOption) => boolean
getPlatformDotClass: (platform: PlatformOption) => string getPlatformDotClass: (platform: PlatformOption) => string
onFilterChange: (filter: PlatformFilter) => void onFilterChange: (filter: PlatformFilter) => void
@@ -36,6 +37,7 @@ export default function PlatformSelectionPanel({
counts, counts,
workspaceWeekTitle, workspaceWeekTitle,
platformLoading, platformLoading,
checkedRemotePlatformStatusIds,
isPlatformUnavailable, isPlatformUnavailable,
getPlatformDotClass, getPlatformDotClass,
onFilterChange, onFilterChange,
@@ -45,7 +47,7 @@ export default function PlatformSelectionPanel({
onOpenSettings onOpenSettings
}: PlatformSelectionPanelProps): React.JSX.Element { }: PlatformSelectionPanelProps): React.JSX.Element {
return ( return (
<div className={['min-h-0 w-1/2 shrink-0 px-6 pb-5 pt-3 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}> <div className={['h-full min-h-0 w-1/2 shrink-0 overflow-y-auto px-6 pb-5 pt-3 pr-5 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}>
{runningPlatforms.length > 0 && ( {runningPlatforms.length > 0 && (
<div className="mb-4 pr-1"> <div className="mb-4 pr-1">
<p className="mb-2 text-[12px] font-black text-[#2563eb]">실행중인 플랫폼</p> <p className="mb-2 text-[12px] font-black text-[#2563eb]">실행중인 플랫폼</p>
@@ -76,24 +78,22 @@ export default function PlatformSelectionPanel({
counts={counts} counts={counts}
onChange={onFilterChange} onChange={onFilterChange}
/> />
<div data-help="platform-list" className={[ <div data-help="platform-list" className="grid content-start gap-3">
'grid content-start gap-3 overflow-y-auto pr-1',
runningPlatforms.length > 0
? 'min-h-[340px] max-h-[340px]'
: 'min-h-[470px] max-h-[470px]'
].join(' ')}>
{filteredPlatforms.length ? filteredPlatforms.map((platform) => { {filteredPlatforms.length ? filteredPlatforms.map((platform) => {
const { id, status, action, state, iconKey } = platform const { id, status, action, state, iconKey } = platform
const Icon = PLATFORM_ICONS[iconKey] ?? Monitor const Icon = PLATFORM_ICONS[iconKey] ?? Monitor
const locked = state === 'locked' const locked = state === 'locked'
const unavailable = isPlatformUnavailable(platform) const statusPending = platform.platformKey !== 'futuredrive-local-pc' && !checkedRemotePlatformStatusIds.has(id)
const actionDisabled = locked || unavailable const unavailable = !statusPending && isPlatformUnavailable(platform)
const actionDisabled = statusPending || locked || unavailable
const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id) const runningInSelectionList = runningPlatforms.some((runningPlatform) => runningPlatform.id === platform.id)
const displayStatus = unavailable && !status.includes('연결 끊김') && !status.includes('연결 불가') const displayStatus = statusPending
? '확인 중'
: unavailable && !status.includes('연결 끊김') && !status.includes('연결 불가')
? '연결 끊김' ? '연결 끊김'
: status : status
const hasControl = platform.control?.status === 'ACTIVE' const hasControl = platform.control?.status === 'ACTIVE'
const primaryAction = hasControl ? '연결' : action const primaryAction = statusPending ? '확인 중' : hasControl ? '연결' : action
const platformActionDisabled = runningInSelectionList || actionDisabled const platformActionDisabled = runningInSelectionList || actionDisabled
return ( return (
@@ -101,8 +101,9 @@ export default function PlatformSelectionPanel({
key={id} key={id}
platform={platform} platform={platform}
Icon={Icon} Icon={Icon}
dotClassName={getPlatformDotClass(platform)} dotClassName={statusPending ? 'bg-[#94a3b8] shadow-[0_0_6px_rgba(148,163,184,0.45)]' : getPlatformDotClass(platform)}
unavailable={unavailable} unavailable={unavailable}
statusPending={statusPending}
displayStatus={displayStatus} displayStatus={displayStatus}
primaryAction={primaryAction} primaryAction={primaryAction}
actionDisabled={platformActionDisabled} actionDisabled={platformActionDisabled}
@@ -75,6 +75,20 @@ export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, plat
if (!response.ok) throw new Error('platform-release failed') if (!response.ok) throw new Error('platform-release failed')
} }
export async function extendPlatformControl(
fetchWithFreshAuth: AuthFetch,
platformId: string,
): Promise<PlatformControlActionResponse> {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/extend`, {
method: 'POST',
})
const payload = await response.json().catch(() => null) as PlatformControlActionResponse | { message?: string } | null
if (!response.ok) {
throw new Error(payload && 'message' in payload && payload.message ? payload.message : '제어 시간 연장에 실패했습니다.')
}
return payload as PlatformControlActionResponse
}
export async function requestOrCancelPlatformControl( export async function requestOrCancelPlatformControl(
fetchWithFreshAuth: AuthFetch, fetchWithFreshAuth: AuthFetch,
platform: PlatformOption platform: PlatformOption
+2 -1
View File
@@ -51,6 +51,7 @@ export type PlatformOption = {
startedAt: string startedAt: string
endsAt: string endsAt: string
warningAt: string warningAt: string
extensionCount?: number
} | null } | null
queue: { queue: {
id: string id: string
@@ -63,7 +64,7 @@ export type PlatformOption = {
export type PlatformControlState = NonNullable<PlatformOption['control']> export type PlatformControlState = NonNullable<PlatformOption['control']>
export type PlatformControlActionResponse = { export type PlatformControlActionResponse = {
result?: 'GRANTED' | 'ALREADY_GRANTED' | 'QUEUED' | 'CANCELED' result?: 'GRANTED' | 'ALREADY_GRANTED' | 'QUEUED' | 'CANCELED' | 'EXTENDED'
platformId?: string platformId?: string
session?: PlatformControlState['session'] session?: PlatformControlState['session']
queue?: PlatformControlState['queue'] queue?: PlatformControlState['queue']
@@ -9,6 +9,7 @@ type DeletableModule = {
type ModuleDeleteConfirmProps<Module extends DeletableModule> = { type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
dark: boolean dark: boolean
module: Module module: Module
allowRepositoryDeletion?: boolean
onCancel: () => void onCancel: () => void
onConfirm: (module: Module, options: { deleteRepository: boolean }) => void onConfirm: (module: Module, options: { deleteRepository: boolean }) => void
} }
@@ -16,6 +17,7 @@ type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
export default function ModuleDeleteConfirm<Module extends DeletableModule>({ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
dark, dark,
module, module,
allowRepositoryDeletion = true,
onCancel, onCancel,
onConfirm onConfirm
}: ModuleDeleteConfirmProps<Module>): React.JSX.Element { }: ModuleDeleteConfirmProps<Module>): React.JSX.Element {
@@ -40,7 +42,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}> <p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
워크스페이스에 설치된 모듈 폴더가 삭제됩니다. 워크스페이스에 설치된 모듈 폴더가 삭제됩니다.
</p> </p>
<label {allowRepositoryDeletion && <label
className={[ className={[
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3', 'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3',
dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]' dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
@@ -60,7 +62,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다. 제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.
</span> </span>
</span> </span>
</label> </label>}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button <button
type="button" type="button"
@@ -74,7 +76,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
</button> </button>
<button <button
type="button" type="button"
onClick={() => onConfirm(module, { deleteRepository })} onClick={() => onConfirm(module, { deleteRepository: allowRepositoryDeletion && deleteRepository })}
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]" className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
> >
삭제 삭제
@@ -6,12 +6,10 @@ type ModuleListHeaderProps = {
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element { export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
return ( return (
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}> <div className={['grid h-9 grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
<span className="text-center">상태</span> <span className="text-center">상태</span>
<span className="text-center">분류</span> <span className="text-center">분류</span>
<span>모듈명</span> <span>모듈명</span>
<span className="text-center">CPU</span>
<span className="text-center">메모리</span>
<span className="text-center">가동시간</span> <span className="text-center">가동시간</span>
<span className="col-span-2 text-center">제어</span> <span className="col-span-2 text-center">제어</span>
</div> </div>
@@ -19,6 +19,7 @@ type ModuleListRowProps<Module extends ModuleListRowItem> = {
module: Module module: Module
installing: boolean installing: boolean
controlling: boolean controlling: boolean
bulkControlling: boolean
onAddModuleTab: (module: Module) => void onAddModuleTab: (module: Module) => void
onOpenSource: (module: Module) => void onOpenSource: (module: Module) => void
onOpenParameters: (module: Module) => void onOpenParameters: (module: Module) => void
@@ -80,6 +81,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
module, module,
installing, installing,
controlling, controlling,
bulkControlling,
onAddModuleTab, onAddModuleTab,
onOpenSource, onOpenSource,
onOpenParameters, onOpenParameters,
@@ -92,6 +94,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
}: ModuleListRowProps<Module>): React.JSX.Element { }: ModuleListRowProps<Module>): React.JSX.Element {
const selectable = module.state !== 'uninstalled' const selectable = module.state !== 'uninstalled'
const busy = installing || controlling const busy = installing || controlling
const parameters = Array.isArray(module.parameters) ? module.parameters : []
const stateStyle: Record<ModuleListRowItem['state'], string> = { const stateStyle: Record<ModuleListRowItem['state'], string> = {
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]', running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]', stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
@@ -121,6 +124,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
const installAction = module.state === 'uninstalled' const installAction = module.state === 'uninstalled'
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone } ? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone } : { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
return ( return (
<div <div
@@ -128,7 +132,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
if (selectable) onOpenSource(module) if (selectable) onOpenSource(module)
}} }}
className={[ className={[
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition', 'grid h-[58px] grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center px-3 text-[12px] transition',
'cursor-default', 'cursor-default',
selectable selectable
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]' ? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
@@ -158,37 +162,9 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]' dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
].join(' ')} ].join(' ')}
> >
{module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'} {parameters.length ? parameters.join(' ') : '파라미터 설정'}
</button> </button>
</span> </span>
<span className="flex items-center justify-center gap-1.5">
{module.cpu === null ? (
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
) : (
<>
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
</span>
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
</span>
</>
)}
</span>
<span className="flex items-center justify-center gap-1.5">
{module.memory === null ? (
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
) : (
<>
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
</span>
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
</span>
</>
)}
</span>
<span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}> <span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}>
{module.runtime} {module.runtime}
</span> </span>
@@ -215,7 +191,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
{controlActions.map(({ label, Icon, tone, action }) => ( {controlActions.map(({ label, Icon, tone, action }) => (
(() => { (() => {
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지' const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
const disabled = busy && !canCancelBuild const disabled = bulkControlling || (busy && !canCancelBuild)
return ( return (
<button <button
key={`${module.name}-${label}`} key={`${module.name}-${label}`}
@@ -238,7 +214,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
<span className="flex justify-center"> <span className="flex justify-center">
<button <button
type="button" type="button"
disabled={busy} disabled={installActionDisabled}
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
if (module.state === 'uninstalled') { if (module.state === 'uninstalled') {
@@ -248,7 +224,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
onDeleteModule(module) onDeleteModule(module)
}} }}
aria-label={installAction.label} aria-label={installAction.label}
className={actionButtonClass(dark, installAction.tone, busy)} className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
> >
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />} {busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel> <TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
@@ -15,6 +15,7 @@ type ModuleListToolbarProps = {
onInstallAll: () => void onInstallAll: () => void
onStartAll: () => void onStartAll: () => void
onStopAll: () => void onStopAll: () => void
onOpenWorldEditor: () => void
} }
const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [ const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [
@@ -35,14 +36,15 @@ export default function ModuleListToolbar({
onFilterChange, onFilterChange,
onInstallAll, onInstallAll,
onStartAll, onStartAll,
onStopAll onStopAll,
onOpenWorldEditor
}: ModuleListToolbarProps): React.JSX.Element { }: ModuleListToolbarProps): React.JSX.Element {
const installingAll = installingModuleName === '__all__' const installingAll = installingModuleName === '__all__'
const startingAll = installingModuleName === '__start_all__' const startingAll = installingModuleName === '__start_all__'
const stoppingAll = installingModuleName === '__stop_all__' const stoppingAll = installingModuleName === '__stop_all__'
const bulkActions = [ const bulkActions = [
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: undefined }, { label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor },
{ {
label: '전체 설치', label: '전체 설치',
Icon: Download, Icon: Download,
@@ -17,6 +17,7 @@ type ModuleListViewProps = {
controllingModuleNames: string[] controllingModuleNames: string[]
listError: string | null listError: string | null
installError: string | null installError: string | null
allowRepositoryDeletion?: boolean
onAddModuleTab: (module: ModuleRow) => void onAddModuleTab: (module: ModuleRow) => void
onOpenSource: (module: ModuleRow) => void onOpenSource: (module: ModuleRow) => void
onInstallModule: (module: ModuleRow) => void onInstallModule: (module: ModuleRow) => void
@@ -29,6 +30,7 @@ type ModuleListViewProps = {
onInstallAll: () => void onInstallAll: () => void
onStartAll: () => void onStartAll: () => void
onStopAll: () => void onStopAll: () => void
onOpenWorldEditor: () => void
} }
export default function ModuleListView({ export default function ModuleListView({
@@ -39,6 +41,7 @@ export default function ModuleListView({
controllingModuleNames, controllingModuleNames,
listError, listError,
installError, installError,
allowRepositoryDeletion = true,
onAddModuleTab, onAddModuleTab,
onOpenSource, onOpenSource,
onInstallModule, onInstallModule,
@@ -50,7 +53,8 @@ export default function ModuleListView({
onRestartModule, onRestartModule,
onInstallAll, onInstallAll,
onStartAll, onStartAll,
onStopAll onStopAll,
onOpenWorldEditor
}: ModuleListViewProps): React.JSX.Element { }: ModuleListViewProps): React.JSX.Element {
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all') const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null) const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
@@ -60,6 +64,7 @@ export default function ModuleListView({
const runningCount = modules.filter((row) => row.state === 'running').length const runningCount = modules.filter((row) => row.state === 'running').length
const stoppedCount = modules.filter((row) => row.state === 'stopped').length const stoppedCount = modules.filter((row) => row.state === 'stopped').length
const installingAll = installingModuleName === '__all__' const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
useEffect(() => { useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => { const handleEscape = (event: KeyboardEvent): void => {
@@ -102,6 +107,7 @@ export default function ModuleListView({
onInstallAll={onInstallAll} onInstallAll={onInstallAll}
onStartAll={onStartAll} onStartAll={onStartAll}
onStopAll={onStopAll} onStopAll={onStopAll}
onOpenWorldEditor={onOpenWorldEditor}
/> />
<ModuleListHeader dark={dark} /> <ModuleListHeader dark={dark} />
{(listError || installError) && ( {(listError || installError) && (
@@ -122,6 +128,7 @@ export default function ModuleListView({
module={row} module={row}
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')} installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
controlling={controllingModuleNames.includes(row.name)} controlling={controllingModuleNames.includes(row.name)}
bulkControlling={bulkControlling}
onAddModuleTab={onAddModuleTab} onAddModuleTab={onAddModuleTab}
onOpenSource={onOpenSource} onOpenSource={onOpenSource}
onOpenParameters={(module) => { onOpenParameters={(module) => {
@@ -148,6 +155,7 @@ export default function ModuleListView({
<ModuleDeleteConfirm <ModuleDeleteConfirm
dark={dark} dark={dark}
module={deleteConfirmModule} module={deleteConfirmModule}
allowRepositoryDeletion={allowRepositoryDeletion}
onCancel={() => setDeleteConfirmModule(null)} onCancel={() => setDeleteConfirmModule(null)}
onConfirm={(module, options) => { onConfirm={(module, options) => {
onDeleteModule(module, options) onDeleteModule(module, options)
@@ -196,7 +196,7 @@ export default function WorkspaceFilePanel({
}: WorkspaceFilePanelProps): React.JSX.Element { }: WorkspaceFilePanelProps): React.JSX.Element {
return ( return (
<aside <aside
className={['min-h-0 border-r transition-opacity', searchActive ? 'flex flex-col' : '', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')} className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined} style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined}
> >
<div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}> <div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}>
@@ -396,7 +396,7 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
} = props } = props
return ( return (
<div className={['px-2 py-2 text-[13px] font-medium', textMid].join(' ')}> <div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<button <button
type="button" type="button"
onClick={onSelectRoot} onClick={onSelectRoot}
@@ -25,6 +25,7 @@ type WorkspaceQaPanelProps = {
iconButtonClass: string iconButtonClass: string
accessToken: string accessToken: string
qaMode: QaMode qaMode: QaMode
teacherQaDisabled?: boolean
teacherChatFlash: boolean teacherChatFlash: boolean
qaMessagesLoading: boolean qaMessagesLoading: boolean
visibleAiMessages: WorkspaceQaMessage[] visibleAiMessages: WorkspaceQaMessage[]
@@ -53,6 +54,7 @@ export default function WorkspaceQaPanel({
iconButtonClass, iconButtonClass,
accessToken, accessToken,
qaMode, qaMode,
teacherQaDisabled = false,
teacherChatFlash, teacherChatFlash,
qaMessagesLoading, qaMessagesLoading,
visibleAiMessages, visibleAiMessages,
@@ -73,6 +75,10 @@ export default function WorkspaceQaPanel({
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1] const lastMessage = activeMessages[activeMessages.length - 1]
useEffect(() => {
if (teacherQaDisabled && qaMode === 'teacher') onModeChange('ai')
}, [onModeChange, qaMode, teacherQaDisabled])
useEffect(() => { useEffect(() => {
if (!contextMenu) return if (!contextMenu) return
const closeContextMenu = (): void => setContextMenu(null) const closeContextMenu = (): void => setContextMenu(null)
@@ -161,16 +167,24 @@ export default function WorkspaceQaPanel({
{ id: 'ai' as const, label: 'AI', Icon: Bot }, { id: 'ai' as const, label: 'AI', Icon: Bot },
{ id: 'teacher' as const, label: '강사', Icon: User } { id: 'teacher' as const, label: '강사', Icon: User }
].map(({ id, label, Icon }) => { ].map(({ id, label, Icon }) => {
const disabled = id === 'teacher' && teacherQaDisabled
const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai' const teacherTabFlash = id === 'teacher' && teacherChatFlash && !minimized && qaMode === 'ai'
return ( return (
<button <button
key={id} key={id}
type="button" type="button"
onClick={() => onModeChange(id)} disabled={disabled}
onClick={() => {
if (!disabled) onModeChange(id)
}}
className={[ className={[
'relative flex h-8 cursor-pointer items-center justify-center gap-1.5 text-[12px] font-bold transition', 'relative flex h-8 items-center justify-center gap-1.5 text-[12px] font-bold transition',
teacherTabFlash disabled
? dark
? 'cursor-not-allowed bg-[#202020] text-white/25'
: 'cursor-not-allowed bg-[#f3f5f8] text-[#aab3c1]'
: teacherTabFlash
? dark ? dark
? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]' ? 'animate-pulse bg-[#2563eb]/28 text-[#9ec2ff] shadow-[inset_0_0_0_1px_rgba(96,165,250,0.28)]'
: 'animate-pulse bg-[#dbeafe] text-[#1d4ed8] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.18)]' : 'animate-pulse bg-[#dbeafe] text-[#1d4ed8] shadow-[inset_0_0_0_1px_rgba(37,99,235,0.18)]'
@@ -234,7 +248,7 @@ export default function WorkspaceQaPanel({
<QaInputBox <QaInputBox
dark={dark} dark={dark}
busy={inputBusy} busy={inputBusy}
disabled={inputDisabled} disabled={inputDisabled || (teacherQaDisabled && qaMode === 'teacher')}
onStop={onStop} onStop={onStop}
onSubmit={onSubmit} onSubmit={onSubmit}
/> />
@@ -16,6 +16,7 @@ type WorkspaceTopBarProps = {
previewOpen: boolean previewOpen: boolean
showControlCountdown: boolean showControlCountdown: boolean
controlCountdownDanger: boolean controlCountdownDanger: boolean
canExtendControl: boolean
controlEndsAt: string | null controlEndsAt: string | null
submitVisible: boolean submitVisible: boolean
submitDisabled: boolean submitDisabled: boolean
@@ -28,6 +29,7 @@ type WorkspaceTopBarProps = {
onTogglePreview: () => void onTogglePreview: () => void
onSubmit: () => void onSubmit: () => void
onOpenPowerActions: () => void onOpenPowerActions: () => void
onRequestControlExtension: () => void
onClose: () => void onClose: () => void
} }
@@ -45,6 +47,7 @@ export default function WorkspaceTopBar({
previewOpen, previewOpen,
showControlCountdown, showControlCountdown,
controlCountdownDanger, controlCountdownDanger,
canExtendControl,
controlEndsAt, controlEndsAt,
submitVisible, submitVisible,
submitDisabled, submitDisabled,
@@ -57,6 +60,7 @@ export default function WorkspaceTopBar({
onTogglePreview, onTogglePreview,
onSubmit, onSubmit,
onOpenPowerActions, onOpenPowerActions,
onRequestControlExtension,
onClose onClose
}: WorkspaceTopBarProps): React.JSX.Element { }: WorkspaceTopBarProps): React.JSX.Element {
return ( return (
@@ -101,6 +105,15 @@ export default function WorkspaceTopBar({
<Clock3 size={13} /> <Clock3 size={13} />
{formatControlRemainingTime(controlEndsAt)} {formatControlRemainingTime(controlEndsAt)}
</span> </span>
{controlCountdownDanger && canExtendControl && (
<button
type="button"
onClick={onRequestControlExtension}
className="h-6 cursor-pointer rounded border border-[#ef4444]/45 px-2 text-[11px] font-bold text-[#ff8b8b] transition hover:bg-[#ef4444]/12"
>
연장
</button>
)}
</> </>
)} )}
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} /> <span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
@@ -2,6 +2,7 @@ import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
export type VisualizationStack = 'autoware' | 'nav2' | 'slam' export type VisualizationStack = 'autoware' | 'nav2' | 'slam'
export type Nav2VisualizationMode = 'slam' | 'navigation'
type WorkspaceVisualizationPanelProps = { type WorkspaceVisualizationPanelProps = {
dark: boolean dark: boolean
@@ -16,11 +17,13 @@ type WorkspaceVisualizationPanelProps = {
running: boolean running: boolean
busy: boolean busy: boolean
viewerUrl: string viewerUrl: string
nav2Mode: Nav2VisualizationMode
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
onToggleLarge: () => void onToggleLarge: () => void
onClose: () => void onClose: () => void
onStackChange: (stack: VisualizationStack) => void onStackChange: (stack: VisualizationStack) => void
onStart: () => void onStart: () => void
onStartNav2: (mode: Nav2VisualizationMode) => void
onStop: () => void onStop: () => void
onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void
} }
@@ -38,14 +41,17 @@ export default function WorkspaceVisualizationPanel({
running, running,
busy, busy,
viewerUrl, viewerUrl,
nav2Mode,
onDragStart, onDragStart,
onToggleLarge, onToggleLarge,
onClose, onClose,
onStackChange, onStackChange,
onStart, onStart,
onStartNav2,
onStop, onStop,
onResizeStart onResizeStart
}: WorkspaceVisualizationPanelProps): React.JSX.Element { }: WorkspaceVisualizationPanelProps): React.JSX.Element {
const isNav2Visualization = activeStack === 'nav2' || activeStack === 'slam'
return ( return (
<section <section
className={[ className={[
@@ -127,21 +133,58 @@ export default function WorkspaceVisualizationPanel({
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{running ? (
<button <button
type="button" type="button"
disabled={busy || !stacks.length} disabled={busy || !stacks.length}
onClick={() => { onClick={onStop}
if (running) onStop()
else onStart()
}}
className={[ className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition', 'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]',
running ? 'bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]' : 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : '' busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')} ].join(' ')}
> >
{busy ? '처리 중' : running ? '종료' : '시작'} {busy ? '처리 중' : '종료'}
</button> </button>
) : isNav2Visualization ? (
<>
<button
type="button"
disabled={busy || !stacks.length}
onClick={() => onStartNav2('slam')}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
nav2Mode === 'slam' ? 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy && nav2Mode === 'slam' ? '처리 중' : 'SLAM 시작'}
</button>
<button
type="button"
disabled={busy || !stacks.length}
onClick={() => onStartNav2('navigation')}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
nav2Mode === 'navigation' ? 'bg-[#2563eb] text-white hover:bg-[#3b82f6]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy && nav2Mode === 'navigation' ? '처리 중' : '주행 시작'}
</button>
</>
) : (
<button
type="button"
disabled={busy || !stacks.length}
onClick={onStart}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : '시작'}
</button>
)}
</div> </div>
</div> </div>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
@@ -155,7 +198,7 @@ export default function WorkspaceVisualizationPanel({
{!running && ( {!running && (
<div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34"> <div className="pointer-events-none absolute inset-0 grid place-items-center bg-black/34">
<div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]"> <div className="rounded-lg border border-white/10 bg-[#101624]/88 px-5 py-4 text-center shadow-[0_18px_48px_rgba(0,0,0,0.35)]">
<p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '해당 주차는 시각화를 지원하지 않습니다'}</p> <p className="text-[13px] font-black text-white">{stacks.length ? '시작하세요' : '시각화 준비 중입니다.'}</p>
<p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p> <p className="mt-2 text-[11px] font-semibold text-white/50">{stacks.length ? '지도와 차량 상태가 표시됩니다.' : '강사 커리큘럼의 모듈 관리 탭에서 시각화 플랫폼을 선택해야 합니다.'}</p>
</div> </div>
</div> </div>
@@ -0,0 +1,509 @@
import { Box, Cylinder, LoaderCircle, Maximize2, Move3D, PanelsTopLeft, Rotate3D, RotateCcw, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type React from 'react'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
type WorldObjectType = WorldLayoutObject['type']
type WorldEditorModalProps = {
workspaceRoot: string
onClose: () => void
onSaved?: () => void
}
type TransformMode = 'translate' | 'rotate' | 'scale'
const objectLabels: Record<WorldObjectType, string> = {
pillar: '기둥',
wall: '벽',
box: '박스'
}
const defaultsByType: Record<WorldObjectType, Omit<WorldLayoutObject, 'id' | 'type'>> = {
pillar: { position: [0, 0, 1], rotation: [0, 0, 0], size: [0.5, 0.5, 2] },
wall: { position: [0, 0, 1], rotation: [0, 0, 0], size: [4, 0.2, 2] },
box: { position: [0, 0, 0.5], rotation: [0, 0, 0], size: [1, 1, 1] }
}
function createObject(type: WorldObjectType, index: number): WorldLayoutObject {
const template = defaultsByType[type]
return {
id: `${type}-${Date.now()}-${index}`,
type,
position: [template.position[0] + index * 0.7, template.position[1] + index * 0.45, template.position[2]],
rotation: [...template.rotation],
size: [...template.size]
}
}
function objectMaterial(type: WorldObjectType): THREE.MeshStandardMaterial {
const color = type === 'pillar' ? '#fb923c' : type === 'wall' ? '#60a5fa' : '#a78bfa'
return new THREE.MeshStandardMaterial({ color, roughness: 0.68, metalness: 0.05 })
}
function createMesh(item: WorldLayoutObject): THREE.Mesh {
const [width, depth, height] = item.size
const geometry = item.type === 'pillar'
? new THREE.CylinderGeometry(Math.max(width, depth) / 2, Math.max(width, depth) / 2, height, 32)
: new THREE.BoxGeometry(width, depth, height)
const mesh = new THREE.Mesh(geometry, objectMaterial(item.type))
mesh.position.set(item.position[0], item.position[1], item.position[2])
mesh.rotation.set(item.rotation[0], item.rotation[1], item.rotation[2])
if (item.type === 'pillar') mesh.rotateX(Math.PI / 2)
mesh.castShadow = true
mesh.receiveShadow = true
mesh.userData.objectId = item.id
return mesh
}
const modelRoot = '/slam-viewer/turtlebot3_world_model/turtlebot3_world'
const modelRoots: Record<string, string> = {
turtlebot3_world: modelRoot,
turtlebot3_house: '/slam-viewer/turtlebot3_world_model/turtlebot3_house',
turtlebot3_common: '/slam-viewer/turtlebot3_world_model/turtlebot3_common'
}
function parseNumbers(value: string | null | undefined, count = 0): number[] {
const numbers = (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isFinite)
while (numbers.length < count) numbers.push(0)
return numbers
}
function directChild(element: Element | null | undefined, name: string): Element | null {
return Array.from(element?.children ?? []).find((child) => child.tagName === name) ?? null
}
function childText(element: Element | null | undefined, name: string, fallback = ''): string {
return directChild(element, name)?.textContent?.trim() || fallback
}
function applySdfPose(target: THREE.Object3D, value: string): void {
const [x, y, z, roll, pitch, yaw] = parseNumbers(value, 6)
target.position.set(x, y, z)
target.rotation.set(roll, pitch, yaw, 'XYZ')
}
function worldMaterial(visual: Element): THREE.MeshLambertMaterial {
const text = `${visual.getAttribute('name') ?? ''} ${Array.from(visual.querySelectorAll('uri, script name')).map((item) => item.textContent ?? '').join(' ')}`.toLowerCase()
const ambient = visual.querySelector('ambient')?.textContent
const [red, green, blue] = parseNumbers(ambient, 3)
const color = ambient ? new THREE.Color(red, green, blue) : text.includes('wall') ? '#171717' : text.includes('green') ? '#16a34a' : '#e5e7eb'
// TurtleBot3 기본 월드는 외곽 벽 메시의 안쪽에서 보게 된다.
// 내부 면도 표시해야 다각형 전체 벽 구조가 보인다.
return new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide })
}
function gazeboAssetPath(uri: string): string {
return uri.replace(/^model:\/\/(\w+)\//, (_match, name: string) => `${modelRoots[name] ?? `/slam-viewer/turtlebot3_world_model/${name}`}/`)
}
async function loadWorldSdf(parent: THREE.Group): Promise<void> {
const response = await fetch(`${modelRoot}/model.sdf`)
if (!response.ok) throw new Error(`기본 월드 모델을 불러오지 못했습니다. (${response.status})`)
const documentNode = new DOMParser().parseFromString(await response.text(), 'application/xml')
const model = documentNode.querySelector('model')
if (!model) throw new Error('기본 월드 모델 형식이 올바르지 않습니다.')
const colladaLoader = new ColladaLoader()
const stlLoader = new STLLoader()
const modelGroup = new THREE.Group()
applySdfPose(modelGroup, childText(model, 'pose', '0 0 0 0 0 0'))
parent.add(modelGroup)
const loadMesh = async (target: THREE.Group, visual: Element, geometry: Element): Promise<void> => {
const meshNode = directChild(geometry, 'mesh')
const uri = childText(meshNode, 'uri')
if (!uri) return
const scale = parseNumbers(childText(meshNode, 'scale', '1 1 1'), 3)
const url = gazeboAssetPath(uri)
const material = worldMaterial(visual)
if (url.endsWith('.stl')) {
const geometryValue = await stlLoader.loadAsync(url)
geometryValue.computeVertexNormals()
const mesh = new THREE.Mesh(geometryValue, material)
mesh.scale.set(scale[0], scale[1], scale[2])
mesh.castShadow = true
mesh.receiveShadow = true
target.add(mesh)
return
}
const collada = await colladaLoader.loadAsync(url)
collada.scene.scale.set(scale[0], scale[1], scale[2])
collada.scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return
child.material = material.clone()
child.castShadow = true
child.receiveShadow = true
})
target.add(collada.scene)
}
for (const link of Array.from(model.querySelectorAll(':scope > link'))) {
const linkGroup = new THREE.Group()
applySdfPose(linkGroup, childText(link, 'pose', '0 0 0 0 0 0'))
modelGroup.add(linkGroup)
for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) {
const visualGroup = new THREE.Group()
applySdfPose(visualGroup, childText(visual, 'pose', '0 0 0 0 0 0'))
linkGroup.add(visualGroup)
const geometry = directChild(visual, 'geometry')
if (!geometry) continue
const material = worldMaterial(visual)
if (directChild(geometry, 'mesh')) {
await loadMesh(visualGroup, visual, geometry).catch(() => undefined)
continue
}
const box = directChild(geometry, 'box')
const cylinder = directChild(geometry, 'cylinder')
if (box) {
const [width, depth, height] = parseNumbers(childText(box, 'size', '1 1 1'), 3)
visualGroup.add(new THREE.Mesh(new THREE.BoxGeometry(width, depth, height), material))
} else if (cylinder) {
const radius = Number(childText(cylinder, 'radius', '0.1')) || 0.1
const height = Number(childText(cylinder, 'length', '1')) || 1
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, 32), material)
mesh.rotateX(Math.PI / 2)
visualGroup.add(mesh)
}
}
}
}
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
const canvasHostRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<HTMLIFrameElement>(null)
const worldRef = useRef<WorldLayout>({ version: 1, objects: [] })
const selectedIdRef = useRef<string | null>(null)
const renderSceneRef = useRef<(() => void) | null>(null)
const [layout, setLayout] = useState<WorldLayout>({ version: 1, objects: [] })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [transformMode, setTransformMode] = useState<TransformMode>('translate')
useEffect(() => {
let active = true
void window.localWorkspace.worldLayout.read(workspaceRoot)
.then((nextLayout) => {
if (!active) return
worldRef.current = nextLayout
setLayout(nextLayout)
setSelectedId(nextLayout.objects[0]?.id ?? null)
})
.catch((cause: unknown) => {
if (!active) return
setError(cause instanceof Error ? cause.message : '월드 정보를 불러오지 못했습니다.')
})
.finally(() => active && setLoading(false))
return () => { active = false }
}, [workspaceRoot])
useEffect(() => {
worldRef.current = layout
}, [layout])
useEffect(() => {
selectedIdRef.current = selectedId
renderSceneRef.current?.()
sendLayoutToViewer(worldRef.current)
}, [selectedId])
const sendLayoutToViewer = (nextLayout: WorldLayout): void => {
viewerRef.current?.contentWindow?.postMessage({
source: 'autonomy-world-editor',
type: 'set-layout',
layout: nextLayout,
selectedId: selectedIdRef.current,
}, '*')
}
useEffect(() => {
sendLayoutToViewer(layout)
}, [layout])
useEffect(() => {
const handleMessage = (event: MessageEvent): void => {
if (event.data?.source !== 'autonomy-world-editor') return
if (event.data.type === 'ready') {
sendLayoutToViewer(worldRef.current)
return
}
if (event.data.type === 'select-object') setSelectedId(typeof event.data.id === 'string' ? event.data.id : null)
if (event.data.type === 'transform-object' && typeof event.data.id === 'string' && event.data.patch) {
const patch = event.data.patch as Partial<WorldLayoutObject>
setLayout((current) => {
const next = {
...current,
objects: current.objects.map((item) => item.id === event.data.id ? { ...item, ...patch } : item),
}
worldRef.current = next
return next
})
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
useEffect(() => {
const host = canvasHostRef.current
// The editor iframe owns the actual TurtleBot3 world rendering. Keep the
// previous canvas path dormant while the shared viewer is mounted.
if (!host || loading || viewerRef.current) return
const scene = new THREE.Scene()
scene.background = new THREE.Color('#0b1220')
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 100)
camera.position.set(4, 6, 5)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
host.replaceChildren(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.target.set(0, 0, 0)
controls.enableDamping = true
controls.maxPolarAngle = Math.PI / 2.08
controls.update()
scene.add(new THREE.HemisphereLight('#dbeafe', '#0b1220', 1.7))
const keyLight = new THREE.DirectionalLight('#ffffff', 1.8)
keyLight.position.set(6, -5, 11)
keyLight.castShadow = true
scene.add(keyLight)
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: '#17233a', roughness: 0.95, metalness: 0 })
)
ground.rotation.x = -Math.PI / 2
ground.receiveShadow = true
scene.add(ground)
scene.add(new THREE.GridHelper(20, 20, '#3b82f6', '#24324c'))
// Gazebo SDF uses Z-up, while Three.js uses Y-up.
const sdfRoot = new THREE.Group()
sdfRoot.rotation.x = -Math.PI / 2
scene.add(sdfRoot)
const objectGroup = new THREE.Group()
const selectionGroup = new THREE.Group()
sdfRoot.add(objectGroup)
scene.add(selectionGroup)
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
const renderScene = (): void => {
objectGroup.clear()
selectionGroup.clear()
for (const item of worldRef.current.objects) {
const mesh = createMesh(item)
objectGroup.add(mesh)
if (item.id === selectedIdRef.current) {
const outline = new THREE.Box3().setFromObject(mesh)
const size = outline.getSize(new THREE.Vector3())
const center = outline.getCenter(new THREE.Vector3())
const helper = new THREE.Box3Helper(new THREE.Box3().setFromCenterAndSize(center, size.multiplyScalar(1.08)), '#93c5fd')
selectionGroup.add(helper)
}
}
}
renderSceneRef.current = renderScene
renderScene()
let disposed = false
void loadWorldSdf(sdfRoot).catch((cause: unknown) => {
if (!disposed) setError(cause instanceof Error ? cause.message : '기본 TurtleBot3 월드 모델을 불러오지 못했습니다.')
})
const resize = (): void => {
const { width, height } = host.getBoundingClientRect()
if (!width || !height) return
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height, false)
}
const observer = new ResizeObserver(resize)
observer.observe(host)
resize()
const handlePointerDown = (event: PointerEvent): void => {
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const hit = raycaster.intersectObjects(objectGroup.children, false)[0]
setSelectedId(typeof hit?.object.userData.objectId === 'string' ? hit.object.userData.objectId : null)
}
renderer.domElement.addEventListener('pointerdown', handlePointerDown)
let frameId = 0
const animate = (): void => {
controls.update()
renderer.render(scene, camera)
frameId = window.requestAnimationFrame(animate)
}
animate()
return () => {
disposed = true
renderSceneRef.current = null
window.cancelAnimationFrame(frameId)
observer.disconnect()
renderer.domElement.removeEventListener('pointerdown', handlePointerDown)
controls.dispose()
renderer.dispose()
host.replaceChildren()
}
}, [loading])
const selectTransformMode = (mode: TransformMode): void => {
setTransformMode(mode)
viewerRef.current?.contentWindow?.postMessage({
source: 'autonomy-world-editor',
type: 'set-transform-mode',
mode,
}, '*')
}
const selected = layout.objects.find((item) => item.id === selectedId) ?? null
const updateObject = (id: string, patch: Partial<WorldLayoutObject>): void => {
setLayout((current) => {
const next = { ...current, objects: current.objects.map((item) => item.id === id ? { ...item, ...patch } : item) }
worldRef.current = next
return next
})
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const updateTuple = (key: 'position' | 'rotation' | 'size', index: number, value: string): void => {
if (!selected) return
const numeric = Number(value)
if (!Number.isFinite(numeric)) return
const next = [...selected[key]] as [number, number, number]
next[index] = key === 'size' ? Math.max(0.1, numeric) : numeric
updateObject(selected.id, { [key]: next })
}
const addObject = (type: WorldObjectType): void => {
const object = createObject(type, layout.objects.length)
setLayout((current) => {
const next = { ...current, objects: [...current.objects, object] }
worldRef.current = next
return next
})
setSelectedId(object.id)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const removeSelected = (): void => {
if (!selected) return
const remaining = layout.objects.filter((item) => item.id !== selected.id)
const next = { ...layout, objects: remaining }
worldRef.current = next
setLayout(next)
setSelectedId(remaining[0]?.id ?? null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const resetLayout = (): void => {
const next: WorldLayout = { version: 1, objects: [] }
worldRef.current = next
setLayout(next)
setSelectedId(null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const save = async (): Promise<void> => {
setSaving(true)
setError(null)
try {
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
setLayout(saved)
onSaved?.()
onClose()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
} finally {
setSaving(false)
}
}
return (
<div className="absolute inset-0 z-[70] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
<div>
<h2 className="text-[13px] font-bold text-white">맵 설정</h2>
<p className="mt-0.5 text-[10px] font-medium text-white/42">주차 워크스페이스에 월드 배치를 저장합니다.</p>
</div>
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label="닫기"><X size={16} /></button>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[214px] shrink-0 flex-col border-r border-white/10 bg-[#101a2a]">
<div className="border-b border-white/10 px-3 py-3">
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{layout.objects.length ? layout.objects.map((item) => {
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
const selected = item.id === selectedId
return (
<button key={item.id} type="button" onClick={() => setSelectedId(item.id)} className={`flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left transition ${selected ? 'bg-[#3b7ffa]/20 text-[#d9e7ff]' : 'text-white/60 hover:bg-white/[0.06] hover:text-white'}`}>
<Icon size={13} className={selected ? 'text-[#75a4ff]' : 'text-white/38'} />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold">{objectLabels[item.type]}</span>
<span className="text-[10px] text-white/30">{item.id.split('-').at(-1)}</span>
</button>
)
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 추가해<br />맵을 구성하세요.</div>}
</div>
<div className="border-t border-white/10 p-2">
<p className="mb-2 px-1 text-[10px] font-bold tracking-[0.08em] text-white/35">ADD OBJECT</p>
<div className="grid grid-cols-3 gap-1">
{([
['pillar', Cylinder, '기둥'],
['wall', PanelsTopLeft, '벽'],
['box', Box, '박스'],
] as const).map(([type, Icon, label]) => (
<button key={type} type="button" onClick={() => addObject(type)} title={`${label} 추가`} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{label}</button>
))}
</div>
</div>
</aside>
<main className="relative min-w-0 flex-1 bg-[#0b1220]">
<div ref={canvasHostRef} className="pointer-events-none absolute inset-0 opacity-0" />
<iframe ref={viewerRef} title="TurtleBot3 World Editor" src="/slam-viewer/index.html?editor=1&mapKey=nav2-custom" className="absolute inset-0 z-10 size-full border-0" />
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />월드 불러오는 중</span></div>}
<div className="absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border border-white/15 bg-[#15223a]/95 shadow">
{([
['translate', Move3D, '이동'],
['rotate', Rotate3D, '회전'],
['scale', Maximize2, '크기'],
] as const).map(([mode, Icon, label]) => (
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={label} aria-label={label} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{label}</button>
))}
</div>
</main>
<aside className="flex w-[270px] shrink-0 flex-col border-l border-white/10 bg-[#131e31]">
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">선택한 오브젝트</span></div>
{selected ? (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title="삭제"><Trash2 size={14} /></button></div>
{(['position', 'rotation', 'size'] as const).map((key) => {
const indexes = key === 'position' ? [0, 1] : key === 'rotation' ? [2] : [0, 1, 2]
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? '위치' : key === 'rotation' ? '회전' : '크기'}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? '각도 (Z)' : ['X', 'Y', '높이'][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" /></label>)}</div></div>
})}
</div>
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 선택하거나 추가하세요.</div>}
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />모두 비우기</button></div>
</aside>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-white/10 bg-[#162238] px-4">
<span className="text-[10px] font-medium text-white/38">저장 위치: world-layout.json</span>
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md px-3 text-[11px] font-semibold text-white/58 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}저장</button></div>
</footer>
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{error}</div>}
</section>
</div>
)
}
@@ -0,0 +1,274 @@
import { AlertTriangle, Check, House, LoaderCircle, Map, PencilRuler, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
export type WorkspaceMapOption = {
key: string
platformKey: 'NAV2'
name: string
description: string
worldFile: string
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
custom?: boolean
saved?: boolean
savedMapId?: string
createdAt?: string
sourceMapKey?: string
yamlPath?: string
pgmPath?: string
previewPgm?: string
layout?: unknown
}
export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
{
key: 'nav2-turtlebot3-world',
platformKey: 'NAV2',
name: '기본 월드',
description: 'TurtleBot3 기본 장애물 환경',
worldFile: 'turtlebot3_world.world',
viewerModel: 'turtlebot3_world'
},
{
key: 'nav2-turtlebot3-house',
platformKey: 'NAV2',
name: '실내 환경',
description: '방과 복도로 구성된 TurtleBot3 House',
worldFile: 'turtlebot3_house.world',
viewerModel: 'turtlebot3_house'
},
{
key: 'nav2-custom',
platformKey: 'NAV2',
name: '커스텀 맵',
description: '장애물을 직접 배치해 학습 환경 구성',
worldFile: 'empty_world.world',
viewerModel: 'turtlebot3_world',
custom: true
}
]
const LOCAL_CUSTOM_MAP = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')!
function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: boolean }): React.JSX.Element {
const Icon = map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!map.previewPgm || !canvasRef.current) return
const canvas = canvasRef.current
const context = canvas.getContext('2d')
if (!context) return
try {
const bytes = Uint8Array.from(atob(map.previewPgm), (char) => char.charCodeAt(0))
let cursor = 0
const nextToken = (): string => {
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
if (bytes[cursor] === 35) {
while (cursor < bytes.length && bytes[cursor] !== 10) cursor += 1
return nextToken()
}
const start = cursor
while (cursor < bytes.length && !/\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
return String.fromCharCode(...bytes.slice(start, cursor))
}
if (nextToken() !== 'P5') throw new Error('unsupported_pgm')
const width = Number(nextToken())
const height = Number(nextToken())
const maxValue = Number(nextToken())
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
if (!Number.isFinite(width) || !Number.isFinite(height) || maxValue !== 255 || bytes.length - cursor < width * height) throw new Error('invalid_pgm')
const targetWidth = 192
const targetHeight = 128
canvas.width = targetWidth
canvas.height = targetHeight
context.fillStyle = '#d9dce0'
context.fillRect(0, 0, targetWidth, targetHeight)
const scale = Math.min(targetWidth / width, targetHeight / height)
const renderedWidth = Math.max(1, Math.floor(width * scale))
const renderedHeight = Math.max(1, Math.floor(height * scale))
const offsetX = Math.floor((targetWidth - renderedWidth) / 2)
const offsetY = Math.floor((targetHeight - renderedHeight) / 2)
const image = context.createImageData(renderedWidth, renderedHeight)
for (let y = 0; y < renderedHeight; y += 1) {
const sourceY = Math.min(height - 1, Math.floor((y / renderedHeight) * height))
for (let x = 0; x < renderedWidth; x += 1) {
const sourceX = Math.min(width - 1, Math.floor((x / renderedWidth) * width))
const value = bytes[cursor + sourceY * width + sourceX]
const index = (y * renderedWidth + x) * 4
image.data[index] = value
image.data[index + 1] = value
image.data[index + 2] = value
image.data[index + 3] = 255
}
}
context.putImageData(image, offsetX, offsetY)
} catch {
// Keep the fallback map schematic visible when a legacy preview is invalid.
}
}, [map.previewPgm])
return (
<span
aria-hidden="true"
className={[
'relative block h-16 w-24 shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
selected ? 'border-[#6f9cff]' : 'border-white/15'
].join(' ')}
>
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
{!map.previewPgm && <span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />}
{!map.previewPgm && (map.custom ? (
<>
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
<span className="absolute bottom-[17%] left-[15%] size-[10%] rounded-full bg-[#536273]" />
</>
) : map.viewerModel === 'turtlebot3_house' ? (
<>
<span className="absolute inset-[12%] border-[3px] border-[#455363]" />
<span className="absolute left-[42%] top-[12%] h-[43%] w-[3px] bg-[#455363]" />
<span className="absolute bottom-[28%] left-[12%] h-[3px] w-[52%] bg-[#455363]" />
<span className="absolute bottom-[12%] right-[25%] h-[30%] w-[3px] bg-[#455363]" />
</>
) : (
<>
<span className="absolute inset-[11%] border-[3px] border-[#455363]" />
<span className="absolute left-[28%] top-[30%] size-[16%] rounded-full bg-[#455363]" />
<span className="absolute right-[22%] top-[22%] h-[16%] w-[24%] bg-[#455363]" />
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
</>
))}
{map.saved && <span className="absolute inset-x-0 bottom-0 bg-[#0f172a]/75 py-0.5 text-center text-[8px] font-extrabold tracking-[0.08em] text-white">SLAM</span>}
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
<Icon className="size-3" />
</span>
</span>
)
}
type WorldMapSelectorModalProps = {
platformKey: string
weekNumber: number
maps?: WorkspaceMapOption[]
selectedMapKey: string
onSelect: (map: WorkspaceMapOption) => void
onCreate: () => void
onDelete?: (map: WorkspaceMapOption) => void
onClose: () => void
}
export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: configuredMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const maps = platformKey.toUpperCase() === 'NAV2'
? (configuredMaps?.length
? [...configuredMaps, ...(configuredMaps.some((map) => map.key === LOCAL_CUSTOM_MAP.key) ? [] : [LOCAL_CUSTOM_MAP])]
: NAV2_MAP_OPTIONS)
: []
const environmentMaps = maps.filter((map) => !map.saved)
const savedMaps = maps.filter((map) => map.saved)
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [tab, setTab] = useState<'environment' | 'saved'>(() => selectedMap?.saved ? 'saved' : 'environment')
const visibleMaps = tab === 'environment' ? environmentMaps : savedMaps
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
useEffect(() => {
setTab(selectedMap?.saved ? 'saved' : 'environment')
setPendingMap(selectedMap)
}, [selectedMapKey])
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
<section className="flex w-full max-w-[560px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차에서 사용할 {platformKey.toUpperCase()} 학습 환경</p>
</div>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" />
</button>
</header>
<div className="flex gap-1 border-b border-white/10 px-5 pt-3">
<button type="button" onClick={() => setTab('environment')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'environment' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>학습 환경</button>
<button type="button" onClick={() => setTab('saved')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'saved' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>저장된 맵</button>
</div>
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
{visibleMaps.map((map) => {
const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey
return (
<button
key={map.key}
type="button"
onClick={() => setPendingMap(map)}
className={[
'group relative flex w-full cursor-pointer items-start gap-3 rounded-md border px-3 py-3 text-left transition',
pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]'
].join(' ')}
>
<MapPreview map={map} selected={pending} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-bold">{map.name}</span>
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
</span>
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
{map.saved && map.key !== selectedMapKey && onDelete && (
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
<Trash2 className="size-3.5" />
</span>
)}
</button>
)
})}
{!visibleMaps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">저장된 SLAM 맵이 없습니다.</div>}
</div>
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
{tab === 'environment' && (
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
<PencilRuler className="size-3.5" />
맵 편집
</button>
)}
<button type="button" disabled={!pendingMap || pendingMap.key === selectedMapKey} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
확인
</button>
</footer>
</section>
</div>
)
}
type WorldMapRestartConfirmModalProps = {
map: WorkspaceMapOption
restarting: boolean
onCancel: () => void
onConfirm: () => void
}
export function WorldMapRestartConfirmModal({ map, restarting, onCancel, onConfirm }: WorldMapRestartConfirmModalProps): React.JSX.Element {
return (
<div className="fixed inset-0 z-[160] grid place-items-center bg-black/65 p-6" role="dialog" aria-modal="true" aria-label="맵 변경 확인">
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center gap-2 border-b border-white/10 px-5 py-4">
<AlertTriangle className="size-4 text-amber-300" />
<h2 className="text-[15px] font-bold">맵 변경</h2>
</header>
<div className="px-5 py-4">
<p className="text-[13px] font-semibold">{map.name}으로 변경하시겠습니까?</p>
<p className="mt-2 text-[12px] leading-5 text-white/55">실행 중인 모듈을 전체 재시작해야 새 맵이 적용됩니다.</p>
<div className="mt-5 flex justify-end gap-2">
<button type="button" disabled={restarting} onClick={onCancel} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/65 transition hover:bg-white/8 hover:text-white disabled:cursor-default disabled:opacity-45">
취소
</button>
<button type="button" disabled={restarting} onClick={onConfirm} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-60">
{restarting && <LoaderCircle className="size-3.5 animate-spin" />}
{restarting ? '재시작 중' : '변경 후 재시작'}
</button>
</div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,102 @@
import type { CurriculumWeek } from '../curriculum/types'
import type { EnvironmentProfile } from '../platforms/types'
import type { ModulePrebuiltArtifact } from './types'
type CurriculumModule = NonNullable<CurriculumWeek['modules']>[number]
type RuntimeModuleOverride = {
id?: string
folderName?: string
name?: string
enabled?: boolean
sortOrder?: number
defaultParameters?: string[]
prebuiltArtifacts?: ModulePrebuiltArtifact[]
dockerImage?: string | null
}
type RuntimeModuleSet = {
target?: 'local' | 'platform'
includeUnlisted?: boolean
modules?: RuntimeModuleOverride[]
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function readModuleSet(profile: EnvironmentProfile | null, runtimeTarget: 'local' | 'platform'): RuntimeModuleSet | null {
const config = asRecord(profile?.config)
const raw = asRecord(config?.moduleSet)
if (!raw) return null
const target = raw.target === 'local' || raw.target === 'platform' ? raw.target : undefined
if (target && target !== runtimeTarget) return null
const modules = Array.isArray(raw.modules)
? raw.modules.map(asRecord).filter((item): item is Record<string, unknown> => item !== null).map((item) => ({
id: typeof item.id === 'string' ? item.id : undefined,
folderName: typeof item.folderName === 'string' ? item.folderName : undefined,
name: typeof item.name === 'string' ? item.name : undefined,
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : undefined,
defaultParameters: Array.isArray(item.defaultParameters)
? item.defaultParameters.filter((value): value is string => typeof value === 'string')
: undefined,
prebuiltArtifacts: Array.isArray(item.prebuiltArtifacts)
? item.prebuiltArtifacts.filter((value): value is ModulePrebuiltArtifact => typeof value === 'object' && value !== null)
: undefined,
dockerImage: typeof item.dockerImage === 'string' ? item.dockerImage : item.dockerImage === null ? null : undefined,
}))
: []
return {
target,
includeUnlisted: raw.includeUnlisted !== false,
modules,
}
}
function matchesModule(module: CurriculumModule, override: RuntimeModuleOverride): boolean {
return Boolean(
(override.id && override.id === module.id)
|| (override.folderName && override.folderName === module.folderName)
|| (override.name && override.name === module.name)
)
}
/** Environment-specific module selection preserves the shared student source repositories. */
export function resolveRuntimeModules(
modules: CurriculumWeek['modules'],
profile: EnvironmentProfile | EnvironmentProfile[] | null,
runtimeTarget: 'local' | 'platform',
): CurriculumWeek['modules'] {
const baseModules = modules ?? []
const profiles = Array.isArray(profile) ? profile : profile ? [profile] : []
const moduleSets = profiles
.map((item) => readModuleSet(item, runtimeTarget))
.filter((item): item is RuntimeModuleSet => item !== null)
if (!moduleSets.length) return baseModules
const overrides = moduleSets.flatMap((moduleSet) => moduleSet.modules ?? [])
const includeUnlisted = moduleSets.every((moduleSet) => moduleSet.includeUnlisted !== false)
return baseModules
.filter((module) => {
const override = overrides.find((item) => matchesModule(module, item))
return includeUnlisted || Boolean(override?.enabled !== false && override)
})
.filter((module) => overrides.find((item) => matchesModule(module, item))?.enabled !== false)
.map((module) => {
const override = overrides.find((item) => matchesModule(module, item))
if (!override) return module
return {
...module,
sortOrder: override.sortOrder ?? module.sortOrder,
defaultParameters: override.defaultParameters ?? module.defaultParameters,
prebuiltArtifacts: override.prebuiltArtifacts ?? module.prebuiltArtifacts,
dockerImage: override.dockerImage !== undefined ? override.dockerImage : module.dockerImage,
}
})
.sort((left, right) => left.sortOrder - right.sortOrder)
}
@@ -25,11 +25,7 @@ export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string { export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '') const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber) if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
const safePlatformKey = platformKey const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
.replace(/^futuredrive[-_]/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.toLowerCase()
const suffix = `${studentSlug}/${safePlatformKey}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
return `${normalizedRoot}/${suffix}` return `${normalizedRoot}/${suffix}`
} }
@@ -1,4 +1,5 @@
import { STUDIO_RUNTIME_SESSION_STORAGE_KEY } from '../../config/appConfig' import { STUDIO_RUNTIME_SESSION_STORAGE_KEY } from '../../config/appConfig'
import type { PlatformOption } from '../platforms/types'
export type StudioRuntimeSessionState = { export type StudioRuntimeSessionState = {
localPcLayoutOpen: boolean localPcLayoutOpen: boolean
@@ -8,6 +9,7 @@ export type StudioRuntimeSessionState = {
sessionEndedAt: string | null sessionEndedAt: string | null
connectedPlatformId: string | null connectedPlatformId: string | null
runningPlatformIds: string[] runningPlatformIds: string[]
runningPlatforms: PlatformOption[]
workspaceWeekNumber: number | null workspaceWeekNumber: number | null
} }
@@ -27,6 +29,15 @@ export function readStudioRuntimeSessionState(): StudioRuntimeSessionState | nul
sessionEndedAt: typeof parsed.sessionEndedAt === 'string' ? parsed.sessionEndedAt : null, sessionEndedAt: typeof parsed.sessionEndedAt === 'string' ? parsed.sessionEndedAt : null,
connectedPlatformId: typeof parsed.connectedPlatformId === 'string' ? parsed.connectedPlatformId : null, connectedPlatformId: typeof parsed.connectedPlatformId === 'string' ? parsed.connectedPlatformId : null,
runningPlatformIds: parsed.runningPlatformIds.filter((id): id is string => typeof id === 'string'), runningPlatformIds: parsed.runningPlatformIds.filter((id): id is string => typeof id === 'string'),
runningPlatforms: Array.isArray(parsed.runningPlatforms)
? parsed.runningPlatforms.filter((platform): platform is PlatformOption => (
Boolean(platform)
&& typeof platform === 'object'
&& typeof (platform as PlatformOption).id === 'string'
&& typeof (platform as PlatformOption).platformKey === 'string'
&& typeof (platform as PlatformOption).title === 'string'
))
: [],
workspaceWeekNumber: typeof parsed.workspaceWeekNumber === 'number' && parsed.workspaceWeekNumber > 0 ? parsed.workspaceWeekNumber : null workspaceWeekNumber: typeof parsed.workspaceWeekNumber === 'number' && parsed.workspaceWeekNumber > 0 ? parsed.workspaceWeekNumber : null
} }
} catch { } catch {
@@ -15,6 +15,7 @@ export type ModuleRow = {
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
sourceType?: string | null sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
dockerImage?: string | null
parameters: string[] parameters: string[]
cpu: number | null cpu: number | null
memory: number | null memory: number | null
@@ -62,19 +62,6 @@ export function useRemoteWorkspaceRuntime({
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
} }
const submitResult = await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
undefined,
undefined,
undefined
)
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
throw new Error(`[${module.name}] ${submitResult.message}`)
}
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.remoteWorkspace.modules.install( await window.remoteWorkspace.modules.install(
getRemoteWorkspaceTarget(), getRemoteWorkspaceTarget(),
@@ -87,23 +74,45 @@ export function useRemoteWorkspaceRuntime({
runtimeImages: activeRuntimeImages runtimeImages: activeRuntimeImages
} }
) )
const submitResult = remoteWorkspace
? await window.remoteWorkspace.git.submit(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
)
: await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
undefined,
undefined,
undefined,
)
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
throw new Error(`[${module.name}] ${submitResult.message}`)
}
}, [ }, [
activeRuntimeImages, activeRuntimeImages,
clonePathPreview, clonePathPreview,
courseKey, courseKey,
getFreshAccessToken, getFreshAccessToken,
getRemoteWorkspaceTarget, getRemoteWorkspaceTarget,
remoteWorkspace,
saveDirtyFilesForModule, saveDirtyFilesForModule,
weekNumber weekNumber
]) ])
const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => { const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => {
if (remoteWorkspace) { if (remoteWorkspace) {
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; sortOrder?: number | null }>() const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null; sortOrder?: number | null }>()
for (const module of availableModules) { for (const module of availableModules) {
moduleMap.set(module.name, { moduleMap.set(module.name, {
moduleName: module.name, moduleName: module.name,
folderName: module.folderName, folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sortOrder: module.sortOrder sortOrder: module.sortOrder
}) })
} }
@@ -112,6 +121,7 @@ export function useRemoteWorkspaceRuntime({
moduleMap.set(module.name, { moduleMap.set(module.name, {
moduleName: module.name, moduleName: module.name,
folderName: module.folderName, folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sortOrder: module.sortOrder sortOrder: module.sortOrder
}) })
} }
@@ -8,6 +8,8 @@ import { getPathName, getPathParent, makeUniquePath, replacePathPrefix } from '.
type UseWorkspaceFileActionsOptions = { type UseWorkspaceFileActionsOptions = {
selectedModule: ModuleRow | null selectedModule: ModuleRow | null
clonePathPreview: string clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
selectedTreePath: string | null selectedTreePath: string | null
setSelectedTreePath: Dispatch<SetStateAction<string | null>> setSelectedTreePath: Dispatch<SetStateAction<string | null>>
setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>> setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>>
@@ -75,6 +77,8 @@ type UseWorkspaceFileActionsResult = {
export function useWorkspaceFileActions({ export function useWorkspaceFileActions({
selectedModule, selectedModule,
clonePathPreview, clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
selectedTreePath, selectedTreePath,
setSelectedTreePath, setSelectedTreePath,
setSelectedExampleTreePath, setSelectedExampleTreePath,
@@ -115,7 +119,9 @@ export function useWorkspaceFileActions({
const loadModuleFiles = async (module: ModuleRow): Promise<void> => { const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true })) setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
try { try {
const result = await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName) const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const nextFiles = result.files const nextFiles = result.files
const nextFolders = result.folders.length ? result.folders : [] const nextFolders = result.folders.length ? result.folders : []
@@ -168,7 +174,11 @@ export function useWorkspaceFileActions({
if (newFileDraft.kind === 'folder') { if (newFileDraft.kind === 'folder') {
const nextFolder = `${newFileDraft.parent}/${normalizedName}` const nextFolder = `${newFileDraft.parent}/${normalizedName}`
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFolder, 'folder', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] create folder failed', error) console.error('[module-files] create folder failed', error)
return return
@@ -183,7 +193,11 @@ export function useWorkspaceFileActions({
const nextFile = `${newFileDraft.parent}/${normalizedName}` const nextFile = `${newFileDraft.parent}/${normalizedName}`
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFile, 'file', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] create file failed', error) console.error('[module-files] create file failed', error)
return return
@@ -215,7 +229,11 @@ export function useWorkspaceFileActions({
const saveEditorFile = async (file: EditorFilePath): Promise<void> => { const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
if (!selectedModule || file.startsWith('examples/example-code/')) return if (!selectedModule || file.startsWith('examples/example-code/')) return
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
}
markEditorFileSaved(file, editorFiles[file] ?? '') markEditorFileSaved(file, editorFiles[file] ?? '')
if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget) if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget)
} }
@@ -293,7 +311,11 @@ export function useWorkspaceFileActions({
} }
const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}` const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}`
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.rename(getRemoteWorkspaceTarget(), selectedModule.name, currentPath, nextPath, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] rename failed', error) console.error('[module-files] rename failed', error)
return return
@@ -334,7 +356,11 @@ export function useWorkspaceFileActions({
const performDeleteTreeTarget = async (targetPath: string): Promise<void> => { const performDeleteTreeTarget = async (targetPath: string): Promise<void> => {
if (!selectedModule) return if (!selectedModule) return
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.delete(getRemoteWorkspaceTarget(), selectedModule.name, targetPath, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] delete failed', error) console.error('[module-files] delete failed', error)
return return
@@ -364,7 +390,11 @@ export function useWorkspaceFileActions({
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))])) const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders) const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders)
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] copy failed', error) console.error('[module-files] copy failed', error)
return return
@@ -87,11 +87,23 @@ export function useWorkspaceModuleControl({
} }
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, { const installOptions = {
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null, prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
runtimeImages: activeRuntimeImages runtimeImages: activeRuntimeImages
}) }
}, [activeRuntimeImages, clonePathPreview, courseKey, weekNumber]) if (remoteWorkspace) {
await window.remoteWorkspace.modules.install(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
templateRepositoryUrl,
prepared.repositoryUrlWithToken,
installOptions,
)
return
}
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, installOptions)
}, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => { const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
await prepareRemoteRuntimeModule(module) await prepareRemoteRuntimeModule(module)
@@ -102,7 +114,8 @@ export function useWorkspaceModuleControl({
getModuleRosParams(module), getModuleRosParams(module),
sourceSettings.gpuEnabled, sourceSettings.gpuEnabled,
sourceSettings.rosDomainId, sourceSettings.rosDomainId,
activeRuntimeImages activeRuntimeImages,
module.dockerImage,
) )
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId]) }, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
@@ -167,7 +180,7 @@ export function useWorkspaceModuleControl({
for (const module of targets) { for (const module of targets) {
const result = remoteWorkspace const result = remoteWorkspace
? await remoteModuleStart(module) ? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
} }
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
@@ -206,10 +219,11 @@ export function useWorkspaceModuleControl({
setModuleInstallError(null) setModuleInstallError(null)
try { try {
if (remoteWorkspace) { if (remoteWorkspace) {
for (const module of targets) { const result = await window.remoteWorkspace.modules.stopMany(
const result = await remoteModuleStop(module) getRemoteWorkspaceTarget(),
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
}
} else { } else {
const result = await window.localWorkspace.modules.stopMany( const result = await window.localWorkspace.modules.stopMany(
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })), targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
@@ -227,7 +241,7 @@ export function useWorkspaceModuleControl({
} finally { } finally {
setInstallingModuleName(null) setInstallingModuleName(null)
} }
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace]) }, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteWorkspace])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => { const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
if (installingModuleName || controllingModuleNames.includes(module.name)) return if (installingModuleName || controllingModuleNames.includes(module.name)) return
@@ -242,14 +256,14 @@ export function useWorkspaceModuleControl({
: action === 'build' : action === 'build'
? remoteWorkspace ? remoteWorkspace
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) ? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage, getModuleRosParams(module))
: action === 'restart' : action === 'restart'
? remoteWorkspace ? remoteWorkspace
? await remoteModuleRestart(module) ? await remoteModuleRestart(module)
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
: remoteWorkspace : remoteWorkspace
? await remoteModuleStart(module) ? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, module.dockerImage)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react'
import type { CurriculumWeek } from '../curriculum/types' import type { CurriculumWeek } from '../curriculum/types'
import type { ModuleRow } from './types' import type { ModuleRow } from './types'
@@ -21,6 +21,7 @@ type UseWorkspaceModuleListOptions = {
type UseWorkspaceModuleListResult = { type UseWorkspaceModuleListResult = {
availableModules: ModuleRow[] availableModules: ModuleRow[]
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>> setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
modulesLoading: boolean modulesLoading: boolean
moduleListError: string | null moduleListError: string | null
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void> loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
@@ -49,43 +50,104 @@ export function useWorkspaceModuleList({
const [modulesLoading, setModulesLoading] = useState(false) const [modulesLoading, setModulesLoading] = useState(false)
const [moduleListError, setModuleListError] = useState<string | null>(null) const [moduleListError, setModuleListError] = useState<string | null>(null)
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey]) const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
const moduleInputsRef = useRef({ curriculumModules, applyModuleParameterOverrides })
const loadSequenceRef = useRef(0)
const moduleLoadInFlightRef = useRef(false)
const pendingModuleStatesRef = useRef(new Map<string, { state: ModuleRow['state']; expiresAt: number }>())
const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name
const applyPendingModuleStates = useCallback((modules: ModuleRow[]): ModuleRow[] => {
const now = Date.now()
return modules.map((module) => {
const key = moduleStateKey(module)
const pending = pendingModuleStatesRef.current.get(key)
if (!pending) return module
if (module.state === pending.state || pending.expiresAt <= now) {
pendingModuleStatesRef.current.delete(key)
return module
}
return {
...module,
state: pending.state,
runtime: pending.state === 'running' ? '시작 중' : '-',
cpu: pending.state === 'running' ? module.cpu : null,
memory: pending.state === 'running' ? module.memory : null,
}
})
}, [])
const markModuleState = useCallback((module: ModuleRow, state: ModuleRow['state']): void => {
const key = moduleStateKey(module)
pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + 10_000 })
setAvailableModules((current) => current.map((item) => {
if (moduleStateKey(item) !== key) return item
return {
...item,
state,
runtime: state === 'running' ? '시작 중' : '-',
cpu: state === 'running' ? item.cpu : null,
memory: state === 'running' ? item.memory : null,
}
}))
}, [])
useEffect(() => {
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => { const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
if (!remoteTarget) return { modules: [] } if (!remoteTarget) throw new Error('원격 플랫폼 SSH 주소를 확인하지 못했습니다.')
return window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? []) const result = await window.remoteWorkspace.modules.list(remoteTarget, moduleInputsRef.current.curriculumModules ?? [])
return {
modules: result.modules.map((module) => {
const rawModule = module as ModuleRow & { defaultParameters?: unknown }
const parameters = Array.isArray(rawModule.parameters)
? rawModule.parameters
: Array.isArray(rawModule.defaultParameters)
? rawModule.defaultParameters.filter((value): value is string => typeof value === 'string')
: []
return { ...module, parameters }
}),
}
}, [curriculumModuleKey, remoteTarget]) }, [curriculumModuleKey, remoteTarget])
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => { const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
// Remote inventory probes open SSH sessions. Keep one request in flight so
// polling cannot continuously replace a slow response with a newer one.
if (moduleLoadInFlightRef.current) return
moduleLoadInFlightRef.current = true
const requestSequence = ++loadSequenceRef.current
if (!silent) setModulesLoading(true) if (!silent) setModulesLoading(true)
try { try {
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? []) const { curriculumModules: currentCurriculumModules, applyModuleParameterOverrides: applyOverrides } = moduleInputsRef.current
if (remoteWorkspace) { if (remoteWorkspace) {
const remoteResult = await loadRemoteModules().catch(() => ({ modules: [] as ModuleRow[] })) const result = await loadRemoteModules()
const remoteStateByName = new Map(remoteResult.modules.map((module) => [module.name, module])) if (requestSequence !== loadSequenceRef.current) return
result.modules = result.modules.map((module) => {
const remoteModule = remoteStateByName.get(module.name)
return remoteModule?.state === 'running'
? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime }
: module
})
}
setModuleListError(null) setModuleListError(null)
setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)) setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
return
}
const result = await window.localWorkspace.modules.list(clonePathPreview, currentCurriculumModules ?? [])
if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null)
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
} catch (error) { } catch (error) {
if (requestSequence !== loadSequenceRef.current) return
console.error('[modules] list failed', error) console.error('[modules] list failed', error)
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.' const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
if (!silent) { if (!silent) {
setModuleListError(message) setModuleListError(message)
setAvailableModules(makeUninstalledModuleRows(curriculumModules)) setAvailableModules(makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules))
} }
} finally { } finally {
if (!silent) setModulesLoading(false) moduleLoadInFlightRef.current = false
if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false)
} }
}, [ }, [
applyModuleParameterOverrides, applyPendingModuleStates,
clonePathPreview, clonePathPreview,
curriculumModuleKey, curriculumModuleKey,
curriculumModules,
loadRemoteModules, loadRemoteModules,
remoteWorkspace remoteWorkspace
]) ])
@@ -99,14 +161,15 @@ export function useWorkspaceModuleList({
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
void loadAvailableModules({ silent: true }) void loadAvailableModules({ silent: true })
}, 5000) }, remoteWorkspace ? 10_000 : 5_000)
return () => window.clearInterval(intervalId) return () => window.clearInterval(intervalId)
}, [loadAvailableModules, pollingActive]) }, [loadAvailableModules, pollingActive, remoteWorkspace])
return { return {
availableModules, availableModules,
setAvailableModules, setAvailableModules,
markModuleState,
modulesLoading, modulesLoading,
moduleListError, moduleListError,
loadAvailableModules, loadAvailableModules,
@@ -14,6 +14,8 @@ type UseWorkspaceTerminalOptions = {
moduleName?: string | null moduleName?: string | null
moduleFolderName?: string | null moduleFolderName?: string | null
clonePathPreview: string clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
dark: boolean dark: boolean
terminalActive: boolean terminalActive: boolean
resetKey: string resetKey: string
@@ -36,6 +38,8 @@ export function useWorkspaceTerminal({
moduleName, moduleName,
moduleFolderName, moduleFolderName,
clonePathPreview, clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
dark, dark,
terminalActive, terminalActive,
resetKey, resetKey,
@@ -47,11 +51,14 @@ export function useWorkspaceTerminal({
const terminalRef = useRef<XTerm | null>(null) const terminalRef = useRef<XTerm | null>(null)
const terminalFitRef = useRef<FitAddon | null>(null) const terminalFitRef = useRef<FitAddon | null>(null)
const terminalSessionIdRef = useRef<string | null>(null) const terminalSessionIdRef = useRef<string | null>(null)
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
const terminalSuppressQueueRef = useRef<string[]>([]) const terminalSuppressQueueRef = useRef<string[]>([])
const terminalOutputBufferRef = useRef<Record<string, string>>({}) const terminalOutputBufferRef = useRef<Record<string, string>>({})
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set()) const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235) const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
const fitTerminal = (): void => { const fitTerminal = (): void => {
terminalFitRef.current?.fit() terminalFitRef.current?.fit()
} }
@@ -71,7 +78,12 @@ export function useWorkspaceTerminal({
void navigator.clipboard.readText() void navigator.clipboard.readText()
.then((text) => { .then((text) => {
if (text) window.localWorkspace.terminal.write(text, terminalSessionId) if (!text) return
if (remoteWorkspace) {
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), text, terminalSessionId)
return
}
window.localWorkspace.terminal.write(text, terminalSessionId)
}) })
.catch((error) => console.error('[terminal] paste failed', error)) .catch((error) => console.error('[terminal] paste failed', error))
.finally(() => { .finally(() => {
@@ -153,8 +165,18 @@ export function useWorkspaceTerminal({
terminalRef.current = term terminalRef.current = term
terminalFitRef.current = fitAddon terminalFitRef.current = fitAddon
const terminalApi = window.localWorkspace.terminal const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal
const removeDataListener = terminalApi.onData((data) => { const removeDataListener = remoteWorkspace
? terminalApi.onData(remoteTargetResolverRef.current(), (data) => {
const suppressQueue = terminalSuppressQueueRef.current
if (suppressQueue.length && suppressQueue[0] === data) {
suppressQueue.shift()
return
}
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${data}`.slice(-100000)
term.write(data)
}, terminalSessionId)
: terminalApi.onData((data) => {
const suppressQueue = terminalSuppressQueueRef.current const suppressQueue = terminalSuppressQueueRef.current
if (suppressQueue.length && suppressQueue[0] === data) { if (suppressQueue.length && suppressQueue[0] === data) {
suppressQueue.shift() suppressQueue.shift()
@@ -168,10 +190,20 @@ export function useWorkspaceTerminal({
term.write(nextData) term.write(nextData)
} }
}, terminalSessionId) }, terminalSessionId)
const removeSuppressListener = terminalApi.onSuppressData((data) => { const removeSuppressListener = remoteWorkspace
? window.remoteWorkspace.terminal.onSuppressData(remoteTargetResolverRef.current(), (data) => {
terminalSuppressQueueRef.current.push(data) terminalSuppressQueueRef.current.push(data)
}, terminalSessionId) }, terminalSessionId)
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId)) : window.localWorkspace.terminal.onSuppressData((data) => {
terminalSuppressQueueRef.current.push(data)
}, terminalSessionId)
const inputDisposable = term.onData((data) => {
if (remoteWorkspace) {
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), data, terminalSessionId)
return
}
window.localWorkspace.terminal.write(data, terminalSessionId)
})
let fallbackPromptTimer: number | null = null let fallbackPromptTimer: number | null = null
let sizeSyncTimer: number | null = null let sizeSyncTimer: number | null = null
@@ -183,8 +215,24 @@ export function useWorkspaceTerminal({
startNestedFrame = window.requestAnimationFrame(() => { startNestedFrame = window.requestAnimationFrame(() => {
fitAddon.fit() fitAddon.fit()
if (disposed) return resolvePromise(false) if (disposed) return resolvePromise(false)
void window.localWorkspace.terminal const start = remoteWorkspace
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined) ? window.remoteWorkspace.terminal.start(
remoteTargetResolverRef.current(),
moduleName,
term.cols,
term.rows,
terminalSessionId,
moduleFolderName ?? undefined,
)
: window.localWorkspace.terminal.start(
moduleName,
term.cols,
term.rows,
terminalSessionId,
clonePathPreview,
moduleFolderName ?? undefined,
)
void start
.then(resolvePromise) .then(resolvePromise)
.catch(() => resolvePromise(false)) .catch(() => resolvePromise(false))
}) })
@@ -195,7 +243,11 @@ export function useWorkspaceTerminal({
if (!started) return if (!started) return
sizeSyncTimer = window.setTimeout(() => { sizeSyncTimer = window.setTimeout(() => {
fitAddon.fit() fitAddon.fit()
void terminalApi.resize(term.cols, term.rows, terminalSessionId) if (remoteWorkspace) {
void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId)
return
}
void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId)
}, 150) }, 150)
fallbackPromptTimer = window.setTimeout(() => { fallbackPromptTimer = window.setTimeout(() => {
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
@@ -228,14 +280,15 @@ export function useWorkspaceTerminal({
// Full-screen programs such as vi cannot be restored from a partial raw output buffer. // Full-screen programs such as vi cannot be restored from a partial raw output buffer.
delete terminalOutputBufferRef.current[terminalSessionId] delete terminalOutputBufferRef.current[terminalSessionId]
terminalFallbackPromptShownRef.current.delete(terminalSessionId) terminalFallbackPromptShownRef.current.delete(terminalSessionId)
terminalApi.stop(terminalSessionId) if (remoteWorkspace) window.remoteWorkspace.terminal.stop(remoteTargetResolverRef.current(), terminalSessionId)
else window.localWorkspace.terminal.stop(terminalSessionId)
term.dispose() term.dispose()
terminalRef.current = null terminalRef.current = null
terminalFitRef.current = null terminalFitRef.current = null
terminalSessionIdRef.current = null terminalSessionIdRef.current = null
terminalSuppressQueueRef.current = [] terminalSuppressQueueRef.current = []
} }
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive]) }, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
return { return {
terminalContextMenu, terminalContextMenu,
@@ -3,12 +3,60 @@ import type { EnvironmentProfile } from '../platforms/types'
import type { RuntimeImageArtifact } from './types' import type { RuntimeImageArtifact } from './types'
import type { VisualizationStack } from './WorkspaceVisualizationPanel' import type { VisualizationStack } from './WorkspaceVisualizationPanel'
export type Nav2VisualizationMode = 'slam' | 'navigation'
const DEFAULT_NAV2_VISUALIZATION_PROFILE: EnvironmentProfile = {
id: 'default-nav2-visualization',
week: 0,
platformKey: 'futuredrive-local-pc',
profileType: 'NAV2',
name: 'NAV2',
config: {
type: 'NAV2',
target: 'local',
viewer: { stack: 'nav2' },
bridge: {
name: 'nav2-ros-bridge',
port: 8765,
image: 'slam-ros-bridge:latest',
buildContext: 'resources/slam-ros-bridge',
env: { ROBOT_STACK: 'nav2' }
}
}
}
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase()
if (type === 'real_slam') return 'slam'
if (type === 'real_navigation') return 'nav2'
return type === 'autoware' || type === 'nav2' || type === 'slam' ? type : null
}
function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationMode {
return profile?.profileType.toLowerCase() === 'real_navigation' ? 'navigation' : 'slam'
}
function isRemoteEnvironmentProfile(profile: EnvironmentProfile | null): boolean {
const target = profile?.config?.target
return target === 'remote-jetson' || target === 'platform'
}
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
const matchingProfiles = profiles.filter((profile) => (
remoteWorkspace ? isRemoteEnvironmentProfile(profile) : !isRemoteEnvironmentProfile(profile)
))
return matchingProfiles.length ? matchingProfiles : profiles
}
type NoticeTone = 'info' | 'warning' | 'error' type NoticeTone = 'info' | 'warning' | 'error'
type UseWorkspaceVisualizationOptions = { type UseWorkspaceVisualizationOptions = {
profiles: EnvironmentProfile[] profiles: EnvironmentProfile[]
clonePathPreview: string clonePathPreview: string
rosDomainId: string rosDomainId: string
remoteWorkspace: boolean
mapKey?: string | null
worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
} }
@@ -19,6 +67,8 @@ type UseWorkspaceVisualizationResult = {
visualizationRunning: boolean visualizationRunning: boolean
visualizationBusy: boolean visualizationBusy: boolean
visualizationViewerUrl: string visualizationViewerUrl: string
nav2VisualizationMode: Nav2VisualizationMode
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
activeEnvironmentProfile: EnvironmentProfile | null activeEnvironmentProfile: EnvironmentProfile | null
activeRuntimeImages: RuntimeImageArtifact[] activeRuntimeImages: RuntimeImageArtifact[]
refreshVisualizationStatus: () => Promise<void> refreshVisualizationStatus: () => Promise<void>
@@ -36,20 +86,29 @@ export function useWorkspaceVisualization({
profiles, profiles,
clonePathPreview, clonePathPreview,
rosDomainId, rosDomainId,
remoteWorkspace,
mapKey,
worldLayoutVersion,
onTitlebarNotice onTitlebarNotice
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult { }: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware') const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware')
const [nav2VisualizationMode, setNav2VisualizationMode] = useState<Nav2VisualizationMode>('slam')
const [visualizationRunning, setVisualizationRunning] = useState(false) const [visualizationRunning, setVisualizationRunning] = useState(false)
const [, setVisualizationStatusMessage] = useState('대기') const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false) const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => {
const candidates = selectRuntimeProfiles(profiles, remoteWorkspace)
const nav2Profiles = candidates.filter((profile) => resolveVisualizationStack(profile.profileType) !== 'autoware')
return nav2Profiles.length ? nav2Profiles : [DEFAULT_NAV2_VISUALIZATION_PROFILE]
}, [profiles, remoteWorkspace])
const visualizationStacks = useMemo(() => ( const visualizationStacks = useMemo(() => (
profiles runtimeProfiles
.map((profile) => profile.profileType.toLowerCase()) .map((profile) => resolveVisualizationStack(profile.profileType))
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam') .filter((stack): stack is VisualizationStack => stack !== null)
), [profiles]) ), [runtimeProfiles])
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null const activeEnvironmentProfile = runtimeProfiles.find((profile) => resolveVisualizationStack(profile.profileType) === visualizationStack) ?? runtimeProfiles[0] ?? null
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {} const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages) const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => ( ? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
@@ -62,7 +121,7 @@ export function useWorkspaceVisualization({
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string' const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
? activeEnvironmentAgent.url.replace(/\/+$/, '') ? activeEnvironmentAgent.url.replace(/\/+$/, '')
: '' : ''
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && activeEnvironmentAgentUrl const visualizationRemote = isRemoteEnvironmentProfile(activeEnvironmentProfile) && activeEnvironmentAgentUrl
const visualizationRemoteBridgeHttpUrl = (() => { const visualizationRemoteBridgeHttpUrl = (() => {
if (!visualizationRemote) return '' if (!visualizationRemote) return ''
try { try {
@@ -78,16 +137,15 @@ export function useWorkspaceVisualization({
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') ? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
: 'ws://localhost:8765' : 'ws://localhost:8765'
const visualizationBackendUrl = visualizationBridgeHttpUrl const visualizationBackendUrl = visualizationBridgeHttpUrl
const visualizationViewerUrl = visualizationStack === 'slam' const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'nav2'
? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}` ? `/slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${isRemoteEnvironmentProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}`
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}` : `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
useEffect(() => { useEffect(() => {
const profileType = profiles[0]?.profileType?.toLowerCase() const stack = runtimeProfiles[0] ? resolveVisualizationStack(runtimeProfiles[0].profileType) : null
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') { if (stack) setVisualizationStack(stack)
setVisualizationStack(profileType) setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
} }, [runtimeProfiles])
}, [profiles.map((profile) => profile.id).join('|')])
const refreshVisualizationStatus = async (): Promise<void> => { const refreshVisualizationStatus = async (): Promise<void> => {
const status = await window.localWorkspace.visualization.status().catch((error) => ({ const status = await window.localWorkspace.visualization.status().catch((error) => ({
@@ -107,11 +165,6 @@ export function useWorkspaceVisualization({
const startVisualizationBridge = async (): Promise<void> => { const startVisualizationBridge = async (): Promise<void> => {
if (visualizationBusy) return if (visualizationBusy) return
if (!activeEnvironmentProfile) {
setVisualizationStatusMessage('지원 안함')
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
return
}
setVisualizationBusy(true) setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중') setVisualizationStatusMessage('시작 중')
try { try {
@@ -155,6 +208,8 @@ export function useWorkspaceVisualization({
visualizationRunning, visualizationRunning,
visualizationBusy, visualizationBusy,
visualizationViewerUrl, visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
activeEnvironmentProfile, activeEnvironmentProfile,
activeRuntimeImages, activeRuntimeImages,
refreshVisualizationStatus, refreshVisualizationStatus,
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript' if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp' if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
if (file.endsWith('.json')) return 'json' if (file.endsWith('.json')) return 'json'
if (file.endsWith('.xml')) return 'xml' if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml'
if (file.endsWith('.html')) return 'html' if (file.endsWith('.html')) return 'html'
if (file.endsWith('.css')) return 'css' if (file.endsWith('.css')) return 'css'
if (file.endsWith('.py')) return 'python' if (file.endsWith('.py')) return 'python'
@@ -18,7 +18,7 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod
templateRepositoryUrl: module.templateRepositoryUrl ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null,
sourceType: module.sourceType ?? null, sourceType: module.sourceType ?? null,
prebuiltArtifacts: module.prebuiltArtifacts ?? null, prebuiltArtifacts: module.prebuiltArtifacts ?? null,
parameters: [], parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [],
cpu: null, cpu: null,
memory: null, memory: null,
runtime: '-' runtime: '-'
+135 -18
View File
@@ -9,6 +9,8 @@ type ModuleInput = {
folderName?: string | null folderName?: string | null
category?: string | null category?: string | null
sortOrder?: number | null sortOrder?: number | null
defaultParameters?: string[] | null
dockerImage?: string | null
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
studentRepositoryUrl?: string | null studentRepositoryUrl?: string | null
} }
@@ -61,6 +63,43 @@ function readLaunchState(): { handoffToken: string | null; audienceApp: string;
} }
const launchState = readLaunchState() const launchState = readLaunchState()
const launcherSessionExpiredListeners = new Set<() => void>()
function notifyLauncherSessionExpired(): void {
for (const listener of launcherSessionExpiredListeners) listener()
}
function watchLauncherSession(): void {
if (!launchState.bridge) return
const controller = new AbortController()
void fetch(`${launchState.bridge.url}/api/session/events`, {
method: 'POST',
headers: { [BRIDGE_HEADER]: launchState.bridge.token },
signal: controller.signal,
}).then(async (response) => {
if (!response.ok || !response.body) return
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) return
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() ?? ''
if (events.some((event) => event.startsWith('event: session-expired'))) {
notifyLauncherSessionExpired()
controller.abort()
return
}
}
}).catch((error) => {
if (error instanceof DOMException && error.name === 'AbortError') return
console.warn('[lms-auth] launcher session event connection failed', error)
})
}
async function bridgeRequest<T>(path: string, body?: unknown): Promise<T> { async function bridgeRequest<T>(path: string, body?: unknown): Promise<T> {
if (!launchState.bridge) throw new Error('런처 로컬 연결 정보가 없습니다. 런처에서 웹 실행을 다시 선택하세요.') if (!launchState.bridge) throw new Error('런처 로컬 연결 정보가 없습니다. 런처에서 웹 실행을 다시 선택하세요.')
@@ -120,6 +159,7 @@ const localModules = {
sourcePath: module.sourcePath ?? module.workspacePath ?? '', sourcePath: module.sourcePath ?? module.workspacePath ?? '',
workspacePath: module.workspacePath ?? null, workspacePath: module.workspacePath ?? null,
templateRepositoryUrl: module.templateRepositoryUrl ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null,
dockerImage: module.dockerImage ?? null,
parameters: Array.isArray(module.parameters) ? module.parameters : [], parameters: Array.isArray(module.parameters) ? module.parameters : [],
cpu: running ? cpu : null, cpu: running ? cpu : null,
memory: running ? memory : null, memory: running ? memory : null,
@@ -134,10 +174,12 @@ const localModules = {
folderName?: string, folderName?: string,
templateRepositoryUrl?: string | null, templateRepositoryUrl?: string | null,
studentRepositoryUrl?: string | null, studentRepositoryUrl?: string | null,
options?: { runtimeImages?: unknown },
) { ) {
const result = await bridgeRequest<{ ok: boolean; workspacePath?: string }>('/api/modules/install', { const result = await bridgeRequest<{ ok: boolean; workspacePath?: string }>('/api/modules/install', {
workspaceRoot, workspaceRoot,
module: moduleInput(name, folderName, { templateRepositoryUrl, studentRepositoryUrl }), module: moduleInput(name, folderName, { templateRepositoryUrl, studentRepositoryUrl }),
runtimeImages: options?.runtimeImages,
}) })
return { return {
ok: result.ok, ok: result.ok,
@@ -156,21 +198,24 @@ const localModules = {
}, },
} }
}, },
async start(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) { async start(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) {
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/start', { return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/start', {
workspaceRoot, workspaceRoot,
module: moduleInput(name, folderName), module: moduleInput(name, folderName, { dockerImage }),
rosParams, rosParams,
gpuEnabled, gpuEnabled,
rosDomainId, rosDomainId,
runtimeImages,
}) })
}, },
async build(name: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string) { async build(name: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null, rosParams?: string) {
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/build', { return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/build', {
workspaceRoot, workspaceRoot,
module: moduleInput(name, folderName), module: moduleInput(name, folderName, { dockerImage }),
gpuEnabled, gpuEnabled,
rosDomainId, rosDomainId,
runtimeImages,
rosParams,
}) })
}, },
async stop(name: string, workspaceRoot?: string, folderName?: string) { async stop(name: string, workspaceRoot?: string, folderName?: string) {
@@ -186,9 +231,9 @@ const localModules = {
async stopRunning() { async stopRunning() {
return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/stop-running', {}) return bridgeRequest<{ ok: boolean; message: string }>('/api/modules/stop-running', {})
}, },
async restart(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) { async restart(name: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) {
await localModules.stop(name, workspaceRoot, folderName) await localModules.stop(name, workspaceRoot, folderName)
return localModules.start(name, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId) return localModules.start(name, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage)
}, },
async logs(name: string, workspaceRoot?: string, folderName?: string) { async logs(name: string, workspaceRoot?: string, folderName?: string) {
return bridgeRequest<{ ok: boolean; logs: string; message?: string }>('/api/modules/logs', { return bridgeRequest<{ ok: boolean; logs: string; message?: string }>('/api/modules/logs', {
@@ -211,6 +256,7 @@ function fileRequest<T>(path: string, moduleName: string, workspaceRoot: string
const localFileApi = { const localFileApi = {
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/read', moduleName, workspaceRoot, folderName), read: (moduleName: string, workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/read', moduleName, workspaceRoot, folderName),
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => fileRequest<{ dataUrl: string }>('/api/files/image', moduleName, workspaceRoot, folderName, { path }),
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/create', moduleName, workspaceRoot, folderName, { path, kind }), create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/create', moduleName, workspaceRoot, folderName, { path, kind }),
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/write', moduleName, workspaceRoot, folderName, { path, content }), write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/write', moduleName, workspaceRoot, folderName, { path, content }),
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/copy', moduleName, workspaceRoot, folderName, { fromPath, toPath, mode }), copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/copy', moduleName, workspaceRoot, folderName, { fromPath, toPath, mode }),
@@ -286,6 +332,17 @@ const unavailableVisualizationApi = {
stop: async () => ({ ok: true, running: false, stack: null }), stop: async () => ({ ok: true, running: false, stack: null }),
} }
const localWorldLayoutApi = {
read: (workspaceRoot?: string) => bridgeRequest<WorldLayout>('/api/world-layout/read', { workspaceRoot }),
write: (workspaceRoot: string | undefined, layout: WorldLayout) => bridgeRequest<WorldLayout>('/api/world-layout/write', { workspaceRoot, layout }),
}
const unavailableNav2Api = {
setSimulatorPose: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
addRuntimeObstacle: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
clearRuntimeObstacles: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
}
function remoteUnavailable(feature: string): never { function remoteUnavailable(feature: string): never {
throw new Error(`원격 플랫폼 ${feature}은 Platform Gateway와 Agent API 연결이 필요합니다.`) throw new Error(`원격 플랫폼 ${feature}은 Platform Gateway와 Agent API 연결이 필요합니다.`)
} }
@@ -301,6 +358,8 @@ function remoteFileRequest<T>(path: string, target: unknown, moduleName: string,
const localWorkspace = { const localWorkspace = {
moduleFiles: localFileApi, moduleFiles: localFileApi,
modules: localModules, modules: localModules,
nav2: unavailableNav2Api,
worldLayout: localWorldLayoutApi,
visualization: unavailableVisualizationApi, visualization: unavailableVisualizationApi,
git: localGitApi, git: localGitApi,
terminal: localTerminalApi, terminal: localTerminalApi,
@@ -309,6 +368,7 @@ const localWorkspace = {
const remoteWorkspace = { const remoteWorkspace = {
moduleFiles: { moduleFiles: {
read: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/files/read', target, moduleName, folderName), read: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/files/read', target, moduleName, folderName),
image: (target: unknown, moduleName: string, path: string, folderName?: string) => remoteFileRequest<{ dataUrl: string }>('/api/remote/files/image', target, moduleName, folderName, { path }),
create: (target: unknown, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => remoteFileRequest('/api/remote/files/create', target, moduleName, folderName, { path, kind }), create: (target: unknown, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => remoteFileRequest('/api/remote/files/create', target, moduleName, folderName, { path, kind }),
write: (target: unknown, moduleName: string, path: string, content: string, folderName?: string) => remoteFileRequest('/api/remote/files/write', target, moduleName, folderName, { path, content }), write: (target: unknown, moduleName: string, path: string, content: string, folderName?: string) => remoteFileRequest('/api/remote/files/write', target, moduleName, folderName, { path, content }),
copy: (target: unknown, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => remoteFileRequest('/api/remote/files/copy', target, moduleName, folderName, { fromPath, toPath, mode }), copy: (target: unknown, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => remoteFileRequest('/api/remote/files/copy', target, moduleName, folderName, { fromPath, toPath, mode }),
@@ -316,26 +376,78 @@ const remoteWorkspace = {
delete: (target: unknown, moduleName: string, path: string, folderName?: string) => remoteFileRequest('/api/remote/files/delete', target, moduleName, folderName, { path }), delete: (target: unknown, moduleName: string, path: string, folderName?: string) => remoteFileRequest('/api/remote/files/delete', target, moduleName, folderName, { path }),
}, },
modules: { modules: {
list: (target: unknown, modules: ModuleInput[] = []) => remoteBridgeRequest('/api/remote/modules/list', target, { modules }), list: async (target: unknown, modules: ModuleInput[] = []) => {
install: async (target: unknown, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => { const result = await remoteBridgeRequest<{ workspaceRoot: string; modules: unknown[] }>('/api/remote/modules/list', target, { modules })
const result = await remoteFileRequest<{ ok: boolean; workspacePath?: string }>('/api/remote/modules/install', target, moduleName, folderName, { templateRepositoryUrl, studentRepositoryUrl }) return {
...result,
modules: result.modules.map((value, index) => {
const module = value as Partial<ModuleInput> & {
state?: 'running' | 'stopped' | 'uninstalled'
workspacePath?: string | null
parameters?: string[]
cpu?: number | null
memory?: number | null
runtime?: string
}
return {
...module,
state: module.state ?? 'uninstalled',
sortOrder: module.sortOrder ?? index + 1,
category: module.category ?? '',
name: module.name ?? '',
folderName: module.folderName ?? module.name ?? '',
workspacePath: module.workspacePath ?? null,
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
parameters: Array.isArray(module.parameters)
? module.parameters
: Array.isArray(module.defaultParameters)
? module.defaultParameters
: [],
cpu: module.cpu ?? null,
memory: module.memory ?? null,
runtime: module.runtime ?? '-',
}
}),
}
},
install: async (
target: unknown,
moduleName: string,
folderName?: string,
templateRepositoryUrl?: string | null,
studentRepositoryUrl?: string | null,
options?: { prebuiltArtifacts?: unknown; runtimeImages?: unknown },
) => {
const result = await remoteFileRequest<{ ok: boolean; workspacePath?: string }>('/api/remote/modules/install', target, moduleName, folderName, {
templateRepositoryUrl,
studentRepositoryUrl,
prebuiltArtifacts: options?.prebuiltArtifacts,
runtimeImages: options?.runtimeImages,
})
return { ok: result.ok, module: { state: 'stopped' as const, sortOrder: 0, category: '', name: moduleName, folderName: folderName ?? moduleName, sourcePath: result.workspacePath ?? '', workspacePath: result.workspacePath ?? null, parameters: [], cpu: null, memory: null, runtime: '-' } } return { ok: result.ok, module: { state: 'stopped' as const, sortOrder: 0, category: '', name: moduleName, folderName: folderName ?? moduleName, sourcePath: result.workspacePath ?? '', workspacePath: result.workspacePath ?? null, parameters: [], cpu: null, memory: null, runtime: '-' } }
}, },
start: (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages }), start: (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage }),
build: (target: unknown, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => remoteFileRequest('/api/remote/modules/build', target, moduleName, folderName, { gpuEnabled, rosDomainId, runtimeImages }), build: (target: unknown, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => remoteFileRequest('/api/remote/modules/build', target, moduleName, folderName, { gpuEnabled, rosDomainId, runtimeImages, dockerImage }),
stop: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/modules/stop', target, moduleName, folderName), stop: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/modules/stop', target, moduleName, folderName),
stopMany: async (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null }>) => { stopMany: (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null }>) => remoteBridgeRequest<{ ok: boolean; message: string }>('/api/remote/modules/stop-many', target, {
const results = await Promise.all(modules.map((module) => remoteFileRequest<{ ok: boolean }>('/api/remote/modules/stop', target, module.moduleName, module.folderName ?? undefined))) modules: modules.map((module) => moduleInput(module.moduleName, module.folderName ?? undefined)),
return { ok: results.every((result) => result.ok), message: '모듈 중지 요청을 완료했습니다.' } }),
},
stopRunning: () => remoteUnavailable('모듈 일괄 중지'), stopRunning: () => remoteUnavailable('모듈 일괄 중지'),
restart: async (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => { restart: async (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => {
await remoteFileRequest('/api/remote/modules/stop', target, moduleName, folderName) await remoteFileRequest('/api/remote/modules/stop', target, moduleName, folderName)
return remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages }) return remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage })
}, },
logs: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/modules/logs', target, moduleName, folderName), logs: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/modules/logs', target, moduleName, folderName),
updateParameters: () => remoteUnavailable('모듈 설정'), updateParameters: () => remoteUnavailable('모듈 설정'),
resetSession: async () => ({ ok: true, message: '플랫폼 세션을 종료했습니다.' }), resetSession: async (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => {
return remoteBridgeRequest<{ ok: boolean; message?: string }>('/api/remote/modules/reset-session', target, {
modules: modules.map((module) => ({
name: module.moduleName,
folderName: module.folderName ?? module.moduleName,
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
})),
})
},
}, },
git: { git: {
status: (target: unknown, moduleName: string, folderName?: string, selectedFilePath?: string | null) => remoteFileRequest('/api/remote/git/status', target, moduleName, folderName, { selectedFilePath }), status: (target: unknown, moduleName: string, folderName?: string, selectedFilePath?: string | null) => remoteFileRequest('/api/remote/git/status', target, moduleName, folderName, { selectedFilePath }),
@@ -388,7 +500,12 @@ window.launcherAuth = {
appUrl: null, appUrl: null,
handoffToken: launchState.handoffToken, handoffToken: launchState.handoffToken,
audienceApp: launchState.audienceApp, audienceApp: launchState.audienceApp,
onSessionExpired: (listener: () => void) => {
launcherSessionExpiredListeners.add(listener)
return () => launcherSessionExpiredListeners.delete(listener)
},
} }
watchLauncherSession()
window.lmsBootSplash = { status: () => undefined, done: () => undefined } window.lmsBootSplash = { status: () => undefined, done: () => undefined }
window.titlebar = { window.titlebar = {
minimize: () => undefined, minimize: () => undefined,