Compare commits
48 Commits
104c083995
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 017093077f | |||
| af6dc560ea | |||
| 065de83ffd | |||
| a8dd23675a | |||
| cb5872c326 | |||
| af54e875df | |||
| c17763f59d | |||
| 00a83ce4ac | |||
| b7870cffa2 | |||
| 8b501266ff | |||
| af21fa0510 | |||
| 7cfd466350 | |||
| 4321d944b5 | |||
| 234c9ad467 | |||
| 6512f5fa04 | |||
| 7b32668e1d | |||
| 105342fbf3 | |||
| cc589fd8cf | |||
| 098af30fa7 | |||
| 280b000496 | |||
| 4bdc5af620 | |||
| ef260c8fb4 | |||
| 1c0d861051 | |||
| 832420f8f9 | |||
| 53518d1114 | |||
| 654fc722f9 | |||
| cf8d81348d | |||
| db9201fd05 | |||
| c9cceb4c01 | |||
| debb38a29b | |||
| b0a4c2b1e6 | |||
| 08f74575c2 | |||
| 9387ade537 | |||
| 6b4ab4365e | |||
| a7fe81e658 | |||
| fb8c15dfd3 | |||
| 0a15102f8c | |||
| d44180db67 | |||
| 4125f71792 | |||
| 656b1bf62b | |||
| a7c9cdc16e | |||
| 11ba2bf8b9 | |||
| 36806e4dc1 | |||
| 33aac68f16 | |||
| 1d959e2c51 | |||
| 76a87fc20d | |||
| 978b782606 | |||
| 54ef5836b1 |
@@ -0,0 +1,47 @@
|
|||||||
|
# Korean and English UI
|
||||||
|
|
||||||
|
Use the language selector at the top of the studio settings screen (local or
|
||||||
|
remote workspace). Select a language and press Save to apply it. Unsaved choices
|
||||||
|
do not change the UI or stored preference. Korean is the default. The choice is
|
||||||
|
stored under `autonomy-studio.lms.language` in local storage and applies on the
|
||||||
|
next launch. Switching language updates React subscribers without remounting the
|
||||||
|
workspace, terminal, editor, or visualization iframe.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- App navigation, curriculum UI, studio controls, settings, dialogs and help.
|
||||||
|
- Known application status/error messages and NAV2/SLAM viewer controls.
|
||||||
|
- Course titles, learning content, materials, module names, source code, terminal
|
||||||
|
output and server-authored notifications remain in their original language.
|
||||||
|
- Unrecognized backend/SSH/Docker errors are preserved verbatim for diagnosis.
|
||||||
|
- Embedded text in guide images and external pages is not translated.
|
||||||
|
- This does not change ROS, Docker, SSH, the OS locale, or module parameters.
|
||||||
|
|
||||||
|
## Adding Text
|
||||||
|
|
||||||
|
Use `t('Korean source text')` from `src/renderer/src/i18n` at the display boundary
|
||||||
|
and add the English value to `en.json`. Call `useLocaleRender()` in components
|
||||||
|
which render translations so an open screen reacts to language changes.
|
||||||
|
|
||||||
|
Keep identifiers and state values unchanged. Do not translate stored form values,
|
||||||
|
parameter strings, module identifiers, or values used in conditions. Use whole
|
||||||
|
sentences with named interpolation placeholders instead of joining translated
|
||||||
|
fragments. Korean falls back to the source key; unknown messages retain the source.
|
||||||
|
|
||||||
|
The standalone SLAM viewer uses `public/slam-viewer/locale.js` and explicitly
|
||||||
|
marked text/attributes. It follows the same saved preference through storage
|
||||||
|
events, without reconnecting the bridge or resetting the scene.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test:i18n
|
||||||
|
npx tsc --noEmit -p tsconfig.web.json
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests cover language persistence, storage failure, cross-window updates,
|
||||||
|
interpolation, source fallback and literal translation-key coverage. Browser
|
||||||
|
checks should additionally cover both languages, narrow studio panels, preserving
|
||||||
|
unsaved edits, and language changes with the viewer open. `npm run build` does not
|
||||||
|
package an AppImage.
|
||||||
@@ -4,6 +4,12 @@ import react from '@vitejs/plugin-react';
|
|||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/main/bootstrap.ts') },
|
||||||
|
output: { inlineDynamicImports: false, chunkFileNames: '[name]-[hash].js' }
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import tailwindcss from '@tailwindcss/vite'
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
main: {
|
main: {
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: resolve(__dirname, 'src/main/bootstrap.ts') },
|
||||||
|
output: { inlineDynamicImports: false, chunkFileNames: '[name]-[hash].js' }
|
||||||
|
}
|
||||||
|
},
|
||||||
plugins: [externalizeDepsPlugin()]
|
plugins: [externalizeDepsPlugin()]
|
||||||
},
|
},
|
||||||
preload: {
|
preload: {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.0.23",
|
"version": "1.2.25",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.0.23",
|
"version": "1.2.25",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@electron-toolkit/preload": "^3.0.1",
|
"@electron-toolkit/preload": "^3.0.1",
|
||||||
"@electron-toolkit/utils": "^4.0.0",
|
"@electron-toolkit/utils": "^4.0.0",
|
||||||
@@ -16,13 +16,18 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
|
"i18next": "^26.4.2",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"react-i18next": "^17.0.15",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-parse": "11.0.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"three": "^0.181.2"
|
"three": "^0.181.2",
|
||||||
|
"unified": "11.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
@@ -289,6 +294,15 @@
|
|||||||
"@babel/core": "^7.0.0-0"
|
"@babel/core": "^7.0.0-0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.29.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||||
|
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||||
@@ -5236,6 +5250,15 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/highlight.js": {
|
||||||
|
"version": "11.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz",
|
||||||
|
"integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||||
@@ -5269,6 +5292,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-parse-stringify": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://locize.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/html-url-attributes": {
|
"node_modules/html-url-attributes": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||||
@@ -5336,6 +5368,34 @@
|
|||||||
"ms": "^2.0.0"
|
"ms": "^2.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "26.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||||
|
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com/i18next"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://www.locize.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/iconv-corefoundation": {
|
"node_modules/iconv-corefoundation": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-corefoundation/-/iconv-corefoundation-1.1.7.tgz",
|
||||||
@@ -6128,6 +6188,21 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lowlight": {
|
||||||
|
"version": "3.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
||||||
|
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"highlight.js": "~11.11.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lru-cache": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
@@ -7933,6 +8008,33 @@
|
|||||||
"react": "^19.2.6"
|
"react": "^19.2.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-i18next": {
|
||||||
|
"version": "17.0.15",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.15.tgz",
|
||||||
|
"integrity": "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.7",
|
||||||
|
"html-parse-stringify": "^4.0.1",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"i18next": ">= 26.2.0",
|
||||||
|
"react": ">= 16.8.0",
|
||||||
|
"typescript": "^5 || ^6 || ^7"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-native": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"typescript": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-markdown": {
|
"node_modules/react-markdown": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||||
@@ -8923,7 +9025,7 @@
|
|||||||
"version": "5.9.3",
|
"version": "5.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
@@ -9102,6 +9204,15 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/utf8-byte-length": {
|
"node_modules/utf8-byte-length": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.23",
|
"version": "1.2.25",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
"email": "hooladhoon@gmail.com"
|
"email": "hooladhoon@gmail.com"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"test:i18n": "node --test scripts/test-i18n.cjs",
|
||||||
|
"test:theme": "node --test scripts/test-studio-theme.cjs",
|
||||||
"dev": "electron-vite dev",
|
"dev": "electron-vite dev",
|
||||||
"build": "electron-vite build",
|
"build": "electron-vite build",
|
||||||
"build:linux": "npm run build && electron-builder --linux"
|
"build:linux": "npm run build && electron-builder --linux"
|
||||||
@@ -18,6 +20,10 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"out/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "resources/icon.png",
|
"from": "resources/icon.png",
|
||||||
@@ -51,13 +57,18 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
"driver.js": "^1.4.0",
|
"driver.js": "^1.4.0",
|
||||||
|
"i18next": "^26.4.2",
|
||||||
|
"lowlight": "^3.3.0",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"react-i18next": "^17.0.15",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-parse": "11.0.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
"three": "^0.181.2"
|
"three": "^0.181.2",
|
||||||
|
"unified": "11.0.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import struct
|
import struct
|
||||||
@@ -27,6 +28,7 @@ from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
|
|||||||
from nav2_msgs.action import NavigateToPose
|
from nav2_msgs.action import NavigateToPose
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from rclpy.action import ActionClient
|
from rclpy.action import ActionClient
|
||||||
|
from rclpy.clock import Clock, ClockType
|
||||||
from rclpy.executors import MultiThreadedExecutor
|
from rclpy.executors import MultiThreadedExecutor
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from rclpy.parameter import Parameter
|
from rclpy.parameter import Parameter
|
||||||
@@ -132,6 +134,7 @@ def resolve_2d_transform(transforms: dict, target_frame: str, source_frame: str)
|
|||||||
class VelocityPayload(BaseModel):
|
class VelocityPayload(BaseModel):
|
||||||
linear: float = 0.0
|
linear: float = 0.0
|
||||||
angular: float = 0.0
|
angular: float = 0.0
|
||||||
|
deadman: bool = False
|
||||||
|
|
||||||
|
|
||||||
class SteeringPayload(BaseModel):
|
class SteeringPayload(BaseModel):
|
||||||
@@ -205,6 +208,8 @@ class SlamNode(Node):
|
|||||||
self._fallback_point_cloud: Optional[PointCloud2] = None
|
self._fallback_point_cloud: Optional[PointCloud2] = None
|
||||||
self._color_point_cloud: Optional[PointCloud2] = None
|
self._color_point_cloud: Optional[PointCloud2] = None
|
||||||
self._camera_image: Optional[CompressedImage] = None
|
self._camera_image: Optional[CompressedImage] = None
|
||||||
|
self._camera_frames = {}
|
||||||
|
self._camera_subscriptions = {}
|
||||||
self._icp_odom: Optional[Odometry] = None
|
self._icp_odom: Optional[Odometry] = None
|
||||||
self._map: Optional[OccupancyGrid] = None
|
self._map: Optional[OccupancyGrid] = None
|
||||||
self._plan: Optional[NavPath] = None
|
self._plan: Optional[NavPath] = None
|
||||||
@@ -257,6 +262,10 @@ class SlamNode(Node):
|
|||||||
if self.color_point_cloud_topic not in (self.point_cloud_topic, self.fallback_point_cloud_topic):
|
if self.color_point_cloud_topic not in (self.point_cloud_topic, self.fallback_point_cloud_topic):
|
||||||
self.create_subscription(PointCloud2, self.color_point_cloud_topic, self._on_color_point_cloud, qos_profile_sensor_data)
|
self.create_subscription(PointCloud2, self.color_point_cloud_topic, self._on_color_point_cloud, qos_profile_sensor_data)
|
||||||
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
|
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
|
||||||
|
if not os.environ.get("CAMERA_IMAGE_TOPIC"):
|
||||||
|
self.create_subscription(CompressedImage, "/image_raw/compressed", self._on_camera_image, qos_profile_sensor_data)
|
||||||
|
self._camera_discovery_timer = self.create_timer(
|
||||||
|
1.0, self._discover_cameras, clock=Clock(clock_type=ClockType.STEADY_TIME))
|
||||||
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
|
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
|
||||||
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
|
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
|
||||||
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
|
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
|
||||||
@@ -268,6 +277,10 @@ class SlamNode(Node):
|
|||||||
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data)
|
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data)
|
||||||
self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos)
|
self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos)
|
||||||
self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
|
self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
|
||||||
|
self._teleop_lock = threading.Lock()
|
||||||
|
self._teleop_deadline = None
|
||||||
|
self._teleop_timer = self.create_timer(
|
||||||
|
0.1, self._check_teleop_timeout, clock=Clock(clock_type=ClockType.STEADY_TIME))
|
||||||
self._steering_pub = self.create_publisher(Float64, self.steering_topic, 10)
|
self._steering_pub = self.create_publisher(Float64, self.steering_topic, 10)
|
||||||
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
|
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
|
||||||
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
|
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
|
||||||
@@ -617,11 +630,45 @@ class SlamNode(Node):
|
|||||||
self._color_point_cloud_count += 1
|
self._color_point_cloud_count += 1
|
||||||
self._last_color_point_cloud_wall_time = time.time()
|
self._last_color_point_cloud_wall_time = time.time()
|
||||||
|
|
||||||
def _on_camera_image(self, msg: CompressedImage):
|
def _discover_cameras(self):
|
||||||
|
try:
|
||||||
|
topics = sorted(name for name, types in self.get_topic_names_and_types()
|
||||||
|
if re.fullmatch(r'/camera_test/cameras/video[0-9]+/image_jpeg', name)
|
||||||
|
and 'sensor_msgs/msg/CompressedImage' in types)[:8]
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
for topic in topics:
|
||||||
|
camera_id = topic.split('/')[-2]
|
||||||
|
if camera_id not in self._camera_subscriptions:
|
||||||
|
self._camera_subscriptions[camera_id] = self.create_subscription(
|
||||||
|
CompressedImage, topic,
|
||||||
|
lambda msg, key=camera_id, name=topic: self._on_camera_image(msg, key, name),
|
||||||
|
qos_profile_sensor_data)
|
||||||
|
active = {topic.split('/')[-2] for topic in topics}
|
||||||
|
for camera_id in list(self._camera_subscriptions):
|
||||||
|
if camera_id not in active:
|
||||||
|
self.destroy_subscription(self._camera_subscriptions.pop(camera_id))
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
self._camera_frames.pop(camera_id, None)
|
||||||
|
|
||||||
|
def _on_camera_image(self, msg: CompressedImage, camera_id='legacy', topic=None):
|
||||||
|
with self._lock:
|
||||||
|
if camera_id != 'legacy' and camera_id not in self._camera_subscriptions:
|
||||||
|
return
|
||||||
self._camera_image = msg
|
self._camera_image = msg
|
||||||
self._camera_image_count += 1
|
self._camera_image_count += 1
|
||||||
self._last_camera_image_wall_time = time.time()
|
self._last_camera_image_wall_time = time.time()
|
||||||
|
self._camera_frames[camera_id] = (msg, self._last_camera_image_wall_time,
|
||||||
|
topic or self.camera_image_topic)
|
||||||
|
|
||||||
|
def camera_sources(self):
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
frames = list(self._camera_frames.items())
|
||||||
|
return [{"id": key, "name": f'/dev/{key}' if key != 'legacy' else 'Camera',
|
||||||
|
"topic": topic, "available": now - received <= _CAMERA_FRAME_MAX_AGE_SECONDS
|
||||||
|
and bool(image.data) and 'jpeg' in image.format.lower()}
|
||||||
|
for key, (image, received, topic) in sorted(frames)]
|
||||||
|
|
||||||
def _active_point_cloud(self) -> Optional[PointCloud2]:
|
def _active_point_cloud(self) -> Optional[PointCloud2]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -679,7 +726,6 @@ class SlamNode(Node):
|
|||||||
point_cloud_last = self._last_point_cloud_wall_time
|
point_cloud_last = self._last_point_cloud_wall_time
|
||||||
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
|
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
|
||||||
color_point_cloud_last = self._last_color_point_cloud_wall_time
|
color_point_cloud_last = self._last_color_point_cloud_wall_time
|
||||||
camera_image_last = self._last_camera_image_wall_time
|
|
||||||
map_count = self._map_count
|
map_count = self._map_count
|
||||||
plan_count = self._plan_count
|
plan_count = self._plan_count
|
||||||
|
|
||||||
@@ -719,7 +765,7 @@ class SlamNode(Node):
|
|||||||
},
|
},
|
||||||
"camera_image": {
|
"camera_image": {
|
||||||
"name": self.camera_image_topic,
|
"name": self.camera_image_topic,
|
||||||
"available": received_recently(camera_image_last),
|
"available": any(camera['available'] for camera in self.camera_sources()),
|
||||||
},
|
},
|
||||||
"plan": {"name": self.plan_topic, "available": plan_available},
|
"plan": {"name": self.plan_topic, "available": plan_available},
|
||||||
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
|
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
|
||||||
@@ -817,14 +863,28 @@ class SlamNode(Node):
|
|||||||
}
|
}
|
||||||
self._tf_count += 1
|
self._tf_count += 1
|
||||||
|
|
||||||
def publish_cmd_vel(self, linear: float, angular: float):
|
def publish_cmd_vel(self, linear: float, angular: float, deadman: bool = False):
|
||||||
if self.read_only:
|
if self.read_only:
|
||||||
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
|
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
|
||||||
|
if not math.isfinite(linear) or not math.isfinite(angular):
|
||||||
|
raise RuntimeError("Invalid velocity")
|
||||||
|
if deadman:
|
||||||
|
linear = max(-0.1, min(0.1, linear))
|
||||||
|
angular = max(-0.5, min(0.5, angular))
|
||||||
msg = Twist()
|
msg = Twist()
|
||||||
msg.linear.x = float(linear)
|
msg.linear.x = float(linear)
|
||||||
msg.angular.z = float(angular)
|
msg.angular.z = float(angular)
|
||||||
|
with self._teleop_lock:
|
||||||
|
self._teleop_deadline = time.monotonic() + 0.5 if deadman and (linear or angular) else None
|
||||||
self._cmd_vel_pub.publish(msg)
|
self._cmd_vel_pub.publish(msg)
|
||||||
|
|
||||||
|
def _check_teleop_timeout(self):
|
||||||
|
# Only stop expired manual commands, never idle autonomous navigation.
|
||||||
|
with self._teleop_lock:
|
||||||
|
if self._teleop_deadline is not None and time.monotonic() >= self._teleop_deadline:
|
||||||
|
self._teleop_deadline = None
|
||||||
|
self._cmd_vel_pub.publish(Twist())
|
||||||
|
|
||||||
def set_simulator_steering(self, angle: float) -> None:
|
def set_simulator_steering(self, angle: float) -> None:
|
||||||
if self.read_only:
|
if self.read_only:
|
||||||
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
|
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
|
||||||
@@ -1192,6 +1252,7 @@ class SlamNode(Node):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
"teleop_deadman": not self.read_only,
|
||||||
"topics": {
|
"topics": {
|
||||||
"odom": self.odom_topic,
|
"odom": self.odom_topic,
|
||||||
"scan": self.scan_topic,
|
"scan": self.scan_topic,
|
||||||
@@ -1215,6 +1276,7 @@ class SlamNode(Node):
|
|||||||
"sensor_point_cloud": sensor_point_cloud_payload,
|
"sensor_point_cloud": sensor_point_cloud_payload,
|
||||||
"color_point_cloud": color_point_cloud_payload,
|
"color_point_cloud": color_point_cloud_payload,
|
||||||
"camera_image": camera_image_payload,
|
"camera_image": camera_image_payload,
|
||||||
|
"cameras": self.camera_sources(),
|
||||||
"map": map_payload,
|
"map": map_payload,
|
||||||
"tf": {
|
"tf": {
|
||||||
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
|
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
|
||||||
@@ -1279,8 +1341,14 @@ class SlamNode(Node):
|
|||||||
payload["received_at"] = received_at
|
payload["received_at"] = received_at
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
def camera_frame_jpeg(self) -> Optional[bytes]:
|
def camera_frame_jpeg(self, camera_id=None) -> Optional[bytes]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
if camera_id is not None:
|
||||||
|
entry = self._camera_frames.get(camera_id)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
image, received_at, _topic = entry
|
||||||
|
else:
|
||||||
image = self._camera_image
|
image = self._camera_image
|
||||||
received_at = self._last_camera_image_wall_time
|
received_at = self._last_camera_image_wall_time
|
||||||
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
|
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
|
||||||
@@ -1508,10 +1576,10 @@ def color_point_cloud():
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/camera-frame")
|
@app.get("/camera-frame")
|
||||||
def camera_frame():
|
def camera_frame(camera_id: Optional[str] = None):
|
||||||
if not slam_node:
|
if not slam_node:
|
||||||
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
frame = slam_node.camera_frame_jpeg()
|
frame = slam_node.camera_frame_jpeg(camera_id)
|
||||||
if frame is None:
|
if frame is None:
|
||||||
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
|
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
|
||||||
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
||||||
@@ -1549,7 +1617,7 @@ def debug_topics():
|
|||||||
def cmd_vel(payload: VelocityPayload):
|
def cmd_vel(payload: VelocityPayload):
|
||||||
if not slam_node:
|
if not slam_node:
|
||||||
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
slam_node.publish_cmd_vel(payload.linear, payload.angular)
|
slam_node.publish_cmd_vel(payload.linear, payload.angular, payload.deadman)
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -1643,11 +1711,12 @@ async def ws_control(websocket: WebSocket):
|
|||||||
velocity = VelocityPayload(
|
velocity = VelocityPayload(
|
||||||
linear=payload.get("linear", 0.0),
|
linear=payload.get("linear", 0.0),
|
||||||
angular=payload.get("angular", 0.0),
|
angular=payload.get("angular", 0.0),
|
||||||
|
deadman=payload.get("deadman", False),
|
||||||
)
|
)
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
slam_node.publish_cmd_vel(velocity.linear, velocity.angular)
|
slam_node.publish_cmd_vel(velocity.linear, velocity.angular, velocity.deadman)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
continue
|
continue
|
||||||
except WebSocketDisconnect:
|
except WebSocketDisconnect:
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Optional
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class CameraTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now = 10.0
|
||||||
|
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
|
||||||
|
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
|
||||||
|
names = ('_discover_cameras', '_on_camera_image', 'camera_sources', 'camera_frame_jpeg')
|
||||||
|
methods = [item for item in node.body if isinstance(item, ast.FunctionDef) and item.name in names]
|
||||||
|
scope = {'re': re, 'Optional': Optional, 'CompressedImage': object,
|
||||||
|
'time': SimpleNamespace(time=lambda: self.now), '_CAMERA_FRAME_MAX_AGE_SECONDS': 3,
|
||||||
|
'qos_profile_sensor_data': object()}
|
||||||
|
exec(compile(ast.Module(body=methods, type_ignores=[]), 'camera-bridge', 'exec'), scope)
|
||||||
|
self.node = type('CameraNode', (), {name: scope[name] for name in names})()
|
||||||
|
self.node._lock = threading.Lock()
|
||||||
|
self.node._camera_frames = {}
|
||||||
|
self.node._camera_subscriptions = {}
|
||||||
|
self.node._camera_image = None
|
||||||
|
self.node._last_camera_image_wall_time = None
|
||||||
|
self.node._camera_image_count = 0
|
||||||
|
self.node.camera_image_topic = '/camera_test/image_jpeg'
|
||||||
|
self.topics = []
|
||||||
|
self.callbacks = {}
|
||||||
|
self.node.get_topic_names_and_types = lambda: self.topics
|
||||||
|
def subscribe(kind, topic, callback, qos):
|
||||||
|
self.callbacks[topic] = callback
|
||||||
|
return topic
|
||||||
|
self.node.create_subscription = subscribe
|
||||||
|
self.node.destroy_subscription = lambda sub: self.callbacks.pop(sub)
|
||||||
|
|
||||||
|
def test_independent_frames_and_disconnect(self):
|
||||||
|
for key in ['video0', 'video2']:
|
||||||
|
self.topics.append((f'/camera_test/cameras/{key}/image_jpeg', ['sensor_msgs/msg/CompressedImage']))
|
||||||
|
self.topics += [('/other/image_jpeg', ['sensor_msgs/msg/CompressedImage']),
|
||||||
|
('/camera_test/cameras/video9/image_jpeg', ['sensor_msgs/msg/Image'])]
|
||||||
|
self.node._discover_cameras()
|
||||||
|
self.assertEqual(len(self.callbacks), 2)
|
||||||
|
for topic, callback in self.callbacks.items():
|
||||||
|
callback(SimpleNamespace(data=topic.encode(), format='jpeg'))
|
||||||
|
self.assertEqual([item['id'] for item in self.node.camera_sources()], ['video0', 'video2'])
|
||||||
|
self.assertIn(b'video2', self.node.camera_frame_jpeg('video2'))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('../video0'))
|
||||||
|
self.now += 4
|
||||||
|
self.assertTrue(all(not item['available'] for item in self.node.camera_sources()))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('video0'))
|
||||||
|
self.topics = self.topics[:1]
|
||||||
|
self.node._discover_cameras()
|
||||||
|
self.assertNotIn('video2', self.node._camera_frames)
|
||||||
|
|
||||||
|
def test_single_camera_compatibility_and_non_jpeg(self):
|
||||||
|
self.node._on_camera_image(SimpleNamespace(data=b'jpeg', format='jpeg'))
|
||||||
|
self.assertEqual(self.node.camera_frame_jpeg(), b'jpeg')
|
||||||
|
self.assertEqual(self.node.camera_frame_jpeg('legacy'), b'jpeg')
|
||||||
|
self.node._on_camera_image(SimpleNamespace(data=b'png', format='png'))
|
||||||
|
self.assertIsNone(self.node.camera_frame_jpeg('legacy'))
|
||||||
|
self.assertFalse(self.node.camera_sources()[0]['available'])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import ast
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import threading
|
||||||
|
from types import SimpleNamespace
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
class Twist:
|
||||||
|
def __init__(self):
|
||||||
|
self.linear = SimpleNamespace(x=0.0)
|
||||||
|
self.angular = SimpleNamespace(z=0.0)
|
||||||
|
|
||||||
|
|
||||||
|
class TeleopTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.now = 10.0
|
||||||
|
self.messages = []
|
||||||
|
source = ast.parse(Path(__file__).with_name('bridge.py').read_text())
|
||||||
|
node = next(item for item in source.body if isinstance(item, ast.ClassDef) and item.name == 'SlamNode')
|
||||||
|
methods = [item for item in node.body if isinstance(item, ast.FunctionDef)
|
||||||
|
and item.name in ('publish_cmd_vel', '_check_teleop_timeout')]
|
||||||
|
scope = {'math': math, 'Twist': Twist, 'time': SimpleNamespace(monotonic=lambda: self.now)}
|
||||||
|
exec(compile(ast.Module(body=methods, type_ignores=[]), 'bridge-teleop', 'exec'), scope)
|
||||||
|
cls = type('Teleop', (), {name: scope[name] for name in ('publish_cmd_vel', '_check_teleop_timeout')})
|
||||||
|
self.node = cls()
|
||||||
|
self.node.read_only = False
|
||||||
|
self.node._teleop_lock = threading.Lock()
|
||||||
|
self.node._teleop_deadline = None
|
||||||
|
self.node._cmd_vel_pub = SimpleNamespace(publish=lambda msg: self.messages.append((msg.linear.x, msg.angular.z)))
|
||||||
|
|
||||||
|
def test_low_speed_and_expiry(self):
|
||||||
|
self.node.publish_cmd_vel(5.0, -5.0, True)
|
||||||
|
self.assertEqual(self.messages[-1], (0.1, -0.5))
|
||||||
|
self.now += 0.6
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(self.messages[-1], (0.0, 0.0))
|
||||||
|
count = len(self.messages)
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(len(self.messages), count)
|
||||||
|
|
||||||
|
def test_heartbeat_and_release(self):
|
||||||
|
self.node.publish_cmd_vel(0.1, 0, True)
|
||||||
|
self.now += 0.4
|
||||||
|
self.node.publish_cmd_vel(0.1, 0, True)
|
||||||
|
self.now += 0.2
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(len(self.messages), 2)
|
||||||
|
self.node.publish_cmd_vel(0, 0, True)
|
||||||
|
self.assertIsNone(self.node._teleop_deadline)
|
||||||
|
|
||||||
|
def test_simulation_and_idle_navigation_unchanged(self):
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(self.messages, [])
|
||||||
|
self.node.publish_cmd_vel(0.22, 2.84)
|
||||||
|
self.now += 2
|
||||||
|
self.node._check_teleop_timeout()
|
||||||
|
self.assertEqual(self.messages, [(0.22, 2.84)])
|
||||||
|
|
||||||
|
def test_invalid_and_readonly_rejected(self):
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.node.publish_cmd_vel(float('nan'), 0, True)
|
||||||
|
self.node.read_only = True
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
self.node.publish_cmd_vel(0.1, 0, True)
|
||||||
|
self.assertEqual(self.messages, [])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Regenerate font coverage after replacing the bundled NanumSquare WOFF2 files.
|
||||||
|
|
||||||
|
Requires fonttools[woff]. Normal app builds use the checked-in CSS, not Python.
|
||||||
|
Run: python3 scripts/generate-nanumsquare-css.py [--check]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import unicodedata
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fontTools.pens.boundsPen import BoundsPen
|
||||||
|
from fontTools.ttLib import TTFont
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
FONTS = ROOT / "src/renderer/public/assets/fonts"
|
||||||
|
OUTPUT = ROOT / "src/renderer/src/styles/nanumSquare.css"
|
||||||
|
FACES = [(300, "NanumSquareL"), (400, "NanumSquare"), (700, "NanumSquareB"), (800, "NanumSquareEB")]
|
||||||
|
# Hangul fillers are intentionally invisible, like whitespace and controls.
|
||||||
|
FILLERS = {0x115F, 0x1160, 0x3164, 0xFFA0}
|
||||||
|
|
||||||
|
|
||||||
|
def coverage(font):
|
||||||
|
glyphs = font.getGlyphSet()
|
||||||
|
supported = []
|
||||||
|
for code, name in sorted(font.getBestCmap().items()):
|
||||||
|
pen = BoundsPen(glyphs)
|
||||||
|
glyphs[name].draw(pen)
|
||||||
|
intentional = chr(code).isspace() or unicodedata.category(chr(code)) in {"Cc", "Cf"} or code in FILLERS
|
||||||
|
if pen.bounds is not None or intentional:
|
||||||
|
supported.append(code)
|
||||||
|
return supported
|
||||||
|
|
||||||
|
|
||||||
|
def unicode_ranges(codes):
|
||||||
|
ranges = []
|
||||||
|
start = end = codes[0]
|
||||||
|
for code in codes[1:]:
|
||||||
|
if code == end + 1:
|
||||||
|
end = code
|
||||||
|
else:
|
||||||
|
ranges.append((start, end))
|
||||||
|
start = end = code
|
||||||
|
ranges.append((start, end))
|
||||||
|
return [f"U+{start:X}" if start == end else f"U+{start:X}-{end:X}" for start, end in ranges]
|
||||||
|
|
||||||
|
|
||||||
|
def generate():
|
||||||
|
sections = [
|
||||||
|
"/* Generated by scripts/generate-nanumsquare-css.py. Do not edit manually.\n"
|
||||||
|
" * Empty glyphs are excluded so the existing system-font fallback can render them.\n"
|
||||||
|
" * Font binaries and visible glyphs are unchanged.\n */"
|
||||||
|
]
|
||||||
|
for weight, name in FACES:
|
||||||
|
file = FONTS / f"{name}.woff2"
|
||||||
|
with TTFont(file) as font:
|
||||||
|
ranges = unicode_ranges(coverage(font))
|
||||||
|
lines = [", ".join(ranges[index:index + 10]) for index in range(0, len(ranges), 10)]
|
||||||
|
sections.append(
|
||||||
|
f"/* {file.name} sha256: {hashlib.sha256(file.read_bytes()).hexdigest()} */\n"
|
||||||
|
"@font-face {\n"
|
||||||
|
" font-family: 'NanumSquare';\n"
|
||||||
|
f" src: url('/assets/fonts/{file.name}') format('woff2');\n"
|
||||||
|
f" font-weight: {weight};\n"
|
||||||
|
" font-style: normal;\n"
|
||||||
|
" font-display: swap;\n"
|
||||||
|
" unicode-range:\n " + ",\n ".join(lines) + ";\n}"
|
||||||
|
)
|
||||||
|
return "\n\n".join(sections) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--check", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
generated = generate()
|
||||||
|
if args.check:
|
||||||
|
if not OUTPUT.exists() or OUTPUT.read_text(encoding="utf-8") != generated:
|
||||||
|
raise SystemExit("NanumSquare coverage CSS is stale; regenerate it")
|
||||||
|
print("NanumSquare coverage matches all four font files")
|
||||||
|
else:
|
||||||
|
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
OUTPUT.write_text(generated, encoding="utf-8")
|
||||||
|
print(f"Generated {OUTPUT.relative_to(ROOT)}")
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { createHash, webcrypto } = require('node:crypto')
|
||||||
|
|
||||||
|
function compile(source, globals = {}, requireModule = require) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
|
||||||
|
exports, require: requireModule, Error, crypto: webcrypto, TextEncoder, window: { setTimeout: callback => setTimeout(callback, 1) }, ...globals
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const tools = compile(fs.readFileSync(require.resolve('../src/shared/agentTools.ts'), 'utf8'))
|
||||||
|
const navigation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentCodeNavigation.ts'), 'utf8'), {}, name => name.endsWith('shared/agentTools') ? tools : require(name))
|
||||||
|
const content = 'def configure():\n speed = 0.4\n return speed\n'
|
||||||
|
const hash = value => createHash('sha256').update(value).digest('hex')
|
||||||
|
const reference = { id: 'location', moduleName: 'Control', path: 'src/node.py', startLine: 2, endLine: 3, reason: 'speed config', snippet: ' speed = 0.4\n return speed', expectedHash: hash(content) }
|
||||||
|
|
||||||
|
test('unchanged source reveals the verified line range, while unsaved insertions relocate it', async () => {
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content, reference))), { startLine: 2, endLine: 3 })
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange('# unsaved\n' + content, reference))), { startLine: 3, endLine: 4 })
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(await navigation.resolveCodeReferenceRange(content.replace(/\n/g, '\r\n'), reference))), { startLine: 2, endLine: 3 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects missing, ambiguous or altered code rather than highlighting a guessed line', async () => {
|
||||||
|
for (const changed of [content.replace('0.4', '0.8'), `${content}\n${content}`]) await assert.rejects(navigation.resolveCodeReferenceRange(changed, reference), /위치를 확정/)
|
||||||
|
for (const patch of [{ expectedHash: undefined }, { snippet: '' }, { snippet: '[REDACTED]' }, { snippet: 'one line' }, { path: '../etc/passwd' }, { path: '.env' }, { startLine: 0 }, { endLine: 500 }]) {
|
||||||
|
await assert.rejects(navigation.resolveCodeReferenceRange(content, { ...reference, ...patch }))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function editorHarness(text = content) {
|
||||||
|
const calls = []
|
||||||
|
const collection = { clear: () => calls.push('clear') }
|
||||||
|
const model = { uri: { path: '/modules/Control/src/node.py' }, getValue: () => text, isDisposed: () => false, getLineMaxColumn: line => text.split('\n')[line - 1].length + 1 }
|
||||||
|
const editor = { getModel: () => model, getDomNode: () => ({ clientWidth: 800, clientHeight: 600 }), layout: () => calls.push('layout'), createDecorationsCollection: value => { calls.push(['decorate', value]); return collection }, revealRangeInCenter: value => calls.push(['reveal', value]), setPosition: value => calls.push(['position', value]), focus: () => calls.push('focus') }
|
||||||
|
const options = { file: 'modules/Control/src/node.py', reference, isCurrent: () => true, getEditor: () => editor, getActiveFile: () => 'modules/Control/src/node.py', isReady: () => true, attempts: 4 }
|
||||||
|
return { model, editor, options, calls, collection }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('focuses and highlights only after the correct Monaco model has loaded', async () => {
|
||||||
|
const h = editorHarness()
|
||||||
|
let reads = 0
|
||||||
|
h.options.getEditor = () => ++reads < 3 ? null : h.editor
|
||||||
|
assert.equal(await navigation.revealAgentCodeReference(h.options), h.collection)
|
||||||
|
assert.deepEqual(h.calls.map(call => Array.isArray(call) ? call[0] : call), ['layout', 'decorate', 'position', 'reveal', 'focus'])
|
||||||
|
const selection = h.calls.find(call => call[0] === 'reveal')[1]
|
||||||
|
assert.equal(selection.startLineNumber, 2)
|
||||||
|
assert.equal(selection.endLineNumber, 3)
|
||||||
|
assert.equal(h.calls[1][1][0].options.isWholeLine, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not decorate another file, a hidden/diff editor, stale scope or unready model', async () => {
|
||||||
|
for (const setup of [
|
||||||
|
h => { h.model.uri.path = '/modules/Other/src/node.py' },
|
||||||
|
h => { h.options.getActiveFile = () => 'other.py' },
|
||||||
|
h => { h.options.isReady = () => false },
|
||||||
|
h => { h.options.isCurrent = () => false },
|
||||||
|
h => { h.editor.getDomNode = () => ({ clientWidth: 0, clientHeight: 0 }) },
|
||||||
|
h => { h.model.isDisposed = () => true }
|
||||||
|
]) {
|
||||||
|
const h = editorHarness()
|
||||||
|
setup(h)
|
||||||
|
await assert.rejects(navigation.revealAgentCodeReference(h.options))
|
||||||
|
assert.equal(h.calls.length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
||||||
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(hookSource) === 'openCodeReference')
|
||||||
|
function consentHarness() {
|
||||||
|
const session = {}
|
||||||
|
const messageStateRef = { current: { session, messages: [{ id: 'answer', role: 'assistant', plan: { codeReferences: [reference] } }] } }
|
||||||
|
const globals = { session, messageStateRef, navigatingRef: { current: false }, applyingRef: { current: false }, abortControllerRef: { current: null }, updateMessages: update => { messageStateRef.current.messages = update(messageStateRef.current.messages) } }
|
||||||
|
return { ...compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), globals), globals, message: () => messageStateRef.current.messages[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('code navigation opens only when the user selects View in editor', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let opened = 0
|
||||||
|
const open = async () => { opened++ }
|
||||||
|
assert.equal(opened, 0)
|
||||||
|
await h.openCodeReference('answer', 'location', open)
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy cancelled references can be opened with the same read-only navigation action', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
h.message().codeReferenceStates = { location: { status: 'cancelled' } }
|
||||||
|
let opened = 0
|
||||||
|
await h.openCodeReference('answer', 'location', async ref => { assert.equal(ref.path, reference.path); opened++ })
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('code navigation does not block or abort a pending model response', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
const controller = new AbortController()
|
||||||
|
h.globals.abortControllerRef.current = controller
|
||||||
|
let opened = 0
|
||||||
|
await h.openCodeReference('answer', 'location', async (_reference, isCurrent) => {
|
||||||
|
assert.equal(isCurrent(), true)
|
||||||
|
opened++
|
||||||
|
})
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
assert.equal(controller.signal.aborted, false)
|
||||||
|
assert.equal(h.globals.abortControllerRef.current, controller)
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an arriving model response does not cancel an in-flight code navigation', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
h.globals.abortControllerRef.current = new AbortController()
|
||||||
|
let finish, current
|
||||||
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||||
|
current = isCurrent
|
||||||
|
return new Promise(resolve => { finish = resolve })
|
||||||
|
})
|
||||||
|
h.globals.messageStateRef.current.messages.push({ id: 'new-answer', role: 'assistant' })
|
||||||
|
h.globals.abortControllerRef.current = null
|
||||||
|
assert.equal(current(), true)
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'opened')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('starting file application invalidates a navigation that is still reading', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let finish, current
|
||||||
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||||
|
current = isCurrent
|
||||||
|
return new Promise((_resolve, reject) => { finish = () => reject(Error('applying')) })
|
||||||
|
})
|
||||||
|
h.globals.applyingRef.current = true
|
||||||
|
assert.equal(current(), false)
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'error')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('duplicate clicks, invalid IDs and busy tasks do not open more files', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let finish, opened = 0
|
||||||
|
const first = h.openCodeReference('answer', 'location', () => { opened++; return new Promise(resolve => { finish = resolve }) })
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'opening')
|
||||||
|
await h.openCodeReference('answer', 'location', async () => { opened++ })
|
||||||
|
finish()
|
||||||
|
await first
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
await h.openCodeReference('answer', 'invented', async () => { opened++ })
|
||||||
|
h.globals.applyingRef.current = true
|
||||||
|
await h.openCodeReference('answer', 'location', async () => { opened++ })
|
||||||
|
assert.equal(opened, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('errors remain visible and late navigation cannot update another conversation', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
await h.openCodeReference('answer', 'location', async () => { throw Error('missing file') })
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.status, 'error')
|
||||||
|
assert.equal(h.message().codeReferenceStates.location.error, 'missing file')
|
||||||
|
let finish
|
||||||
|
const pending = h.openCodeReference('answer', 'location', () => new Promise(resolve => { finish = resolve }))
|
||||||
|
const other = { id: 'other' }
|
||||||
|
h.globals.messageStateRef.current = { session: {}, messages: [other] }
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.message(), other)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a new question cancels pending navigation without leaving its old state stuck opening', async () => {
|
||||||
|
const h = consentHarness()
|
||||||
|
let finish, current
|
||||||
|
const pending = h.openCodeReference('answer', 'location', (_reference, isCurrent) => {
|
||||||
|
current = isCurrent
|
||||||
|
return new Promise((_resolve, reject) => { finish = () => reject(Error('cancelled')) })
|
||||||
|
})
|
||||||
|
h.globals.messageStateRef.current.messages.push({ id: 'new-question', role: 'user' })
|
||||||
|
assert.equal(current(), false)
|
||||||
|
finish()
|
||||||
|
await pending
|
||||||
|
assert.equal(h.globals.messageStateRef.current.messages[0].codeReferenceStates.location.status, 'error')
|
||||||
|
assert.equal(h.globals.messageStateRef.current.messages.at(-1).id, 'new-question')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restored interrupted navigation becomes retryable without reopening any file', () => {
|
||||||
|
const evidence = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
|
||||||
|
const { recoverInterruptedAgentMessages } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {}, name => name === './agentEvidence' ? evidence : require(name))
|
||||||
|
const message = { id: 'answer', role: 'assistant', codeReferenceStates: { pending: { status: 'opening' }, approved: { status: 'opened' }, declined: { status: 'cancelled' } } }
|
||||||
|
const restored = recoverInterruptedAgentMessages([message])[0]
|
||||||
|
assert.equal(restored.codeReferenceStates.pending.status, 'error')
|
||||||
|
assert.equal(restored.codeReferenceStates.approved.status, 'opened')
|
||||||
|
assert.equal(restored.codeReferenceStates.declined.status, 'cancelled')
|
||||||
|
assert.equal(message.codeReferenceStates.pending.status, 'opening')
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(tree, predicate) {
|
||||||
|
if (!React.isValidElement(tree)) return []
|
||||||
|
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
function text(tree) {
|
||||||
|
return typeof tree === 'string' ? tree : React.isValidElement(tree) ? React.Children.toArray(tree.props.children).map(text).join('') : ''
|
||||||
|
}
|
||||||
|
const render = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentCodeReferences.tsx'), 'utf8'), {}, name => name.endsWith('/i18n') ? { t: key => key, useLocaleRender() {} } : require(name)).AgentCodeReferences
|
||||||
|
for (const dark of [true, false]) test(`code navigation has only a vertically aligned View in editor button (${dark})`, () => {
|
||||||
|
const clicked = []
|
||||||
|
const props = { dark, references: [reference], onOpen: id => clicked.push(`open:${id}`) }
|
||||||
|
const buttons = elements(render(props), element => element.type === 'button')
|
||||||
|
assert.equal(clicked.length, 0)
|
||||||
|
assert.deepEqual(buttons.map(text), ['코드에서 보기'])
|
||||||
|
const label = elements(buttons[0], element => element.type === 'span')[0]
|
||||||
|
assert.match(buttons[0].props.className, /items-center.*leading-none/)
|
||||||
|
assert.match(label.props.className, /top-px leading-none/)
|
||||||
|
buttons[0].props.onClick()
|
||||||
|
assert.deepEqual(clicked, ['open:location'])
|
||||||
|
for (const button of elements(render({ ...props, busy: true }), element => element.type === 'button')) assert.equal(button.props.disabled, true)
|
||||||
|
for (const status of ['cancelled', 'opened', 'error']) {
|
||||||
|
assert.deepEqual(elements(render({ ...props, states: { location: { status } } }), element => element.type === 'button').map(text), ['코드에서 보기'])
|
||||||
|
}
|
||||||
|
const opening = elements(render({ ...props, states: { location: { status: 'opening' } } }), element => element.type === 'button')[0]
|
||||||
|
assert.equal(opening.props.disabled, true)
|
||||||
|
assert.equal(text(opening), '파일 여는 중...')
|
||||||
|
assert.equal(elements(render({ ...props, states: { location: { status: 'error', error: 'missing' } } }), element => element.props.role === 'alert').length, 1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const output = {}
|
||||||
|
const evidenceApi = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: evidenceApi })
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: output, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
|
||||||
|
const { resolveAgentContext } = output
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const moduleInfo = (name, files = []) => ({ name, state: 'stopped', files })
|
||||||
|
const response = (requestedModules = [], requestedLogs = []) => ({
|
||||||
|
provider: 'QWEN', model: 'test', usage: { totalTokens: 10 },
|
||||||
|
plan: { summary: 'result', explanation: 'analysis', requestedModules, requestedLogs, actionPlan: [], commands: [], changes: [], createModule: null }
|
||||||
|
})
|
||||||
|
const options = overrides => ({
|
||||||
|
initialContext: { modules: [moduleInfo('MKMini'), moduleInfo('Control')], runtimeLogs: [] },
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
...overrides
|
||||||
|
})
|
||||||
|
|
||||||
|
test('continues MKMini -> Control -> final analysis and retains earlier source files', async () => {
|
||||||
|
const inputs = []
|
||||||
|
const loads = []
|
||||||
|
const result = await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
inputs.push(plain(context))
|
||||||
|
return [response(['MKMini']), response(['Control']), response()][inputs.length - 1]
|
||||||
|
},
|
||||||
|
loadRequestedContext: async request => {
|
||||||
|
loads.push(plain(request))
|
||||||
|
return { modules: ['MKMini', 'Control'].map(name => moduleInfo(name, request.modules.includes(name) ? [{ path: `${name}.py`, content: `${name} source` }] : [])), runtimeLogs: [] }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
assert.equal(inputs.length, 3)
|
||||||
|
assert.deepEqual(loads, [{ modules: ['MKMini'], logs: [] }, { modules: ['Control'], logs: [] }])
|
||||||
|
assert.equal(inputs[2].modules[0].files[0].content, 'MKMini source')
|
||||||
|
assert.equal(inputs[2].modules[1].files[0].content, 'Control source')
|
||||||
|
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||||
|
assert.equal(result.usage.totalTokens, 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps logs while loading a later source module', async () => {
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
calls += 1
|
||||||
|
if (calls === 3) assert.equal(context.runtimeLogs[0].content, 'error log')
|
||||||
|
return [response([], ['Control']), response(['Control']), response()][calls - 1]
|
||||||
|
},
|
||||||
|
loadRequestedContext: async request => ({
|
||||||
|
modules: [moduleInfo('Control', request.modules.length ? [{ path: 'node.py', content: 'code' }] : [])],
|
||||||
|
runtimeLogs: request.logs.length ? [{ moduleName: 'Control', state: 'running', content: 'error log' }] : []
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
assert.equal(calls, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('makes one analysis-only completion when the model repeats a context request', async () => {
|
||||||
|
let loads = 0
|
||||||
|
let calls = 0
|
||||||
|
const result = await resolveAgentContext(options({
|
||||||
|
requestPlan: async (context, finalize) => {
|
||||||
|
calls++
|
||||||
|
if (!finalize) return response(['Control'])
|
||||||
|
assert.equal(context.modules.find(module => module.name === 'Control').files[0].path, 'controller.py')
|
||||||
|
return { ...response(), plan: { ...response().plan, explanation: 'controller.py에서 조향 설정을 확인했습니다.' } }
|
||||||
|
},
|
||||||
|
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'steering = 1' }])], runtimeLogs: [] } }
|
||||||
|
}))
|
||||||
|
assert.equal(loads, 1)
|
||||||
|
assert.equal(calls, 3)
|
||||||
|
assert.match(result.plan.explanation, /조향 설정을 확인/)
|
||||||
|
assert.equal(result.usage.totalTokens, 30)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finishes honestly without an infinite loop if the model ignores the finalization request', async () => {
|
||||||
|
let loads = 0
|
||||||
|
let calls = 0
|
||||||
|
const result = await resolveAgentContext(options({
|
||||||
|
requestPlan: async () => { calls++; return response(['Control']) },
|
||||||
|
loadRequestedContext: async () => { loads += 1; return { modules: [moduleInfo('Control')], runtimeLogs: [] } }
|
||||||
|
}))
|
||||||
|
assert.equal(loads, 1)
|
||||||
|
assert.equal(calls, 3)
|
||||||
|
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||||
|
assert.deepEqual(plain(result.plan.actionPlan), [])
|
||||||
|
assert.match(result.plan.explanation, /결론을 확정하지 못했습니다/)
|
||||||
|
assert.match(result.plan.explanation, /Control: 제공된 파일 0개/)
|
||||||
|
assert.equal(result.plan.createModule, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('uses already provided source files without loading the same module again', async () => {
|
||||||
|
let loads = 0
|
||||||
|
let calls = 0
|
||||||
|
const result = await resolveAgentContext(options({
|
||||||
|
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'steering = 1' }])], runtimeLogs: [] },
|
||||||
|
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) },
|
||||||
|
loadRequestedContext: async () => { loads++; return { modules: [], runtimeLogs: [] } }
|
||||||
|
}))
|
||||||
|
assert.equal(loads, 0)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recovery cannot turn repeated source requests into executable commands or edits', async () => {
|
||||||
|
const result = await resolveAgentContext(options({
|
||||||
|
initialContext: { modules: [moduleInfo('Control', [{ path: 'control.py', content: 'source' }])], runtimeLogs: [] },
|
||||||
|
requestPlan: async (_, finalize) => finalize ? {
|
||||||
|
...response(),
|
||||||
|
plan: {
|
||||||
|
...response().plan, actionPlan: ['execute'], commands: [{ id: 'run', command: './run.sh' }],
|
||||||
|
changes: [{ moduleName: 'Control', path: 'control.py', content: 'changed' }], createModule: { name: 'new' }
|
||||||
|
}
|
||||||
|
} : response(['Control']),
|
||||||
|
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
|
||||||
|
}))
|
||||||
|
for (const field of ['commands', 'changes', 'actionPlan']) assert.deepEqual(plain(result.plan[field]), [])
|
||||||
|
assert.equal(result.plan.createModule, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not publish a finalization response received after cancellation', async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
let calls = 0
|
||||||
|
await assert.rejects(resolveAgentContext(options({
|
||||||
|
signal: controller.signal,
|
||||||
|
requestPlan: async (_, finalize) => {
|
||||||
|
calls++
|
||||||
|
if (finalize) controller.abort()
|
||||||
|
return finalize ? response() : response(['Control'])
|
||||||
|
}
|
||||||
|
})), { name: 'AbortError' })
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requests only new resources when the model also repeats an earlier module', async () => {
|
||||||
|
let count = 0
|
||||||
|
const loads = []
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async () => [response(['MKMini']), response(['MKMini', 'Control']), response()][count++],
|
||||||
|
loadRequestedContext: async request => { loads.push(plain(request)); return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
|
||||||
|
}))
|
||||||
|
assert.deepEqual(loads[1], { modules: ['Control'], logs: [] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not start another API call after cancellation during context loading', async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
let calls = 0
|
||||||
|
await assert.rejects(resolveAgentContext(options({
|
||||||
|
signal: controller.signal,
|
||||||
|
requestPlan: async () => { calls += 1; return response(['Control']) },
|
||||||
|
loadRequestedContext: async () => { controller.abort(); return { modules: [], runtimeLogs: [] } }
|
||||||
|
})), { name: 'AbortError' })
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bounds the number of distinct automatic context requests', async () => {
|
||||||
|
let calls = 0
|
||||||
|
let loads = 0
|
||||||
|
const result = await resolveAgentContext(options({
|
||||||
|
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response([`Module${calls}`]) },
|
||||||
|
loadRequestedContext: async request => { loads++; return { modules: request.modules.map(name => moduleInfo(name)), runtimeLogs: [] } }
|
||||||
|
}))
|
||||||
|
assert.equal(calls, 7)
|
||||||
|
assert.equal(loads, 5)
|
||||||
|
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('passes file-read failures to the model for a concrete final explanation', async () => {
|
||||||
|
let count = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (count++ === 0) return response(['Control'])
|
||||||
|
assert.equal(context.modules.find(module => module.name === 'Control').fileContext.status, 'error')
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async () => ({ modules: [{ ...moduleInfo('Control'), fileContext: { status: 'error', message: 'permission denied' } }], runtimeLogs: [] })
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('excludes oversized source files with an explicit notice and continues with complete smaller files', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const smallFile = { path: 'config/steering.yaml', content: 'kinematics: ackermann' }
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (calls++ === 0) return response(['Control'])
|
||||||
|
const control = context.modules.find(module => module.name === 'Control')
|
||||||
|
assert.deepEqual(plain(control.files), [smallFile])
|
||||||
|
assert.equal(control.fileContext.status, 'limited')
|
||||||
|
assert.match(control.fileContext.message, /전체 2개 중 1개/)
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'large.py', content: 'x'.repeat(160_001) }, smallFile])], runtimeLogs: [] })
|
||||||
|
}))
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requests a final answer rather than leaving a context-only response when no loader is available', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const result = await resolveAgentContext(options({
|
||||||
|
requestPlan: async (_, finalize) => { calls++; return finalize ? response() : response(['Control']) }
|
||||||
|
}))
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
assert.deepEqual(plain(result.plan.requestedModules), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps a just-started module running when a stale collector supplies source files', async () => {
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
initialContext: { modules: [{ ...moduleInfo('Control'), state: 'running' }], runtimeLogs: [] },
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (calls++ === 0) return response(['Control'])
|
||||||
|
assert.equal(context.modules[0].state, 'running')
|
||||||
|
assert.equal(context.modules[0].files[0].content, 'code')
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'node.py', content: 'code' }])], runtimeLogs: [] })
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const files = (name, count, size = 10) => Array.from({ length: count }, (_, index) => ({ path: `${name}-${index}.py`, content: String(index).padEnd(size, 'x') }))
|
||||||
|
const sourceSize = modules => modules.reduce((sum, module) => sum + module.files.reduce((total, file) => total + file.path.length + file.content.length, 0), 0)
|
||||||
|
|
||||||
|
test('balances 24 old and 24 new files instead of failing the second module lookup', async () => {
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
calls++
|
||||||
|
if (calls === 1) return response(['MKMini'])
|
||||||
|
if (calls === 2) return response(['Control'])
|
||||||
|
assert.equal(context.modules.reduce((sum, module) => sum + module.files.length, 0), 24)
|
||||||
|
for (const name of ['MKMini', 'Control']) {
|
||||||
|
const module = context.modules.find(module => module.name === name)
|
||||||
|
assert.equal(module.files.length, 12)
|
||||||
|
assert.equal(module.fileContext.status, 'limited')
|
||||||
|
assert.match(module.fileContext.message, /전체 24개 중 12개/)
|
||||||
|
}
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async request => ({ modules: request.modules.map(name => moduleInfo(name, files(name, 24))), runtimeLogs: [] })
|
||||||
|
}))
|
||||||
|
assert.equal(calls, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('enforces the combined character budget without slicing source file contents', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const contents = new Set(['a'.repeat(60_000), 'b'.repeat(60_000)])
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (calls++ === 0) return response(['Control'])
|
||||||
|
assert.ok(sourceSize(context.modules) <= 160_000)
|
||||||
|
for (const module of context.modules) {
|
||||||
|
assert.equal(module.files.length, 1)
|
||||||
|
assert.ok(contents.has(module.files[0].content))
|
||||||
|
assert.equal(module.fileContext.status, 'limited')
|
||||||
|
}
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async () => ({ modules: ['MKMini', 'Control'].map((name, index) => moduleInfo(name, [
|
||||||
|
{ path: 'one.py', content: [...contents][index] },
|
||||||
|
{ path: 'two.py', content: [...contents][index] }
|
||||||
|
])), runtimeLogs: [] }),
|
||||||
|
initialContext: { modules: [moduleInfo('MKMini', [{ path: 'one.py', content: [...contents][0] }, { path: 'two.py', content: [...contents][0] }]), moduleInfo('Control')], runtimeLogs: [] }
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prioritizes newly requested source when earlier context nearly fills the character budget', async () => {
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
initialContext: { modules: [moduleInfo('MKMini', files('old', 2, 70_000)), moduleInfo('Control')], runtimeLogs: [] },
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (calls++ === 0) return response(['Control'])
|
||||||
|
assert.equal(context.modules.find(module => module.name === 'Control').files[0].content, 'new'.repeat(20_000))
|
||||||
|
assert.ok(context.modules.find(module => module.name === 'MKMini').files.length > 0)
|
||||||
|
assert.ok(sourceSize(context.modules) <= 160_000)
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async () => ({ modules: [moduleInfo('Control', [{ path: 'controller.py', content: 'new'.repeat(20_000) }])], runtimeLogs: [] })
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('budgets initial source context before sending the first API request', async () => {
|
||||||
|
const initialContext = { modules: [moduleInfo('Control', files('source', 30))], runtimeLogs: [] }
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
initialContext,
|
||||||
|
requestPlan: async context => {
|
||||||
|
assert.equal(context.modules[0].files.length, 24)
|
||||||
|
assert.equal(context.modules[0].fileContext.status, 'limited')
|
||||||
|
return response()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
assert.equal(initialContext.modules[0].files.length, 30)
|
||||||
|
assert.equal(initialContext.modules[0].fileContext, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rotates older logs out with a notice rather than failing on a fourth module', async () => {
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${moduleName} log` })) },
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (calls++ === 0) return response([], ['D'])
|
||||||
|
assert.deepEqual(plain(context.runtimeLogs.map(log => log.moduleName)), ['D', 'C', 'B'])
|
||||||
|
assert.match(context.runtimeLogs[0].message, /이전 로그 제외: A/)
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async () => ({ modules: [], runtimeLogs: [{ moduleName: 'D', state: 'running', content: 'latest error' }] })
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('counts log messages in the server budget and keeps recent log content', async () => {
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
initialContext: { modules: [], runtimeLogs: ['A', 'B', 'C'].map(moduleName => ({ moduleName, state: 'running', content: `${'x'.repeat(20_000)}LATEST`, message: 'm'.repeat(500) })) },
|
||||||
|
requestPlan: async context => {
|
||||||
|
assert.equal(context.runtimeLogs.length, 3)
|
||||||
|
assert.ok(context.runtimeLogs.reduce((sum, log) => sum + log.content.length + (log.message?.length ?? 0), 0) <= 60_000)
|
||||||
|
for (const log of context.runtimeLogs) {
|
||||||
|
assert.ok(log.content.endsWith('LATEST'))
|
||||||
|
assert.match(log.message, /최근 구간/)
|
||||||
|
assert.ok(log.message.length <= 500)
|
||||||
|
}
|
||||||
|
return response()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('collects candidates from every requested module and prioritizes configuration before large source trees', async () => {
|
||||||
|
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
let collector
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'collectAgentWorkspace') collector = node.initializer.getText(source)
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
assert.ok(collector)
|
||||||
|
const api = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(`exports.collect = ${collector}`, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, {
|
||||||
|
exports: api,
|
||||||
|
availableModules: ['MKMini', 'Control'].map(name => ({ name, state: 'stopped' })),
|
||||||
|
remoteWorkspace: false,
|
||||||
|
clonePathPreview: '/workspace',
|
||||||
|
currentActiveEditorFile: null,
|
||||||
|
editorFiles: {},
|
||||||
|
window: { localWorkspace: { moduleFiles: { read: async name => ({ files: Object.fromEntries([
|
||||||
|
...files(name, 30).map((file, index) => [`src/node${index}.py`, file.content]),
|
||||||
|
['config/steering.yaml', 'kinematics: ackermann']
|
||||||
|
]) }) } } }
|
||||||
|
})
|
||||||
|
const modules = await api.collect(['MKMini', 'Control'])
|
||||||
|
assert.deepEqual(plain(modules.map(module => module.files.length)), [24, 24])
|
||||||
|
for (const module of modules) assert.equal(module.files[0].path, 'config/steering.yaml')
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext(options({
|
||||||
|
requestPlan: async context => {
|
||||||
|
if (calls++ === 0) return response(['MKMini', 'Control'])
|
||||||
|
for (const module of context.modules) {
|
||||||
|
assert.equal(module.files.length, 12)
|
||||||
|
assert.equal(module.files[0].path, 'config/steering.yaml')
|
||||||
|
}
|
||||||
|
return response()
|
||||||
|
},
|
||||||
|
loadRequestedContext: async request => ({ modules: await api.collect(request.modules), runtimeLogs: [] })
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const api = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: api })
|
||||||
|
const { agentConversationWorkspace: scope, buildAgentConversationContext: build } = api
|
||||||
|
const now = Date.now()
|
||||||
|
const local = scope('local', 'local-host', '/workspace/a')
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const turn = (n, workspace = local, createdAt = now) => [
|
||||||
|
{ id: `u${n}`, role: 'user', content: `question-${n}`, conversationScope: { workspace, createdAt } },
|
||||||
|
{ id: `a${n}`, role: 'assistant', content: `summary-${n}`, conversationScope: { workspace, createdAt }, plan: {
|
||||||
|
summary: `summary-${n}`, explanation: `answer-${n}`, conversationSummary: `누적 기억-${n}`, changes: [], commands: [], requestedModules: [], requestedLogs: [], createModule: null
|
||||||
|
} }
|
||||||
|
]
|
||||||
|
|
||||||
|
test('uses the latest cumulative summary with only two recent exchanges and no duplicate current question', () => {
|
||||||
|
const messages = Array.from({ length: 20 }, (_, i) => turn(i)).flat()
|
||||||
|
messages.push({ ...turn(20)[0], content: 'pending current question' })
|
||||||
|
const context = build(messages, local, now)
|
||||||
|
assert.deepEqual(plain(context.recentMessages).map(item => item.content), ['question-18', 'summary-18\nanswer-18', 'question-19', 'summary-19\nanswer-19'])
|
||||||
|
assert.equal(context.summary, '누적 기억-19')
|
||||||
|
assert.doesNotMatch(JSON.stringify(context), /question-17|pending current question/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('hard caps payload text even with huge original answers and requests', () => {
|
||||||
|
const messages = Array.from({ length: 10 }, (_, i) => {
|
||||||
|
const pair = turn(i)
|
||||||
|
pair[0].content = 'q'.repeat(4000)
|
||||||
|
pair[1].plan.summary = 's'.repeat(500)
|
||||||
|
pair[1].plan.explanation = 'e'.repeat(12000)
|
||||||
|
pair[1].plan.conversationSummary = 'm'.repeat(2000)
|
||||||
|
return pair
|
||||||
|
}).flat()
|
||||||
|
const context = build(messages, local, now)
|
||||||
|
assert.ok(context.summary.length <= 1000)
|
||||||
|
assert.equal(context.recentMessages.length, 4)
|
||||||
|
assert.ok(context.recentMessages.every(message => message.content.length <= 700))
|
||||||
|
assert.ok(context.summary.length + context.recentMessages.reduce((sum, item) => sum + item.content.length, 0) <= 3800)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not mix hosts, roots, SSH identities, unscoped legacy messages or expired history', () => {
|
||||||
|
for (const other of [scope('remote', 'ssh:a', '/workspace/a'), scope('local', 'local-host', '/workspace/b'), scope('remote', 'ssh:b', '/workspace/a')]) {
|
||||||
|
assert.equal(build(turn(1, other), local, now), undefined)
|
||||||
|
}
|
||||||
|
assert.equal(build(turn(1, local, now - 86400000), local, now), undefined)
|
||||||
|
assert.equal(build(turn(1, local, now + 1), local, now), undefined)
|
||||||
|
assert.equal(build(turn(1).map(({ conversationScope, ...message }) => message), local, now), undefined)
|
||||||
|
assert.equal(scope('local', 'local-host'), undefined)
|
||||||
|
assert.equal(build(turn(1), undefined, now), undefined)
|
||||||
|
assert.equal(build([], local, now), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a missing or empty generated summary preserves earlier valid memory without an extra model call', () => {
|
||||||
|
const first = turn(1), second = turn(2)
|
||||||
|
delete second[1].plan.conversationSummary
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
|
||||||
|
second[1].plan.conversationSummary = ''
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '누적 기억-1')
|
||||||
|
delete first[1].plan.conversationSummary
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '')
|
||||||
|
second[1].plan.conversationSummary = '새 누적 요약'
|
||||||
|
assert.equal(build([...first, ...second], local, now).summary, '새 누적 요약')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps file identities and action outcomes, but excludes payloads and tool evidence', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
pair[1].plan.explanation = '설명\n```python\nPRIVATE_SOURCE\n```\n끝'
|
||||||
|
pair[1].plan.changes = [{ moduleName: 'Control', path: 'config.yaml', content: 'PRIVATE_CHANGE' }]
|
||||||
|
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build', command: 'PRIVATE_COMMAND' }]
|
||||||
|
pair[1].plan.evidence = [{ data: 'PRIVATE_LOG' }]
|
||||||
|
pair[1].plan.codeReferences = [{ moduleName: 'Control', path: 'node.py', startLine: 20, endLine: 23, snippet: 'PRIVATE_SNIPPET' }]
|
||||||
|
pair[1].commandExecutions = { build: { status: 'completed', output: 'PRIVATE_OUTPUT' } }
|
||||||
|
pair[1].applyState = 'cancelled'
|
||||||
|
const context = JSON.stringify(build(pair, local, now))
|
||||||
|
assert.match(context, /Control\/node.py:20-23/)
|
||||||
|
assert.match(context, /file change: cancelled/)
|
||||||
|
assert.match(context, /build: completed/)
|
||||||
|
assert.doesNotMatch(context, /PRIVATE_/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ignores errors, interrupted turns and initial lookup responses', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
const error = { ...pair[1], plan: undefined, content: 'network error' }
|
||||||
|
assert.equal(build([pair[0], error], local, now), undefined)
|
||||||
|
pair[1].plan.toolRequests = [{ name: 'files.read' }]
|
||||||
|
assert.equal(build(pair, local, now), undefined)
|
||||||
|
assert.equal(build([turn(1)[0], ...turn(2, scope('remote', 'ssh:x', '/workspace'))], local, now), undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rebuilds from persisted metadata and reflects updated execution outcomes without new memory storage', () => {
|
||||||
|
const pair = turn(1)
|
||||||
|
pair[1].plan.commands = [{ id: 'build', moduleName: 'Control', purpose: 'build' }]
|
||||||
|
assert.match(JSON.stringify(build(plain(pair), local, now)), /pending/)
|
||||||
|
pair[1].commandExecutions = { build: { status: 'failed' } }
|
||||||
|
assert.match(JSON.stringify(build(plain(pair), local, now)), /failed/)
|
||||||
|
const followUp = { ...pair[1], content: 'result', plan: { ...pair[1].plan, summary: '실행 결과 분석', explanation: '실패했습니다.' } }
|
||||||
|
assert.match(JSON.stringify(build([...pair, followUp], local, now)), /실행 결과 분석/)
|
||||||
|
assert.equal(build([], local, now), undefined)
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const api = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceEditorPane.tsx'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true } }).outputText, {
|
||||||
|
exports: api,
|
||||||
|
require: id => id === '../../i18n' ? { t: value => value, useLocaleRender() {} }
|
||||||
|
: id === '@monaco-editor/react' ? { __esModule: true, default: 'Editor', DiffEditor: 'DiffEditor' }
|
||||||
|
: id === 'lucide-react' ? {} : require(id)
|
||||||
|
})
|
||||||
|
const props = { dark: true, textDim: '', editorPaneRef: { current: null }, currentActiveEditorFile: 'config/control.yaml', activeFileIsImage: false, editorLanguage: 'yaml', editorValue: 'steering: ackermann', fontSize: 12, tabSize: 4, minimapEnabled: false, exampleActive: false, beforeMount() {}, onMount() {}, onChange() {} }
|
||||||
|
for (const locked of [false, true]) {
|
||||||
|
test(`the source editor is read-only only while an agent file batch is applying (locked=${locked})`, () => {
|
||||||
|
const tree = api.default({ ...props, editingLocked: locked })
|
||||||
|
assert.equal(tree.props.children.type, 'Editor')
|
||||||
|
assert.equal(tree.props.children.props.options.readOnly, locked)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
test('agent diff previews remain read-only and set tab width through text models', () => {
|
||||||
|
const tree = api.default({ ...props, diffPreview: { id: 'diff', originalContent: 'old', modifiedContent: 'new' } })
|
||||||
|
const diff = tree.props.children
|
||||||
|
assert.equal(diff.type, 'DiffEditor')
|
||||||
|
assert.equal(diff.props.options.readOnly, true)
|
||||||
|
assert.equal('tabSize' in diff.props.options, false)
|
||||||
|
const updates = []
|
||||||
|
const editor = { getModel: () => ({ updateOptions: options => updates.push(options.tabSize) }) }
|
||||||
|
diff.props.onMount({ getOriginalEditor: () => editor, getModifiedEditor: () => editor })
|
||||||
|
assert.deepEqual(updates, [4, 4])
|
||||||
|
})
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const ts = require('typescript')
|
||||||
|
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, filename)
|
||||||
|
const { compactAgentEvidence, missingAgentRead, readFileEvidence } = require('../src/renderer/src/features/workspace/agentEvidence.ts')
|
||||||
|
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
|
||||||
|
const file = (startLine, endLine, extra = {}, result = {}) => ({
|
||||||
|
id: `read-${startLine}-${endLine}`, name: 'files.read', moduleName: 'Control', target: 'ssh:jetson', status: 'ok', observedAt: '2026-10-20T00:00:00.000Z', truncated: true,
|
||||||
|
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), content: Array.from({ length: endLine - startLine + 1 }, (_, i) => `line${startLine + i}`).join('\n'),
|
||||||
|
startLine, endLine, totalLines: 100, complete: false, contentTruncated: false, ...extra }), ...result
|
||||||
|
})
|
||||||
|
const read = (startLine, lineCount, path = 'config/car.yaml') => ({ id: 'read', name: 'files.read', moduleName: 'Control', args: { path, ...(startLine === undefined ? {} : { startLine }), ...(lineCount === undefined ? {} : { lineCount }) } })
|
||||||
|
const answer = toolRequests => ({ provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'verified code', toolRequests, changes: [], commands: [], actionPlan: [], createModule: null } })
|
||||||
|
|
||||||
|
test('overlapping and adjacent ranges merge without promoting partial evidence to editable full files', () => {
|
||||||
|
const results = compactAgentEvidence([file(1, 10), file(8, 15), file(16, 20)])
|
||||||
|
assert.equal(results.length, 1)
|
||||||
|
const data = readFileEvidence(results[0])
|
||||||
|
assert.equal(data.startLine, 1)
|
||||||
|
assert.equal(data.endLine, 20)
|
||||||
|
assert.equal(data.content.split('\n').length, 20)
|
||||||
|
assert.equal(data.complete, false)
|
||||||
|
assert.equal(results[0].truncated, true)
|
||||||
|
assert.equal(missingAgentRead(read(3, 16), results), undefined)
|
||||||
|
assert.deepEqual(missingAgentRead(read(), results), read())
|
||||||
|
})
|
||||||
|
|
||||||
|
test('queries only the first missing range and never joins disjoint or conflicting content', () => {
|
||||||
|
const results = compactAgentEvidence([file(1, 5), file(10, 15)])
|
||||||
|
assert.equal(results.length, 2)
|
||||||
|
assert.deepEqual(missingAgentRead(read(3, 15), results).args, { path: 'config/car.yaml', startLine: 6, lineCount: 4 })
|
||||||
|
assert.equal(compactAgentEvidence([file(1, 10), file(8, 15, { content: 'different\ncontent' })]).length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('host boundaries and file revisions cannot be combined or used to fill each other’s gaps', () => {
|
||||||
|
const changed = compactAgentEvidence([file(1, 10), file(11, 20, { hash: 'b'.repeat(64) })])
|
||||||
|
assert.equal(changed.length, 1)
|
||||||
|
assert.equal(missingAgentRead(read(1, 20), changed).args.startLine, 1)
|
||||||
|
const hosts = compactAgentEvidence([file(1, 10, {}, { target: 'local-host' }), file(11, 20)])
|
||||||
|
assert.equal(hosts.length, 2)
|
||||||
|
assert.equal(missingAgentRead(read(1, 20), hosts).args.startLine, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('full and missing observations are reused but partial character fragments are never coverage', () => {
|
||||||
|
const full = file(1, 10, { complete: true }, { truncated: false })
|
||||||
|
assert.equal(missingAgentRead(read(5, 100), [full]), undefined)
|
||||||
|
assert.equal(compactAgentEvidence([full, file(5, 8)]).length, 1)
|
||||||
|
const missing = file(1, 1, { exists: false, content: '', hash: null, complete: true }, { truncated: false })
|
||||||
|
assert.equal(missingAgentRead(read(), [missing]), undefined)
|
||||||
|
const cut = file(1, 2, { content: 'whole\ncut', endLine: 1, contentTruncated: true })
|
||||||
|
assert.equal(missingAgentRead(read(2, 1), [cut]).args.startLine, 2)
|
||||||
|
const legacy = file(1, 2, { endLine: undefined, contentTruncated: undefined })
|
||||||
|
assert.equal(missingAgentRead(read(2, 1), [legacy]).args.startLine, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('malformed or oversized evidence never becomes reusable complete coverage', () => {
|
||||||
|
assert.equal(readFileEvidence(file(1, 10, { hash: 'not-a-hash' })), undefined)
|
||||||
|
assert.equal(readFileEvidence(file(1, 10, { endLine: 999 })), undefined)
|
||||||
|
const large = compactAgentEvidence([file(1, 1, { content: 'a'.repeat(30_000) }), file(2, 2, { content: 'b'.repeat(30_000) })])
|
||||||
|
assert.equal(large.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('overlap requests execute only missing lines and finish once the requested range is cached', async () => {
|
||||||
|
const executions = []
|
||||||
|
let calls = 0
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async tool => { executions.push(tool.args); return file(tool.args.startLine, tool.args.startLine + tool.args.lineCount - 1) },
|
||||||
|
requestPlan: async (context, final) => {
|
||||||
|
calls++
|
||||||
|
if (final) {
|
||||||
|
assert.equal(context.toolResults.length, 1)
|
||||||
|
assert.equal(JSON.parse(context.toolResults[0].data).endLine, 15)
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
return answer([[read(1, 10)], [read(5, 11)], [read(4, 10)]][calls - 1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.deepEqual(executions, [read(1, 10).args, read(11, 5).args])
|
||||||
|
assert.equal(calls, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source evidence survives many later searches and omissions are reported within DTO limits', async () => {
|
||||||
|
const searchResults = Array.from({ length: 15 }, (_, i) => ({ ...file(1, 1), id: `search-${i}`, name: 'files.search', data: JSON.stringify({ query: `speed${i}`, matches: [] }) }))
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10), ...searchResults] }, signal: new AbortController().signal,
|
||||||
|
requestPlan: async context => {
|
||||||
|
assert.equal(context.toolResults.length, 12)
|
||||||
|
assert.equal(context.toolResults[0].name, 'files.read')
|
||||||
|
assert.match(context.contextNotes[0], /4개/)
|
||||||
|
assert.ok(context.contextNotes.length <= 12)
|
||||||
|
assert.ok(context.contextNotes.every(note => note.length <= 500))
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a cached file omitted by budgets is restored when the model requests it again', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const results = Array.from({ length: 13 }, (_, i) => file(1, 10, { path: `file${i}.yaml` }))
|
||||||
|
await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: results }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => { throw Error('cached file must not be queried again') },
|
||||||
|
requestPlan: async (context, final) => {
|
||||||
|
calls++
|
||||||
|
if (!final) return answer([read(1, 5, 'file0.yaml')])
|
||||||
|
assert.ok(context.toolResults.some(result => JSON.parse(result.data).path === 'file0.yaml'))
|
||||||
|
return answer([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finalization retains verified code locations while stripping all proposed mutations', async () => {
|
||||||
|
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
|
||||||
|
const result = await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => { throw Error('already cached') },
|
||||||
|
requestPlan: async (_, final) => ({ ...answer([read(1, 5)]), plan: { ...answer([read(1, 5)]).plan, ...(final ? { changes: [{ path: 'unsafe' }], commands: [{ command: 'unsafe' }] } : { codeReferences: [reference] }) } })
|
||||||
|
})
|
||||||
|
assert.equal(result.plan.codeReferences.length, 1)
|
||||||
|
assert.match(result.plan.explanation, /verified code/)
|
||||||
|
assert.deepEqual(result.plan.changes, [])
|
||||||
|
assert.deepEqual(result.plan.commands, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('recovery never resurrects code locations after a different file revision was observed', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const reference = { id: 'code-1', moduleName: 'Control', path: 'config/car.yaml', startLine: 1, endLine: 3, reason: 'verified', expectedHash: 'a'.repeat(64), snippet: 'line1' }
|
||||||
|
const result = await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [], toolResults: [file(1, 10)] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async () => file(11, 20, { hash: 'b'.repeat(64) }),
|
||||||
|
requestPlan: async (_, final) => {
|
||||||
|
calls++
|
||||||
|
return !final && calls === 1 ? { ...answer([read(11, 10)]), plan: { ...answer([read(11, 10)]).plan, codeReferences: [reference] } } : answer([read(11, 10)])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.deepEqual(result.plan.codeReferences, [])
|
||||||
|
assert.match(result.plan.summary, /확정하지 못했습니다/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
|
||||||
|
function compile(path, overrides = {}) {
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(require.resolve(path), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText,
|
||||||
|
{ exports, document: { body: {} }, require: name => overrides[name] ?? require(name) })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const models = compile('../src/renderer/src/features/workspace/agentModels.ts')
|
||||||
|
const options = [{ provider: 'OPENAI', model: 'gpt-6.1-sol', imagesEnabled: false }, { provider: 'QWEN', model: 'qwen3.8-flash', imagesEnabled: true }]
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
test('normalizes enabled models, validates IDs and preserves old single-model servers', () => {
|
||||||
|
const status = models.normalizeAgentStatus({ enabled: true, models: [options[1], options[0], options[0], { provider: 'FORGED', model: 'bad' }, { provider: 'GEMINI', model: '' }] })
|
||||||
|
assert.deepEqual(plain(status.models), options)
|
||||||
|
assert.equal(status.selectionSupported, true)
|
||||||
|
assert.deepEqual(plain(models.normalizeAgentStatus({ enabled: true, ...options[1] }).models), [options[1]])
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: true, ...options[1] }).selectionSupported, false)
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: false, models: options }).models.length, 0)
|
||||||
|
assert.equal(models.normalizeAgentStatus({ enabled: true, models: [] }).enabled, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('selects the saved provider only while it is available, and never invents one', () => {
|
||||||
|
assert.equal(models.selectedAgentModel(options, 'QWEN'), options[1])
|
||||||
|
assert.equal(models.selectedAgentModel(options, 'ANTHROPIC'), options[0])
|
||||||
|
assert.equal(models.selectedAgentModel([options[0]], 'QWEN'), options[0])
|
||||||
|
assert.equal(models.selectedAgentModel([], 'QWEN'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return []
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
function selectorHarness(props) {
|
||||||
|
const state = [], refs = []
|
||||||
|
let stateIndex = 0, refIndex = 0
|
||||||
|
const { AgentModelSelector } = compile('../src/renderer/src/components/AgentModelSelector.tsx', {
|
||||||
|
'../i18n': { t: value => value }, '../features/workspace/agentModels': models,
|
||||||
|
'react-dom': { createPortal: child => child },
|
||||||
|
react: { ...React, useId: () => 'model-menu', useEffect() {}, useLayoutEffect() {},
|
||||||
|
useState(initial) { const index = stateIndex++; if (!(index in state)) state[index] = initial; return [state[index], value => { state[index] = typeof value === 'function' ? value(state[index]) : value }] },
|
||||||
|
useRef(initial) { return refs[refIndex++] ?? (refs[refIndex - 1] = { current: initial }) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { render(patch = {}) { stateIndex = 0; refIndex = 0; return AgentModelSelector({ ...props, ...patch }) } }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [true, false]) test(`model control lists only allowed providers and follows the theme (${dark})`, () => {
|
||||||
|
let selected
|
||||||
|
const h = selectorHarness({ models: options, provider: 'QWEN', dark, disabled: false, onChange: value => { selected = value } })
|
||||||
|
const tree = h.render()
|
||||||
|
const trigger = elements(tree, node => node.type === 'button')[0]
|
||||||
|
assert.equal(trigger.props['aria-haspopup'], 'menu')
|
||||||
|
assert.equal(trigger.props['aria-expanded'], false)
|
||||||
|
assert.equal(trigger.props.title, 'Qwen · qwen3.8-flash')
|
||||||
|
assert.equal(elements(tree, node => node.props.role === 'menu').length, 0)
|
||||||
|
assert.equal(elements(tree, node => node.type === 'select').length, 0)
|
||||||
|
trigger.props.onClick()
|
||||||
|
const menu = elements(h.render(), node => node.props.role === 'menu')[0]
|
||||||
|
assert.match(menu.props.className, dark ? /bg-\[#252526\]/ : /bg-white/)
|
||||||
|
assert.match(menu.props.className, /fixed/)
|
||||||
|
const items = elements(menu, node => node.props.role === 'menuitemradio')
|
||||||
|
assert.deepEqual(items.map(node => node.props.title), ['OpenAI · gpt-6.1-sol', 'Qwen · qwen3.8-flash'])
|
||||||
|
assert.deepEqual(items.map(node => node.props['aria-checked']), [false, true])
|
||||||
|
assert.ok(items.every(node => elements(node, item => item.type === 'span' && item.props.className.includes('truncate')).length === 1))
|
||||||
|
items[0].props.onClick()
|
||||||
|
assert.equal(selected, 'OPENAI')
|
||||||
|
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 0)
|
||||||
|
assert.ok(elements(h.render({ disabled: true }), node => node.type === 'button')[0].props.disabled)
|
||||||
|
const empty = elements(h.render({ models: [], provider: null }), node => node.type === 'button')[0]
|
||||||
|
assert.ok(empty.props.disabled)
|
||||||
|
assert.equal(empty.props.title, '사용 가능한 모델 없음')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('model menu opens from arrow keys and closes when a request blocks the selector', () => {
|
||||||
|
const h = selectorHarness({ models: options, provider: 'QWEN', dark: true, disabled: false, onChange() {} })
|
||||||
|
const trigger = elements(h.render(), node => node.type === 'button')[0]
|
||||||
|
trigger.props.onKeyDown({ key: 'ArrowUp', preventDefault() {} })
|
||||||
|
assert.equal(elements(h.render(), node => node.props.role === 'menu').length, 1)
|
||||||
|
assert.equal(elements(h.render({ disabled: true }), node => node.props.role === 'menu').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('input footer places the model action before the attachment and clear controls', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||||
|
const row = source.slice(source.indexOf('{(attachmentsEnabled || onClearConversation || footerAction)'))
|
||||||
|
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'파일 첨부\')}'))
|
||||||
|
assert.ok(row.indexOf('{footerAction}') < row.indexOf('title={t(\'대화내역 삭제\')}'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('A-Copilot has a single managed label', () => {
|
||||||
|
assert.equal(models.agentModelLabel({ provider: 'A_COPILOT', model: 'A-Copilot', imagesEnabled: false }), 'A-Copilot')
|
||||||
|
assert.equal(models.agentModelLabel(options[0]), 'OpenAI · gpt-6.1-sol')
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
|
||||||
|
function compile(source, globals) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, { exports, require, ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const hookSource = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const hook = hookSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
||||||
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['applyPlan', 'cancelPlan'].includes(node.declarationList.declarations[0].name.getText(hookSource)))
|
||||||
|
const proposal = (extra = {}) => ({
|
||||||
|
id: 'proposal', role: 'assistant', content: '로그 추가', applyState: 'ready',
|
||||||
|
plan: { summary: '로그 추가', explanation: '시작 로그', requestedModules: [], requestedLogs: [], actionPlan: [], createModule: null,
|
||||||
|
changes: [{ moduleName: 'Control', path: 'node.py', content: 'new', reason: 'log' }],
|
||||||
|
commands: [{ id: 'build', moduleName: 'Control', command: 'bash build.sh', purpose: 'build' }] },
|
||||||
|
commandExecutions: { build: { status: 'pending' } }, ...extra
|
||||||
|
})
|
||||||
|
function harness(message) {
|
||||||
|
const session = {}
|
||||||
|
const messageStateRef = { current: { session, messages: [message] } }
|
||||||
|
const applyingRef = { current: false }
|
||||||
|
const abortControllerRef = { current: null }
|
||||||
|
const saved = []
|
||||||
|
const api = compile(functions.map(node => `export ${node.getText(hookSource)}`).join('\n'), {
|
||||||
|
session, messageStateRef, applyingRef, abortControllerRef,
|
||||||
|
updateMessages(update) {
|
||||||
|
const next = update(messageStateRef.current.messages)
|
||||||
|
messageStateRef.current.messages = next
|
||||||
|
saved.push(JSON.parse(JSON.stringify(next)))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { ...api, messageStateRef, applyingRef, abortControllerRef, saved, current: () => messageStateRef.current.messages[0] }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cancel marks the proposal and unfinished commands without applying files', async () => {
|
||||||
|
const h = harness(proposal({ applyError: 'old error' }))
|
||||||
|
assert.equal(h.cancelPlan('proposal'), true)
|
||||||
|
assert.equal(h.current().applyState, 'cancelled')
|
||||||
|
assert.equal(h.current().applyError, undefined)
|
||||||
|
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
||||||
|
assert.equal(h.saved.at(-1)[0].applyState, 'cancelled')
|
||||||
|
let applied = false
|
||||||
|
await h.applyPlan('proposal', async () => { applied = true })
|
||||||
|
assert.equal(applied, false)
|
||||||
|
assert.equal(h.cancelPlan('proposal'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancel preserves existing successful command results and rejects failed commands', () => {
|
||||||
|
const message = proposal()
|
||||||
|
message.plan.commands.push({ id: 'inspect', moduleName: 'Control', command: 'inspect', purpose: 'inspect' })
|
||||||
|
message.commandExecutions = { build: { status: 'failed', output: 'build failed' }, inspect: { status: 'completed', output: 'done' } }
|
||||||
|
const h = harness(message)
|
||||||
|
h.cancelPlan('proposal')
|
||||||
|
assert.equal(h.current().commandExecutions.inspect.status, 'completed')
|
||||||
|
assert.equal(h.current().commandExecutions.inspect.output, 'done')
|
||||||
|
assert.equal(h.current().commandExecutions.build.status, 'rejected')
|
||||||
|
assert.equal(h.current().commandExecutions.build.output, 'build failed')
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const applyState of ['applying', 'applied', 'cancelled']) {
|
||||||
|
test(`cannot cancel a proposal in ${applyState} state`, () => {
|
||||||
|
const h = harness(proposal({ applyState }))
|
||||||
|
assert.equal(h.cancelPlan('proposal'), false)
|
||||||
|
assert.equal(h.saved.length, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cannot cancel during a running command, another apply, pending request, or changed conversation', () => {
|
||||||
|
for (const setup of [
|
||||||
|
h => { h.current().commandExecutions.build.status = 'running' },
|
||||||
|
h => { h.applyingRef.current = true },
|
||||||
|
h => { h.abortControllerRef.current = {} },
|
||||||
|
h => { h.messageStateRef.current.session = {} }
|
||||||
|
]) {
|
||||||
|
const h = harness(proposal())
|
||||||
|
setup(h)
|
||||||
|
assert.equal(h.cancelPlan('proposal'), false)
|
||||||
|
assert.equal(h.saved.length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('an apply already started cannot be cancelled and completes normally', async () => {
|
||||||
|
const h = harness(proposal())
|
||||||
|
let finish
|
||||||
|
const applying = h.applyPlan('proposal', () => new Promise(resolve => { finish = resolve }))
|
||||||
|
assert.equal(h.current().applyState, 'applying')
|
||||||
|
assert.equal(h.cancelPlan('proposal'), false)
|
||||||
|
finish()
|
||||||
|
await applying
|
||||||
|
assert.equal(h.current().applyState, 'applied')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('new module proposals can be cancelled without creating anything', () => {
|
||||||
|
const message = proposal()
|
||||||
|
message.plan.changes = []
|
||||||
|
message.plan.createModule = { name: 'Example' }
|
||||||
|
const h = harness(message)
|
||||||
|
assert.equal(h.cancelPlan('proposal'), true)
|
||||||
|
assert.equal(h.current().applyState, 'cancelled')
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return []
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
function text(root) {
|
||||||
|
if (typeof root === 'string') return root
|
||||||
|
return React.isValidElement(root) ? React.Children.toArray(root.props.children).map(text).join('') : ''
|
||||||
|
}
|
||||||
|
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const renderAgent = compile(`export ${panelSource.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'AgentMessages').getText(panelSource)}`, {
|
||||||
|
...require('lucide-react'), t: key => key, useLocaleRender() {},
|
||||||
|
ProgressiveChatResponse: () => null, MessageCopyButton: () => null,
|
||||||
|
AgentWorkDuration: () => null,
|
||||||
|
agentMessageCopyText: message => message.content
|
||||||
|
}).AgentMessages
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`cancel is left of apply, disabled during work, and absent after cancellation (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
let cancelled
|
||||||
|
const props = { dark, enabled: true, statusLoading: false, pending: false,
|
||||||
|
messages: [proposal()], onApply() {}, onCancel: id => { cancelled = id }, onOpenChange() {}, onRunCommand() {}, onRejectCommand() {} }
|
||||||
|
let tree = renderAgent(props)
|
||||||
|
let buttons = elements(tree, node => node.type === 'button')
|
||||||
|
const cancel = buttons.find(button => text(button) === '취소')
|
||||||
|
const apply = buttons.find(button => text(button) === '변경 적용')
|
||||||
|
assert.ok(buttons.indexOf(cancel) < buttons.indexOf(apply))
|
||||||
|
const cancelLabel = elements(cancel, node => node.type === 'span' && text(node) === '취소')[0]
|
||||||
|
assert.match(cancelLabel.props.className, /top-px leading-none/)
|
||||||
|
cancel.props.onClick()
|
||||||
|
assert.equal(cancelled, 'proposal')
|
||||||
|
const busyCancel = elements(renderAgent({ ...props, busy: true }), node => node.type === 'button' && text(node) === '취소')[0]
|
||||||
|
assert.equal(busyCancel.props.disabled, true)
|
||||||
|
tree = renderAgent({ ...props, messages: [proposal({ applyState: 'cancelled' })] })
|
||||||
|
buttons = elements(tree, node => node.type === 'button')
|
||||||
|
assert.equal(buttons.some(button => ['취소', '승인', '다시 적용', '변경 적용'].includes(text(button))), false)
|
||||||
|
assert.equal(buttons.find(button => text(button) === '취소됨').props.disabled, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const api = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentPractice.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: api, DOMException, setTimeout })
|
||||||
|
const { AGENT_PRACTICE_PROFILES: profiles, inspectAgentPractice, verifyAgentPractice, prepareAgentPractice } = api
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const profile = profiles.find(item => item.id === 'mkmini-2d')
|
||||||
|
const modulesFor = p => p.modules.map((name, sortOrder) => ({ name, sortOrder, category: name.includes('MKMini') || name.includes('TurtleBot') || name === 'NAV2-Yahboom' ? 'Vehicle' : name.includes('Gazebo') ? 'Simulator' : name.includes('Perception') ? 'Sensing' : 'System', state: 'stopped', parameters: [`sim_mode:=${p.environment === 'local'}`, `platform:=${p.platform}`, `robot:=${p.platform}`, `level:=${p.level}`] }))
|
||||||
|
const probeFor = (p, overrides = {}) => ({ id: 'probe', name: 'ros.inspect', status: 'ok', target: 'test:container', observedAt: new Date().toISOString(), truncated: false, data: JSON.stringify({ topics: p.topics.map(name => ({ name, publishers: 1, samples: 3, validSamples: 3, frameIds: name === p.topics[0] ? ['laser'] : [] })), tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_footprint' }, { parent: 'base_footprint', child: 'base_link' }, { parent: 'base_link', child: 'laser' }], ...overrides }) })
|
||||||
|
function scenario(overrides = {}) {
|
||||||
|
const modules = modulesFor(profile)
|
||||||
|
const starts = []
|
||||||
|
const controller = new AbortController()
|
||||||
|
const options = {
|
||||||
|
profileId: profile.id, environment: 'remote', signal: controller.signal,
|
||||||
|
approvedStarts: [...profile.modules],
|
||||||
|
list: async () => modules.map(module => ({ ...module, parameters: [...module.parameters] })),
|
||||||
|
start: async module => { starts.push(module.name); modules.find(item => item.name === module.name).state = 'running'; return true },
|
||||||
|
probe: async () => probeFor(profile), progress: () => {}, pause: async () => {}, ...overrides
|
||||||
|
}
|
||||||
|
return { modules, starts, controller, options }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('SLAM profiles follow the module guide module sequences', () => {
|
||||||
|
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
let expression
|
||||||
|
const visit = node => { if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'moduleGuidePages') expression = node.initializer.getText(source); ts.forEachChild(node, visit) }
|
||||||
|
visit(source)
|
||||||
|
const guide = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(`exports.pages = ${expression}`, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports: guide })
|
||||||
|
for (const p of profiles) assert.ok(guide.pages.some(page => JSON.stringify(page.slamModules) === JSON.stringify(p.modules)))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('preflight rejects wrong environments, missing modules, conflicting parameters and unrelated running modules', () => {
|
||||||
|
const modules = modulesFor(profile)
|
||||||
|
assert.equal(inspectAgentPractice(profile.id, modules, 'remote').canPrepare, true)
|
||||||
|
assert.equal(inspectAgentPractice(profile.id, modules, 'local').canPrepare, false)
|
||||||
|
assert.equal(inspectAgentPractice(profile.id, modules.slice(1), 'remote').canPrepare, false)
|
||||||
|
modules[0].parameters[0] = 'sim_mode:=true'
|
||||||
|
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
|
||||||
|
modules[0].parameters = []
|
||||||
|
assert.match(inspectAgentPractice(profile.id, modules, 'remote').blockers.join(), /sim_mode/)
|
||||||
|
const conflicting = [...modulesFor(profile), { name: 'NAV2-Control', category: 'Control', state: 'running', parameters: [] }]
|
||||||
|
assert.match(inspectAgentPractice(profile.id, conflicting, 'remote').blockers.join(), /NAV2-Control/)
|
||||||
|
assert.equal(inspectAgentPractice(profile.id, [...modulesFor(profile), { name: 'NAV2-Camera', category: 'Sensing', state: 'running', parameters: [] }], 'remote').canPrepare, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('readiness requires observed sensor, odometry, map data and a connected TF chain', () => {
|
||||||
|
assert.equal(verifyAgentPractice(profile.id, probeFor(profile)).ready, true)
|
||||||
|
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [] })).ready, false)
|
||||||
|
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { topics: profile.topics.map(name => ({ name, publishers: 1, samples: 0 })) })).ready, false)
|
||||||
|
assert.equal(verifyAgentPractice(profile.id, probeFor(profile, { tfEdges: [{ parent: 'map', child: 'odom' }, { parent: 'odom', child: 'base_link' }] })).ready, false)
|
||||||
|
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), status: 'error' }).ready, false)
|
||||||
|
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), truncated: true }).ready, false)
|
||||||
|
assert.equal(verifyAgentPractice(profile.id, { ...probeFor(profile), data: 'null' }).ready, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('prepares only missing starts, in dependency order, and verifies in the SLAM container', async () => {
|
||||||
|
const s = scenario()
|
||||||
|
s.modules[0].state = 'running'
|
||||||
|
let observed
|
||||||
|
s.options.probe = async (module, topics) => { observed = { module: module.name, topics }; return probeFor(profile) }
|
||||||
|
const result = await prepareAgentPractice(s.options)
|
||||||
|
assert.equal(result.ready, true)
|
||||||
|
assert.deepEqual(s.starts, [...profile.modules].slice(1))
|
||||||
|
assert.equal(observed.module, 'NAV2-SLAM')
|
||||||
|
assert.ok(observed.topics.includes('/tf_static'))
|
||||||
|
assert.equal(result.steps[0].status, 'already-running')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a failed start stops subsequent starts and does not issue rollback stop commands', async () => {
|
||||||
|
const s = scenario()
|
||||||
|
s.options.start = async module => { s.starts.push(module.name); if (s.starts.length === 2) return false; s.modules[0].state = 'running'; return true }
|
||||||
|
const result = await prepareAgentPractice(s.options)
|
||||||
|
assert.equal(result.ready, false)
|
||||||
|
assert.deepEqual(s.starts, [...profile.modules].slice(0, 2))
|
||||||
|
assert.match(result.failures[0], /자동 종료하지/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not start modules that became stopped after approval', async () => {
|
||||||
|
const s = scenario({ approvedStarts: ['NAV2-SLAM'] })
|
||||||
|
await assert.rejects(prepareAgentPractice(s.options), /승인 이후/)
|
||||||
|
assert.equal(s.starts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not start with changed parameters after approval', async () => {
|
||||||
|
const s = scenario()
|
||||||
|
s.options.approvedParameters = Object.fromEntries(s.modules.map(module => [module.name, [...module.parameters]]))
|
||||||
|
s.modules[0].parameters.push('custom_speed:=9')
|
||||||
|
await assert.rejects(prepareAgentPractice(s.options), /실행 파라미터가 바뀌/)
|
||||||
|
assert.equal(s.starts.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('cancellation during a start prevents any subsequent module start', async () => {
|
||||||
|
const s = scenario()
|
||||||
|
s.options.start = async module => { s.starts.push(module.name); s.controller.abort(); return true }
|
||||||
|
await assert.rejects(prepareAgentPractice(s.options), { name: 'AbortError' })
|
||||||
|
assert.equal(s.starts.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('verification retries are bounded and never report ready for publisher-only data', async () => {
|
||||||
|
const s = scenario()
|
||||||
|
let probes = 0
|
||||||
|
s.options.probe = async () => { probes++; return probeFor(profile, { topics: [] }) }
|
||||||
|
const result = await prepareAgentPractice(s.options)
|
||||||
|
assert.equal(result.ready, false)
|
||||||
|
assert.equal(probes, 3)
|
||||||
|
assert.equal(result.failures.length, 4)
|
||||||
|
assert.ok(result.failures.some(message => message.includes('센서 프레임')))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the common confirmation and apply-before-build guard are connected in the workspace', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
|
||||||
|
assert.match(source, /<ConfirmStatusModal[\s\S]*?title="SLAM 실습 준비"/)
|
||||||
|
assert.match(source, /requiresAppliedChanges && message.applyState !== 'applied'/)
|
||||||
|
assert.match(source, /agent\.applyFiles\(/)
|
||||||
|
assert.match(source, /getDirtyModuleFiles\(change.moduleName\)/)
|
||||||
|
assert.match(source, /background: true, freshSlam: true/)
|
||||||
|
assert.match(source, /onResult: result =>/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
function compile(source, globals = {}) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports, DOMException, Error, ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const evidenceApi = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'))
|
||||||
|
const context = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||||
|
require: name => { assert.equal(name, './agentEvidence'); return evidenceApi }
|
||||||
|
})
|
||||||
|
const conversation = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentConversationContext.ts'), 'utf8'))
|
||||||
|
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const hook = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'useAiAgent')
|
||||||
|
const functions = hook.body.statements.filter(node => ts.isVariableStatement(node) && ['requestPlan', 'stop'].includes(node.declarationList.declarations[0].name.getText(source)))
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const response = (toolRequests = []) => ({ provider: 'QWEN', model: 'fixture', plan: { summary: '분석 완료', explanation: '최종 설명', requestedModules: [], requestedLogs: [], changes: [], commands: [], createModule: null, actionPlan: [], toolRequests } })
|
||||||
|
const deferred = () => { let resolve; let reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no }); return { promise, resolve, reject } }
|
||||||
|
const tick = () => new Promise(resolve => setImmediate(resolve))
|
||||||
|
const result = (extra = {}) => ({ id: 'tool', name: 'files.read', status: 'ok', target: 'fixture', observedAt: new Date().toISOString(), data: '{"exists":true,"complete":true,"content":"PRIVATE_SOURCE"}', truncated: false, ...extra })
|
||||||
|
|
||||||
|
function harness(requestApi) {
|
||||||
|
const session = {}
|
||||||
|
let now = 1000
|
||||||
|
class Clock extends Date { static now() { return now } }
|
||||||
|
const state = { pending: false, activity: '', steps: [], messages: [], saved: [], notices: 0, requestStartedAt: null }
|
||||||
|
const messageStateRef = { current: { session, messages: [] } }
|
||||||
|
const selectedModel = { provider: 'QWEN', model: 'fixture', imagesEnabled: true }
|
||||||
|
const api = compile(functions.map(node => `export ${node.getText(source)}`).join('\n'), {
|
||||||
|
...context, ...conversation, session, messageStateRef, AbortController, FormData,
|
||||||
|
Date: Clock,
|
||||||
|
courseId: 'course', weekNumber: 3, pending: false, historyLoading: false, statusLoading: false,
|
||||||
|
selectedModel,
|
||||||
|
status: { enabled: true, selectionSupported: true }, abortControllerRef: { current: null },
|
||||||
|
setPending: value => { state.pending = value },
|
||||||
|
setRequestStartedAt: value => { state.requestStartedAt = value },
|
||||||
|
setActivity: value => { state.activity = value },
|
||||||
|
setProgressSteps: value => { state.steps = typeof value === 'function' ? value(state.steps) : value },
|
||||||
|
updateMessages: update => { state.messages = update(state.messages); messageStateRef.current.messages = state.messages; state.saved.push(plain(state.messages)) },
|
||||||
|
onResponseRef: { current: () => { state.notices++ } }, requestApi
|
||||||
|
})
|
||||||
|
return { ...api, state, selectedModel, messageStateRef, advance: ms => { now += ms } }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('shows and persists the question, pending state and timer before a slow module snapshot finishes', async () => {
|
||||||
|
const snapshot = deferred(), completion = deferred()
|
||||||
|
const bodies = []
|
||||||
|
let loads = 0
|
||||||
|
const h = harness((_, request) => { bodies.push(JSON.parse(request.body)); return completion.promise })
|
||||||
|
const running = h.requestPlan('원격 차량 속도 제한 위치', [], [], {
|
||||||
|
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10', executeTool: async () => result(),
|
||||||
|
loadInitialContext: signal => { loads++; assert.equal(signal.aborted, false); return snapshot.promise }
|
||||||
|
})
|
||||||
|
assert.equal(h.state.messages[0].content, '원격 차량 속도 제한 위치')
|
||||||
|
assert.equal(h.state.saved[0][0].role, 'user')
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.requestStartedAt, 1000)
|
||||||
|
assert.equal(h.state.activity, '모듈 상태 조회 중')
|
||||||
|
assert.equal(bodies.length, 0)
|
||||||
|
await h.requestPlan('중복 전송', [], [], { loadInitialContext: () => { loads++; return snapshot.promise } })
|
||||||
|
assert.equal(loads, 1)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
h.advance(5000)
|
||||||
|
h.selectedModel.provider = 'OPENAI'
|
||||||
|
snapshot.resolve({ modules: [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], runtimeLogs: [] })
|
||||||
|
await tick()
|
||||||
|
assert.equal(bodies.length, 1)
|
||||||
|
assert.equal(bodies[0].provider, 'QWEN')
|
||||||
|
assert.equal(bodies[0].modules[0].state, 'running')
|
||||||
|
assert.deepEqual(bodies[0].modules[0].parameters, ['sim_mode:=false'])
|
||||||
|
h.advance(2000)
|
||||||
|
completion.resolve(response())
|
||||||
|
await running
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 7000)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const reject of [false, true]) test(`cancels during initial module lookup and ignores its late ${reject ? 'failure' : 'result'} without affecting a new request`, async () => {
|
||||||
|
const old = deferred(), next = deferred()
|
||||||
|
let calls = 0, signal
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const first = h.requestPlan('첫 질문', [], [], { loadInitialContext: current => { signal = current; return old.promise } })
|
||||||
|
h.stop()
|
||||||
|
assert.equal(signal.aborted, true)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
const second = h.requestPlan('다음 질문', [], [], { loadInitialContext: () => next.promise })
|
||||||
|
if (reject) old.reject(Error('늦게 도착한 SSH 오류'))
|
||||||
|
else old.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await first
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.activity, '모듈 상태 조회 중')
|
||||||
|
assert.equal(h.state.messages.length, 2)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
next.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await second
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
assert.equal(h.state.messages.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('initial snapshot errors preserve the question and show a timed error without calling the model', async () => {
|
||||||
|
const snapshot = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const running = h.requestPlan('질문', [], [], { loadInitialContext: () => snapshot.promise })
|
||||||
|
h.advance(3000)
|
||||||
|
snapshot.reject(Error('원격 모듈 상태 조회 실패'))
|
||||||
|
await running
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.messages[0].content, '질문')
|
||||||
|
assert.equal(h.state.messages[1].content, '원격 모듈 상태 조회 실패')
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 3000)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
assert.equal(h.state.activity, '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope changes during initial snapshot loading suppress later API calls and errors', async () => {
|
||||||
|
const snapshot = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => { calls++; return response() })
|
||||||
|
const running = h.requestPlan('이전 강좌 질문', [], [], { loadInitialContext: () => snapshot.promise })
|
||||||
|
h.messageStateRef.current.session = {}
|
||||||
|
snapshot.resolve({ modules: [], runtimeLogs: [] })
|
||||||
|
await running
|
||||||
|
assert.equal(calls, 0)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('publishes completed stages immediately without persisting progress or adding model calls', async () => {
|
||||||
|
const first = deferred(), tool = deferred(), final = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(() => (++calls === 1 ? first.promise : final.promise))
|
||||||
|
const running = h.requestPlan('카메라 FPS 확인', [{ name: 'Camera', files: [] }], [], { executeTool: () => tool.promise })
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.requestStartedAt, 1000)
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
first.resolve(response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config/camera.yaml' } }]))
|
||||||
|
await tick()
|
||||||
|
assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis'])
|
||||||
|
assert.match(h.state.activity, /files.read 조회 중/)
|
||||||
|
tool.resolve(result())
|
||||||
|
await tick()
|
||||||
|
assert.deepEqual(plain(h.state.steps).map(step => step.kind), ['analysis', 'tool'])
|
||||||
|
assert.equal(h.state.steps[1].path, 'config/camera.yaml')
|
||||||
|
assert.equal(JSON.stringify(h.state.steps).includes('PRIVATE_SOURCE'), false)
|
||||||
|
assert.equal(h.state.messages.length, 1)
|
||||||
|
assert.equal(h.state.saved.flat().some(message => message.progressSteps), false)
|
||||||
|
assert.equal(calls, 2)
|
||||||
|
h.advance(20_900)
|
||||||
|
final.resolve(response())
|
||||||
|
await running
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.activity, '')
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
assert.equal(h.state.messages.length, 2)
|
||||||
|
assert.equal(h.state.messages[1].plan.summary, '분석 완료')
|
||||||
|
assert.equal(h.state.messages[1].durationMs, 20_900)
|
||||||
|
assert.equal(h.state.saved.at(-1)[1].durationMs, 20_900)
|
||||||
|
assert.equal(h.state.notices, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('image uploads and every lookup round retain the original provider without uploading the image twice', async () => {
|
||||||
|
const bodies = []
|
||||||
|
let uploads = 0
|
||||||
|
const h = harness(async (path, request) => {
|
||||||
|
if (path.endsWith('/attachments/images')) {
|
||||||
|
uploads++
|
||||||
|
assert.equal(request.body.get('provider'), 'QWEN')
|
||||||
|
assert.equal(request.body.get('courseId'), 'course')
|
||||||
|
assert.equal(request.body.get('week'), '3')
|
||||||
|
assert.equal(request.body.get('file').name, 'screen.png')
|
||||||
|
return { token: 'image-reference', name: 'screen.png', url: 'https://images.test/screen.png' }
|
||||||
|
}
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
h.selectedModel.provider = 'OPENAI'
|
||||||
|
return bodies.length === 1
|
||||||
|
? { ...response(), plan: { ...response().plan, requestedModules: ['Control'] } }
|
||||||
|
: response()
|
||||||
|
})
|
||||||
|
await h.requestPlan('이미지 확인', [{ name: 'Control', files: [] }], [], {
|
||||||
|
attachments: [{ kind: 'image', name: 'screen.png', file: new Blob(['fixture'], { type: 'image/png' }) }],
|
||||||
|
loadRequestedContext: async () => ({ modules: [{ name: 'Control', files: [{ path: 'config/control.yaml', content: 'speed: 0.3' }] }], runtimeLogs: [] })
|
||||||
|
})
|
||||||
|
assert.equal(uploads, 1)
|
||||||
|
assert.equal(bodies.length, 2)
|
||||||
|
assert.ok(bodies.every(body => body.provider === 'QWEN'))
|
||||||
|
assert.ok(bodies.every(body => body.imageTokens[0] === 'image-reference'))
|
||||||
|
assert.equal(h.state.messages.at(-1).model, 'fixture')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote target and observed source ranges reach each provider request including finalization', async () => {
|
||||||
|
const bodies = []
|
||||||
|
const tool = { id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config/car.yaml', startLine: 20, lineCount: 1 } }
|
||||||
|
const h = harness(async (_, request) => {
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
return bodies.length < 3 ? response([tool]) : response()
|
||||||
|
})
|
||||||
|
await h.requestPlan('속도 제한 코드 위치', [{ name: 'Control', state: 'running', parameters: ['sim_mode:=false'], files: [] }], [], {
|
||||||
|
environment: 'remote', workspaceTarget: 'ssh:100.64.0.10',
|
||||||
|
executeTool: async input => result({ id: input.id, moduleName: 'Control', target: 'ssh:100.64.0.10', truncated: true,
|
||||||
|
data: JSON.stringify({ path: 'config/car.yaml', exists: true, hash: 'a'.repeat(64), complete: false, content: 'max_speed: 0.4', startLine: 20, endLine: 20, contentTruncated: false, totalLines: 100 }) })
|
||||||
|
})
|
||||||
|
assert.equal(bodies.length, 3)
|
||||||
|
for (const body of bodies) {
|
||||||
|
assert.equal(body.environment, 'remote')
|
||||||
|
assert.equal(body.workspaceTarget, 'ssh:100.64.0.10')
|
||||||
|
assert.deepEqual(body.modules[0].parameters, ['sim_mode:=false'])
|
||||||
|
}
|
||||||
|
assert.equal(JSON.parse(bodies[1].toolResults[0].data).content, 'max_speed: 0.4')
|
||||||
|
assert.equal(JSON.parse(bodies[2].toolResults[0].data).endLine, 20)
|
||||||
|
assert.match(bodies[2].request, /자료 조회는 종료되었습니다/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends only prior same-workspace context and keeps it stable throughout tool lookups', async () => {
|
||||||
|
const bodies = []
|
||||||
|
const h = harness(async (_, request) => {
|
||||||
|
bodies.push(JSON.parse(request.body))
|
||||||
|
const reply = bodies.length === 2 ? response([{ id: 'read', name: 'files.read', moduleName: 'Control', args: { path: 'config.yaml' } }]) : response()
|
||||||
|
return { ...reply, plan: { ...reply.plan, conversationSummary: 'Control의 config.yaml 설명을 진행 중입니다.' } }
|
||||||
|
})
|
||||||
|
const options = { environment: 'local', workspaceTarget: 'local-host', workspaceRoot: '/workspace/a', executeTool: async () => result() }
|
||||||
|
await h.requestPlan('Control 설정 위치', [], [], options)
|
||||||
|
assert.equal(bodies[0].conversationContext, undefined)
|
||||||
|
await h.requestPlan('그 파일 설명해줘', [], [], options)
|
||||||
|
assert.equal(bodies.length, 3)
|
||||||
|
assert.equal(bodies[1].conversationContext.recentMessages[0].content, 'Control 설정 위치')
|
||||||
|
assert.equal(bodies[1].conversationContext.summary, 'Control의 config.yaml 설명을 진행 중입니다.')
|
||||||
|
assert.equal(h.state.saved[1][1].plan.conversationSummary, 'Control의 config.yaml 설명을 진행 중입니다.')
|
||||||
|
assert.equal(JSON.stringify(bodies[1].conversationContext).includes('그 파일 설명해줘'), false)
|
||||||
|
assert.deepEqual(bodies[1].conversationContext, bodies[2].conversationContext)
|
||||||
|
assert.equal(bodies[1].workspaceRoot, undefined)
|
||||||
|
await h.requestPlan('다른 원격 질문', [], [], { ...options, environment: 'remote', workspaceTarget: 'ssh:device' })
|
||||||
|
assert.equal(bodies.at(-1).conversationContext, undefined)
|
||||||
|
await h.requestPlan('다른 로컬 폴더', [], [], { ...options, workspaceRoot: '/workspace/b' })
|
||||||
|
assert.equal(bodies.at(-1).conversationContext, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('errors replace temporary progress with the existing error reply', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(async () => {
|
||||||
|
if (++calls === 1) return response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }])
|
||||||
|
throw Error('서버 오류')
|
||||||
|
})
|
||||||
|
await h.requestPlan('확인', [{ name: 'Camera', files: [] }], [], { executeTool: async () => result({ status: 'error' }) })
|
||||||
|
assert.equal(h.state.messages.at(-1).content, '서버 오류')
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('stopping clears progress and a late response cannot overwrite a newer request', async () => {
|
||||||
|
const old = deferred(), next = deferred()
|
||||||
|
let calls = 0
|
||||||
|
const h = harness(() => (++calls === 1 ? old.promise : next.promise))
|
||||||
|
const first = h.requestPlan('첫 질문', [], [])
|
||||||
|
h.stop()
|
||||||
|
assert.equal(h.state.requestStartedAt, null)
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.activity, '')
|
||||||
|
const second = h.requestPlan('다음 질문', [], [])
|
||||||
|
old.resolve(response())
|
||||||
|
await first
|
||||||
|
assert.equal(h.state.pending, true)
|
||||||
|
assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 0)
|
||||||
|
next.resolve(response())
|
||||||
|
await second
|
||||||
|
assert.equal(h.state.pending, false)
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.messages.filter(message => message.role === 'assistant').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('scope changes suppress late progress callbacks', async () => {
|
||||||
|
const tool = deferred()
|
||||||
|
const h = harness(async () => response([{ id: 'read', name: 'files.read', moduleName: 'Camera', args: { path: 'config.yaml' } }]))
|
||||||
|
const running = h.requestPlan('확인', [], [], { executeTool: () => tool.promise })
|
||||||
|
await tick()
|
||||||
|
h.messageStateRef.current.session = {}
|
||||||
|
const previous = h.state.steps.length
|
||||||
|
h.stop()
|
||||||
|
tool.resolve(result())
|
||||||
|
await running
|
||||||
|
assert.equal(previous, 1)
|
||||||
|
assert.equal(h.state.steps.length, 0)
|
||||||
|
assert.equal(h.state.notices, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('source context stages report missing and limited modules and empty logs honestly', async () => {
|
||||||
|
const steps = []
|
||||||
|
let calls = 0
|
||||||
|
await context.resolveAgentContext({
|
||||||
|
signal: new AbortController().signal,
|
||||||
|
initialContext: { modules: [{ name: 'Missing', files: [] }, { name: 'Limited', files: [] }], runtimeLogs: [] },
|
||||||
|
onStep: step => steps.push(step),
|
||||||
|
requestPlan: async () => ++calls === 1 ? { ...response(), plan: { ...response().plan, requestedModules: ['Missing', 'Limited'], requestedLogs: ['Missing'] } } : response(),
|
||||||
|
loadRequestedContext: async () => ({ modules: [{ name: 'Missing', files: [], fileContext: { status: 'uninstalled' } }, { name: 'Limited', files: [{ path: 'x', content: 'PRIVATE_SOURCE' }], fileContext: { status: 'limited' } }], runtimeLogs: [] })
|
||||||
|
})
|
||||||
|
assert.deepEqual(plain(steps).map(step => [step.kind, step.status]), [['analysis', 'ok'], ['files', 'missing'], ['files', 'partial'], ['logs', 'empty'], ['analysis', 'ok']])
|
||||||
|
assert.equal(JSON.stringify(steps).includes('PRIVATE_SOURCE'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tool stages distinguish missing files, partial reads and counts without exposing raw output', async () => {
|
||||||
|
const steps = []
|
||||||
|
const tools = [
|
||||||
|
{ id: 'missing', name: 'files.read', moduleName: 'Camera', args: { path: 'missing.yaml' } },
|
||||||
|
{ id: 'partial', name: 'files.read', moduleName: 'Camera', args: { path: 'large.py' } },
|
||||||
|
{ id: 'list', name: 'files.list', moduleName: 'Camera' },
|
||||||
|
{ id: 'search', name: 'files.search', moduleName: 'Camera', args: { query: 'fps' } }
|
||||||
|
]
|
||||||
|
let calls = 0
|
||||||
|
await context.resolveAgentContext({
|
||||||
|
signal: new AbortController().signal, initialContext: { modules: [], runtimeLogs: [] }, onStep: step => steps.push(step),
|
||||||
|
requestPlan: async () => response(++calls === 1 ? tools : []),
|
||||||
|
executeTool: async tool => result({ data: JSON.stringify(tool.args?.path === 'missing.yaml' ? { exists: false } : tool.args?.path === 'large.py' ? { complete: false } : tool.name === 'files.list' ? { paths: ['a', 'b'] } : { matches: [] }) })
|
||||||
|
})
|
||||||
|
assert.deepEqual(plain(steps.filter(step => step.kind === 'tool')).map(step => [step.status, step.count]), [['missing', undefined], ['partial', undefined], ['ok', 2], ['ok', 0]])
|
||||||
|
})
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const contextApi = {}
|
||||||
|
const evidenceApi = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentEvidence.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: evidenceApi })
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentContext.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: contextApi, DOMException, require: name => { assert.equal(name, './agentEvidence'); return evidenceApi } })
|
||||||
|
|
||||||
|
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
let payloadExpression
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('imageTokens')) {
|
||||||
|
payloadExpression = node.getText(source)
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
assert.ok(payloadExpression, 'Agent request JSON serialization must exist')
|
||||||
|
|
||||||
|
test('user messages preserve only typed text while attachments remain separate metadata', () => {
|
||||||
|
let initializer
|
||||||
|
function find(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && node.name.getText(source) === 'userMessage') initializer = node.initializer.getText(source)
|
||||||
|
ts.forEachChild(node, find)
|
||||||
|
}
|
||||||
|
find(source)
|
||||||
|
assert.ok(initializer)
|
||||||
|
for (const request of ['이 이미지에서 슬램에 필요한 모듈만 골라봐', '첫 줄\n첨부 파일: 직접 입력한 내용']) {
|
||||||
|
const message = vm.runInNewContext(`(${initializer})`, { request, conversationScope: undefined, options: { attachments: [{ name: '스크린샷.png' }] } })
|
||||||
|
assert.equal(message.content, request)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function serialize(attachments, finalize = false, request = '설정 위치 알려줘', imageTokens = [], provider = 'QWEN') {
|
||||||
|
return JSON.parse(vm.runInNewContext(payloadExpression, {
|
||||||
|
courseId: 'course-1', weekNumber: 3, request, options: { attachments }, finalize,
|
||||||
|
textAttachments: attachments ?? [], imageTokens, conversationContext: undefined,
|
||||||
|
selection: provider ? { provider } : {},
|
||||||
|
agentPlanRequestText: contextApi.agentPlanRequestText,
|
||||||
|
context: { modules: [{ name: 'Control', files: [] }], runtimeLogs: [] }
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
test('sends image reference tokens without image bytes or public URLs on every context round', () => {
|
||||||
|
const body = serialize(undefined, true, '이미지 분석', ['signed-image-token'])
|
||||||
|
assert.deepEqual(body.imageTokens, ['signed-image-token'])
|
||||||
|
assert.equal(Object.hasOwn(body, 'attachments'), false)
|
||||||
|
assert.equal(JSON.stringify(body).includes('base64'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const attachments of [undefined, []]) {
|
||||||
|
test(`omits attachments when ${attachments ? 'empty' : 'undefined'}`, () => {
|
||||||
|
const body = serialize(attachments)
|
||||||
|
assert.equal(Object.hasOwn(body, 'attachments'), false)
|
||||||
|
assert.equal(body.request, '설정 위치 알려줘')
|
||||||
|
assert.equal(body.modules[0].name, 'Control')
|
||||||
|
assert.equal(body.week, 3)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('preserves actual attachment contents rather than silently dropping them', () => {
|
||||||
|
const attachments = [{ name: 'config.yaml', content: 'steering: ackermann' }]
|
||||||
|
assert.deepEqual(serialize(attachments).attachments, attachments)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finalization instruction reaches the API without sending unsupported DTO fields', () => {
|
||||||
|
const body = serialize(undefined, true)
|
||||||
|
assert.match(body.request, /자료 조회는 종료/)
|
||||||
|
assert.match(body.request, /설정 위치 알려줘/)
|
||||||
|
assert.match(body.request, /최종 답변/)
|
||||||
|
assert.equal(Object.hasOwn(body, 'finalize'), false)
|
||||||
|
assert.deepEqual(Object.keys(body).sort(), ['courseId', 'modules', 'provider', 'request', 'runtimeLogs', 'week'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('finalization keeps the request within the backend 4000-character limit', () => {
|
||||||
|
const body = serialize(undefined, true, 'x'.repeat(4000))
|
||||||
|
assert.equal(body.request.length, 4000)
|
||||||
|
assert.match(body.request, /원래 학생 질문/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('sends only a provider selection rather than student-supplied models or keys', () => {
|
||||||
|
for (const finalize of [false, true]) {
|
||||||
|
const body = serialize(undefined, finalize, '확인', [], 'OPENAI')
|
||||||
|
assert.equal(body.provider, 'OPENAI')
|
||||||
|
assert.equal(Object.hasOwn(body, 'model'), false)
|
||||||
|
assert.equal(Object.hasOwn(body, 'apiKey'), false)
|
||||||
|
assert.equal(Object.hasOwn(body, 'endpointUrl'), false)
|
||||||
|
}
|
||||||
|
assert.equal(Object.hasOwn(serialize(undefined, false, '확인', [], null), 'provider'), false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
function load(file, overrides = {}, globals = {}) {
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(require.resolve(file), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const reveal = load('../src/renderer/src/components/agentResponseReveal.ts')
|
||||||
|
const { createAgentRevealDocument, agentRevealFrame, AGENT_REVEAL_INTERVAL } = reveal
|
||||||
|
const paragraph = 'MK-mini는 바퀴가 4개라도 전륜 조향 모델을 사용합니다.'
|
||||||
|
const content = `${paragraph}\n\n1. 물리적 바퀴 수와 운동학 모델은 다릅니다.\n - 앞바퀴로 조향합니다.\n2. 제어 설정을 확인합니다.\n\n마지막 설명입니다.`
|
||||||
|
|
||||||
|
test('reveals paragraphs and complete list items at fixed intervals without splitting Korean sentences', () => {
|
||||||
|
const document = createAgentRevealDocument(content)
|
||||||
|
const first = agentRevealFrame(document, 1000, 1000)
|
||||||
|
assert.equal(first.content, paragraph)
|
||||||
|
assert.equal(first.complete, false)
|
||||||
|
const second = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL)
|
||||||
|
assert.match(second.content, /1\. 물리적/)
|
||||||
|
assert.match(second.content, /앞바퀴로 조향합니다\./)
|
||||||
|
assert.doesNotMatch(second.content, /2\. 제어/)
|
||||||
|
const third = agentRevealFrame(document, 1000, 1000 + AGENT_REVEAL_INTERVAL * 2)
|
||||||
|
assert.match(third.content, /2\. 제어 설정을 확인합니다\./)
|
||||||
|
assert.doesNotMatch(third.content, /마지막 설명/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps fenced code and GFM tables intact', () => {
|
||||||
|
const text = '설명입니다.\n\n```python\nprint("시작")\nprint("종료")\n```\n\n| 항목 | 값 |\n| --- | --- |\n| 조향 | 전륜 |'
|
||||||
|
const document = createAgentRevealDocument(text)
|
||||||
|
assert.equal(document.boundaries.length, 3)
|
||||||
|
const code = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL).content
|
||||||
|
assert.equal((code.match(/```/g) ?? []).length, 2)
|
||||||
|
assert.match(code, /print\("종료"\)/)
|
||||||
|
assert.doesNotMatch(code, /\| 항목/)
|
||||||
|
assert.equal(agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL * 2).content, text)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reference link definitions are available before their position in the answer is revealed', () => {
|
||||||
|
const document = createAgentRevealDocument('[설명][guide]\n\n다음 문단\n\n[guide]: https://example.com/guide')
|
||||||
|
const first = agentRevealFrame(document, 0, 0)
|
||||||
|
assert.match(first.content, /\[guide\]: https:\/\/example.com\/guide/)
|
||||||
|
assert.doesNotMatch(first.content, /다음 문단/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('history, elapsed answers and reduced-motion users see the complete response immediately', () => {
|
||||||
|
const document = createAgentRevealDocument(content)
|
||||||
|
for (const frame of [agentRevealFrame(document, undefined, 0), agentRevealFrame(document, 0, 60_000), agentRevealFrame(document, 0, 0, true)]) {
|
||||||
|
assert.equal(frame.content, content)
|
||||||
|
assert.equal(frame.complete, true)
|
||||||
|
assert.equal(frame.fadeFrom, undefined)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('long answers are grouped into at most twenty steps and finish within five seconds', () => {
|
||||||
|
const text = Array.from({ length: 100 }, (_, index) => `문단 ${index}입니다.`).join('\n\n')
|
||||||
|
const document = createAgentRevealDocument(text)
|
||||||
|
assert.ok(document.boundaries.length <= 20)
|
||||||
|
assert.ok(agentRevealFrame(document, 0, 0).duration < 5000)
|
||||||
|
assert.equal(agentRevealFrame(document, 0, 5000).content, text)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only the newly appended paragraph gets the fade class', () => {
|
||||||
|
const { MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||||
|
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
|
||||||
|
})
|
||||||
|
const document = createAgentRevealDocument('첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n세 번째 문단입니다.')
|
||||||
|
const frame = agentRevealFrame(document, 0, AGENT_REVEAL_INTERVAL)
|
||||||
|
const html = renderToStaticMarkup(React.createElement(MarkdownMessage, { content: frame.content, fadeFrom: frame.fadeFrom, dark: true }))
|
||||||
|
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
|
||||||
|
assert.match(html, /첫 번째 문단/)
|
||||||
|
assert.match(html, /두 번째 문단/)
|
||||||
|
assert.doesNotMatch(html, /세 번째 문단/)
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderResponse(message, now, reducedMotion = false, extra = {}) {
|
||||||
|
const MarkdownMessage = ({ content }) => React.createElement('p', null, content)
|
||||||
|
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
|
||||||
|
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage: MarkdownMessage }, './agentResponseReveal': reveal,
|
||||||
|
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
|
||||||
|
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: reducedMotion }) } })
|
||||||
|
return renderToStaticMarkup(ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra }))
|
||||||
|
}
|
||||||
|
|
||||||
|
test('internal cumulative memory is not rendered in the response bubble', () => {
|
||||||
|
const html = renderResponse({ content: '화면 답변', plan: { summary: '화면 요약', explanation: '화면 설명', conversationSummary: 'HIDDEN_CONVERSATION_MEMORY' } }, 0)
|
||||||
|
assert.match(html, /화면 설명/)
|
||||||
|
assert.doesNotMatch(html, /HIDDEN_CONVERSATION_MEMORY/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('managed agent responses display A-Copilot without an internal provider ID or duplicate model name', () => {
|
||||||
|
const html = renderResponse({ content: '분석 결과', provider: 'A_COPILOT', model: 'A-Copilot' }, 0)
|
||||||
|
assert.ok(html.includes('A-Copilot'))
|
||||||
|
assert.ok(!html.includes('A_COPILOT'))
|
||||||
|
assert.ok(!html.includes('A-Copilot · A-Copilot'))
|
||||||
|
assert.equal((html.match(/>A-Copilot<\/p>/g) ?? []).length, 1)
|
||||||
|
assert.ok(renderResponse({ content: '결과', provider: 'QWEN', model: 'qwen3.8-max' }, 0).includes('QWEN · qwen3.8-max'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('approval controls wait for the explanation; restored history does not replay', () => {
|
||||||
|
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '조향 설명', explanation: content }, provider: 'QWEN', model: 'test' }
|
||||||
|
const first = renderResponse(message, 1000)
|
||||||
|
assert.match(first, /조향 설명/)
|
||||||
|
assert.match(first, /aria-busy="true"/)
|
||||||
|
assert.doesNotMatch(first, /승인|QWEN/)
|
||||||
|
for (const html of [renderResponse(message, 10_000), renderResponse({ ...message, revealStartedAt: undefined }, 1000), renderResponse(message, 1000, true)]) {
|
||||||
|
assert.match(html, /aria-busy="false"/)
|
||||||
|
assert.match(html, /마지막 설명/)
|
||||||
|
assert.match(html, /승인/)
|
||||||
|
assert.match(html, /QWEN/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reveal timing is not included in conversation persistence API payloads', () => {
|
||||||
|
const source = ts.createSourceFile('useAiAgent.ts', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useAiAgent.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
let expression
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isCallExpression(node) && node.expression.getText(source) === 'JSON.stringify' && node.arguments[0]?.getText(source).includes('messages: batch')) expression = node.getText(source)
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
assert.ok(expression)
|
||||||
|
const batch = [{ id: '1', role: 'assistant', content: '전체 답변', revealStartedAt: 1000 }]
|
||||||
|
const payload = JSON.parse(vm.runInNewContext(expression, { batch, courseId: 'course', weekNumber: 1 }))
|
||||||
|
assert.equal(Object.hasOwn(payload.messages[0], 'revealStartedAt'), false)
|
||||||
|
assert.equal(payload.messages[0].content, '전체 답변')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('animation honors reduced motion in CSS', () => {
|
||||||
|
const css = fs.readFileSync(require.resolve('../src/renderer/src/index.css'), 'utf8')
|
||||||
|
assert.match(css, /@keyframes agent-response-fade/)
|
||||||
|
assert.match(css, /prefers-reduced-motion: reduce[\s\S]*?\.agent-response-fade\s*\{\s*animation: none/)
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return []
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
|
||||||
|
test('agent answers use the shared collapse widget after reveal, without hiding action controls', () => {
|
||||||
|
const MarkdownMessage = () => null
|
||||||
|
const ExpandableMarkdownMessage = () => null
|
||||||
|
let now = 1000
|
||||||
|
const { ProgressiveChatResponse } = load('../src/renderer/src/components/ProgressiveChatResponse.tsx', {
|
||||||
|
'./ChatWidgets': { MarkdownMessage, ExpandableMarkdownMessage }, './agentResponseReveal': reveal,
|
||||||
|
react: { ...React, useMemo: fn => fn(), useState: initial => [typeof initial === 'function' ? initial() : initial, () => {}], useEffect() {} }
|
||||||
|
}, { Date: { now: () => now }, window: { matchMedia: () => ({ matches: false }) } })
|
||||||
|
const message = { role: 'assistant', revealStartedAt: 1000, plan: { summary: '요약', explanation: '상세 내용입니다.\n\n'.repeat(60) } }
|
||||||
|
const render = (extra = {}) => ProgressiveChatResponse({ message, dark: true, children: React.createElement('button', null, '승인'), ...extra })
|
||||||
|
assert.equal(elements(render(), node => node.type === ExpandableMarkdownMessage).length, 0)
|
||||||
|
now = 10_000
|
||||||
|
const fresh = render()
|
||||||
|
assert.equal(elements(fresh, node => node.type === ExpandableMarkdownMessage)[0].props.defaultCollapsed, false)
|
||||||
|
assert.equal(elements(fresh, node => node.type === 'button').length, 1)
|
||||||
|
const history = render({ message: { ...message, revealStartedAt: undefined }, collapseOnSignal: true, collapseSignal: 4 })
|
||||||
|
const widget = elements(history, node => node.type === ExpandableMarkdownMessage)[0]
|
||||||
|
assert.equal(widget.props.defaultCollapsed, true)
|
||||||
|
assert.equal(widget.props.collapseOnSignal, true)
|
||||||
|
assert.equal(widget.props.collapseSignal, 4)
|
||||||
|
assert.equal(elements(history, node => node.type === 'button').length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
test(`shared answer can expand and collapse while preserving full source text (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
let expanded
|
||||||
|
const { ExpandableMarkdownMessage, MarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||||
|
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
|
||||||
|
react: { ...React, useEffect() {}, useState(initial) {
|
||||||
|
if (expanded === undefined) expanded = initial
|
||||||
|
return [expanded, update => { expanded = typeof update === 'function' ? update(expanded) : update }]
|
||||||
|
} }
|
||||||
|
})
|
||||||
|
const content = '파일과 파라미터에 대한 상세 설명입니다.\n'.repeat(50)
|
||||||
|
const render = () => ExpandableMarkdownMessage({ content, dark, defaultCollapsed: true })
|
||||||
|
let tree = render()
|
||||||
|
let button = elements(tree, node => node.type === 'button')[0]
|
||||||
|
assert.equal(button.props['aria-label'], '자세히 보기')
|
||||||
|
assert.ok(elements(tree, node => node.type === MarkdownMessage)[0].props.content.length < content.length)
|
||||||
|
button.props.onClick()
|
||||||
|
tree = render()
|
||||||
|
button = elements(tree, node => node.type === 'button')[0]
|
||||||
|
assert.equal(button.props['aria-label'], '접기')
|
||||||
|
assert.equal(elements(tree, node => node.type === MarkdownMessage)[0].props.content, content)
|
||||||
|
button.props.onClick()
|
||||||
|
assert.equal(elements(render(), node => node.type === 'button')[0].props['aria-label'], '자세히 보기')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('short answers do not show an unnecessary collapse button', () => {
|
||||||
|
const { ExpandableMarkdownMessage } = load('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||||
|
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {},
|
||||||
|
react: { ...React, useEffect() {}, useState: initial => [initial, () => {}] }
|
||||||
|
})
|
||||||
|
const tree = ExpandableMarkdownMessage({ content: '짧은 답변입니다.', dark: true, defaultCollapsed: true })
|
||||||
|
assert.equal(elements(tree, node => node.type === 'button').length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
test(`model label and copy action share the last row after the operation controls (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const html = renderResponse({ content: '전체 답변', provider: 'QWEN', model: 'qwen-model-name'.repeat(8) }, 1000, false, {
|
||||||
|
dark,
|
||||||
|
footerAction: React.createElement('button', { title: '복사', className: 'copy-action' }, 'Copy')
|
||||||
|
})
|
||||||
|
assert.match(html, /class="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2"><p[^>]*class="min-w-0 flex-1 truncate[^>]*>QWEN[^<]*<\/p><button title="복사"/)
|
||||||
|
assert.ok(html.indexOf('승인') < html.indexOf('QWEN'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('copy footer remains available when the message has no model metadata', () => {
|
||||||
|
const html = renderResponse({ content: '오류 안내' }, 1000, false, {
|
||||||
|
footerAction: React.createElement('button', { title: '복사' }, 'Copy')
|
||||||
|
})
|
||||||
|
assert.match(html, /min-h-6/)
|
||||||
|
assert.match(html, /title="복사"/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reply copy is an inline fixed-size hover button and still copies the full response', async () => {
|
||||||
|
const source = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const declaration = source.statements.find(node => ts.isFunctionDeclaration(node) && node.name?.text === 'MessageCopyButton')
|
||||||
|
let copied = false
|
||||||
|
let clipboardText
|
||||||
|
const { MessageCopyButton } = compileCopy(declaration.getText(source))
|
||||||
|
function compileCopy(text) {
|
||||||
|
const code = ts.transpileModule(`export ${text}`, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
|
||||||
|
} }).outputText
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(code, {
|
||||||
|
exports, require,
|
||||||
|
useState: () => [copied, value => { copied = value }], t: key => key,
|
||||||
|
Check: () => null, Copy: () => null,
|
||||||
|
navigator: { clipboard: { writeText: async text => { clipboardText = text } } },
|
||||||
|
window: { setTimeout() {} }
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const content = '# 요약\n\n전체 답변입니다.'
|
||||||
|
const button = MessageCopyButton({ content, dark: true })
|
||||||
|
assert.match(button.props.className, /opacity-0.*group-hover:opacity-100/)
|
||||||
|
assert.match(button.props.className, /shrink-0 cursor-pointer/)
|
||||||
|
assert.doesNotMatch(button.props.className, /absolute|bottom-|right-/)
|
||||||
|
button.props.onClick()
|
||||||
|
await Promise.resolve()
|
||||||
|
assert.equal(clipboardText, content)
|
||||||
|
assert.equal(MessageCopyButton({ content, dark: true }).props.title, '복사됨')
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const exportsObject = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/agentSessionSync.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: exportsObject, console: { warn() {} } })
|
||||||
|
const { createAgentSessionSync } = exportsObject
|
||||||
|
const message = (id, content = id) => ({ id, role: 'assistant', content })
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const deferred = () => {
|
||||||
|
let resolve
|
||||||
|
const promise = new Promise(done => { resolve = done })
|
||||||
|
return { promise, resolve }
|
||||||
|
}
|
||||||
|
const storage = () => {
|
||||||
|
const data = new Map()
|
||||||
|
return {
|
||||||
|
getItem: key => data.get(key) ?? null,
|
||||||
|
setItem: (key, value) => data.set(key, value),
|
||||||
|
removeItem: key => data.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('saves replies in order and preserves a newer result arriving during an active save', async () => {
|
||||||
|
const gate = deferred()
|
||||||
|
const calls = []
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => {
|
||||||
|
calls.push(plain(batch))
|
||||||
|
if (calls.length === 1) await gate.promise
|
||||||
|
} })
|
||||||
|
sync.enqueue([message('command', 'running')])
|
||||||
|
await Promise.resolve()
|
||||||
|
sync.enqueue([message('command', 'completed'), message('answer')])
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
gate.resolve()
|
||||||
|
await sync.flush()
|
||||||
|
assert.equal(calls.length, 2)
|
||||||
|
assert.deepEqual(calls[1], [message('command', 'completed'), message('answer')])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps a reply cached before network completion and after failed persistence', async () => {
|
||||||
|
const disk = storage()
|
||||||
|
const sync = createAgentSessionSync({ storage: disk, storageKey: 'student-a', save: async () => { throw Error('offline') } })
|
||||||
|
sync.enqueue([message('answer')])
|
||||||
|
assert.deepEqual(JSON.parse(disk.getItem('student-a')), [message('answer')])
|
||||||
|
await sync.flush()
|
||||||
|
assert.ok(sync.getError())
|
||||||
|
const batches = []
|
||||||
|
const reopened = createAgentSessionSync({ storage: disk, storageKey: 'student-a', save: async batch => { batches.push(plain(batch)) } })
|
||||||
|
assert.deepEqual(plain(reopened.restore([{ id: 'question', role: 'user', content: '?' }])), [
|
||||||
|
{ id: 'question', role: 'user', content: '?' }, message('answer')
|
||||||
|
])
|
||||||
|
await reopened.flush()
|
||||||
|
assert.deepEqual(batches, [[message('answer')]])
|
||||||
|
assert.equal(disk.getItem('student-a'), null)
|
||||||
|
assert.equal(reopened.getError(), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not overwrite a new reply with an older history response', async () => {
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async () => {} })
|
||||||
|
sync.enqueue([message('answer', 'new answer')])
|
||||||
|
await sync.flush()
|
||||||
|
assert.deepEqual(plain(sync.restore([message('answer', 'old answer')])), [message('answer', 'new answer')])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('saves only modified messages even when restored history exceeds 100 messages', async () => {
|
||||||
|
const batches = []
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { batches.push(plain(batch)) } })
|
||||||
|
const history = Array.from({ length: 150 }, (_, index) => message(`message-${index}`))
|
||||||
|
sync.restore(history)
|
||||||
|
sync.enqueue([...history, message('new-answer')])
|
||||||
|
await sync.flush()
|
||||||
|
assert.deepEqual(batches, [[message('new-answer')]])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('chunks a large backlog below the server message-count limit', async () => {
|
||||||
|
const batches = []
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { batches.push(plain(batch)) } })
|
||||||
|
sync.enqueue(Array.from({ length: 205 }, (_, index) => message(`message-${index}`)))
|
||||||
|
await sync.flush()
|
||||||
|
assert.deepEqual(batches.map(batch => batch.length), [100, 100, 5])
|
||||||
|
assert.equal(new Set(batches.flat().map(item => item.id)).size, 205)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('leaving the view does not cancel an accepted write', async () => {
|
||||||
|
const gate = deferred()
|
||||||
|
const disk = storage()
|
||||||
|
let saved = false
|
||||||
|
const sync = createAgentSessionSync({ storage: disk, storageKey: 'test', save: async () => { await gate.promise; saved = true } })
|
||||||
|
const unsubscribe = sync.subscribe(() => {})
|
||||||
|
sync.enqueue([message('answer')])
|
||||||
|
unsubscribe()
|
||||||
|
gate.resolve()
|
||||||
|
await sync.flush()
|
||||||
|
assert.equal(saved, true)
|
||||||
|
assert.equal(disk.getItem('test'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('archives after outstanding writes complete and clears cached replies', async () => {
|
||||||
|
const gate = deferred()
|
||||||
|
const order = []
|
||||||
|
const disk = storage()
|
||||||
|
const sync = createAgentSessionSync({ storage: disk, storageKey: 'test', save: async () => { await gate.promise; order.push('saved') } })
|
||||||
|
sync.enqueue([message('answer')])
|
||||||
|
const clearing = sync.clear(async () => { order.push('archived') })
|
||||||
|
gate.resolve()
|
||||||
|
await clearing
|
||||||
|
assert.deepEqual(order, ['saved', 'archived'])
|
||||||
|
assert.deepEqual(plain(sync.restore([])), [])
|
||||||
|
assert.equal(disk.getItem('test'), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pending replies stay isolated by student and course scope', async () => {
|
||||||
|
const disk = storage()
|
||||||
|
const first = createAgentSessionSync({ storage: disk, storageKey: 'student-a:course-1', save: async () => { throw Error('offline') } })
|
||||||
|
first.enqueue([message('private-answer')])
|
||||||
|
await first.flush()
|
||||||
|
const second = createAgentSessionSync({ storage: disk, storageKey: 'student-b:course-1', save: async () => {} })
|
||||||
|
assert.deepEqual(plain(second.restore([])), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('late command updates cannot recreate a conversation while it is being cleared', async () => {
|
||||||
|
const archive = deferred()
|
||||||
|
const calls = []
|
||||||
|
const sync = createAgentSessionSync({ storageKey: 'test', save: async batch => { calls.push(plain(batch)) } })
|
||||||
|
sync.enqueue([message('answer')])
|
||||||
|
await sync.flush()
|
||||||
|
const clearing = sync.clear(() => archive.promise)
|
||||||
|
sync.enqueue([message('answer', 'late command result')])
|
||||||
|
archive.resolve()
|
||||||
|
await clearing
|
||||||
|
await sync.flush()
|
||||||
|
assert.equal(calls.length, 1)
|
||||||
|
assert.deepEqual(plain(sync.restore([])), [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const os = require('node:os')
|
||||||
|
const { spawnSync } = require('node:child_process')
|
||||||
|
const ts = require('typescript')
|
||||||
|
require.extensions['.ts'] = (module, filename) => module._compile(ts.transpileModule(require('node:fs').readFileSync(filename, 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, filename)
|
||||||
|
const { validateAgentTool, validateAgentOperation, safeAgentPath } = require('../src/shared/agentTools.ts')
|
||||||
|
const { runAgentFileTool, applyAgentFiles, agentFileHash } = require('../src/main/agentFileTools.ts')
|
||||||
|
const { agentRemoteFilesScript } = require('../src/main/agentRemoteFiles.ts')
|
||||||
|
const { inspectAgentRuntime, agentRosProbePython } = require('../src/main/agentRuntimeTools.ts')
|
||||||
|
const { resolveAgentContext } = require('../src/renderer/src/features/workspace/agentContext.ts')
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
const request = (name, args = {}) => ({ id: 'query-1', name, moduleName: 'NAV2-MKMini', args })
|
||||||
|
function remote(input) {
|
||||||
|
const result = spawnSync('bash', ['--noprofile', '--norc', '-s'], { input: agentRemoteFilesScript(input), encoding: 'utf8', maxBuffer: 4_000_000, timeout: 10_000 })
|
||||||
|
if (result.error || result.status !== 0) throw new Error(result.stderr || result.error?.message)
|
||||||
|
return JSON.parse(result.stdout)
|
||||||
|
}
|
||||||
|
async function fixture(t) {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-agent-tools-'))
|
||||||
|
t.after(() => fs.rm(root, { recursive: true, force: true }))
|
||||||
|
await fs.mkdir(path.join(root, 'config'))
|
||||||
|
await fs.writeFile(path.join(root, 'config', 'sensor.yaml'), 'host_ip: 192.168.2.88\n조향: ackermann\n')
|
||||||
|
await fs.writeFile(path.join(root, 'CMakeLists.txt'), 'project(vehicle)\n')
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
test('tool requests reject writes, extra shell arguments, unsafe paths and unbounded observations', () => {
|
||||||
|
for (const name of ['shell', 'files.write', 'network.set', 'ros.publish']) assert.throws(() => validateAgentTool(request(name)))
|
||||||
|
for (const name of ['files.list', 'files.read', 'files.search']) {
|
||||||
|
for (const p of ['../secret', '/etc/passwd', '.env', 'config/.env', 'build/secret.py', 'config/id_rsa', 'config/key.pem', 'config\\x.py', 'a//b']) assert.throws(() => validateAgentTool(request(name, { path: p, ...(name === 'files.search' ? { query: 'ip' } : {}) })))
|
||||||
|
}
|
||||||
|
assert.throws(() => validateAgentTool(request('network.inspect', { command: 'ip addr replace' })))
|
||||||
|
assert.throws(() => validateAgentTool(request('ros.inspect', { topics: ['/scan; reboot'] })))
|
||||||
|
assert.throws(() => validateAgentTool(request('files.read', { path: 'a.py', lineCount: 401 })))
|
||||||
|
assert.throws(() => validateAgentOperation({ name: 'modules.stop' }))
|
||||||
|
assert.throws(() => validateAgentOperation({ name: 'practice.prepare', profile: 'guess' }))
|
||||||
|
assert.equal(validateAgentOperation({ name: 'practice.prepare', profile: 'mkmini-3d' }).name, 'practice.prepare')
|
||||||
|
assert.equal(safeAgentPath('src/launch/vehicle.launch.py'), 'src/launch/vehicle.launch.py')
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const backend of ['local', 'remote']) {
|
||||||
|
const read = async (root, tool) => backend === 'local' ? runAgentFileTool(root, validateAgentTool(tool)) : remote({ root, request: validateAgentTool(tool) })
|
||||||
|
const apply = changes => backend === 'local' ? applyAgentFiles(changes) : Promise.resolve().then(() => remote({ changes }))
|
||||||
|
test(`${backend}: lists and searches sources without hidden, generated or symlinked data`, async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
await fs.writeFile(path.join(root, '.env'), 'host_ip: PRIVATE')
|
||||||
|
await fs.mkdir(path.join(root, 'build'))
|
||||||
|
await fs.writeFile(path.join(root, 'build', 'generated.yaml'), 'host_ip: PRIVATE')
|
||||||
|
await fs.symlink('/etc/passwd', path.join(root, 'config', 'outside.txt'))
|
||||||
|
const listing = await read(root, request('files.list'))
|
||||||
|
assert.deepEqual([...listing.data.paths].sort(), ['CMakeLists.txt', 'config/sensor.yaml'])
|
||||||
|
const search = await read(root, request('files.search', { query: 'HOST_IP' }))
|
||||||
|
assert.equal(search.data.matches.length, 1)
|
||||||
|
assert.equal(search.data.matches[0].path, 'config/sensor.yaml')
|
||||||
|
assert.equal(search.data.matches[0].line, 1)
|
||||||
|
await assert.rejects(read(root, request('files.read', { path: 'config/outside.txt' })))
|
||||||
|
})
|
||||||
|
test(`${backend}: full file reads include a reproducible hash, partial and missing reads are explicit`, async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
const full = await read(root, request('files.read', { path: 'config/sensor.yaml' }))
|
||||||
|
assert.equal(full.data.hash, agentFileHash(await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')))
|
||||||
|
assert.equal(full.data.complete, true)
|
||||||
|
const rangedFull = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 1, lineCount: 200 }))
|
||||||
|
assert.equal(rangedFull.data.complete, true)
|
||||||
|
assert.equal(rangedFull.truncated, false)
|
||||||
|
assert.equal(rangedFull.data.endLine, 3)
|
||||||
|
assert.equal(rangedFull.data.contentTruncated, false)
|
||||||
|
const partial = await read(root, request('files.read', { path: 'config/sensor.yaml', startLine: 2, lineCount: 1 }))
|
||||||
|
assert.equal(partial.data.content, '조향: ackermann')
|
||||||
|
assert.equal(partial.data.complete, false)
|
||||||
|
assert.equal(partial.truncated, true)
|
||||||
|
assert.equal(partial.data.endLine, 2)
|
||||||
|
assert.equal(partial.data.contentTruncated, false)
|
||||||
|
const missing = await read(root, request('files.read', { path: 'config/new.yaml' }))
|
||||||
|
assert.equal(missing.data.exists, false)
|
||||||
|
assert.equal(missing.data.hash, null)
|
||||||
|
await assert.rejects(read(root, request('files.read', { path: 'missing/new.yaml' })))
|
||||||
|
})
|
||||||
|
test(`${backend}: character truncation never advertises a cut line as fully observed`, async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
await fs.writeFile(path.join(root, 'config/long.yaml'), `first: true\n${'x'.repeat(40_000)}\nlast: true`)
|
||||||
|
const readout = await read(root, request('files.read', { path: 'config/long.yaml', startLine: 1, lineCount: 3 }))
|
||||||
|
assert.equal(readout.data.complete, false)
|
||||||
|
assert.equal(readout.data.contentTruncated, true)
|
||||||
|
assert.equal(readout.data.endLine, 1)
|
||||||
|
})
|
||||||
|
test(`${backend}: a scoped search does not consume its budget in unrelated source trees`, async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
await fs.mkdir(path.join(root, 'code'))
|
||||||
|
for (let i = 0; i < 5; i++) await fs.writeFile(path.join(root, 'code', `large${i}.py`), 'x'.repeat(900_000))
|
||||||
|
const result = await read(root, request('files.search', { query: 'host_ip', path: 'config' }))
|
||||||
|
assert.equal(result.truncated, false)
|
||||||
|
assert.equal(result.data.matches[0].path, 'config/sensor.yaml')
|
||||||
|
})
|
||||||
|
test(`${backend}: validates every original before changing any file and preserves permissions`, async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
const original = await fs.readFile(path.join(root, 'config/sensor.yaml'), 'utf8')
|
||||||
|
const first = { root, moduleName: 'NAV2-MKMini', path: 'config/sensor.yaml', content: 'host_ip: 192.168.1.88\n', expectedHash: agentFileHash(original) }
|
||||||
|
const stale = { root, moduleName: 'NAV2-MKMini', path: 'CMakeLists.txt', content: 'changed', expectedHash: '0'.repeat(64) }
|
||||||
|
await assert.rejects(apply([first, stale]))
|
||||||
|
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), original)
|
||||||
|
await fs.chmod(path.join(root, first.path), 0o664)
|
||||||
|
await apply([first, { root, moduleName: 'NAV2-MKMini', path: 'config/new.yaml', content: 'created: true', expectedHash: null }])
|
||||||
|
assert.equal(await fs.readFile(path.join(root, first.path), 'utf8'), first.content)
|
||||||
|
assert.equal((await fs.stat(path.join(root, first.path))).mode & 0o777, 0o664)
|
||||||
|
assert.equal(await fs.readFile(path.join(root, 'config/new.yaml'), 'utf8'), 'created: true')
|
||||||
|
await assert.rejects(apply([first]))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('remote batch streams large content rather than putting it into command arguments', async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
const content = 'large YAML value '.repeat(11_000)
|
||||||
|
remote({ changes: [{ root, moduleName: 'NAV2-MKMini', path: 'config/large.yaml', expectedHash: null, content }] })
|
||||||
|
assert.equal(await fs.readFile(path.join(root, 'config/large.yaml'), 'utf8'), content)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local batch rolls back earlier replacements if a later replacement fails', async t => {
|
||||||
|
const root = await fixture(t)
|
||||||
|
const rename = fs.rename
|
||||||
|
let calls = 0
|
||||||
|
fs.rename = async (...args) => { if (++calls === 2) throw new Error('injected rename error'); return rename(...args) }
|
||||||
|
try {
|
||||||
|
await assert.rejects(applyAgentFiles([
|
||||||
|
{ root, moduleName: 'M', path: 'config/new.yaml', expectedHash: null, content: 'new' },
|
||||||
|
{ root, moduleName: 'M', path: 'CMakeLists.txt', expectedHash: agentFileHash('project(vehicle)\n'), content: 'changed' }
|
||||||
|
]), /injected/)
|
||||||
|
await assert.rejects(fs.stat(path.join(root, 'config/new.yaml')), { code: 'ENOENT' })
|
||||||
|
assert.equal(await fs.readFile(path.join(root, 'CMakeLists.txt'), 'utf8'), 'project(vehicle)\n')
|
||||||
|
} finally { fs.rename = rename }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('network inspection keeps real host and container observations separate', async () => {
|
||||||
|
const calls = []
|
||||||
|
const result = await inspectAgentRuntime(request('network.inspect'), async (executable, args) => { calls.push([executable, args]); return '[{"ifname":"eth0"}]' }, async command => { calls.push(command); throw new Error('container is stopped') })
|
||||||
|
assert.equal(result.host.interfaces[0].ifname, 'eth0')
|
||||||
|
assert.match(result.container.error, /stopped/)
|
||||||
|
assert.equal(calls[0][0], 'ip')
|
||||||
|
assert.equal(calls[1][0], 'ip')
|
||||||
|
assert.equal(calls[2], 'ip -j address show')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('ROS probe samples retained maps with matching QoS and distinguishes empty sensor payloads', () => {
|
||||||
|
const preamble = String.raw`
|
||||||
|
import sys, types, time
|
||||||
|
NS = types.SimpleNamespace
|
||||||
|
tick = [0]
|
||||||
|
def monotonic():
|
||||||
|
tick[0] += 1
|
||||||
|
return tick[0]
|
||||||
|
time.monotonic = monotonic
|
||||||
|
class Node:
|
||||||
|
def get_topic_names_and_types(self):
|
||||||
|
return [(topic, ['test/Message']) for topic in ['/map', '/scan', '/tf_static']]
|
||||||
|
def get_publishers_info_by_topic(self, topic):
|
||||||
|
return [NS(qos_profile=NS(durability='retained' if topic in ['/map', '/tf_static'] else 'volatile'))]
|
||||||
|
def create_subscription(self, message_type, topic, callback, qos):
|
||||||
|
if topic in ['/map', '/tf_static'] and qos.durability != 'retained':
|
||||||
|
raise ValueError('wrong durability')
|
||||||
|
if topic == '/map':
|
||||||
|
callback(NS(info=NS(width=10, height=10), data=[]))
|
||||||
|
elif topic == '/scan':
|
||||||
|
callback(NS(ranges=[1, 2, 3]))
|
||||||
|
else:
|
||||||
|
callback(NS(transforms=[NS(header=NS(frame_id='map'), child_frame_id='odom')]))
|
||||||
|
return object()
|
||||||
|
def count_publishers(self, topic):
|
||||||
|
return 1
|
||||||
|
def get_node_names(self):
|
||||||
|
return ['probe']
|
||||||
|
def destroy_node(self):
|
||||||
|
pass
|
||||||
|
sys.modules['rclpy'] = NS(init=lambda **kw: None, create_node=lambda name: Node(), spin_once=lambda *a, **kw: None, shutdown=lambda: None)
|
||||||
|
sys.modules['rclpy.qos'] = NS(QoSProfile=lambda **kw: NS(**kw), ReliabilityPolicy=NS(BEST_EFFORT='best-effort'), DurabilityPolicy=NS(TRANSIENT_LOCAL='retained', VOLATILE='volatile'))
|
||||||
|
sys.modules['rosidl_runtime_py'] = types.ModuleType('rosidl_runtime_py')
|
||||||
|
sys.modules['rosidl_runtime_py.utilities'] = NS(get_message=lambda name: object)
|
||||||
|
`
|
||||||
|
const run = spawnSync('python3', ['-c', `${preamble}\nexec(${JSON.stringify(agentRosProbePython)})`, Buffer.from(JSON.stringify(['/map', '/scan', '/tf_static'])).toString('base64')], { encoding: 'utf8', timeout: 5000 })
|
||||||
|
assert.equal(run.status, 0, run.stderr)
|
||||||
|
const data = JSON.parse(run.stdout)
|
||||||
|
assert.equal(data.topics.find(topic => topic.name === '/map').samples, 1)
|
||||||
|
assert.equal(data.topics.find(topic => topic.name === '/map').validSamples, 0)
|
||||||
|
assert.equal(data.topics.find(topic => topic.name === '/scan').validSamples, 1)
|
||||||
|
assert.deepEqual(data.tfEdges, [{ parent: 'map', child: 'odom' }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restoring an interrupted action does not leave history permanently running', () => {
|
||||||
|
const { recoverInterruptedAgentMessages } = require('../src/renderer/src/features/workspace/agentContext.ts')
|
||||||
|
const messages = [{ id: 'reply', role: 'assistant', content: 'result', applyState: 'applying', commandExecutions: { start: { status: 'running', output: 'started one module' }, build: { status: 'completed' } } }]
|
||||||
|
const restored = recoverInterruptedAgentMessages(messages)
|
||||||
|
assert.equal(restored[0].applyState, 'error')
|
||||||
|
assert.equal(restored[0].commandExecutions.start.status, 'failed')
|
||||||
|
assert.equal(restored[0].commandExecutions.start.output, 'started one module')
|
||||||
|
assert.equal(restored[0].commandExecutions.build.status, 'completed')
|
||||||
|
assert.equal(messages[0].commandExecutions.start.status, 'running')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('tool loop sends actual evidence, deduplicates requests despite new IDs and never runs actions', async () => {
|
||||||
|
let calls = 0
|
||||||
|
const executed = []
|
||||||
|
const result = await resolveAgentContext({
|
||||||
|
initialContext: { modules: [], runtimeLogs: [] }, signal: new AbortController().signal,
|
||||||
|
executeTool: async tool => { executed.push(tool); return { id: tool.id, name: tool.name, status: 'ok', target: 'test', observedAt: new Date().toISOString(), data: '{"paths":["config/a.yaml"]}', truncated: false } },
|
||||||
|
requestPlan: async (context, finalize) => {
|
||||||
|
calls++
|
||||||
|
if (calls === 2) assert.equal(context.toolResults.length, 1)
|
||||||
|
return { provider: 'test', model: 'test', plan: { summary: 'result', explanation: 'explanation', requestedModules: [], requestedLogs: [], actionPlan: [], commands: finalize ? [{ operation: { name: 'modules.start' } }] : [], changes: [], createModule: null, toolRequests: [request('files.list', {})] } }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
assert.equal(executed.length, 1)
|
||||||
|
assert.equal(calls, 3)
|
||||||
|
assert.deepEqual(plain(result.plan.commands), [])
|
||||||
|
assert.deepEqual(plain(result.plan.toolRequests), [])
|
||||||
|
assert.match(result.plan.explanation, /files.list: ok/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('backend and desktop share exactly the same tool validation contract', async () => {
|
||||||
|
assert.equal(await fs.readFile(path.resolve(__dirname, '../../lms-be/src/ai/agent-tools.ts'), 'utf8'), await fs.readFile(path.resolve(__dirname, '../src/shared/agentTools.ts'), 'utf8'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
function harness(initialNow = 1000) {
|
||||||
|
let now = initialNow, state, effect, interval, cleared = 0
|
||||||
|
const windowEvents = new Map(), documentEvents = new Map()
|
||||||
|
class Clock extends Date { static now() { return now } }
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, {
|
||||||
|
exports, Date: Clock,
|
||||||
|
window: {
|
||||||
|
setInterval(callback, delay) { assert.equal(delay, 1000); interval = callback; return 7 },
|
||||||
|
clearInterval(id) { assert.equal(id, 7); interval = undefined; cleared++ },
|
||||||
|
addEventListener: (name, callback) => windowEvents.set(name, callback),
|
||||||
|
removeEventListener: name => windowEvents.delete(name)
|
||||||
|
},
|
||||||
|
document: {
|
||||||
|
addEventListener: (name, callback) => documentEvents.set(name, callback),
|
||||||
|
removeEventListener: name => documentEvents.delete(name)
|
||||||
|
},
|
||||||
|
require(name) {
|
||||||
|
if (name === '../i18n') return { t: (key, values) => key.replace(/\{\{seconds\}\}/g, values.seconds), useLocaleRender() {} }
|
||||||
|
if (name === 'react') return { ...React,
|
||||||
|
useState(initial) { state ??= typeof initial === 'function' ? initial() : initial; return [state, value => { state = value }] },
|
||||||
|
useEffect(callback) { effect = callback }
|
||||||
|
}
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
render: props => renderToStaticMarkup(exports.AgentWorkDuration({ dark: true, ...props })),
|
||||||
|
mount: () => effect(), advance: ms => { now += ms }, tick: () => interval?.(),
|
||||||
|
visible: () => documentEvents.get('visibilitychange')?.(), focus: () => windowEvents.get('focus')?.(),
|
||||||
|
interval: () => interval, cleared: () => cleared,
|
||||||
|
listeners: () => windowEvents.size + documentEvents.size
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('running duration is based on start time, not the number of interval ticks', () => {
|
||||||
|
const h = harness()
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /0초 동안 작업 중입니다/)
|
||||||
|
const cleanup = h.mount()
|
||||||
|
h.advance(12_900)
|
||||||
|
h.tick()
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /12초 동안 작업 중입니다/)
|
||||||
|
h.advance(60_000)
|
||||||
|
h.visible()
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /72초 동안 작업 중입니다/)
|
||||||
|
h.advance(5_000)
|
||||||
|
h.focus()
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /77초 동안 작업 중입니다/)
|
||||||
|
assert.equal(h.listeners(), 2)
|
||||||
|
cleanup()
|
||||||
|
assert.equal(h.interval(), undefined)
|
||||||
|
assert.equal(h.listeners(), 0)
|
||||||
|
assert.equal(h.cleared(), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returning to a remounted chat shows the full elapsed time immediately', () => {
|
||||||
|
const h = harness(74_000)
|
||||||
|
assert.match(h.render({ startedAt: 1000 }), /73초 동안 작업 중입니다/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('completed duration stays fixed and never starts a timer', () => {
|
||||||
|
const h = harness()
|
||||||
|
assert.match(h.render({ startedAt: 1000, durationMs: 20_900 }), /20초 동안 작업함/)
|
||||||
|
assert.equal(h.mount(), undefined)
|
||||||
|
h.advance(60_000)
|
||||||
|
h.tick()
|
||||||
|
assert.match(h.render({ durationMs: 20_900 }), /20초 동안 작업함/)
|
||||||
|
assert.equal(h.interval(), undefined)
|
||||||
|
assert.equal(h.listeners(), 0)
|
||||||
|
assert.match(h.render({ durationMs: 0 }), /0초 동안 작업함/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy replies and invalid timing do not invent an elapsed duration', () => {
|
||||||
|
const h = harness()
|
||||||
|
for (const durationMs of [undefined, -1, NaN, Infinity]) assert.equal(h.render({ durationMs }), '')
|
||||||
|
assert.match(h.render({ startedAt: 2000 }), /0초 동안 작업 중입니다/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const app = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const declarations = new Map()
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) declarations.set(node.name.text, node.initializer?.getText(app))
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(app)
|
||||||
|
const plain = value => JSON.parse(JSON.stringify(value))
|
||||||
|
|
||||||
|
function harness(remoteWorkspace) {
|
||||||
|
const executions = []
|
||||||
|
const calls = []
|
||||||
|
const contexts = []
|
||||||
|
const events = []
|
||||||
|
const errors = []
|
||||||
|
const target = { host: '100.64.0.10', user: 'fixture', workspaceRoot: '/fixture/workspace' }
|
||||||
|
const live = [{ name: 'NAV2-MKMini', folderName: 'vehicle', state: 'running', parameters: ['sim_mode:=false', 'robot:=mkmini'] }]
|
||||||
|
const readTool = backend => async (...args) => { executions.push({ backend, args: plain(args) }); return { status: 'ok', data: '{"content":"remote source"}' } }
|
||||||
|
const exports = {}
|
||||||
|
const globals = {
|
||||||
|
exports, remoteWorkspace, availableModules: [{ ...live[0], state: 'stopped', parameters: ['sim_mode:=true'] }],
|
||||||
|
clonePathPreview: '/local/workspace', runtimeModules: [], agentActionControllerRef: { current: null }, agentApplying: false,
|
||||||
|
getRemoteWorkspaceTarget: () => target, applyModuleParameterOverrides: modules => modules,
|
||||||
|
validateAgentTool: request => request, AGENT_PRACTICE_PROFILES: [],
|
||||||
|
window: {
|
||||||
|
remoteWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('remote') } },
|
||||||
|
localWorkspace: { modules: { list: async () => { events.push('snapshot-read'); return { modules: live } } }, agent: { readTool: readTool('local') } }
|
||||||
|
},
|
||||||
|
requestAgentPlan: async (...args) => {
|
||||||
|
calls.push(args)
|
||||||
|
events.push('plan-start')
|
||||||
|
contexts.push(await args[3].loadInitialContext(new AbortController().signal))
|
||||||
|
},
|
||||||
|
onTitlebarNotice: (...args) => { errors.push(args) }, console: { error: () => {} },
|
||||||
|
loadAgentRequestedContext: async () => ({ modules: [], runtimeLogs: [] })
|
||||||
|
}
|
||||||
|
const names = ['collectAgentWorkspace', 'readAgentModules', 'executeWorkspaceAgentTool', 'submitAgentRequest']
|
||||||
|
vm.runInNewContext(ts.transpileModule(names.map(name => `const ${name} = ${declarations.get(name)}; exports.${name} = ${name}`).join('\n'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, globals)
|
||||||
|
return { ...exports, executions, calls, contexts, events, errors }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const remote of [false, true]) {
|
||||||
|
test(`${remote ? 'remote' : 'local'}: enters the chat lifecycle immediately and refreshes state before the provider request`, async () => {
|
||||||
|
const h = harness(remote)
|
||||||
|
await h.submitAgentRequest('최대 속도 제한 코드 위치')
|
||||||
|
assert.deepEqual(h.errors, [])
|
||||||
|
assert.equal(h.calls.length, 1)
|
||||||
|
const [, initialModules, , options] = h.calls[0]
|
||||||
|
assert.deepEqual(plain(initialModules), [])
|
||||||
|
assert.deepEqual(h.events, ['plan-start', 'snapshot-read'])
|
||||||
|
const { modules } = h.contexts[0]
|
||||||
|
assert.equal(modules[0].state, 'running')
|
||||||
|
assert.deepEqual(plain(modules[0].parameters), ['sim_mode:=false', 'robot:=mkmini'])
|
||||||
|
assert.deepEqual(plain(modules[0].files), [])
|
||||||
|
assert.equal(options.environment, remote ? 'remote' : 'local')
|
||||||
|
assert.equal(options.workspaceTarget, remote ? 'ssh:100.64.0.10' : 'local-host')
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`${remote ? 'remote' : 'local'}: source reads use only the connected workspace transport and module folder`, async () => {
|
||||||
|
const h = harness(remote)
|
||||||
|
const tool = { id: 'read-1', name: 'files.read', moduleName: 'NAV2-MKMini', args: { path: 'src/profiles.py', startLine: 10, lineCount: 20 } }
|
||||||
|
await h.executeWorkspaceAgentTool(tool)
|
||||||
|
assert.equal(h.executions.length, 1)
|
||||||
|
assert.equal(h.executions[0].backend, remote ? 'remote' : 'local')
|
||||||
|
const args = h.executions[0].args
|
||||||
|
assert.deepEqual(args[remote ? 1 : 0], tool)
|
||||||
|
assert.equal(args[2], 'vehicle')
|
||||||
|
assert.equal(remote ? args[0].host : args[1], remote ? '100.64.0.10' : '/local/workspace')
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
function compile(source, overrides = {}, globals = {}) {
|
||||||
|
const exports = {}
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const widgets = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8'), {
|
||||||
|
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': {}, './ChatImageAttachments': {}, './ChatCodeBlock': {}
|
||||||
|
})
|
||||||
|
const panelSource = ts.createSourceFile('WorkspaceQaPanel.tsx', fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const declarations = panelSource.statements.filter(node => ts.isFunctionDeclaration(node) && ['AiMessages', 'AgentMessages', 'TeacherMessages'].includes(node.name?.text))
|
||||||
|
const ProgressiveChatResponse = props => React.createElement('div', { 'data-reveal-start': props.message.revealStartedAt, 'data-collapsed': props.defaultCollapsed }, props.message.content)
|
||||||
|
const ChatImageAttachments = props => React.createElement('div', { 'data-chat-image-attachments': props.variant }, props.images.map(image => React.createElement('img', { key: image.url, src: image.url, alt: image.name })))
|
||||||
|
const AgentCodeReferences = () => null
|
||||||
|
const { AgentWorkDuration } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentWorkDuration.tsx'), 'utf8'), {
|
||||||
|
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} }
|
||||||
|
})
|
||||||
|
const { AgentProgressBubble } = compile(fs.readFileSync(require.resolve('../src/renderer/src/components/AgentProgressBubble.tsx'), 'utf8'), {
|
||||||
|
'../i18n': { t: (key, values = {}) => key.replace(/\{\{(\w+)\}\}/g, (_, name) => values[name] ?? ''), useLocaleRender() {} },
|
||||||
|
react: { ...React, useEffect() {}, useRef: () => ({ current: null }) },
|
||||||
|
'./AgentWorkDuration': { AgentWorkDuration }
|
||||||
|
})
|
||||||
|
const messages = compile(declarations.map(node => `export ${node.getText(panelSource)}`).join('\n'), {}, {
|
||||||
|
t: key => key, useLocaleRender() {},
|
||||||
|
ChatResponseSkeleton: widgets.ChatResponseSkeleton,
|
||||||
|
ChatEmptyState: widgets.ChatEmptyState,
|
||||||
|
ProgressiveChatResponse,
|
||||||
|
ChatImageAttachments,
|
||||||
|
AgentCodeReferences,
|
||||||
|
AgentProgressBubble,
|
||||||
|
AgentWorkDuration,
|
||||||
|
MessageCopyButton: () => null,
|
||||||
|
agentMessageCopyText: message => message.content,
|
||||||
|
Bot: () => React.createElement('svg'), WandSparkles: () => React.createElement('svg')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('existing code locations stay usable during model and command work but not file application', () => {
|
||||||
|
for (const applying of [false, true]) {
|
||||||
|
const onOpen = () => {}
|
||||||
|
const tree = messages.AgentMessages({
|
||||||
|
dark: true, enabled: true, statusLoading: false, pending: true, busy: true,
|
||||||
|
messages: [{ id: 'answer', role: 'assistant', content: '코드 위치', applyState: applying ? 'applying' : undefined, plan: { codeReferences: [{ id: 'code-1' }], changes: [], commands: [], actionPlan: [], createModule: null } }],
|
||||||
|
onOpenCodeReference: onOpen
|
||||||
|
})
|
||||||
|
let references
|
||||||
|
function visit(element) {
|
||||||
|
if (!React.isValidElement(element)) return
|
||||||
|
if (element.type === AgentCodeReferences) references = element
|
||||||
|
React.Children.toArray(element.props.children).forEach(visit)
|
||||||
|
}
|
||||||
|
visit(tree)
|
||||||
|
assert.ok(references)
|
||||||
|
assert.equal(references.props.busy, applying)
|
||||||
|
assert.equal(typeof references.props.onOpen, 'function')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('agent image thumbnails are siblings below the user bubble rather than bubble contents', () => {
|
||||||
|
const tree = messages.AgentMessages({
|
||||||
|
dark: true, enabled: true, statusLoading: false, pending: false,
|
||||||
|
messages: [{ id: 'user', role: 'user', content: '이 화면을 확인해줘\n두 번째 줄', imageAttachments: [{ name: '스크린샷.png', url: 'https://files.test/image.png' }] }]
|
||||||
|
})
|
||||||
|
let row
|
||||||
|
function visit(element) {
|
||||||
|
if (!React.isValidElement(element)) return
|
||||||
|
const children = React.Children.toArray(element.props.children)
|
||||||
|
if (children.some(child => React.isValidElement(child) && child.props['data-agent-user-bubble'])) row = children
|
||||||
|
children.forEach(visit)
|
||||||
|
}
|
||||||
|
visit(tree)
|
||||||
|
assert.ok(row)
|
||||||
|
assert.equal(row[0].props.children, '이 화면을 확인해줘\n두 번째 줄')
|
||||||
|
assert.match(row[0].props.className, /whitespace-pre-wrap/)
|
||||||
|
assert.equal(row[1].type, ChatImageAttachments)
|
||||||
|
assert.equal(row[1].props.variant, 'sent')
|
||||||
|
assert.equal(row[1].props.images.length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
for (const mode of ['AiMessages', 'AgentMessages']) {
|
||||||
|
test(`${mode} uses a three-line skeleton instead of a spinner (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, statusLoading: false, pending: true, messages: [] }))
|
||||||
|
assert.match(html, /role="status"/)
|
||||||
|
assert.equal((html.match(/<span class="h-2 /g) ?? []).length, 3)
|
||||||
|
for (const width of ['w-full', 'w-[76%]', 'w-[52%]']) assert.ok(html.includes(width))
|
||||||
|
assert.doesNotMatch(html, /animate-spin/)
|
||||||
|
assert.match(html, /motion-reduce:animate-none/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('AI answer forwards reveal timing and collapse settings to the same presentation component', () => {
|
||||||
|
const html = renderToStaticMarkup(messages.AiMessages({
|
||||||
|
dark: true, pending: false, collapseSignal: 2,
|
||||||
|
messages: [{ id: 'answer', role: 'assistant', content: '전체 답변입니다.', revealStartedAt: 1234, keepExpanded: true }]
|
||||||
|
}))
|
||||||
|
assert.match(html, /data-reveal-start="1234"/)
|
||||||
|
assert.match(html, /data-collapsed="false"/)
|
||||||
|
assert.match(html, /전체 답변/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`agent progress accumulates only during the request and is replaced by the final reply (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const progressSteps = [
|
||||||
|
{ id: 'analysis-0', kind: 'analysis', status: 'ok', round: 1, requests: 1 },
|
||||||
|
{ id: 'tool-0-0', kind: 'tool', name: 'files.list', moduleName: 'Camera', status: 'ok', count: 3 },
|
||||||
|
{ id: 'tool-1-0', kind: 'tool', name: 'files.read', moduleName: 'Camera', path: 'config/camera.yaml', status: 'missing' },
|
||||||
|
{ id: 'tool-1-1', kind: 'tool', name: 'logs.read', moduleName: 'Camera', status: 'error' },
|
||||||
|
{ id: 'tool-1-2', kind: 'tool', name: 'files.search', moduleName: 'Camera', status: 'partial', count: 2 }
|
||||||
|
]
|
||||||
|
const props = { dark, enabled: true, statusLoading: false, pending: true, activity: '조회 결과 분석 중', progressSteps, messages: [] }
|
||||||
|
const html = renderToStaticMarkup(messages.AgentMessages(props))
|
||||||
|
assert.equal((html.match(/data-agent-progress="true"/g) ?? []).length, 1)
|
||||||
|
assert.match(html, /1차 분석 완료 · 추가 조회 1건/)
|
||||||
|
assert.match(html, /Camera · 파일 목록 · 조회 완료 · 3건/)
|
||||||
|
assert.match(html, /Camera · 파일 읽기 · config\/camera.yaml · 대상 없음/)
|
||||||
|
assert.match(html, /Camera · 로그 조회 · 조회 실패/)
|
||||||
|
assert.match(html, /Camera · 파일 검색 · 일부 결과 · 2건/)
|
||||||
|
assert.match(html, /조회 결과 분석 중/)
|
||||||
|
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 5)
|
||||||
|
const final = renderToStaticMarkup(messages.AgentMessages({ ...props, pending: false, messages: [{ id: 'answer', role: 'assistant', content: '최종 답변입니다.', durationMs: 20_900 }] }))
|
||||||
|
assert.doesNotMatch(final, /data-agent-progress|config\/camera.yaml|진행 상황/)
|
||||||
|
assert.match(final, /최종 답변입니다/)
|
||||||
|
assert.match(final, /20초 동안 작업함/)
|
||||||
|
assert.ok(final.indexOf('20초 동안 작업함') < final.indexOf('최종 답변입니다'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
for (const [mode, expected] of [
|
||||||
|
['AiMessages', /AI에게 질문할<br\/>내용을 입력하세요/],
|
||||||
|
['AgentMessages', /현재 모듈의 코드 수정이나 새 모듈 생성을 요청하세요\.<br\/>변경 내용은 확인 후 직접 적용합니다\./],
|
||||||
|
['TeacherMessages', /강사에게 문의할<br\/>내용을 입력하세요/]
|
||||||
|
]) {
|
||||||
|
test(`empty ${mode} view uses the shared speech bubble (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const html = renderToStaticMarkup(messages[mode]({ dark, enabled: true, pending: false, messages: [] }))
|
||||||
|
assert.match(html, expected)
|
||||||
|
assert.match(html, /rounded-lg border px-8 py-5/)
|
||||||
|
assert.match(html, /aria-hidden="true"[^>]*rotate-45 border-b border-r/)
|
||||||
|
assert.doesNotMatch(html, /<button|role="status"/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function chatHarness(revealInView) {
|
||||||
|
const states = []
|
||||||
|
const refs = []
|
||||||
|
let stateIndex = 0
|
||||||
|
let refIndex = 0
|
||||||
|
let waits = 0
|
||||||
|
const content = '첫 번째 문단입니다.\n\n두 번째 문단입니다.\n\n마지막 문단입니다.'
|
||||||
|
const { useQaChat } = compile(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/useQaChat.ts'), 'utf8'), {
|
||||||
|
react: {
|
||||||
|
useEffect() {},
|
||||||
|
useState(initial) {
|
||||||
|
const index = stateIndex++
|
||||||
|
if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial
|
||||||
|
return [states[index], value => { states[index] = typeof value === 'function' ? value(states[index]) : value }]
|
||||||
|
},
|
||||||
|
useRef(initial) { const index = refIndex++; return refs[index] ??= { current: initial } }
|
||||||
|
},
|
||||||
|
'socket.io-client': {}
|
||||||
|
}, {
|
||||||
|
URLSearchParams, AbortController, DOMException,
|
||||||
|
fetch: async () => ({ ok: true, json: async () => ({ threadId: 'thread', content, message: { id: 'answer', content, createdAt: '2026-10-19T03:00:00Z' } }) }),
|
||||||
|
window: { setTimeout(fn) { waits++; fn() } }
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
render() {
|
||||||
|
stateIndex = 0; refIndex = 0
|
||||||
|
return useQaChat({ accessToken: 'test', courseId: 'course', weekNumber: 1, revealInView, getFreshAccessToken: async () => 'test', onTitlebarNotice() {} })
|
||||||
|
},
|
||||||
|
content, waits: () => waits
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('workspace AI stores the complete answer immediately and leaves animation to the view', async () => {
|
||||||
|
const harness = chatHarness(true)
|
||||||
|
await harness.render().sendQaMessage('조향 설정을 알려줘')
|
||||||
|
const state = harness.render()
|
||||||
|
const answer = state.visibleAiMessages.find(message => message.id === 'answer')
|
||||||
|
assert.equal(answer.content, harness.content)
|
||||||
|
assert.equal(typeof answer.revealStartedAt, 'number')
|
||||||
|
assert.equal(answer.keepExpanded, true)
|
||||||
|
assert.equal(state.aiPending, false)
|
||||||
|
assert.equal(state.aiTypingMessageId, null)
|
||||||
|
assert.equal(harness.waits(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the separate AI guide keeps its existing reveal mode unless explicitly opted in', async () => {
|
||||||
|
const harness = chatHarness(false)
|
||||||
|
await harness.render().sendQaMessage('질문')
|
||||||
|
const state = harness.render()
|
||||||
|
assert.equal(state.visibleAiMessages.find(message => message.id === 'answer').content, harness.content)
|
||||||
|
assert.ok(harness.waits() > 0)
|
||||||
|
assert.equal(state.aiTypingMessageId, null)
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const source = ts.createSourceFile('index.ts', fs.readFileSync(path.join(__dirname, '../src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const names = ['buildFastDdsPeerConfig', 'parseCameraLanAddresses', 'cameraLanEnabled', 'rosNetworkEnabled', 'prepareRemoteRosNetworkConfig']
|
||||||
|
const code = source.statements.filter(node => ts.isFunctionDeclaration(node) && names.includes(node.name?.text)).map(node => node.getText(source)).join('\n')
|
||||||
|
function load(overrides = {}) {
|
||||||
|
const dependencies = {
|
||||||
|
parseRosDomainId: value => Number(value ?? 0),
|
||||||
|
readLocalTailscaleIpv4: async () => '100.64.0.10',
|
||||||
|
readRemoteTailscaleIpv4: async () => '100.64.0.20',
|
||||||
|
runSshOutput: async () => JSON.stringify([{ addr_info: [{ family: 'inet', scope: 'global', local: '192.168.1.20' }] }]),
|
||||||
|
runSsh: async () => {},
|
||||||
|
quoteShellSingle: value => "'" + value.replaceAll("'", "'\\''") + "'",
|
||||||
|
expandRemotePathVar: () => [],
|
||||||
|
encodeBase64: value => Buffer.from(value).toString('base64'),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
return new Function(...Object.keys(dependencies), ts.transpile(code, { target: ts.ScriptTarget.ES2022 }) + `;return {${names.join(',')}}`)(...Object.values(dependencies))
|
||||||
|
}
|
||||||
|
|
||||||
|
test('LAN discovery adds physical addresses and multicast while keeping Tailscale peers', () => {
|
||||||
|
const { buildFastDdsPeerConfig } = load()
|
||||||
|
const xml = buildFastDdsPeerConfig('100.64.0.10', '100.64.0.20', '77', ['192.168.1.20'])
|
||||||
|
const whitelist = xml.match(/<interfaceWhiteList>(.*?)<\/interfaceWhiteList>/s)[1]
|
||||||
|
for (const address of ['127.0.0.1', '100.64.0.20', '192.168.1.20']) assert.ok(whitelist.includes(address))
|
||||||
|
assert.ok(xml.includes('<address>100.64.0.10</address>'))
|
||||||
|
assert.ok(xml.includes('<address>239.255.0.1</address><port>26650</port>'))
|
||||||
|
assert.ok(xml.includes('<metatrafficMulticastLocatorList>'))
|
||||||
|
const original = buildFastDdsPeerConfig('100.64.0.10', '100.64.0.20', '77')
|
||||||
|
assert.ok(!original.includes('192.168.1.20'))
|
||||||
|
assert.ok(!original.includes('239.255.0.1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only the Yahboom camera opts in', () => {
|
||||||
|
const { cameraLanEnabled } = load()
|
||||||
|
assert.equal(cameraLanEnabled('[lms-nav2-yahboom-camera]yahboom_camera_v1.0.0_ws'), true)
|
||||||
|
for (const role of ['vehicle', 'agent', 'control', 'slam', 'planning']) {
|
||||||
|
assert.equal(cameraLanEnabled(`[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`), false)
|
||||||
|
}
|
||||||
|
assert.equal(cameraLanEnabled('[lms-nav2-custom]camera_v1.0.0_ws'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('physical interface responses are parsed, validated and deduplicated', () => {
|
||||||
|
const { parseCameraLanAddresses } = load()
|
||||||
|
const output = JSON.stringify([{ addr_info: [
|
||||||
|
{ family: 'inet', scope: 'global', local: '192.168.1.20' },
|
||||||
|
{ family: 'inet', scope: 'global', local: '999.1.1.1' },
|
||||||
|
{ family: 'inet6', scope: 'global', local: 'fe80::1' },
|
||||||
|
] }])
|
||||||
|
assert.deepEqual(parseCameraLanAddresses(output + '\n' + output), ['192.168.1.20'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('camera config uses its own path and LAN works without ROS platform linking', async () => {
|
||||||
|
let script = ''
|
||||||
|
const api = load({
|
||||||
|
readLocalTailscaleIpv4: async () => { throw new Error('Must not require local Tailscale') },
|
||||||
|
readRemoteTailscaleIpv4: async () => { throw new Error('Must not require remote Tailscale') },
|
||||||
|
runSsh: async (_target, command) => { script = command },
|
||||||
|
})
|
||||||
|
assert.equal(await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: false }, '77'), null)
|
||||||
|
const result = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: false }, '77', true)
|
||||||
|
assert.ok(result.endsWith('-camera-lan.xml'))
|
||||||
|
const encoded = script.match(/printf %s '([^']+)'/)[1]
|
||||||
|
const xml = Buffer.from(encoded, 'base64').toString()
|
||||||
|
assert.ok(xml.includes('192.168.1.20'))
|
||||||
|
assert.ok(xml.includes('239.255.0.1'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('linked camera retains both networks and does not overwrite restricted config', async () => {
|
||||||
|
const api = load()
|
||||||
|
const restricted = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77')
|
||||||
|
const camera = await api.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77', true)
|
||||||
|
assert.notEqual(camera, restricted)
|
||||||
|
assert.ok(camera.includes('100-64-0-10'))
|
||||||
|
const noLan = load({ runSshOutput: async () => '' })
|
||||||
|
await assert.rejects(noLan.prepareRemoteRosNetworkConfig({}, '/runtime', { enabled: true }, '77', true), /IPv4/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const output = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: output, TextDecoder })
|
||||||
|
const { readChatAttachments } = output
|
||||||
|
const file = (name, content) => new File([content], name)
|
||||||
|
|
||||||
|
test('reads source/config/log files and preserves Korean UTF-8 text', async () => {
|
||||||
|
const files = await readChatAttachments([file('control.py', '# 조향 설정'), file('config.yaml', 'robot: mkmini'), file('runtime.log', 'started')], [])
|
||||||
|
assert.equal(files[0].content, '# 조향 설정')
|
||||||
|
assert.equal(files[2].name, 'runtime.log')
|
||||||
|
})
|
||||||
|
test('rejects unsupported image/PDF files', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('photo.png', 'image')], []), /현재 모델/)
|
||||||
|
await assert.rejects(readChatAttachments([file('guide.pdf', 'pdf')], []), /PDF/)
|
||||||
|
})
|
||||||
|
test('rejects more than five attachments including existing ones', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a')], Array(5).fill({ name: 'b.txt', content: '' })), /최대 5개/)
|
||||||
|
})
|
||||||
|
test('rejects binary and non-UTF8 content even under a text extension', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a\0b')], []), /바이너리/)
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', new Uint8Array([255, 254]))], []), /UTF-8/)
|
||||||
|
})
|
||||||
|
test('rejects oversized files instead of silently truncating', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(100_001))], []), /100KB/)
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_001))], []), /5만 자/)
|
||||||
|
})
|
||||||
|
test('rejects aggregate attachment context above 100000 characters', async () => {
|
||||||
|
await assert.rejects(readChatAttachments([file('a.txt', 'a'.repeat(50_000)), file('b.txt', 'a'.repeat(50_000))], []), /10만 자/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
function compile(file, overrides = {}, globals = {}) {
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(require.resolve(file), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
const i18n = { t: key => key, useLocaleRender() {} }
|
||||||
|
const { ConfirmStatusModal } = compile('../src/renderer/src/components/AppStatusModals.tsx', { '../i18n': i18n })
|
||||||
|
const QaInputBox = () => null
|
||||||
|
const ChatTextContextMenu = () => null
|
||||||
|
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return []
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(overrides = {}, browser = {}) {
|
||||||
|
const state = []
|
||||||
|
const effects = []
|
||||||
|
let index = 0
|
||||||
|
let cleared = 0
|
||||||
|
const modeChanges = []
|
||||||
|
const panel = compile('../src/renderer/src/features/workspace/WorkspaceQaPanel.tsx', {
|
||||||
|
'../../i18n': i18n,
|
||||||
|
'../../components/AppStatusModals': { ConfirmStatusModal },
|
||||||
|
'../../components/ProgressiveChatResponse': { ProgressiveChatResponse: () => null },
|
||||||
|
'../../components/ChatWidgets': { QaInputBox },
|
||||||
|
'../../components/ChatTextContextMenu': { ChatTextContextMenu },
|
||||||
|
'../../components/ChatImageAttachments': {},
|
||||||
|
'../../components/AgentProgressBubble': {},
|
||||||
|
'../../components/AgentWorkDuration': {},
|
||||||
|
'../../components/AgentCodeReferences': {},
|
||||||
|
'../../components/AgentModelSelector': { AgentModelSelector: () => null },
|
||||||
|
'react-dom': { createPortal: child => child },
|
||||||
|
react: {
|
||||||
|
...React,
|
||||||
|
useEffect(effect) { effects.push(effect) },
|
||||||
|
useState(initial) {
|
||||||
|
const slot = index++
|
||||||
|
if (!(slot in state)) state[slot] = initial
|
||||||
|
return [state[slot], value => { state[slot] = value }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { document: { body: {} }, window: { getSelection: () => null, ...browser } }).default
|
||||||
|
const props = {
|
||||||
|
dark: true, workspaceActive: true, qaMode: 'ai', agentEnabled: true, visibleAiMessages: [{ id: '1', content: 'hello' }],
|
||||||
|
visibleTeacherMessages: [], agentMessages: [], workspaceChatScrollRef: { current: null },
|
||||||
|
onModeChange(mode) { modeChanges.push(mode) },
|
||||||
|
onClearConversation() { cleared++ }, ...overrides
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
render() { index = 0; effects.length = 0; return panel(props) },
|
||||||
|
cleared: () => cleared,
|
||||||
|
runEffects() { effects.forEach(effect => effect()) },
|
||||||
|
modeChanges
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const agentProvider of ['A_COPILOT', 'QWEN', 'OPENAI', 'ANTHROPIC', 'GEMINI']) {
|
||||||
|
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
||||||
|
test(`attachment controls follow the provider and chat mode (${agentProvider}, ${qaMode})`, () => {
|
||||||
|
const harness = setup({ qaMode, agentProvider })
|
||||||
|
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
||||||
|
assert.equal(input.props.attachmentsEnabled, qaMode === 'agent' && agentProvider !== 'A_COPILOT')
|
||||||
|
assert.equal(typeof input.props.onClearConversation, 'function')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('the workspace passes the current provider from agent status to the chat panel', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8')
|
||||||
|
assert.match(source, /provider: agentProvider,[\s\S]*?\} = useAiAgent\(/)
|
||||||
|
assert.match(source, /<WorkspaceQaPanel[\s\S]*?agentProvider=\{agentProvider\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const qaMode of ['ai', 'agent', 'teacher']) {
|
||||||
|
test(`${qaMode} history is deleted only after shared modal confirmation`, () => {
|
||||||
|
const harness = setup({ qaMode, agentMessages: [{ id: 'a', content: 'answer' }], visibleTeacherMessages: [{ id: 't', content: 'answer' }] })
|
||||||
|
const input = tree => elements(tree, node => node.type === QaInputBox)[0]
|
||||||
|
const modal = tree => elements(tree, node => node.type === ConfirmStatusModal)[0]
|
||||||
|
assert.equal(modal(harness.render()), undefined)
|
||||||
|
input(harness.render()).props.onClearConversation()
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
modal(harness.render()).props.onCancel()
|
||||||
|
assert.equal(modal(harness.render()), undefined)
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
input(harness.render()).props.onClearConversation()
|
||||||
|
modal(harness.render()).props.onConfirm()
|
||||||
|
assert.equal(harness.cleared(), 1)
|
||||||
|
assert.equal(modal(harness.render()), undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('context menu deletion also requests confirmation', () => {
|
||||||
|
const harness = setup()
|
||||||
|
const root = harness.render()
|
||||||
|
const area = elements(root, node => Boolean(node.props.onContextMenu))[0]
|
||||||
|
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10 })
|
||||||
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||||
|
menu.props.onClear()
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const [selected, inside] of [[false, true], [true, true], [true, false]]) {
|
||||||
|
test(`themed chat menu copies only a selection inside the chat (selected=${selected}, inside=${inside})`, async () => {
|
||||||
|
const node = {}
|
||||||
|
const harness = setup({}, {
|
||||||
|
getSelection: () => selected ? { anchorNode: node, focusNode: node, toString: () => '선택한 답변' } : null,
|
||||||
|
chatTextMenu: { show: async () => assert.fail('the themed menu must not open an OS menu') }
|
||||||
|
})
|
||||||
|
const area = elements(harness.render(), element => Boolean(element.props.onContextMenu))[0]
|
||||||
|
area.props.onContextMenu({
|
||||||
|
currentTarget: { contains: () => inside }, target: { closest: () => ({}) },
|
||||||
|
preventDefault() {}, stopPropagation() {}, clientX: 10, clientY: 10
|
||||||
|
})
|
||||||
|
await Promise.resolve()
|
||||||
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||||
|
assert.equal(menu.props.menu.kind, 'message')
|
||||||
|
assert.equal(Boolean(menu.props.menu.text), selected && inside)
|
||||||
|
assert.equal(menu.props.canClear, true)
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('themed menu deletion still goes through the shared confirmation dialog', async () => {
|
||||||
|
const harness = setup()
|
||||||
|
const area = elements(harness.render(), node => Boolean(node.props.onContextMenu))[0]
|
||||||
|
area.props.onContextMenu({ currentTarget: { contains: () => true }, preventDefault() {}, stopPropagation() {} })
|
||||||
|
await Promise.resolve()
|
||||||
|
const menu = elements(harness.render(), node => node.type === ChatTextContextMenu)[0]
|
||||||
|
menu.props.onClear()
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
||||||
|
assert.ok(modal)
|
||||||
|
modal.props.onConfirm()
|
||||||
|
assert.equal(harness.cleared(), 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const props of [{ inputBusy: true }, { qaMessagesLoading: true }, { visibleAiMessages: [] }]) {
|
||||||
|
test(`does not open deletion confirmation when disabled: ${JSON.stringify(props)}`, () => {
|
||||||
|
const harness = setup(props)
|
||||||
|
const input = elements(harness.render(), node => node.type === QaInputBox)[0]
|
||||||
|
assert.equal(input.props.clearDisabled, true)
|
||||||
|
input.props.onClearConversation()
|
||||||
|
assert.equal(elements(harness.render(), node => node.type === ConfirmStatusModal).length, 0)
|
||||||
|
assert.equal(harness.cleared(), 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
test(`shared confirmation renders cancel left and delete right (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const harness = setup({ dark })
|
||||||
|
elements(harness.render(), node => node.type === QaInputBox)[0].props.onClearConversation()
|
||||||
|
const modal = elements(harness.render(), node => node.type === ConfirmStatusModal)[0]
|
||||||
|
assert.equal(modal.props.dark, dark)
|
||||||
|
assert.match(modal.props.overlayClassName, /fixed.*z-\[180\]/)
|
||||||
|
const html = renderToStaticMarkup(modal)
|
||||||
|
assert.match(html, /취소<\/button><button[^>]*>삭제<\/button>/)
|
||||||
|
assert.match(html, /bg-\[#d14b4b\]/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('input no longer opens a native confirmation dialog', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||||
|
assert.doesNotMatch(source, /window\.confirm/)
|
||||||
|
assert.match(source, /onClick=\{onClearConversation\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
for (const agentEnabled of [false, true]) {
|
||||||
|
test(`agent availability controls tab count and layout (${dark ? 'dark' : 'light'}, enabled=${agentEnabled})`, () => {
|
||||||
|
const harness = setup({ dark, agentEnabled })
|
||||||
|
const grids = elements(harness.render(), node => node.props.className?.includes('mt-3 grid'))
|
||||||
|
assert.equal(grids.length, 1)
|
||||||
|
const tabs = elements(grids[0], node => node.type === 'button')
|
||||||
|
assert.deepEqual(tabs.map(tab => tab.props.children[1].props.children), agentEnabled ? ['AI', '에이전트', '강사'] : ['AI', '강사'])
|
||||||
|
assert.ok(grids[0].props.className.includes(agentEnabled ? 'grid-cols-3' : 'grid-cols-2'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('returns to AI when the selected agent is disabled after status loading', () => {
|
||||||
|
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: false, minimized: true })
|
||||||
|
harness.render()
|
||||||
|
harness.runEffects()
|
||||||
|
assert.deepEqual(harness.modeChanges, ['ai'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not reset the selected agent while status is still loading', () => {
|
||||||
|
const harness = setup({ qaMode: 'agent', agentEnabled: false, agentStatusLoading: true, minimized: true })
|
||||||
|
harness.render()
|
||||||
|
harness.runEffects()
|
||||||
|
assert.deepEqual(harness.modeChanges, [])
|
||||||
|
})
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
function compile(file, overrides = {}, globals = {}) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
test('custom menu clipboard IPC validates the main frame and rechecks paste availability', () => {
|
||||||
|
let clipboard = ''
|
||||||
|
let pastes = 0
|
||||||
|
const frame = {}
|
||||||
|
const sender = { mainFrame: frame, isDestroyed: () => false, paste() { pastes++ } }
|
||||||
|
const event = { sender, senderFrame: frame }
|
||||||
|
const native = compile('../src/main/chatTextMenu.ts', {
|
||||||
|
electron: { BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) }, clipboard: { readText: () => clipboard, writeText: text => { clipboard = text } } }
|
||||||
|
})
|
||||||
|
assert.equal(native.readChatClipboardState(event).hasText, false)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'selected text' }), true)
|
||||||
|
assert.equal(clipboard, 'selected text')
|
||||||
|
assert.equal(native.readChatClipboardState(event).hasText, true)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), true)
|
||||||
|
assert.equal(pastes, 1)
|
||||||
|
clipboard = ''
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'paste' }), false)
|
||||||
|
assert.equal(pastes, 1)
|
||||||
|
event.senderFrame = {}
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'blocked' }), false)
|
||||||
|
assert.equal(native.readChatClipboardState(event).hasText, false)
|
||||||
|
event.senderFrame = frame
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: '' }), false)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'copy', text: 'x'.repeat(5_000_001) }), false)
|
||||||
|
assert.equal(native.performChatClipboardAction(event, { action: 'unknown' }), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
function renderMenu({ dark, kind = 'message', text = '', hasText = false, readOnly = false }) {
|
||||||
|
const menu = { kind, x: 20, y: 30, text, input: { readOnly, disabled: false } }
|
||||||
|
let state = 0
|
||||||
|
const { ChatTextContextMenu } = compile('../src/renderer/src/components/ChatTextContextMenu.tsx', {
|
||||||
|
'../i18n': { t: key => key, useLocaleRender() {} },
|
||||||
|
'react-dom': { createPortal: child => child },
|
||||||
|
react: { ...React, useEffect() {}, useLayoutEffect() {}, useRef: initial => ({ current: initial }), useState() { return [state++ === 0 ? { menu, hasText } : { x: 20, y: 30 }, () => {}] } }
|
||||||
|
}, { document: { body: {} } })
|
||||||
|
return ChatTextContextMenu({ dark, menu, onClose() {}, onClear() {}, canClear: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`chat menu uses the terminal menu surface and item metrics (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const tree = renderMenu({ dark, text: 'selected' })
|
||||||
|
const html = renderToStaticMarkup(tree)
|
||||||
|
assert.match(html, dark ? /bg-\[#252526\]/ : /bg-white/)
|
||||||
|
assert.match(html, dark ? /enabled:hover:bg-\[#37373d\]/ : /enabled:hover:bg-\[#eef1f7\]/)
|
||||||
|
assert.match(html, /rounded-md border py-1 text-\[12px\] font-medium/)
|
||||||
|
assert.match(html, /flex h-8 w-full cursor-pointer/)
|
||||||
|
assert.match(html, /role="menu"/)
|
||||||
|
assert.match(html, /role="menuitem"/)
|
||||||
|
assert.match(html, /대화내역 삭제/)
|
||||||
|
assert.doesNotMatch(html, /붙여넣기/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const hasText of [true, false]) {
|
||||||
|
for (const readOnly of [true, false]) {
|
||||||
|
test(`paste disabled state follows clipboard and input editability (clipboard=${hasText}, readOnly=${readOnly})`, () => {
|
||||||
|
const tree = renderMenu({ dark: true, kind: 'input', hasText, readOnly })
|
||||||
|
const buttons = React.Children.toArray(tree.props.children).filter(child => child.type === 'button')
|
||||||
|
assert.equal(buttons[0].props.disabled, true)
|
||||||
|
assert.equal(buttons[1].props.disabled, !hasText || readOnly)
|
||||||
|
assert.doesNotMatch(renderToStaticMarkup(tree), /대화내역 삭제/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const api = {}
|
||||||
|
const revoked = []
|
||||||
|
let count = 0
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/chatAttachments.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, {
|
||||||
|
exports: api, TextDecoder,
|
||||||
|
createImageBitmap: async file => {
|
||||||
|
if (file.invalid) throw Error('invalid')
|
||||||
|
return { width: file.width ?? 10, height: file.height ?? 10, close() {} }
|
||||||
|
},
|
||||||
|
URL: { createObjectURL: () => `blob:fixture-${++count}`, revokeObjectURL: url => revoked.push(url) }
|
||||||
|
})
|
||||||
|
return { api, read: (files, existing = []) => api.readChatAttachments(files, existing, { imagesEnabled: true }), revoked }
|
||||||
|
}
|
||||||
|
const image = (extra = {}) => ({ name: 'screen.png', size: 100, ...extra })
|
||||||
|
|
||||||
|
test('image and text attachments coexist without counting image bytes as text context', async () => {
|
||||||
|
const h = setup()
|
||||||
|
const files = await h.read([image({ size: 5 * 1024 * 1024 }), { name: 'config.yaml', size: 10, arrayBuffer: async () => new TextEncoder().encode('robot: mini').buffer }])
|
||||||
|
assert.equal(files[0].kind, 'image')
|
||||||
|
assert.equal(files[0].previewUrl, 'blob:fixture-1')
|
||||||
|
assert.equal(files[1].content, 'robot: mini')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bounds image counts, byte sizes, decoded pixels and rejects malformed images', async () => {
|
||||||
|
const h = setup()
|
||||||
|
await assert.rejects(h.read([image({ size: 5 * 1024 * 1024 + 1 })]), /5MB/)
|
||||||
|
await assert.rejects(h.read([image({ width: 4001, height: 4001 })]), /1600만/)
|
||||||
|
await assert.rejects(h.read([image({ invalid: true })]), /읽지 못/)
|
||||||
|
await assert.rejects(h.read(Array.from({ length: 4 }, () => image())), /3장/)
|
||||||
|
assert.equal(h.revoked.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('a mixed batch failure revokes only newly created image previews', async () => {
|
||||||
|
const h = setup()
|
||||||
|
const existing = await h.read([image()])
|
||||||
|
await assert.rejects(h.read([image(), { name: 'file.pdf', size: 20 }], existing), /PDF/)
|
||||||
|
assert.deepEqual(h.revoked, ['blob:fixture-2'])
|
||||||
|
assert.equal(existing[0].previewUrl, 'blob:fixture-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restores cached multipart filenames while preserving valid Unicode and Latin-1 names', () => {
|
||||||
|
const { api } = setup()
|
||||||
|
const name = '스크린샷 2026-10-19 14-30-45.png'
|
||||||
|
assert.equal(api.displayAttachmentName(Buffer.from(name).toString('latin1')), name)
|
||||||
|
for (const value of [name, 'screen.png', 'caf\u00e9.png', '\uD83D\uDCF7.png']) assert.equal(api.displayAttachmentName(value), value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function previewHarness() {
|
||||||
|
const { api } = setup()
|
||||||
|
const exports = {}
|
||||||
|
let preview = null
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/components/ChatImageAttachments.tsx'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, {
|
||||||
|
exports,
|
||||||
|
document: { body: {} },
|
||||||
|
require(name) {
|
||||||
|
if (name === 'react') return { ...React, useEffect() {}, useRef: () => ({ current: null }), useState: () => [preview, value => { preview = value }] }
|
||||||
|
if (name === 'react-dom') return { createPortal: child => child }
|
||||||
|
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||||
|
if (name === './chatAttachments') return api
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const find = (node, predicate) => {
|
||||||
|
if (!React.isValidElement(node)) return null
|
||||||
|
if (predicate(node)) return node
|
||||||
|
return React.Children.toArray(node.props.children).map(child => find(child, predicate)).find(Boolean)
|
||||||
|
}
|
||||||
|
return { render: props => exports.ChatImageAttachments(props), find }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`draft combines thumbnail, truncated filename and removal in one bounded item (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const h = previewHarness()
|
||||||
|
const name = '스크린샷-긴파일이름-'.repeat(12) + '.png'
|
||||||
|
let removed
|
||||||
|
const props = { images: [{ name, url: 'blob:fixture' }], dark, onRemove: image => { removed = image.url } }
|
||||||
|
const tree = h.render(props)
|
||||||
|
const item = h.find(tree, node => node.props['data-chat-image-draft'])
|
||||||
|
assert.ok(item)
|
||||||
|
assert.match(item.props.className, /w-56.*max-w-full/)
|
||||||
|
assert.ok(h.find(item, node => node.type === 'img'))
|
||||||
|
const label = h.find(item, node => node.type === 'span')
|
||||||
|
assert.equal(label.props.children, name)
|
||||||
|
assert.match(label.props.className, /truncate/)
|
||||||
|
const zoom = h.find(item, node => node.type === 'button' && node.props.title === name)
|
||||||
|
assert.equal(zoom.props.title, name)
|
||||||
|
h.find(item, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.onClick()
|
||||||
|
assert.equal(removed, 'blob:fixture')
|
||||||
|
const busyItem = h.find(h.render({ ...props, removeDisabled: true }), node => node.props['data-chat-image-draft'])
|
||||||
|
assert.equal(h.find(busyItem, node => node.type === 'button' && node.props['aria-label'] === `첨부 제거: ${name}`).props.disabled, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('cached garbled names are restored in the thumbnail label and enlarged dialog', () => {
|
||||||
|
const h = previewHarness()
|
||||||
|
const name = '스크린샷 2026-10-19 14-30-45.png'
|
||||||
|
const props = { images: [{ name: Buffer.from(name).toString('latin1'), url: 'https://files.test/image.png' }], variant: 'sent' }
|
||||||
|
const tree = h.render(props)
|
||||||
|
assert.equal(h.find(tree, node => node.type === 'button').props.title, name)
|
||||||
|
assert.equal(h.find(tree, node => node.props['data-chat-image-draft']), undefined)
|
||||||
|
h.find(tree, node => node.type === 'button').props.onClick()
|
||||||
|
const html = renderToStaticMarkup(h.render(props))
|
||||||
|
assert.match(html, /<dialog[^>]*aria-label="스크린샷 2026-10-19 14-30-45.png"/)
|
||||||
|
assert.ok(html.includes(`>${name}</span>`))
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
|
||||||
|
function harness() {
|
||||||
|
let layoutEffects = []
|
||||||
|
let effects = []
|
||||||
|
let resizeCallback
|
||||||
|
let disconnected = false
|
||||||
|
const states = []
|
||||||
|
const refs = []
|
||||||
|
let stateIndex = 0
|
||||||
|
let refIndex = 0
|
||||||
|
const submissions = []
|
||||||
|
const input = {
|
||||||
|
style: {}, scrollHeight: 56,
|
||||||
|
getBoundingClientRect: () => ({ width: 300 })
|
||||||
|
}
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatWidgets.tsx'), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, {
|
||||||
|
exports,
|
||||||
|
window: { getComputedStyle: () => ({ lineHeight: '20px', paddingTop: '10px', paddingBottom: '10px', borderTopWidth: '0px', borderBottomWidth: '0px', minHeight: '56px' }) },
|
||||||
|
ResizeObserver: class {
|
||||||
|
constructor(callback) { resizeCallback = callback }
|
||||||
|
observe(element) { assert.equal(element, input) }
|
||||||
|
disconnect() { disconnected = true }
|
||||||
|
},
|
||||||
|
require(name) {
|
||||||
|
if (name === '../i18n') return { t: key => key, useLocaleRender() {} }
|
||||||
|
if (name === './chatAttachments') return {}
|
||||||
|
if (name === './ChatTextContextMenu') return { ChatTextContextMenu: () => null }
|
||||||
|
if (name === './ChatImageAttachments') return { ChatImageAttachments: () => null }
|
||||||
|
if (name === './ChatCodeBlock') return {}
|
||||||
|
if (name === 'react') return {
|
||||||
|
...React,
|
||||||
|
useState(initial) {
|
||||||
|
const index = stateIndex++
|
||||||
|
if (!(index in states)) states[index] = initial
|
||||||
|
return [states[index], value => { states[index] = value }]
|
||||||
|
},
|
||||||
|
useRef(initial) { return refs[refIndex++] ??= { current: initial } },
|
||||||
|
useEffect(callback) { effects.push(callback) },
|
||||||
|
useLayoutEffect(callback) { layoutEffects.push(callback) }
|
||||||
|
}
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
function find(node, type) {
|
||||||
|
if (!React.isValidElement(node)) return null
|
||||||
|
if (node.type === type) return node
|
||||||
|
return React.Children.toArray(node.props.children).map(child => find(child, type)).find(Boolean)
|
||||||
|
}
|
||||||
|
function render() {
|
||||||
|
stateIndex = 0
|
||||||
|
refIndex = 0
|
||||||
|
layoutEffects = []
|
||||||
|
effects = []
|
||||||
|
const tree = exports.QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit: content => submissions.push(content) })
|
||||||
|
const textarea = find(tree, 'textarea')
|
||||||
|
textarea.props.ref.current = input
|
||||||
|
layoutEffects.forEach(callback => callback())
|
||||||
|
return textarea
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
input, render, submissions,
|
||||||
|
mountEffects: () => effects.map(callback => callback()),
|
||||||
|
resize: width => resizeCallback([{ contentRect: { width } }]),
|
||||||
|
disconnected: () => disconnected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('input grows to eight visible lines and scrolls only above the cap', () => {
|
||||||
|
const h = harness()
|
||||||
|
h.render()
|
||||||
|
assert.equal(h.input.style.height, '56px')
|
||||||
|
assert.equal(h.input.style.overflowY, 'hidden')
|
||||||
|
h.input.scrollHeight = 100
|
||||||
|
h.render()
|
||||||
|
assert.equal(h.input.style.height, '100px')
|
||||||
|
h.input.scrollHeight = 180
|
||||||
|
h.render()
|
||||||
|
assert.equal(h.input.style.height, '180px')
|
||||||
|
assert.equal(h.input.style.overflowY, 'hidden')
|
||||||
|
h.input.scrollHeight = 300
|
||||||
|
h.render()
|
||||||
|
assert.equal(h.input.style.height, '180px')
|
||||||
|
assert.equal(h.input.style.overflowY, 'auto')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('deleting text and submitting restore the initial height', () => {
|
||||||
|
const h = harness()
|
||||||
|
let textarea = h.render()
|
||||||
|
textarea.props.onChange({ target: { value: 'question\nsecond line\nthird line' } })
|
||||||
|
h.input.scrollHeight = 80
|
||||||
|
textarea = h.render()
|
||||||
|
assert.equal(h.input.style.height, '80px')
|
||||||
|
let prevented = false
|
||||||
|
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: {}, preventDefault() { prevented = true } })
|
||||||
|
assert.equal(prevented, true)
|
||||||
|
assert.equal(h.submissions.length, 1)
|
||||||
|
h.input.scrollHeight = 40
|
||||||
|
textarea = h.render()
|
||||||
|
assert.equal(textarea.props.value, '')
|
||||||
|
assert.equal(h.input.style.height, '56px')
|
||||||
|
assert.equal(h.input.style.overflowY, 'hidden')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Shift+Enter and IME composition do not submit', () => {
|
||||||
|
const h = harness()
|
||||||
|
let textarea = h.render()
|
||||||
|
textarea.props.onChange({ target: { value: 'question' } })
|
||||||
|
textarea = h.render()
|
||||||
|
const preventDefault = () => assert.fail('newline or composing Enter must not be intercepted')
|
||||||
|
textarea.props.onKeyDown({ key: 'Enter', shiftKey: true, nativeEvent: {}, preventDefault })
|
||||||
|
textarea.props.onKeyDown({ key: 'Enter', shiftKey: false, nativeEvent: { isComposing: true }, preventDefault })
|
||||||
|
assert.equal(h.submissions.length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('panel width changes recalculate wrapped lines and observer disconnects on unmount', () => {
|
||||||
|
const h = harness()
|
||||||
|
h.render()
|
||||||
|
const cleanups = h.mountEffects()
|
||||||
|
h.input.scrollHeight = 120
|
||||||
|
h.resize(180)
|
||||||
|
assert.equal(h.input.style.height, '120px')
|
||||||
|
h.input.scrollHeight = 50
|
||||||
|
h.resize(400)
|
||||||
|
assert.equal(h.input.style.height, '56px')
|
||||||
|
cleanups.forEach(cleanup => cleanup?.())
|
||||||
|
assert.equal(h.disconnected(), true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
function compile(file, overrides = {}, globals = {}) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX } }).outputText, {
|
||||||
|
exports, require: name => overrides[name] ?? require(name), clearTimeout, setTimeout, ...globals
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
const i18n = { t: key => key, useLocaleRender() {} }
|
||||||
|
const block = compile('../src/renderer/src/components/ChatCodeBlock.tsx', { '../i18n': i18n })
|
||||||
|
const { MarkdownMessage } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||||
|
'../i18n': i18n, './ChatCodeBlock': block, './chatAttachments': {}, './ChatImageAttachments': {}, './ChatTextContextMenu': {}
|
||||||
|
})
|
||||||
|
const render = (content, dark = true, extra = {}) => renderToStaticMarkup(React.createElement(MarkdownMessage, { content, dark, ...extra }))
|
||||||
|
|
||||||
|
for (const dark of [true, false]) {
|
||||||
|
test(`renders numbered/nested/task lists without losing the starting number (${dark})`, () => {
|
||||||
|
const html = render('3. 설정 확인\n - 속도 제한\n - 조향 제한\n4. 결과 확인\n\n- [x] 완료\n- [ ] 대기', dark)
|
||||||
|
assert.match(html, /<ol start="3" class="[^"]*list-decimal/)
|
||||||
|
assert.match(html, /<ul class="[^"]*list-disc/)
|
||||||
|
assert.match(html, /contains-task-list list-none/)
|
||||||
|
assert.match(html, /task-list-item list-none/)
|
||||||
|
assert.equal((html.match(/type="checkbox"/g) ?? []).length, 2)
|
||||||
|
})
|
||||||
|
test(`renders GFM tables with keyboard-scrollable framing and column alignment (${dark})`, () => {
|
||||||
|
const html = render('| 모듈 | 속도 |\n| --- | ---: |\n| Control | `0.3 m/s` |', dark)
|
||||||
|
assert.match(html, /data-chat-table/)
|
||||||
|
assert.match(html, /tabindex="0" role="region" aria-label="표"/)
|
||||||
|
assert.match(html, /overflow-x-auto/)
|
||||||
|
assert.match(html, /<table class="[^"]*table-auto/)
|
||||||
|
assert.match(html, /text-align:right/)
|
||||||
|
assert.match(html, /chat-inline-code/)
|
||||||
|
})
|
||||||
|
test(`renders fenced Python with language, token colors, copy control and preserved indentation (${dark})`, () => {
|
||||||
|
const html = render('```python\ndef speed():\n return 0.3\n```', dark)
|
||||||
|
assert.match(html, /data-chat-code-block/)
|
||||||
|
assert.ok(html.includes(`data-code-theme="${dark ? 'dark' : 'light'}"`))
|
||||||
|
assert.match(html, />Python<\/span>/)
|
||||||
|
assert.match(html, /aria-label="코드 복사"/)
|
||||||
|
assert.match(html, /hljs-keyword/)
|
||||||
|
assert.match(html, /hljs-number/)
|
||||||
|
assert.match(html, /\n <span/)
|
||||||
|
assert.match(html, /max-h-80 max-w-full overflow-auto/)
|
||||||
|
assert.match(html, /whitespace-pre font-mono/)
|
||||||
|
assert.doesNotMatch(html, /chat-inline-code/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('unlabelled and unknown languages remain code blocks rather than inline code', () => {
|
||||||
|
for (const language of ['', 'unknown-language']) {
|
||||||
|
const html = render('```' + language + '\n unformatted <value>\n```')
|
||||||
|
assert.match(html, /data-chat-code-block/)
|
||||||
|
assert.ok(html.includes(language || '일반 텍스트'))
|
||||||
|
assert.ok(html.includes(' unformatted <value>'))
|
||||||
|
assert.doesNotMatch(html, /hljs-/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('HTML-looking code is rendered as escaped text, never live elements', () => {
|
||||||
|
const html = render('```html\n<script>window.injected = true</script>\n<img src=x onerror="window.injected=true">\n```')
|
||||||
|
assert.doesNotMatch(html, /<script\b|<img\b/)
|
||||||
|
assert.match(html, /</)
|
||||||
|
assert.match(html, /window.injected/)
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/ChatCodeBlock.tsx'), 'utf8')
|
||||||
|
assert.doesNotMatch(source, /dangerouslySetInnerHTML/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('only newly revealed code and tables receive the fade class', () => {
|
||||||
|
const markdown = '설명\n\n```yaml\nspeed: 0.3\n```\n\n| 키 | 값 |\n| --- | --- |\n| speed | 0.3 |'
|
||||||
|
const html = render(markdown, true, { fadeFrom: markdown.indexOf('| 키') })
|
||||||
|
assert.equal((html.match(/agent-response-fade/g) ?? []).length, 1)
|
||||||
|
assert.match(html, /overflow-x-auto rounded-md agent-response-fade/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('oversized code skips tokenization but still preserves all content', () => {
|
||||||
|
const source = '# comment\n'.repeat(6000)
|
||||||
|
const html = renderToStaticMarkup(React.createElement(block.ChatCodeBlock, { source, language: 'python', dark: true }))
|
||||||
|
assert.doesNotMatch(html, /hljs-comment/)
|
||||||
|
assert.equal((html.match(/# comment/g) ?? []).length, 6000)
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(tree, predicate) {
|
||||||
|
if (!React.isValidElement(tree)) return []
|
||||||
|
return [...(predicate(tree) ? [tree] : []), ...React.Children.toArray(tree.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
for (const fails of [false, true]) test(`code copy preserves the full source and reports clipboard ${fails ? 'failure' : 'success'}`, async () => {
|
||||||
|
let state = 'idle', copied, timer
|
||||||
|
const api = compile('../src/renderer/src/components/ChatCodeBlock.tsx', {
|
||||||
|
'../i18n': i18n,
|
||||||
|
react: { ...React, useState: () => [state, value => { state = value }], useMemo: fn => fn(), useRef: () => ({ current: null }), useEffect() {} }
|
||||||
|
}, { navigator: { clipboard: { writeText: async text => { if (fails) throw Error('denied'); copied = text } } }, setTimeout: callback => { timer = callback; return 1 }, clearTimeout() {} })
|
||||||
|
const source = '\t# 한글 주석\n speed = 0.3\n\n'
|
||||||
|
const renderBlock = () => api.ChatCodeBlock({ source, language: 'python', dark: true })
|
||||||
|
const button = elements(renderBlock(), element => element.type === 'button')[0]
|
||||||
|
let stopped = false
|
||||||
|
button.props.onClick({ stopPropagation() { stopped = true } })
|
||||||
|
await new Promise(resolve => setImmediate(resolve))
|
||||||
|
assert.equal(stopped, true)
|
||||||
|
assert.equal(state, fails ? 'error' : 'copied')
|
||||||
|
if (!fails) assert.equal(copied, source)
|
||||||
|
assert.equal(elements(renderBlock(), element => element.type === 'button')[0].props['aria-label'], fails ? '코드를 복사하지 못했습니다.' : '복사됨')
|
||||||
|
timer()
|
||||||
|
assert.equal(state, 'idle')
|
||||||
|
})
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
|
||||||
|
function compile(file, overrides = {}, globals = {}) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve(file), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, { exports, require: name => overrides[name] ?? require(name), ...globals })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
function nativeMenu() {
|
||||||
|
let template
|
||||||
|
let close
|
||||||
|
let clipboard = ''
|
||||||
|
let copies = 0
|
||||||
|
let pastes = 0
|
||||||
|
const frame = {}
|
||||||
|
const sender = { mainFrame: frame, isDestroyed: () => false, copy() { copies++ }, paste() { pastes++ } }
|
||||||
|
const event = { sender, senderFrame: frame }
|
||||||
|
const { showChatTextMenu } = compile('../src/main/chatTextMenu.ts', {
|
||||||
|
electron: {
|
||||||
|
BrowserWindow: { fromWebContents: () => ({ isDestroyed: () => false }) },
|
||||||
|
clipboard: { readText: () => clipboard },
|
||||||
|
Menu: { buildFromTemplate(items) {
|
||||||
|
template = items
|
||||||
|
return { popup(options) { close = options.callback } }
|
||||||
|
} }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
show: options => showChatTextMenu(event, options),
|
||||||
|
items: () => template,
|
||||||
|
close: () => close(),
|
||||||
|
setClipboard: text => { clipboard = text },
|
||||||
|
copies: () => copies, pastes: () => pastes, event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = { copy: '복사', paste: '붙여넣기', clear: '대화내역 삭제' }
|
||||||
|
|
||||||
|
for (const canCopy of [false, true]) {
|
||||||
|
test(`message copy is enabled only with a selection (selected=${canCopy})`, async () => {
|
||||||
|
const menu = nativeMenu()
|
||||||
|
const result = menu.show({ kind: 'message', canCopy, canClear: true, labels })
|
||||||
|
assert.equal(menu.items()[0].enabled, canCopy)
|
||||||
|
assert.equal(menu.items()[0].label, '복사')
|
||||||
|
assert.equal(menu.items().some(item => item.label === '붙여넣기'), false)
|
||||||
|
menu.items()[0].click()
|
||||||
|
assert.equal(await result, null)
|
||||||
|
assert.equal(menu.copies(), canCopy ? 1 : 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const clipboard of ['', '복사해 둔 내용']) {
|
||||||
|
test(`input paste availability is read from the OS clipboard (empty=${!clipboard})`, async () => {
|
||||||
|
const menu = nativeMenu()
|
||||||
|
menu.setClipboard(clipboard)
|
||||||
|
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
|
||||||
|
assert.equal(menu.items().length, 2)
|
||||||
|
assert.equal(menu.items()[0].enabled, false)
|
||||||
|
assert.equal(menu.items()[1].label, '붙여넣기')
|
||||||
|
assert.equal(menu.items()[1].enabled, Boolean(clipboard))
|
||||||
|
menu.items()[1].click()
|
||||||
|
await result
|
||||||
|
assert.equal(menu.pastes(), clipboard ? 1 : 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('read-only input never allows paste, even with clipboard text', async () => {
|
||||||
|
const menu = nativeMenu()
|
||||||
|
menu.setClipboard('text')
|
||||||
|
const result = menu.show({ kind: 'input', canCopy: true, canPaste: false, labels })
|
||||||
|
assert.equal(menu.items()[1].enabled, false)
|
||||||
|
menu.items()[1].click()
|
||||||
|
await result
|
||||||
|
assert.equal(menu.pastes(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('paste rechecks an emptied clipboard before editing the input', async () => {
|
||||||
|
const menu = nativeMenu()
|
||||||
|
menu.setClipboard('text')
|
||||||
|
const result = menu.show({ kind: 'input', canCopy: false, canPaste: true, labels })
|
||||||
|
assert.equal(menu.items()[1].enabled, true)
|
||||||
|
menu.setClipboard('')
|
||||||
|
menu.items()[1].click()
|
||||||
|
await result
|
||||||
|
assert.equal(menu.pastes(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('delete selection returns a confirmation request rather than deleting data', async () => {
|
||||||
|
const menu = nativeMenu()
|
||||||
|
const result = menu.show({ kind: 'message', canCopy: false, canClear: true, labels })
|
||||||
|
menu.items().find(item => item.label === '대화내역 삭제').click()
|
||||||
|
assert.equal(await result, 'clear')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('closing a menu without choosing an action does nothing', async () => {
|
||||||
|
const menu = nativeMenu()
|
||||||
|
const result = menu.show({ kind: 'message', canCopy: true, labels })
|
||||||
|
menu.close()
|
||||||
|
assert.equal(await result, null)
|
||||||
|
assert.equal(menu.copies(), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('untrusted frames and invalid menu targets cannot open a native menu', () => {
|
||||||
|
const menu = nativeMenu()
|
||||||
|
assert.equal(menu.show({ kind: 'unknown', canCopy: true, labels }), null)
|
||||||
|
menu.event.senderFrame = {}
|
||||||
|
assert.equal(menu.show({ kind: 'input', canCopy: true, labels }), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return []
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const selected of [false, true]) {
|
||||||
|
test(`textarea opens the themed menu with a selection snapshot (selected=${selected})`, async () => {
|
||||||
|
let options
|
||||||
|
let prevented = false
|
||||||
|
let stopped = false
|
||||||
|
const { QaInputBox } = compile('../src/renderer/src/components/ChatWidgets.tsx', {
|
||||||
|
'../i18n': { t: key => key, useLocaleRender() {} }, './chatAttachments': {}, './ChatTextContextMenu': { ChatTextContextMenu: () => null }, './ChatImageAttachments': { ChatImageAttachments: () => null }, './ChatCodeBlock': {},
|
||||||
|
react: { ...React, useState: initial => [initial, value => { if (value?.kind === 'input') options = value }], useEffect() {}, useLayoutEffect() {}, useRef: () => ({ current: null }) }
|
||||||
|
}, { window: { chatTextMenu: { show: async input => { options = input; return null } } } })
|
||||||
|
const tree = QaInputBox.type({ dark: true, busy: false, disabled: false, onStop() {}, onSubmit() {} })
|
||||||
|
const textarea = elements(tree, node => node.type === 'textarea')[0]
|
||||||
|
textarea.props.onContextMenu({
|
||||||
|
currentTarget: { value: 'abcdef', selectionStart: 2, selectionEnd: selected ? 5 : 2, disabled: false, readOnly: false },
|
||||||
|
preventDefault() { prevented = true }, stopPropagation() { stopped = true }
|
||||||
|
})
|
||||||
|
await Promise.resolve()
|
||||||
|
assert.equal(options.kind, 'input')
|
||||||
|
assert.equal(options.text, selected ? 'cde' : '')
|
||||||
|
assert.equal(options.input.disabled, false)
|
||||||
|
assert.equal(options.selectionStart, 2)
|
||||||
|
assert.equal(prevented, true)
|
||||||
|
assert.equal(stopped, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/components/AppStatusModals.tsx'), 'utf8')
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
function loadModals() {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(compiled, {
|
||||||
|
exports,
|
||||||
|
require: name => name === '../i18n' ? { t: key => key, useLocaleRender() {} } : require(name)
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
function buttons(element) {
|
||||||
|
if (!React.isValidElement(element)) return []
|
||||||
|
if (element.type === 'button') return [element]
|
||||||
|
return React.Children.toArray(element.props.children).flatMap(buttons)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
test(`extension action stays on the right in both dialogs (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const { StatusNoticeModal, ControlExtensionConfirmModal } = loadModals()
|
||||||
|
let confirmed = 0
|
||||||
|
let extended = 0
|
||||||
|
const notice = StatusNoticeModal({
|
||||||
|
overlayClassName: 'overlay', dark, tone: 'clock', title: '제어 시간 안내', message: '제어 시간이 곧 종료됩니다.',
|
||||||
|
actionLabel: '연장', onAction: () => { extended++ }, onConfirm: () => { confirmed++ }
|
||||||
|
})
|
||||||
|
const noticeButtons = buttons(notice)
|
||||||
|
assert.deepEqual(noticeButtons.map(button => button.props.children), ['확인', '연장'])
|
||||||
|
assert.match(noticeButtons[1].props.className, /bg-\[#6f4df6\]/)
|
||||||
|
assert.doesNotMatch(noticeButtons[0].props.className, /bg-\[#6f4df6\]/)
|
||||||
|
noticeButtons[0].props.onClick()
|
||||||
|
noticeButtons[1].props.onClick()
|
||||||
|
assert.equal(confirmed, 1)
|
||||||
|
assert.equal(extended, 1)
|
||||||
|
|
||||||
|
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark, platformName: 'MK-mini', onCancel() {}, onConfirm() {} })
|
||||||
|
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장'])
|
||||||
|
assert.match(buttons(confirmation)[1].props.className, /bg-\[#6f4df6\]/)
|
||||||
|
assert.match(renderToStaticMarkup(confirmation), /취소<\/button><button[^>]*>연장<\/button>/)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('notices without an extension action retain their single primary confirmation button', () => {
|
||||||
|
const { StatusNoticeModal } = loadModals()
|
||||||
|
const notice = StatusNoticeModal({ overlayClassName: 'overlay', dark: true, tone: 'warning', title: '안내', message: '메시지', onConfirm() {} })
|
||||||
|
assert.equal(buttons(notice).length, 1)
|
||||||
|
assert.equal(buttons(notice)[0].props.children, '확인')
|
||||||
|
assert.match(buttons(notice)[0].props.className, /bg-\[#6f4df6\]/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('the extension confirmation keeps the right button disabled while submitting', () => {
|
||||||
|
const { ControlExtensionConfirmModal } = loadModals()
|
||||||
|
const confirmation = ControlExtensionConfirmModal({ overlayClassName: 'overlay', dark: true, platformName: 'MK-mini', submitting: true, onCancel() {}, onConfirm() {} })
|
||||||
|
assert.deepEqual(buttons(confirmation).map(button => button.props.children), ['취소', '연장 중'])
|
||||||
|
assert.ok(buttons(confirmation).every(button => button.props.disabled))
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const root = path.resolve(__dirname, '../src')
|
||||||
|
const app = fs.readFileSync(path.join(root, 'renderer/src/App.tsx'), 'utf8')
|
||||||
|
const view = fs.readFileSync(path.join(root, 'renderer/src/features/workspace/ModuleListView.tsx'), 'utf8')
|
||||||
|
|
||||||
|
test('remote creation and Git import are enabled while raw folder import remains local', () => {
|
||||||
|
assert.match(app, /allowCustomModuleCreation=\{true\}/)
|
||||||
|
assert.match(app, /allowCustomModuleImport=\{true\}/)
|
||||||
|
assert.match(app, /remoteGitImport=\{remoteWorkspace \? \{ workspaceRoot: localVisualizationWorkspaceRoot, onImport: importGitModule \} : undefined\}/)
|
||||||
|
assert.match(view, /remoteGitImport \? <RemoteModuleImportDialog/)
|
||||||
|
assert.match(app, /const importCustomModule[\s\S]*?if \(remoteWorkspace\) throw new Error/)
|
||||||
|
const importButton = view.slice(view.indexOf('aria-label={t("모듈 불러오기")}'), view.indexOf('{deleteConfirmModule &&'))
|
||||||
|
assert.match(importButton, /disabled=\{!allowCustomModuleImport\}/)
|
||||||
|
assert.doesNotMatch(importButton, /allowCustomModuleCreation/)
|
||||||
|
assert.match(view, /allowCustomModuleImport && moduleImportDialogOpen/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('new module dialog requests images from the current workspace', () => {
|
||||||
|
assert.match(app, /loadDockerImages=\{\(\) => remoteWorkspace\s*\? window\.remoteWorkspace\.modules\.listDockerImages\(getRemoteWorkspaceTarget\(\)\)/)
|
||||||
|
const dialog = view.slice(view.indexOf('function ModuleAddDialog('), view.indexOf('function ModuleImportDialog('))
|
||||||
|
assert.match(dialog, /void loadDockerImages\(\)/)
|
||||||
|
assert.doesNotMatch(dialog, /window\.localWorkspace/)
|
||||||
|
const preload = fs.readFileSync(path.join(root, 'preload/index.ts'), 'utf8')
|
||||||
|
const main = fs.readFileSync(path.join(root, 'main/index.ts'), 'utf8')
|
||||||
|
assert.match(preload, /invoke\('remote-workspace:modules:docker-images', target\)/)
|
||||||
|
assert.match(main, /handle\('remote-workspace:modules:docker-images'/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const os = require('node:os')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const exportsObject = {}
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../src/main/dailyLog.ts'), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText, { exports: exportsObject, require })
|
||||||
|
const { createDailyLog, redactLog } = exportsObject
|
||||||
|
|
||||||
|
test('appends and rotates by local date, retaining unrelated files', (t) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lms-log-test-'))
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||||
|
fs.writeFileSync(path.join(root, 'renderer.log'), 'existing log')
|
||||||
|
let date = new Date(2026, 8, 29, 23, 59)
|
||||||
|
const log = createDailyLog([root], () => date)
|
||||||
|
log.write('test', 'first')
|
||||||
|
log.write('test', 'second')
|
||||||
|
const oldPath = log.path()
|
||||||
|
assert.match(oldPath, /lms-2026-09-29\.log$/)
|
||||||
|
assert.match(fs.readFileSync(oldPath, 'utf8'), /first[\s\S]*second/)
|
||||||
|
assert.equal(fs.statSync(oldPath).mode & 0o777, 0o600)
|
||||||
|
date = new Date(2026, 8, 30, 0, 1)
|
||||||
|
log.write('test', 'next day')
|
||||||
|
assert.match(log.path(), /lms-2026-09-30\.log$/)
|
||||||
|
assert.equal(fs.existsSync(oldPath), false)
|
||||||
|
assert.equal(fs.readFileSync(path.join(root, 'renderer.log'), 'utf8'), 'existing log')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('falls back when the primary directory is unusable', (t) => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lms-log-test-'))
|
||||||
|
t.after(() => fs.rmSync(root, { recursive: true, force: true }))
|
||||||
|
const blocked = path.join(root, 'file')
|
||||||
|
fs.writeFileSync(blocked, '')
|
||||||
|
const log = createDailyLog([blocked, path.join(root, 'fallback')])
|
||||||
|
log.write('test', 'fallback')
|
||||||
|
assert.equal(path.dirname(log.path()), path.join(root, 'fallback'))
|
||||||
|
const unavailable = createDailyLog([blocked])
|
||||||
|
assert.doesNotThrow(() => unavailable.write('test', 'ignored'))
|
||||||
|
assert.equal(unavailable.path(), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('redacts common authentication fields and credential URLs', () => {
|
||||||
|
const result = redactLog('https://oauth2:secret123@example.com/repo.git Bearer secret456 --handoff-token=secret789 ?password=secret000 {"accessToken":"secret111"} password=secret222')
|
||||||
|
for (const secret of ['secret123', 'secret456', 'secret789', 'secret000', 'secret111', 'secret222']) {
|
||||||
|
assert.equal(result.includes(secret), false)
|
||||||
|
}
|
||||||
|
assert.match(result, /example.com\/repo.git/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('bootstrap logs dependency loading failures before exiting', async () => {
|
||||||
|
const events = []
|
||||||
|
const bootstrap = fs.readFileSync(path.resolve(__dirname, '../src/main/bootstrap.ts'), 'utf8')
|
||||||
|
const code = ts.transpileModule(bootstrap, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
vm.runInNewContext(code, {
|
||||||
|
exports: {}, Error,
|
||||||
|
require: (name) => {
|
||||||
|
if (name === 'electron') return { app: { exit: (code) => events.push(['exit', code]) } }
|
||||||
|
if (name === './appLogging') {
|
||||||
|
events.push(['logger-ready'])
|
||||||
|
return { appLog: { write: (...args) => events.push(args) } }
|
||||||
|
}
|
||||||
|
if (name === './index') throw new Error('test native module failure')
|
||||||
|
throw new Error(`Unexpected dependency: ${name}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await new Promise((resolve) => setImmediate(resolve))
|
||||||
|
assert.equal(events[0][0], 'logger-ready')
|
||||||
|
assert.equal(events[1][0], 'startup-error')
|
||||||
|
assert.match(events[1][1], /test native module failure/)
|
||||||
|
assert.deepEqual(events[2], ['exit', 1])
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const crypto = require('node:crypto')
|
||||||
|
const postcss = require('postcss')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const source = fs.readFileSync(path.join(root, 'src/renderer/src/styles/nanumSquare.css'), 'utf8')
|
||||||
|
const css = postcss.parse(source)
|
||||||
|
const faces = []
|
||||||
|
css.walkAtRules('font-face', rule => {
|
||||||
|
faces.push(Object.fromEntries(rule.nodes.filter(node => node.type === 'decl').map(node => [node.prop, node.value])))
|
||||||
|
})
|
||||||
|
|
||||||
|
function codepoints(range) {
|
||||||
|
const result = new Set()
|
||||||
|
let previous = -1
|
||||||
|
for (const entry of range.split(',')) {
|
||||||
|
const match = entry.trim().match(/^U\+([0-9A-F]+)(?:-([0-9A-F]+))?$/)
|
||||||
|
assert.ok(match, `Invalid unicode-range: ${entry}`)
|
||||||
|
const start = parseInt(match[1], 16)
|
||||||
|
const end = parseInt(match[2] ?? match[1], 16)
|
||||||
|
assert.ok(start > previous && end >= start && end <= 0x10ffff)
|
||||||
|
for (let code = start; code <= end; code++) result.add(code)
|
||||||
|
previous = end
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
test('coverage is tied to the original bundled font bytes for all four weights', () => {
|
||||||
|
assert.equal(faces.length, 4)
|
||||||
|
assert.deepEqual(faces.map(face => face['font-weight']), ['300', '400', '700', '800'])
|
||||||
|
for (const face of faces) {
|
||||||
|
assert.equal(face['font-family'], "'NanumSquare'")
|
||||||
|
assert.equal(face['font-display'], 'swap')
|
||||||
|
const filename = face.src.match(/\/([^/]+\.woff2)'/)[1]
|
||||||
|
const hash = crypto.createHash('sha256').update(fs.readFileSync(path.join(root, 'src/renderer/public/assets/fonts', filename))).digest('hex')
|
||||||
|
assert.ok(source.includes(`${filename} sha256: ${hash}`), 'Regenerate coverage if a font changes')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const face of faces) {
|
||||||
|
test(`weight ${face['font-weight']} preserves visible Hangul and lets blank glyphs fall back`, () => {
|
||||||
|
const included = codepoints(face['unicode-range'])
|
||||||
|
assert.equal([...included].filter(code => code >= 0xac00 && code <= 0xd7a3).length, 2479)
|
||||||
|
for (const char of '가모한글학생SLAM0123 →') assert.ok(included.has(char.codePointAt(0)), `Keep ${char}`)
|
||||||
|
for (const char of ['몯', '힣', '\u2022']) assert.equal(included.has(char.codePointAt(0)), false, `Let ${char} use fallback`)
|
||||||
|
for (const code of [0x20, 0x3000, 0x3164]) assert.ok(included.has(code), 'Preserve intentional spacing')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('UI keeps its original default font and native fallback without Neo', () => {
|
||||||
|
const entry = fs.readFileSync(path.join(root, 'src/renderer/src/index.css'), 'utf8')
|
||||||
|
assert.match(entry, /@import "\.\/styles\/nanumSquare\.css"/)
|
||||||
|
assert.match(entry, /font-family: 'NanumSquare', Inter, system-ui, sans-serif/)
|
||||||
|
assert.doesNotMatch(entry + source, /NanumSquareNeo/)
|
||||||
|
const directFaces = []
|
||||||
|
postcss.parse(entry).walkAtRules('font-face', rule => directFaces.push(rule))
|
||||||
|
assert.equal(directFaces.length, 0, 'Unrestricted duplicate faces would suppress fallback')
|
||||||
|
})
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const os = require('node:os')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const { execFile } = require('node:child_process')
|
||||||
|
const { promisify } = require('node:util')
|
||||||
|
const exec = promisify(execFile)
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
|
||||||
|
async function loadHelpers() {
|
||||||
|
const exports = {}
|
||||||
|
const code = ts.transpileModule(await fs.readFile(path.join(root, 'src/main/gitModuleImport.ts'), 'utf8'), { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require, URL })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
test('repository addresses strip credentials and reject local/helper protocols', async () => {
|
||||||
|
const api = await loadHelpers()
|
||||||
|
assert.equal(api.repositoryAddress('https://user:secret@example.com/team/module.git?token=secret'), 'https://example.com/team/module.git')
|
||||||
|
assert.equal(api.repositoryAddress('git@example.com:team/module.git'), 'ssh://git@example.com/team/module.git')
|
||||||
|
for (const url of ['file:///tmp/repo', 'ext::command', '/tmp/repo']) assert.throws(() => api.repositoryAddress(url))
|
||||||
|
assert.equal(api.remoteBranches('a'.repeat(40) + '\trefs/heads/main\ninvalid')[0].name, 'main')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local module discovery ignores malformed folders and rejects symlink escapes', async () => {
|
||||||
|
const api = await loadHelpers()
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-git-list-'))
|
||||||
|
try {
|
||||||
|
const workspace = path.join(dir, 'workspace')
|
||||||
|
const folder = '[lms-nav2-custom]demo_v1.0.0_ws'
|
||||||
|
await fs.mkdir(path.join(workspace, folder), { recursive: true })
|
||||||
|
await fs.writeFile(path.join(workspace, folder, '.autonomy-studio-module.json'), JSON.stringify({ sourceType: 'CUSTOM', name: 'Demo', category: 'Control', packageName: 'demo', launchFile: 'demo.launch.py' }))
|
||||||
|
assert.equal((await api.listLocalGitModules(workspace))[0].name, 'Demo')
|
||||||
|
await fs.symlink(dir, path.join(workspace, '[lms-nav2-custom]escape_v1.0.0_ws'))
|
||||||
|
await assert.rejects(api.localGitModulePath(workspace, '[lms-nav2-custom]escape_v1.0.0_ws'), /outside/)
|
||||||
|
await assert.rejects(api.localGitModulePath(workspace, '../escape'), /Invalid/)
|
||||||
|
assert.equal((await api.listLocalGitModules(path.join(dir, 'missing'))).length, 0)
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Git clone imports only the selected commit, refuses overwrite and cleans failed staging', async () => {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-git-import-'))
|
||||||
|
const repo = path.join(dir, 'source')
|
||||||
|
const workspace = path.join(dir, 'remote')
|
||||||
|
const folderName = '[lms-nav2-custom]demo_v1.0.0_ws'
|
||||||
|
const git = (...args) => exec('git', args, { cwd: repo })
|
||||||
|
try {
|
||||||
|
await fs.mkdir(path.join(repo, 'src/demo/launch'), { recursive: true })
|
||||||
|
await fs.writeFile(path.join(repo, 'src/demo/package.xml'), '<package><name>demo</name></package>')
|
||||||
|
await fs.writeFile(path.join(repo, 'src/demo/launch/demo.launch.py'), '# launch')
|
||||||
|
for (const file of ['build.sh', 'run.sh']) await fs.writeFile(path.join(repo, file), '#!/bin/bash\nexit 0\n')
|
||||||
|
await git('init', '-b', 'main')
|
||||||
|
await git('add', '.')
|
||||||
|
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'initial')
|
||||||
|
const commit = (await git('rev-parse', 'HEAD')).stdout.trim()
|
||||||
|
await fs.writeFile(path.join(repo, 'uncommitted.txt'), 'must not transfer')
|
||||||
|
let dirty = false
|
||||||
|
let pushed = true
|
||||||
|
let requestedCommit = commit
|
||||||
|
const index = await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8')
|
||||||
|
const fn = index.slice(index.indexOf('async function importRemoteGitModule('), index.indexOf('async function transferLocalWorkspaceModuleToRemote('))
|
||||||
|
const quote = value => "'" + value.replaceAll("'", "'\\''") + "'"
|
||||||
|
const context = {
|
||||||
|
URL, Buffer,
|
||||||
|
normalizeRemoteTarget: target => target,
|
||||||
|
validateCustomModuleScaffold: input => input,
|
||||||
|
inspectLocalGitModule: async () => ({ repository: 'https://example.com/module.git', dirty, pushed, branches: [{ name: 'main', commit: requestedCommit }] }),
|
||||||
|
remoteRepositoryUrl: () => 'file://' + repo,
|
||||||
|
withGitCredential: value => value,
|
||||||
|
customModuleConfigContent: input => JSON.stringify(input),
|
||||||
|
encodeBase64: value => Buffer.from(value).toString('base64'),
|
||||||
|
quoteShellSingle: quote,
|
||||||
|
expandRemotePathVar: () => [],
|
||||||
|
runSshOutput: async (_target, script) => (await exec('bash', ['-c', script], { timeout: 15000 })).stdout,
|
||||||
|
}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fn, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText, context)
|
||||||
|
const input = { sourceFolder: folderName, folderName, branch: 'main', commit, name: 'Remote demo', category: 'Control', packageName: 'demo', launchFile: 'demo.launch.py', dockerImage: 'vehicle:arm64' }
|
||||||
|
const run = () => context.importRemoteGitModule({ workspaceRoot: workspace }, repo, input)
|
||||||
|
dirty = true
|
||||||
|
await assert.rejects(run(), /커밋/)
|
||||||
|
dirty = false
|
||||||
|
pushed = false
|
||||||
|
await assert.rejects(run(), /푸시/)
|
||||||
|
pushed = true
|
||||||
|
assert.equal((await run()).ok, true)
|
||||||
|
const target = path.join(workspace, folderName)
|
||||||
|
assert.equal((await exec('git', ['rev-parse', 'HEAD'], { cwd: target })).stdout.trim(), commit)
|
||||||
|
await assert.rejects(fs.stat(path.join(target, 'uncommitted.txt')), { code: 'ENOENT' })
|
||||||
|
assert.equal(JSON.parse(await fs.readFile(path.join(target, '.autonomy-studio-module.json'), 'utf8')).dockerImage, 'vehicle:arm64')
|
||||||
|
await fs.writeFile(path.join(target, 'keep.txt'), 'keep')
|
||||||
|
await assert.rejects(run(), /복제에 실패/)
|
||||||
|
assert.equal(await fs.readFile(path.join(target, 'keep.txt'), 'utf8'), 'keep')
|
||||||
|
await fs.rm(target, { recursive: true })
|
||||||
|
requestedCommit = 'a'.repeat(40)
|
||||||
|
input.commit = requestedCommit
|
||||||
|
await assert.rejects(run(), /복제에 실패/)
|
||||||
|
assert.equal((await fs.readdir(workspace)).length, 0)
|
||||||
|
await fs.mkdir(path.join(repo, 'install'))
|
||||||
|
await fs.writeFile(path.join(repo, 'install/binary'), 'not portable')
|
||||||
|
await git('add', 'install')
|
||||||
|
await git('-c', 'user.name=Test', '-c', 'user.email=test@example.com', 'commit', '-m', 'bad artifacts')
|
||||||
|
input.commit = requestedCommit = (await git('rev-parse', 'HEAD')).stdout.trim()
|
||||||
|
await assert.rejects(run(), /복제에 실패/)
|
||||||
|
assert.equal((await fs.readdir(workspace)).length, 0)
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const React = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/WorkspaceGuidePanel.tsx'), 'utf8')
|
||||||
|
const output = {}
|
||||||
|
let videos = []
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, {
|
||||||
|
exports: output,
|
||||||
|
require: name => name === '../../i18n' ? { t: key => key, useLocaleRender() {} }
|
||||||
|
: name === 'react' ? { ...React, useId: () => 'guide' } : require(name),
|
||||||
|
URL,
|
||||||
|
window: { learningVideo: { open: async url => { videos.push(url) } } }
|
||||||
|
})
|
||||||
|
|
||||||
|
function elements(root, predicate) {
|
||||||
|
if (!React.isValidElement(root)) return []
|
||||||
|
return [...(predicate(root) ? [root] : []), ...React.Children.toArray(root.props.children).flatMap(child => elements(child, predicate))]
|
||||||
|
}
|
||||||
|
|
||||||
|
function setup(overrides = {}) {
|
||||||
|
const toggled = []
|
||||||
|
const props = {
|
||||||
|
dark: true, workspaceActive: true, minimized: false, resourceMode: 'material',
|
||||||
|
materialItems: [{ title: 'SLAM', description: '센서 토픽과 TF 상태를 점검하고 지도를 생성합니다.', concepts: ['TF 점검', '지도 저장'], videoUrl: '' }],
|
||||||
|
activeMaterialIndex: 0, expandedMaterialIndexes: [0], topics: [], topicDetails: [], overview: '',
|
||||||
|
resources: [], downloadingResourceIds: [], onToggleMaterial: index => toggled.push(index), ...overrides
|
||||||
|
}
|
||||||
|
const tree = output.default(props)
|
||||||
|
const headers = elements(tree, node => node.type === 'button' && node.props['aria-controls'])
|
||||||
|
return { tree, headers, toggled }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const dark of [false, true]) {
|
||||||
|
test(`only the material header toggles the card (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const { tree, headers, toggled } = setup({ dark })
|
||||||
|
assert.equal(headers.length, 1)
|
||||||
|
const header = headers[0]
|
||||||
|
assert.equal(header.props.type, 'button')
|
||||||
|
assert.equal(header.props['aria-expanded'], true)
|
||||||
|
assert.equal(header.props.disabled, false)
|
||||||
|
assert.doesNotMatch(header.props.className, /hover:bg/)
|
||||||
|
assert.match(header.props.className, /focus-visible:ring-2/)
|
||||||
|
const card = elements(tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
|
||||||
|
assert.ok(card)
|
||||||
|
assert.equal(card.props.onClick, undefined)
|
||||||
|
assert.equal(card.props.onKeyDown, undefined)
|
||||||
|
assert.equal(card.props.role, undefined)
|
||||||
|
assert.equal(card.props.tabIndex, undefined)
|
||||||
|
header.props.onClick()
|
||||||
|
assert.deepEqual(toggled, [0])
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`description is selectable outside the interactive header (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const { tree, headers, toggled } = setup({ dark })
|
||||||
|
const contentId = headers[0].props['aria-controls']
|
||||||
|
const body = elements(tree, node => node.props.id === contentId)[0]
|
||||||
|
assert.match(body.props.className, /select-text/)
|
||||||
|
assert.equal(body.props.onClick, undefined)
|
||||||
|
assert.equal(elements(headers[0], node => node.props.id === contentId).length, 0)
|
||||||
|
assert.ok(renderToStaticMarkup(body).includes('센서 토픽과 TF 상태'))
|
||||||
|
assert.equal(elements(body, node => Boolean(node.props.onClick || node.props.onKeyDown)).length, 0)
|
||||||
|
assert.deepEqual(toggled, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test(`header padding and dimensions stay identical when toggled (${dark ? 'dark' : 'light'})`, () => {
|
||||||
|
const expanded = setup({ dark, expandedMaterialIndexes: [0] })
|
||||||
|
const collapsed = setup({ dark, expandedMaterialIndexes: [] })
|
||||||
|
for (const state of [expanded, collapsed]) {
|
||||||
|
const header = state.headers[0]
|
||||||
|
const card = elements(state.tree, node => node.type === 'section' && React.Children.toArray(node.props.children).some(child => child.props?.['aria-controls'] === header.props['aria-controls']))[0]
|
||||||
|
assert.match(card.props.className, /\bpx-3 pt-3\b/)
|
||||||
|
assert.doesNotMatch(card.props.className, /px-2\.5|pt-2\.5/)
|
||||||
|
assert.match(header.props.className, /\bflex h-6 w-full\b/)
|
||||||
|
}
|
||||||
|
assert.equal(expanded.headers[0].props.className, collapsed.headers[0].props.className)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
test('future material remains disabled, while previous/current steps keep native keyboard buttons', () => {
|
||||||
|
const items = ['이전', '현재', '다음'].map(title => ({ title, description: '', videoUrl: '' }))
|
||||||
|
const { headers } = setup({ materialItems: items, activeMaterialIndex: 1, expandedMaterialIndexes: [1] })
|
||||||
|
assert.deepEqual(headers.map(header => header.props.disabled), [false, false, true])
|
||||||
|
assert.equal(headers[2].props.onClick, undefined)
|
||||||
|
for (const header of headers) {
|
||||||
|
assert.equal(header.props.type, 'button')
|
||||||
|
assert.equal(header.props.onKeyDown, undefined)
|
||||||
|
}
|
||||||
|
assert.ok(setup({ materialItems: items, allowFutureMaterialSteps: true }).headers.every(header => !header.props.disabled))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('video playback is not nested in the collapse button and does not toggle the card', async () => {
|
||||||
|
videos = []
|
||||||
|
const { tree, headers, toggled } = setup({ materialItems: [{ title: 'SLAM', description: '설명', videoUrl: 'https://www.youtube.com/watch?v=test123' }] })
|
||||||
|
const video = elements(tree, node => node.type === 'button' && node.props.className?.includes('aspect-video'))[0]
|
||||||
|
assert.ok(video)
|
||||||
|
assert.equal(elements(headers[0], node => node === video).length, 0)
|
||||||
|
video.props.onClick({ stopPropagation() {} })
|
||||||
|
await Promise.resolve()
|
||||||
|
assert.deepEqual(videos, ['https://www.youtube.com/embed/test123'])
|
||||||
|
assert.deepEqual(toggled, [])
|
||||||
|
for (const button of elements(tree, node => node.type === 'button')) {
|
||||||
|
assert.equal(React.Children.toArray(button.props.children).flatMap(child => elements(child, node => node.type === 'button')).length, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const { createInstance } = require('i18next')
|
||||||
|
|
||||||
|
const root = path.resolve(__dirname, '../src/renderer/src')
|
||||||
|
const dictionary = JSON.parse(fs.readFileSync(path.join(root, 'i18n/en.json'), 'utf8'))
|
||||||
|
|
||||||
|
test('only the default simulation description follows language; vehicle and custom text stay unchanged', () => {
|
||||||
|
const { api } = setup('ko')
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'features/platforms/PlatformCards.tsx'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => name === '../../i18n'
|
||||||
|
? { ...api, useLocaleRender() {} } : require(name) })
|
||||||
|
const { createElement } = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
const description = '내 PC에서 직접 개발 환경을 실행합니다.'
|
||||||
|
const platform = { id: 'local', platformKey: 'futuredrive-local-pc', title: '로컬 PC', description, state: 'ready' }
|
||||||
|
const render = value => renderToStaticMarkup(createElement(exports.PlatformOptionCard, {
|
||||||
|
platform: value, Icon: () => null, dotClassName: '', unavailable: false,
|
||||||
|
statusPending: false, displayStatus: '사용 가능', primaryAction: '연결',
|
||||||
|
actionDisabled: false, running: false, onAction() {}, onSettings() {}
|
||||||
|
}))
|
||||||
|
assert.ok(render(platform).includes(description))
|
||||||
|
api.setLanguage('en')
|
||||||
|
assert.ok(render(platform).includes('Run the development environment directly on this PC.'))
|
||||||
|
assert.ok(!render(platform).includes(description))
|
||||||
|
const vehicleDescription = '기초 SLAM 및 자율주행 실습 장비입니다.'
|
||||||
|
assert.ok(render({ ...platform, platformKey: 'mk-mini', description: vehicleDescription }).includes(vehicleDescription))
|
||||||
|
assert.ok(render({ ...platform, platformKey: 'mk-mini' }).includes(description))
|
||||||
|
assert.ok(render({ ...platform, description: '강사가 작성한 시뮬레이션 안내' }).includes('강사가 작성한 시뮬레이션 안내'))
|
||||||
|
assert.ok(render({ ...platform, description: '설정' }).includes('>설정</p>'))
|
||||||
|
api.setLanguage('ko')
|
||||||
|
assert.ok(render(platform).includes(description))
|
||||||
|
assert.equal(platform.description, description)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('close studio modal translates the built-in Local PC title but preserves vehicle names', () => {
|
||||||
|
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
|
||||||
|
const expression = source.match(/platformTitle=\{(runningPlatformCloseTarget\.platformKey[^\n]+)\}/)?.[1]
|
||||||
|
assert.ok(expression)
|
||||||
|
for (const language of ['en', 'ko']) {
|
||||||
|
const { api } = setup(language)
|
||||||
|
const title = vm.runInNewContext(expression, {
|
||||||
|
runningPlatformCloseTarget: { platformKey: 'futuredrive-local-pc', title: '로컬 PC' }, t: api.t
|
||||||
|
})
|
||||||
|
assert.equal(title, language === 'en' ? 'Local PC' : '로컬 PC')
|
||||||
|
if (language === 'en') assert.equal(api.t('{{v0}} 개발 스튜디오를 종료할까요?', { v0: title }), 'Close the Local PC studio?')
|
||||||
|
assert.equal(vm.runInNewContext(expression, {
|
||||||
|
runningPlatformCloseTarget: { platformKey: 'vehicle', title: '교육용 차량' }, t: api.t
|
||||||
|
}), '교육용 차량')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('session confirmation uses a localized platform display name', () => {
|
||||||
|
const source = fs.readFileSync(path.join(root, 'App.tsx'), 'utf8')
|
||||||
|
const expression = source.match(/const activePlatformDisplayName = ([\s\S]*?)\n const sessionPlatformLabel/)[1]
|
||||||
|
for (const language of ['en', 'ko']) {
|
||||||
|
const { api } = setup(language)
|
||||||
|
const name = vm.runInNewContext(expression, {
|
||||||
|
workspacePlatform: null,
|
||||||
|
sessionPlatform: { platformKey: 'futuredrive-local-pc' },
|
||||||
|
activePlatformName: '로컬 PC', t: api.t
|
||||||
|
})
|
||||||
|
assert.equal(name, language === 'en' ? 'Local PC' : '로컬 PC')
|
||||||
|
if (language === 'en') assert.equal(api.t('{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.', { v0: name }),
|
||||||
|
'Disconnect from the Local PC studio only. Platform control is retained.')
|
||||||
|
assert.equal(vm.runInNewContext(expression, {
|
||||||
|
workspacePlatform: { platformKey: 'vehicle' }, sessionPlatform: null,
|
||||||
|
activePlatformName: '교육용 차량', t: api.t
|
||||||
|
}), '교육용 차량')
|
||||||
|
}
|
||||||
|
assert.match(source, /<SessionConfirmModal[\s\S]*?platformName=\{activePlatformDisplayName\}/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('close and disconnect modals localize raw Local PC names at render time', () => {
|
||||||
|
const source = fs.readFileSync(path.join(root, 'components/AppStatusModals.tsx'), 'utf8')
|
||||||
|
for (const [key, variable] of [
|
||||||
|
['{{v0}} 개발 스튜디오를 종료할까요?', 'platformTitle'],
|
||||||
|
['{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.', 'platformName']
|
||||||
|
]) {
|
||||||
|
const line = source.split('\n').find(line => line.includes(key))
|
||||||
|
const expression = line.match(/v0: (.*?) \}/)[1]
|
||||||
|
for (const language of ['ko', 'en']) {
|
||||||
|
const { api } = setup(language)
|
||||||
|
const name = vm.runInNewContext(expression, { [variable]: '로컬 PC', t: api.t })
|
||||||
|
assert.equal(name, language === 'en' ? 'Local PC' : '로컬 PC')
|
||||||
|
if (language === 'en') assert.ok(!api.t(key, { v0: name }).includes('로컬'))
|
||||||
|
assert.equal(vm.runInNewContext(expression, { [variable]: 'Yahboom Micro #02', t: api.t }), 'Yahboom Micro #02')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all execution guide page titles have English translations', () => {
|
||||||
|
const source = fs.readFileSync(path.join(root, 'features/workspace/ModuleListView.tsx'), 'utf8')
|
||||||
|
const titles = [...source.matchAll(/environment: '([^']+)'/g)].map(match => match[1])
|
||||||
|
assert.equal(titles.length, 4)
|
||||||
|
for (const title of titles) assert.ok(dictionary[title], title)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('visualization progress uses exact runtime translation keys', () => {
|
||||||
|
const { api } = setup('en')
|
||||||
|
assert.equal(api.t('맵 환경 준비 중'), 'Preparing map environment')
|
||||||
|
assert.equal(api.t('시작 중'), 'Starting')
|
||||||
|
assert.equal(api.t('종료 중'), 'Stopping')
|
||||||
|
const panel = fs.readFileSync(path.join(root, 'features/workspace/WorkspaceVisualizationPanel.tsx'), 'utf8')
|
||||||
|
assert.ok(panel.includes('busy ? t(statusMessage)'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('viewer translates before 3D initialization and accepts language only from its parent', () => {
|
||||||
|
const events = new Map()
|
||||||
|
const sent = []
|
||||||
|
const parent = { postMessage: message => sent.push(message) }
|
||||||
|
const label = { dataset: { i18n: '응답을 기다리는 중입니다.' }, textContent: '' }
|
||||||
|
const document = {
|
||||||
|
documentElement: {},
|
||||||
|
querySelectorAll: selector => selector === '[data-i18n]' ? [label] : []
|
||||||
|
}
|
||||||
|
const source = fs.readFileSync(path.join(root, '../public/slam-viewer/locale.js'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, allowJs: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, {
|
||||||
|
exports: {}, document,
|
||||||
|
localStorage: { getItem: () => 'ko' },
|
||||||
|
window: { parent, addEventListener: (type, listener) => events.set(type, listener) }
|
||||||
|
})
|
||||||
|
assert.equal(sent[0].type, 'lms:language:ready')
|
||||||
|
events.get('message')({ source: {}, data: { type: 'lms:language', language: 'en' } })
|
||||||
|
assert.equal(label.textContent, '응답을 기다리는 중입니다.')
|
||||||
|
events.get('message')({ source: parent, data: { type: 'lms:language', language: 'en' } })
|
||||||
|
assert.equal(label.textContent, 'Waiting for a response.')
|
||||||
|
events.get('message')({ source: parent, data: { type: 'lms:language', language: 'ko' } })
|
||||||
|
assert.equal(label.textContent, '응답을 기다리는 중입니다.')
|
||||||
|
const html = fs.readFileSync(path.join(root, '../public/slam-viewer/index.html'), 'utf8')
|
||||||
|
assert.ok(html.includes('<script type="module" src="./locale.js"></script>'))
|
||||||
|
})
|
||||||
|
|
||||||
|
function setup(savedLanguage, storageBlocked = false) {
|
||||||
|
const instance = createInstance()
|
||||||
|
const storage = new Map([['autonomy-studio.lms.language', savedLanguage]])
|
||||||
|
const events = new Map()
|
||||||
|
const document = { documentElement: { lang: '' } }
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'i18n/index.ts'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, {
|
||||||
|
exports, document,
|
||||||
|
localStorage: {
|
||||||
|
getItem(key) { if (storageBlocked) throw Error('blocked'); return storage.get(key) },
|
||||||
|
setItem(key, value) { if (storageBlocked) throw Error('blocked'); storage.set(key, value) }
|
||||||
|
},
|
||||||
|
window: { addEventListener: (name, listener) => events.set(name, listener) },
|
||||||
|
require(name) {
|
||||||
|
if (name === 'i18next') return instance
|
||||||
|
if (name === './en.json') return dictionary
|
||||||
|
return require(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return { api: exports, storage, document, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Korean defaults, English persists, interpolation and fallback preserve user content', () => {
|
||||||
|
const { api, storage, document } = setup('invalid')
|
||||||
|
assert.equal(api.t('설정'), '설정')
|
||||||
|
assert.equal(document.documentElement.lang, 'ko')
|
||||||
|
api.setLanguage('en')
|
||||||
|
assert.equal(api.t('설정'), 'Settings')
|
||||||
|
assert.equal(api.t('{{week}}주차', { week: 3 }), 'Week 3')
|
||||||
|
assert.equal(api.t('강사가 작성한 강좌 제목'), '강사가 작성한 강좌 제목')
|
||||||
|
assert.equal(api.t('sim_mode:=false'), 'sim_mode:=false')
|
||||||
|
assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'en')
|
||||||
|
assert.equal(document.documentElement.lang, 'en')
|
||||||
|
api.setLanguage('ko')
|
||||||
|
assert.equal(api.t('{{week}}주차', { week: 3 }), '3주차')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('restores preference and synchronizes other renderer windows', () => {
|
||||||
|
const { api, document, events } = setup('en')
|
||||||
|
assert.equal(api.t('설정'), 'Settings')
|
||||||
|
events.get('storage')({ key: api.LANGUAGE_STORAGE_KEY, newValue: 'ko' })
|
||||||
|
assert.equal(document.documentElement.lang, 'ko')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('blocked storage does not prevent switching language', () => {
|
||||||
|
const { api } = setup(undefined, true)
|
||||||
|
api.setLanguage('en')
|
||||||
|
assert.equal(api.t('설정'), 'Settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('language selector edits a draft without applying or persisting it', () => {
|
||||||
|
const { api, storage, document } = setup('ko')
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'i18n/LanguageSelect.tsx'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => name === './index' ? api : require(name) })
|
||||||
|
let draft = 'ko'
|
||||||
|
const element = exports.default({ dark: false, value: draft, onChange: value => { draft = value } })
|
||||||
|
const select = element.props.children.find(child => child.type === 'select')
|
||||||
|
select.props.onChange({ target: { value: 'en' } })
|
||||||
|
assert.equal(draft, 'en')
|
||||||
|
assert.equal(document.documentElement.lang, 'ko')
|
||||||
|
assert.equal(storage.get(api.LANGUAGE_STORAGE_KEY), 'ko')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('built-in map labels follow language without changing map data or saved names', () => {
|
||||||
|
const { api } = setup('ko')
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'features/workspace/WorldMapSelectorModal.tsx'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => {
|
||||||
|
if (name === '../../i18n') return api
|
||||||
|
if (name === './studioTheme') return { useStudioTheme: () => 'dark' }
|
||||||
|
if (name.endsWith('.css')) return {}
|
||||||
|
return require(name)
|
||||||
|
} })
|
||||||
|
const map = exports.NAV2_MAP_OPTIONS[0]
|
||||||
|
assert.equal(exports.getMapDisplayName(map), '기본 월드')
|
||||||
|
api.setLanguage('en')
|
||||||
|
assert.equal(exports.getMapDisplayName(map), 'Default world')
|
||||||
|
assert.equal(exports.getMapDisplayName({ ...map, saved: true }), '기본 월드')
|
||||||
|
assert.equal(map.name, '기본 월드')
|
||||||
|
assert.equal(map.worldFile, 'turtlebot3_world.world')
|
||||||
|
assert.equal(api.t('새 2D 맵'), 'New 2D map')
|
||||||
|
assert.equal(api.t('새 3D 맵'), 'New 3D map')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('translations preserve every interpolation placeholder', () => {
|
||||||
|
const placeholders = value => [...value.matchAll(/{{\s*([^}]+)\s*}}/g)].map(match => match[1]).sort()
|
||||||
|
for (const [key, value] of Object.entries(dictionary)) {
|
||||||
|
assert.ok(value.trim(), `Empty translation: ${key}`)
|
||||||
|
assert.deepEqual(placeholders(value), placeholders(key), key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('learning statistics translate API labels but preserve numeric values', () => {
|
||||||
|
const { api } = setup('ko')
|
||||||
|
const exports = {}
|
||||||
|
const source = fs.readFileSync(path.join(root, 'features/curriculum/LearningStatsCard.tsx'), 'utf8')
|
||||||
|
const code = ts.transpileModule(source, { compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
|
||||||
|
jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true
|
||||||
|
} }).outputText
|
||||||
|
vm.runInNewContext(code, { exports, require: name => name === '../../i18n'
|
||||||
|
? { ...api, useLocaleRender() {} } : require(name) })
|
||||||
|
const stats = [
|
||||||
|
{ key: 'weeks', label: '완료한 주차', value: '0/3', iconKey: 'check' },
|
||||||
|
{ key: 'time', label: '학습 시간', value: '02:08', iconKey: 'clock' },
|
||||||
|
{ key: 'commits', label: '커밋 수', value: '0', iconKey: 'trending' },
|
||||||
|
{ key: 'score', label: '평균 점수', value: '0', iconKey: 'gauge' }
|
||||||
|
]
|
||||||
|
const { createElement } = require('react')
|
||||||
|
const { renderToStaticMarkup } = require('react-dom/server')
|
||||||
|
const render = () => renderToStaticMarkup(createElement(exports.default, { stats }))
|
||||||
|
assert.ok(render().includes('완료한 주차'))
|
||||||
|
api.setLanguage('en')
|
||||||
|
const html = render()
|
||||||
|
for (const text of ['Completed weeks', 'Learning time', 'Commits', 'Average score', '0/3', '02:08']) {
|
||||||
|
assert.ok(html.includes(text), text)
|
||||||
|
}
|
||||||
|
for (const item of stats) assert.ok(!html.includes(item.label), item.label)
|
||||||
|
api.setLanguage('ko')
|
||||||
|
assert.ok(render().includes('평균 점수'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all literal translation calls have an English entry', () => {
|
||||||
|
function check(dir) {
|
||||||
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
const file = path.join(dir, entry.name)
|
||||||
|
if (entry.isDirectory()) { check(file); continue }
|
||||||
|
if (!/\.tsx?$/.test(file)) continue
|
||||||
|
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isCallExpression(node) && node.expression.getText(source) === 't' && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) {
|
||||||
|
assert.ok(Object.hasOwn(dictionary, node.arguments[0].text), `${file}: ${node.arguments[0].text}`)
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(root)
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
function setup() {
|
||||||
|
const data = new Map()
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(fs.readFileSync(require.resolve('../src/renderer/src/features/workspace/sourceSettings.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 },
|
||||||
|
}).outputText, {
|
||||||
|
exports,
|
||||||
|
require: () => ({ STUDIO_SOURCE_SETTINGS_STORAGE_KEY: 'test-settings' }),
|
||||||
|
window: { localStorage: { getItem: key => data.get(key) ?? null, setItem: (key, value) => data.set(key, value) } },
|
||||||
|
})
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
test('remote workspace reads local domain and linked platform by actual platform ID', () => {
|
||||||
|
const api = setup()
|
||||||
|
api.storeStudioSourceSettings('local-platform-uuid', { ...api.defaultStudioSourceSettings, rosDomainId: '75', rosLinkedPlatformId: 'yahboom-uuid' })
|
||||||
|
const result = api.readLocalStudioSourceSettings([
|
||||||
|
{ id: 'yahboom-uuid', platformKey: 'yahboom', sourceSettings: { rosDomainId: '77' } },
|
||||||
|
{ id: 'local-platform-uuid', platformKey: 'futuredrive-local-pc' },
|
||||||
|
])
|
||||||
|
assert.equal(result.rosDomainId, '75')
|
||||||
|
assert.equal(result.rosLinkedPlatformId, 'yahboom-uuid')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local platform defaults and unlinked selection are preserved', () => {
|
||||||
|
const api = setup()
|
||||||
|
const platforms = [{ id: 'local-platform-uuid', platformKey: 'futuredrive-local-pc', sourceSettings: { rosDomainId: '76' } }]
|
||||||
|
assert.equal(api.readLocalStudioSourceSettings(platforms).rosDomainId, '76')
|
||||||
|
api.storeStudioSourceSettings('local-platform-uuid', { ...api.defaultStudioSourceSettings, rosDomainId: '75', rosLinkedPlatformId: null })
|
||||||
|
assert.equal(api.readLocalStudioSourceSettings(platforms).rosLinkedPlatformId, null)
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const source = ts.createSourceFile('App.tsx', fs.readFileSync(require.resolve('../src/renderer/src/App.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const names = ['isRemotePlatformStatusPending', 'filteredPlatformOptions', 'platformFilterCounts']
|
||||||
|
const declarations = new Map()
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(source))) {
|
||||||
|
declarations.set(node.name.getText(source), node.getText(source))
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
for (const name of names) assert.ok(declarations.has(name), `missing ${name}`)
|
||||||
|
const filterScript = ts.transpileModule(`${names.map(name => `const ${declarations.get(name)}`).join('\n')}
|
||||||
|
result = { counts: platformFilterCounts, ids: filteredPlatformOptions.map(platform => platform.id) }`, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
const controlSource = fs.readFileSync(require.resolve('../src/renderer/src/features/platforms/platformControl.ts'), 'utf8')
|
||||||
|
const control = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(controlSource, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, {
|
||||||
|
exports: control,
|
||||||
|
require: name => {
|
||||||
|
assert.equal(name, '../../config/appConfig')
|
||||||
|
return { LMS_API_BASE_URL: 'http://unused.invalid' }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function classify(platformOptions, checkedIds = ['mini'], platformFilter = 'all') {
|
||||||
|
const context = {
|
||||||
|
platformOptions, platformFilter,
|
||||||
|
checkedRemotePlatformStatusIds: new Set(checkedIds),
|
||||||
|
isPlatformUnavailable: control.isPlatformUnavailable
|
||||||
|
}
|
||||||
|
vm.runInNewContext(filterScript, context)
|
||||||
|
return JSON.parse(JSON.stringify(context.result))
|
||||||
|
}
|
||||||
|
|
||||||
|
const simulation = { id: 'sim', platformKey: 'futuredrive-local-pc', state: 'ready', status: '사용 가능', action: '시작' }
|
||||||
|
const mini = { id: 'mini', platformKey: 'mkmini', state: 'ready', status: '사용 가능', action: '제어권 요청', connection: { headscaleOnline: true, agentHealthy: true } }
|
||||||
|
|
||||||
|
test('disconnected ready platform counts as unavailable and appears only in that tab', () => {
|
||||||
|
const disconnected = { ...mini, status: '연결 끊김', connection: { headscaleOnline: false, agentHealthy: false } }
|
||||||
|
const platforms = [simulation, disconnected]
|
||||||
|
assert.deepEqual(classify(platforms), { counts: { all: 2, available: 1, unavailable: 1 }, ids: ['sim', 'mini'] })
|
||||||
|
assert.deepEqual(classify(platforms, ['mini'], 'available').ids, ['sim'])
|
||||||
|
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('offline network, unhealthy agent and explicit unavailability all exclude ready platforms', () => {
|
||||||
|
for (const patch of [
|
||||||
|
{ connection: { headscaleOnline: false, agentHealthy: true } },
|
||||||
|
{ connection: { headscaleOnline: true, agentHealthy: false } },
|
||||||
|
{ connection: undefined, status: '연결 끊김' },
|
||||||
|
{ connection: undefined, status: '연결 불가' },
|
||||||
|
{ connection: undefined, action: '연결 불가' }
|
||||||
|
]) {
|
||||||
|
const platforms = [simulation, { ...mini, ...patch }]
|
||||||
|
assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 })
|
||||||
|
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pending remote status remains in All without prematurely entering either availability tab', () => {
|
||||||
|
const platforms = [simulation, mini]
|
||||||
|
assert.deepEqual(classify(platforms, []).counts, { all: 2, available: 1, unavailable: 0 })
|
||||||
|
assert.deepEqual(classify(platforms, [], 'available').ids, ['sim'])
|
||||||
|
assert.deepEqual(classify(platforms, [], 'unavailable').ids, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reconnecting moves a platform back to available without changing All', () => {
|
||||||
|
assert.deepEqual(classify([simulation, mini]).counts, { all: 2, available: 2, unavailable: 0 })
|
||||||
|
assert.deepEqual(classify([simulation, mini], ['mini'], 'available').ids, ['sim', 'mini'])
|
||||||
|
assert.deepEqual(classify([simulation, mini], ['mini'], 'unavailable').ids, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('locked, requested and reserved platform classifications are preserved', () => {
|
||||||
|
for (const state of ['locked', 'request', 'reserved']) {
|
||||||
|
const platforms = [simulation, { ...mini, state }]
|
||||||
|
assert.deepEqual(classify(platforms).counts, { all: 2, available: 1, unavailable: 1 })
|
||||||
|
assert.deepEqual(classify(platforms, ['mini'], 'unavailable').ids, ['mini'])
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const { readFileSync, mkdtempSync, writeFileSync, rmSync } = require('node:fs')
|
||||||
|
const { tmpdir } = require('node:os')
|
||||||
|
const { join } = require('node:path')
|
||||||
|
const { spawn } = require('node:child_process')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
|
||||||
|
const transpile = (source) => ts.transpileModule(source, {
|
||||||
|
compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }
|
||||||
|
}).outputText
|
||||||
|
const helpers = { exports: {} }
|
||||||
|
vm.runInNewContext(transpile(readFileSync(join(__dirname, '../src/main/remoteShell.ts'), 'utf8')), helpers)
|
||||||
|
const { remoteShellCommand, remoteShellEnvironment } = helpers.exports
|
||||||
|
const source = readFileSync(join(__dirname, '../src/main/index.ts'), 'utf8')
|
||||||
|
const start = source.indexOf('async function runCommandOutput(')
|
||||||
|
const end = source.indexOf('\nasync function runCommand(', start)
|
||||||
|
const context = { spawn, process, setTimeout, clearTimeout, createModuleProgressParser: () => () => {} }
|
||||||
|
vm.runInNewContext(transpile(source.slice(start, end)), context)
|
||||||
|
const run = context.runCommandOutput
|
||||||
|
|
||||||
|
test('SSH data output excludes locale warnings even when command succeeds', async () => {
|
||||||
|
const result = await run('/bin/bash', ['--noprofile', '--norc', '-c', 'printf %s "/tmp/module with spaces"'], undefined,
|
||||||
|
{ LC_ALL: 'missing_TEST.UTF-8' }, 5000, 'stdout')
|
||||||
|
assert.equal(result, '/tmp/module with spaces')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('failed commands retain stderr diagnostics and stdout', async () => {
|
||||||
|
await assert.rejects(run('/bin/sh', ['-c', 'printf context; printf "permission denied" >&2; exit 7'], undefined, undefined, 5000, 'stdout'),
|
||||||
|
error => error.message.includes('context') && error.message.includes('permission denied'))
|
||||||
|
})
|
||||||
|
|
||||||
|
test('non-SSH combined output stays compatible and explicit log merging works', async () => {
|
||||||
|
const command = 'printf output; printf diagnostic >&2'
|
||||||
|
const combined = await run('/bin/sh', ['-c', command])
|
||||||
|
assert.match(combined, /output/)
|
||||||
|
assert.match(combined, /diagnostic/)
|
||||||
|
const logs = await run('/bin/sh', ['-c', `{ ${command}; } 2>&1`], undefined, undefined, 5000, 'stdout')
|
||||||
|
assert.match(logs, /diagnostic/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('SSH environment neutralizes locale forwarding without losing other settings', () => {
|
||||||
|
const original = { LANG: 'ko_KR.UTF-8', LC_ALL: 'en_US.UTF-8', LC_CTYPE: 'bad', LANGUAGE: 'ko:en', PATH: '/bin', TERM: 'xterm-256color' }
|
||||||
|
const env = remoteShellEnvironment(original)
|
||||||
|
assert.equal(env.LANG, 'C')
|
||||||
|
assert.equal(env.LC_ALL, 'C')
|
||||||
|
assert.equal(env.LC_CTYPE, undefined)
|
||||||
|
assert.equal(env.LANGUAGE, undefined)
|
||||||
|
assert.equal(env.PATH, '/bin')
|
||||||
|
assert.equal(env.TERM, 'xterm-256color')
|
||||||
|
assert.equal(original.LC_CTYPE, 'bad')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote wrapper preserves multiline commands, Unicode, quotes and exit status', async () => {
|
||||||
|
const command = `cat <<'END'
|
||||||
|
/tmp/한글 module's folder
|
||||||
|
END`
|
||||||
|
const result = await run('/bin/sh', ['-c', remoteShellCommand(command)], undefined, remoteShellEnvironment(process.env), 5000, 'stdout')
|
||||||
|
assert.equal(result, "/tmp/한글 module's folder\n")
|
||||||
|
await assert.rejects(run('/bin/sh', ['-c', remoteShellCommand('echo remote-failure >&2; exit 9')], undefined, undefined, 5000, 'stdout'), /remote-failure/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('locale selection prefers C UTF-8, supports other UTF-8 locales and falls back to C', async () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'lms-locale-test-'))
|
||||||
|
try {
|
||||||
|
for (const [locales, expected] of [['en_GB.utf8 C.utf8', 'C.utf8'], ['C en_GB.utf8', 'en_GB.utf8'], ['C POSIX', 'C']]) {
|
||||||
|
writeFileSync(join(dir, 'locale'), `#!/bin/sh\nprintf '%s\\n' ${locales}\n`, { mode: 0o755 })
|
||||||
|
const result = await run('/bin/sh', ['-c', remoteShellCommand('printf "%s|%s" "$LANG" "$LC_ALL"')], undefined,
|
||||||
|
{ PATH: `${dir}:${process.env.PATH}`, LC_ALL: 'C' }, 5000, 'stdout')
|
||||||
|
assert.equal(result, `${expected}|${expected}`)
|
||||||
|
}
|
||||||
|
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('queued SSH, stats probes and remote PTY use normalization; container logs keep stderr', () => {
|
||||||
|
const ssh = source.slice(source.indexOf('async function runSshOutput('), source.indexOf('async function readRemoteDockerStatsOutput('))
|
||||||
|
assert.equal((ssh.match(/remoteShellCommand\(command\)/g) || []).length, 2)
|
||||||
|
assert.equal((ssh.match(/'stdout'/g) || []).length, 2)
|
||||||
|
const terminal = source.slice(source.indexOf("ipcMain.handle('remote-workspace:terminal:start'"), source.indexOf("ipcMain.handle('terminal-resize'"))
|
||||||
|
assert.match(terminal, /remoteShellCommand\(mode === 'container'/)
|
||||||
|
assert.match(terminal, /remoteShellEnvironment\(terminalEnvironment/)
|
||||||
|
assert.match(source, /target \? remoteShellCommand\(init\)/)
|
||||||
|
assert.match(source, /docker logs --timestamps --tail 300.*2>&1/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const exportsObject = {}
|
||||||
|
const source = fs.readFileSync(path.resolve(__dirname, '../src/renderer/src/features/workspace/WorkspaceRosConnectionsView.tsx'), 'utf8')
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, { exports: exportsObject, require: () => ({}) })
|
||||||
|
const { rosGraphConnections } = exportsObject
|
||||||
|
const endpoint = (node, type = 'sensor_msgs/msg/CompressedImage') => ({ node, type })
|
||||||
|
|
||||||
|
test('shows image and custom topics without module definitions', () => {
|
||||||
|
const connections = rosGraphConnections({ topics: [
|
||||||
|
{ name: '/camera/image/compressed', types: ['sensor_msgs/msg/CompressedImage'], publishers: [endpoint('/camera')], subscribers: [endpoint('/bridge')] },
|
||||||
|
{ name: '/custom/new_topic', types: ['custom_msgs/msg/Value'], publishers: [], subscribers: [endpoint('/custom', 'custom_msgs/msg/Value')] },
|
||||||
|
] })
|
||||||
|
assert.equal(connections.length, 2)
|
||||||
|
assert.equal(connections[0].topic.name, '/camera/image/compressed')
|
||||||
|
assert.equal(connections[0].topic.type, 'sensor_msgs/msg/CompressedImage')
|
||||||
|
assert.equal(connections[0].active, true)
|
||||||
|
assert.equal(connections[1].publishers.length, 0)
|
||||||
|
assert.equal(connections[1].active, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('empty and failed queries never synthesize topics or nodes', () => {
|
||||||
|
assert.equal(rosGraphConnections({ topics: [] }).length, 0)
|
||||||
|
assert.equal(rosGraphConnections({ topics: [], message: 'SSH timeout' }).length, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('keeps discovered topics without endpoints and deduplicates node entries', () => {
|
||||||
|
const connections = rosGraphConnections({ topics: [
|
||||||
|
{ name: '/z', types: [], publishers: [], subscribers: [] },
|
||||||
|
{ name: '/a', types: ['sensor_msgs/msg/CompressedImage'], publishers: [endpoint('/camera'), endpoint('/camera')], subscribers: [] },
|
||||||
|
] })
|
||||||
|
assert.equal(connections.length, 2)
|
||||||
|
assert.equal(connections[0].topic.name, '/a')
|
||||||
|
assert.equal(connections[0].publishers.length, 1)
|
||||||
|
assert.equal(connections[1].topic.name, '/z')
|
||||||
|
assert.equal(connections.every((connection) => !connection.active), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('module selection and guessed topic mappings are removed from the view', () => {
|
||||||
|
assert.doesNotMatch(source, /selectedModuleKeys|moduleTopics|fallbackPublishers|fallbackSubscribers|onToggleModule|ListChecks|ListX/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('active toggle filters only topics, preserves totals and hides filtered details', () => {
|
||||||
|
const graph = { topics: [
|
||||||
|
{ name: '/active', types: [], publishers: [endpoint('/pub')], subscribers: [endpoint('/sub')] },
|
||||||
|
{ name: '/waiting', types: [], publishers: [endpoint('/pub')], subscribers: [] },
|
||||||
|
] }
|
||||||
|
function render(activeOnly) {
|
||||||
|
const states = [graph, false, activeOnly, '/waiting', true, 320]
|
||||||
|
let index = 0
|
||||||
|
const api = {}
|
||||||
|
const jsx = (type, props, key) => ({ type, props, key })
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.ReactJSX }
|
||||||
|
}).outputText, { exports: api, require: (name) => {
|
||||||
|
if (name === 'react') return { useState: () => [states[index++], () => {}], useMemo: fn => fn(), useEffect: () => {} }
|
||||||
|
if (name === 'react/jsx-runtime') return { jsx, jsxs: jsx }
|
||||||
|
if (name === '../../i18n') return { t: value => value, useLocaleRender: () => {} }
|
||||||
|
return {}
|
||||||
|
} })
|
||||||
|
const tree = api.default({ dark: false, modules: [{ name: 'Running module', state: 'running' }], remoteWorkspace: false, rosDomainId: '77' })
|
||||||
|
const nodes = []
|
||||||
|
function visit(node) {
|
||||||
|
if (Array.isArray(node)) return node.forEach(visit)
|
||||||
|
if (!node || typeof node !== 'object') return
|
||||||
|
nodes.push(node)
|
||||||
|
visit(node.props?.children)
|
||||||
|
}
|
||||||
|
visit(tree)
|
||||||
|
return nodes
|
||||||
|
}
|
||||||
|
for (const activeOnly of [false, true]) {
|
||||||
|
const nodes = render(activeOnly)
|
||||||
|
assert.equal(nodes.filter(node => node.type === 'button' && node.key?.startsWith('/')).length, activeOnly ? 1 : 2)
|
||||||
|
const toggle = nodes.find(node => node.props?.title === '활성 연결만 보기')
|
||||||
|
assert.equal(toggle.props['aria-pressed'], activeOnly)
|
||||||
|
assert.equal(toggle.props.children.at(-1), 1)
|
||||||
|
assert.equal(nodes.find(node => node.type?.name === 'RuntimeLane').props.modules.length, 1)
|
||||||
|
assert.equal(nodes.some(node => node.type?.name === 'TopicDetailDrawer'), !activeOnly)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const path = require('node:path')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const root = path.resolve(__dirname, '../src/renderer/src/features/workspace')
|
||||||
|
|
||||||
|
function setup(saved, blocked = false) {
|
||||||
|
const storage = new Map([['autonomy-studio.lms.studio-theme', saved]])
|
||||||
|
const events = new Map()
|
||||||
|
const exports = {}
|
||||||
|
const code = ts.transpileModule(fs.readFileSync(path.join(root, 'studioTheme.ts'), 'utf8'), {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }
|
||||||
|
}).outputText
|
||||||
|
vm.runInNewContext(code, { exports,
|
||||||
|
require: () => ({ useSyncExternalStore: (_subscribe, snapshot) => snapshot() }),
|
||||||
|
localStorage: {
|
||||||
|
getItem(key) { if (blocked) throw Error('blocked'); return storage.get(key) },
|
||||||
|
setItem(key, value) { if (blocked) throw Error('blocked'); storage.set(key, value) }
|
||||||
|
},
|
||||||
|
window: { addEventListener: (type, listener) => events.set(type, listener) }
|
||||||
|
})
|
||||||
|
return { api: exports, storage, events }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('theme defaults dark, persists light, and restores the preference', () => {
|
||||||
|
const { api, storage } = setup('invalid')
|
||||||
|
assert.equal(api.useStudioTheme(), 'dark')
|
||||||
|
api.setStudioTheme('light')
|
||||||
|
assert.equal(api.useStudioTheme(), 'light')
|
||||||
|
assert.equal(storage.get(api.STUDIO_THEME_KEY), 'light')
|
||||||
|
assert.equal(setup('light').api.useStudioTheme(), 'light')
|
||||||
|
})
|
||||||
|
test('theme remains usable when storage is blocked', () => {
|
||||||
|
const { api } = setup(null, true)
|
||||||
|
api.setStudioTheme('light')
|
||||||
|
assert.equal(api.useStudioTheme(), 'light')
|
||||||
|
})
|
||||||
|
test('theme synchronizes across windows without writing runtime settings', () => {
|
||||||
|
const { api, storage, events } = setup('dark')
|
||||||
|
storage.set(api.STUDIO_THEME_KEY, 'light')
|
||||||
|
events.get('storage')({ key: api.STUDIO_THEME_KEY })
|
||||||
|
assert.equal(api.useStudioTheme(), 'light')
|
||||||
|
storage.clear()
|
||||||
|
events.get('storage')({ key: null })
|
||||||
|
assert.equal(api.useStudioTheme(), 'dark')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('module content scrollbar uses workspace colors without thinner scrollbar dimensions', () => {
|
||||||
|
const source = ts.createSourceFile('ModuleListView.tsx', fs.readFileSync(path.join(root, 'ModuleListView.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
||||||
|
const scrollClasses = []
|
||||||
|
function visit(node) {
|
||||||
|
if (ts.isJsxAttribute(node) && node.name.getText(source) === 'className' && node.initializer && ts.isStringLiteral(node.initializer)) {
|
||||||
|
const classes = node.initializer.text.split(/\s+/)
|
||||||
|
if (classes.includes('overflow-x-auto')) scrollClasses.push(classes)
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit)
|
||||||
|
}
|
||||||
|
visit(source)
|
||||||
|
assert.ok(scrollClasses.length > 0)
|
||||||
|
for (const classes of scrollClasses) {
|
||||||
|
assert.ok(classes.includes('module-content-scrollbar'))
|
||||||
|
assert.ok(!classes.includes('thin-x-scrollbar'))
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs/promises')
|
||||||
|
const path = require('node:path')
|
||||||
|
const os = require('node:os')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const { execFile } = require('node:child_process')
|
||||||
|
const { promisify } = require('node:util')
|
||||||
|
const exec = promisify(execFile)
|
||||||
|
const root = path.resolve(__dirname, '..')
|
||||||
|
const placeholder = 'Binary or large file preview is not available.'
|
||||||
|
const xml = '<?xml version="1.0"?>\n<model><name>mkmini_gazebo</name><description>주행 모델</description></model>\n'
|
||||||
|
|
||||||
|
function compile(source, context = {}) {
|
||||||
|
const exports = {}
|
||||||
|
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, { exports, require, Buffer, ...context })
|
||||||
|
return exports
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fixture(run) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'lms-xml-editor-'))
|
||||||
|
try {
|
||||||
|
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) await fs.writeFile(path.join(dir, name), xml)
|
||||||
|
await fs.writeFile(path.join(dir, 'large.config'), 'x'.repeat(1024 * 1024 + 1))
|
||||||
|
await fs.writeFile(path.join(dir, 'model.glb'), Buffer.from([0, 1, 2, 3]))
|
||||||
|
await run(dir)
|
||||||
|
} finally { await fs.rm(dir, { recursive: true, force: true }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('local module and root collectors read small Gazebo XML files without changing binary and size exclusions', async () => {
|
||||||
|
const source = ts.createSourceFile('index.ts', await fs.readFile(path.join(root, 'src/main/index.ts'), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const functions = source.statements.filter(node => ts.isFunctionDeclaration(node) && ['collectModuleFiles', 'collectRootFiles'].includes(node.name?.text))
|
||||||
|
const pattern = source.statements.find(node => ts.isVariableStatement(node) && node.declarationList.declarations[0].name.getText(source) === 'textFilePattern')
|
||||||
|
assert.equal(functions.length, 2)
|
||||||
|
assert.ok(pattern)
|
||||||
|
await fixture(async dir => {
|
||||||
|
const api = compile([pattern.getText(source), ...functions.map(node => `export ${node.getText(source)}`)].join('\n'), {
|
||||||
|
readdir: fs.readdir, stat: fs.stat, readFile: fs.readFile,
|
||||||
|
resolve: path.resolve, basename: path.basename,
|
||||||
|
skippedCacheDirs: new Set(['.git', 'build', 'install', 'log', 'node_modules', 'docker']),
|
||||||
|
findModuleRoot: async () => dir,
|
||||||
|
toRendererModulePath: (name, file, base) => `modules/${name}/${path.relative(base, file)}`,
|
||||||
|
toRendererRootPath: (name, file, base) => `${name}/${path.relative(base, file)}`
|
||||||
|
})
|
||||||
|
for (const result of [await api.collectModuleFiles('NAV2-MKMini'), await api.collectRootFiles(dir, 'examples')]) {
|
||||||
|
const files = Object.fromEntries(Object.entries(result.files).map(([file, content]) => [path.basename(file), content]))
|
||||||
|
for (const name of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal(files[name], xml)
|
||||||
|
for (const name of ['large.config', 'model.glb']) assert.equal(files[name], placeholder)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote listing, content and search include Gazebo XML while preserving size, NUL and path protections', async () => {
|
||||||
|
const api = compile(await fs.readFile(path.join(root, 'src/main/remoteModuleFiles.ts'), 'utf8'))
|
||||||
|
await fixture(async dir => {
|
||||||
|
const request = async input => JSON.parse((await exec('bash', ['-c', api.remoteModuleFilesScript(dir, input)], { timeout: 10000 })).stdout)
|
||||||
|
const listing = await request({ action: 'list' })
|
||||||
|
assert.ok(listing.filePaths.includes('model.config'))
|
||||||
|
for (const file of ['model.config', 'model.sdf', 'simulation.world', 'MODEL.CONFIG']) assert.equal((await request({ action: 'content', path: file })).content, xml)
|
||||||
|
for (const file of ['large.config', 'model.glb']) assert.equal((await request({ action: 'content', path: file })).content, placeholder)
|
||||||
|
await fs.writeFile(path.join(dir, 'binary.config'), Buffer.from('text\0binary'))
|
||||||
|
assert.equal((await request({ action: 'content', path: 'binary.config' })).content, placeholder)
|
||||||
|
const matches = await request({ action: 'search', query: 'mkmini_gazebo' })
|
||||||
|
assert.deepEqual(matches.map(match => match.file).sort(), ['MODEL.CONFIG', 'model.config', 'model.sdf', 'simulation.world'].sort())
|
||||||
|
await assert.rejects(request({ action: 'content', path: '../outside.config' }), /Invalid module path/)
|
||||||
|
await fs.symlink(path.join(dir, '..'), path.join(dir, 'escape'))
|
||||||
|
await assert.rejects(request({ action: 'content', path: 'escape/outside.config' }), /Invalid module path/)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Gazebo model.config, SDF and world files use XML highlighting without treating arbitrary config formats as XML', async () => {
|
||||||
|
const api = compile(await fs.readFile(path.join(root, 'src/renderer/src/features/workspace/workspaceFiles.ts'), 'utf8'))
|
||||||
|
for (const file of ['model.config', 'models/mkmini/model.config', 'models/MODEL.CONFIG', 'model.sdf', 'simulation.world', 'WORLD.SDF', 'package.xml', 'robot.urdf', 'robot.xacro']) assert.equal(api.getEditorLanguage(file), 'xml', file)
|
||||||
|
assert.equal(api.getEditorLanguage('app.config'), 'plaintext')
|
||||||
|
assert.equal(api.getEditorLanguage('control.yaml'), 'yaml')
|
||||||
|
assert.equal(api.getEditorLanguage('model.glb'), 'plaintext')
|
||||||
|
})
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { createHash, randomUUID } from 'node:crypto'
|
||||||
|
import { chmod, lstat, readdir, readFile, realpath, rename, unlink, writeFile } from 'node:fs/promises'
|
||||||
|
import { dirname, join, relative, resolve, sep } from 'node:path'
|
||||||
|
import { safeAgentPath, type AgentCheckedChange, type AgentToolRequest } from '../shared/agentTools'
|
||||||
|
|
||||||
|
const textFile = /\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$/i
|
||||||
|
export const agentFileHash = (content: string): string => createHash('sha256').update(content, 'utf8').digest('hex')
|
||||||
|
|
||||||
|
async function checkedPath(root: string, path: string, allowMissing = false): Promise<string> {
|
||||||
|
safeAgentPath(path)
|
||||||
|
const base = await realpath(root)
|
||||||
|
const absolute = resolve(base, path)
|
||||||
|
if (!absolute.startsWith(base + sep)) throw new Error('모듈 경로를 벗어날 수 없습니다.')
|
||||||
|
let current = base
|
||||||
|
const parts = relative(base, absolute).split(sep)
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
current = join(current, parts[i])
|
||||||
|
try {
|
||||||
|
const info = await lstat(current)
|
||||||
|
if (info.isSymbolicLink() || (i < parts.length - 1 && !info.isDirectory())) throw new Error('심볼릭 링크와 일반 파일이 아닌 경로는 사용할 수 없습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
if (allowMissing && i === parts.length - 1 && (error as NodeJS.ErrnoException).code === 'ENOENT') return absolute
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return absolute
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readText(root: string, path: string): Promise<string> {
|
||||||
|
if (!textFile.test(path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 조회·수정할 수 있습니다.')
|
||||||
|
const absolute = await checkedPath(root, path)
|
||||||
|
const info = await lstat(absolute)
|
||||||
|
if (!info.isFile() || info.size > 1_048_576) throw new Error('일반 텍스트 파일(1MB 이하)만 조회할 수 있습니다.')
|
||||||
|
const text = await readFile(absolute, 'utf8')
|
||||||
|
if (text.includes('\0') || text.includes('\uFFFD')) throw new Error('UTF-8 텍스트 파일이 아닙니다.')
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runAgentFileTool(root: string, request: AgentToolRequest): Promise<{ data: unknown; truncated: boolean }> {
|
||||||
|
const args = request.args ?? {}
|
||||||
|
if (request.name === 'files.read') {
|
||||||
|
const path = safeAgentPath(args.path)
|
||||||
|
try {
|
||||||
|
const content = await readText(root, path)
|
||||||
|
const lines = content.split('\n')
|
||||||
|
const startLine = args.startLine ?? 1
|
||||||
|
const ranged = args.startLine !== undefined || args.lineCount !== undefined || content.length > 40_000
|
||||||
|
const selection = ranged ? lines.slice(startLine - 1, startLine - 1 + (args.lineCount ?? 200)).join('\n') : content
|
||||||
|
const selected = selection.slice(0, 40_000)
|
||||||
|
const contentTruncated = selected.length < selection.length
|
||||||
|
const complete = startLine === 1 && selected === content
|
||||||
|
const endLine = Math.min(lines.length, startLine + selected.split('\n').length - 1 - Number(contentTruncated))
|
||||||
|
return { data: { path, exists: true, hash: agentFileHash(content), content: selected, complete, startLine, endLine, contentTruncated, totalLines: lines.length }, truncated: !complete }
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
// A missing leaf in an existing directory is evidence for a new file.
|
||||||
|
await checkedPath(root, path, true)
|
||||||
|
return { data: { path, exists: false, hash: null, content: '', complete: true }, truncated: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const prefix = safeAgentPath(args.path, true)
|
||||||
|
if (prefix) await checkedPath(root, prefix)
|
||||||
|
const paths: string[] = []
|
||||||
|
const matches: Array<{ path: string; line: number; text: string }> = []
|
||||||
|
let visited = 0
|
||||||
|
let scanned = 0
|
||||||
|
let truncated = false
|
||||||
|
const visit = async (directory: string, depth: number): Promise<void> => {
|
||||||
|
if (depth > 10 || visited >= 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; return }
|
||||||
|
const entries = (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
for (const entry of entries) {
|
||||||
|
const path = relative(root, join(directory, entry.name)).split(sep).join('/')
|
||||||
|
if (prefix && path !== prefix && !path.startsWith(prefix + '/') && !prefix.startsWith(path + '/')) continue
|
||||||
|
if (++visited > 5000 || paths.length >= 250 || matches.length >= 80 || scanned >= 4_000_000) { truncated = true; break }
|
||||||
|
try { safeAgentPath(path) } catch { continue }
|
||||||
|
if (entry.isSymbolicLink()) continue
|
||||||
|
if (entry.isDirectory()) { await visit(join(directory, entry.name), depth + 1); continue }
|
||||||
|
if (!entry.isFile() || !textFile.test(entry.name)) continue
|
||||||
|
if (prefix && path !== prefix && !path.startsWith(prefix + '/')) continue
|
||||||
|
if (request.name === 'files.list') { paths.push(path); continue }
|
||||||
|
let content: string
|
||||||
|
try { content = await readText(root, path) } catch { continue }
|
||||||
|
scanned += content.length
|
||||||
|
for (const [index, line] of content.split('\n').entries()) {
|
||||||
|
if (line.toLowerCase().includes(args.query!.toLowerCase())) {
|
||||||
|
matches.push({ path, line: index + 1, text: line.slice(0, 500) })
|
||||||
|
if (matches.length >= 80) { truncated = true; break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await visit(root, 0)
|
||||||
|
return { data: request.name === 'files.list' ? { paths } : { query: args.query, matches }, truncated }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAgentFiles(changes: Array<AgentCheckedChange & { root: string }>): Promise<void> {
|
||||||
|
if (!changes.length || changes.length > 24 || changes.reduce((n, c) => n + c.content.length, 0) > 1_000_000) throw new Error('변경 파일 용량을 확인하세요.')
|
||||||
|
const prepared: Array<{ path: string; temp: string; original: string | null; mode: number }> = []
|
||||||
|
const committed: typeof prepared = []
|
||||||
|
const seen = new Set<string>()
|
||||||
|
try {
|
||||||
|
for (const change of changes) {
|
||||||
|
if (change.expectedHash !== null && !/^[a-f0-9]{64}$/.test(change.expectedHash)) throw new Error('원본 파일 조회 정보가 없습니다. 다시 분석해주세요.')
|
||||||
|
if (!textFile.test(change.path.split('/').pop() ?? '')) throw new Error('텍스트 소스 파일만 수정할 수 있습니다.')
|
||||||
|
const path = await checkedPath(change.root, change.path, true)
|
||||||
|
if (seen.has(path)) throw new Error('중복된 변경 파일입니다.')
|
||||||
|
seen.add(path)
|
||||||
|
let original: string | null = null
|
||||||
|
let mode = 0o644
|
||||||
|
try { original = await readText(change.root, change.path); mode = (await lstat(path)).mode } catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||||
|
}
|
||||||
|
if ((original === null ? null : agentFileHash(original)) !== change.expectedHash) throw new Error(`분석 이후 파일이 변경되었습니다: ${change.moduleName}/${change.path}. 다시 분석해주세요.`)
|
||||||
|
prepared.push({ path, original, mode, temp: join(dirname(path), `.lms-agent-${randomUUID()}.tmp`) })
|
||||||
|
}
|
||||||
|
for (const [index, file] of prepared.entries()) {
|
||||||
|
await writeFile(file.temp, changes[index].content, { encoding: 'utf8', mode: file.mode, flag: 'wx' })
|
||||||
|
await chmod(file.temp, file.mode & 0o777)
|
||||||
|
}
|
||||||
|
// Recheck the entire batch immediately before committing any file.
|
||||||
|
for (const file of prepared) {
|
||||||
|
let latest: string | null = null
|
||||||
|
try { latest = await readFile(file.path, 'utf8') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
|
||||||
|
if (latest !== file.original) throw new Error('적용 직전 파일이 변경되었습니다. 다시 분석해주세요.')
|
||||||
|
}
|
||||||
|
for (const file of prepared) { await rename(file.temp, file.path); committed.push(file) }
|
||||||
|
} catch (error) {
|
||||||
|
const failures: string[] = []
|
||||||
|
for (const file of committed.reverse()) {
|
||||||
|
try { if (file.original === null) await unlink(file.path); else await writeFile(file.path, file.original, { mode: file.mode }) } catch { failures.push(file.path) }
|
||||||
|
}
|
||||||
|
if (failures.length) throw new Error(`일부 파일 복구에 실패했습니다: ${failures.join(', ')}. ${String(error)}`)
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
await Promise.all(prepared.map(file => unlink(file.temp).catch(() => undefined)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import type { AgentCheckedChange, AgentToolRequest } from '../shared/agentTools'
|
||||||
|
|
||||||
|
export function agentRemoteFilesScript(input: { root: string; request: AgentToolRequest } | { changes: Array<AgentCheckedChange & { root: string }> }): string {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(input)).toString('base64')
|
||||||
|
return `python3 - <<'LMS_AGENT_FILES'
|
||||||
|
import sys
|
||||||
|
sys.argv = ['lms-agent', '${encoded}']
|
||||||
|
${agentRemoteFilesPython}
|
||||||
|
LMS_AGENT_FILES`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentRemoteFilesPython = String.raw`
|
||||||
|
import base64, hashlib, json, os, re, stat, sys, tempfile
|
||||||
|
args = json.loads(base64.b64decode(sys.argv[1]))
|
||||||
|
blocked = {'node_modules', 'build', 'install', 'log', 'logs', '__pycache__', 'id_rsa', 'id_ed25519', 'credentials'}
|
||||||
|
text_pattern = re.compile(r'\.(c|cc|cpp|cxx|h|hh|hpp|hxx|py|js|jsx|ts|tsx|json|ya?ml|xml|md|txt|toml|ini|cfg|conf|sh|bash|launch|urdf|xacro|rviz|proto|cmake)$|^(Dockerfile|CMakeLists\.txt)$', re.I)
|
||||||
|
def safe(value):
|
||||||
|
if not isinstance(value, str) or not value or len(value) > 500 or value.startswith('/') or any(c in value for c in '\\\x00\r\n'):
|
||||||
|
raise ValueError('Invalid source path')
|
||||||
|
if any(not p or p.startswith('.') or p.lower() in blocked or re.search(r'\.(pem|key|p12|pfx)$', p, re.I) for p in value.split('/')):
|
||||||
|
raise ValueError('Private or excluded source path')
|
||||||
|
return value
|
||||||
|
def checked(root, path, missing=False):
|
||||||
|
safe(path)
|
||||||
|
root = os.path.realpath(root)
|
||||||
|
current = root
|
||||||
|
parts = path.split('/')
|
||||||
|
for i, part in enumerate(parts):
|
||||||
|
current = os.path.join(current, part)
|
||||||
|
try:
|
||||||
|
info = os.lstat(current)
|
||||||
|
if stat.S_ISLNK(info.st_mode) or (i < len(parts)-1 and not stat.S_ISDIR(info.st_mode)):
|
||||||
|
raise ValueError('Symlink or non-directory source path')
|
||||||
|
except FileNotFoundError:
|
||||||
|
if not (missing and i == len(parts)-1):
|
||||||
|
raise
|
||||||
|
if os.path.commonpath([root, current]) != root:
|
||||||
|
raise ValueError('Source path escapes module')
|
||||||
|
return current
|
||||||
|
def read(root, path):
|
||||||
|
if not text_pattern.search(os.path.basename(path)):
|
||||||
|
raise ValueError('Only text source files are supported')
|
||||||
|
absolute = checked(root, path)
|
||||||
|
info = os.lstat(absolute)
|
||||||
|
if not stat.S_ISREG(info.st_mode) or info.st_size > 1048576:
|
||||||
|
raise ValueError('Only regular source files up to 1 MB are supported')
|
||||||
|
with open(absolute, 'rb') as fp:
|
||||||
|
content = fp.read(1048577)
|
||||||
|
if len(content) > 1048576 or b'\x00' in content:
|
||||||
|
raise ValueError('Binary or oversized file')
|
||||||
|
return content.decode('utf-8')
|
||||||
|
def digest(content):
|
||||||
|
return hashlib.sha256(content.encode('utf-8')).hexdigest()
|
||||||
|
if 'changes' in args:
|
||||||
|
changes = args['changes']
|
||||||
|
if not changes or len(changes) > 24 or sum(len(c['content']) for c in changes) > 1000000:
|
||||||
|
raise ValueError('Invalid change batch size')
|
||||||
|
prepared, committed, seen = [], [], set()
|
||||||
|
try:
|
||||||
|
for c in changes:
|
||||||
|
path = checked(c['root'], c['path'], True)
|
||||||
|
if path in seen or not text_pattern.search(os.path.basename(path)):
|
||||||
|
raise ValueError('Duplicate or unsupported change file')
|
||||||
|
seen.add(path)
|
||||||
|
try:
|
||||||
|
original = read(c['root'], c['path'])
|
||||||
|
mode = stat.S_IMODE(os.stat(path).st_mode)
|
||||||
|
except FileNotFoundError:
|
||||||
|
original, mode = None, 0o644
|
||||||
|
if (None if original is None else digest(original)) != c.get('expectedHash', 'missing'):
|
||||||
|
raise ValueError('File changed after analysis: ' + c['moduleName'] + '/' + c['path'])
|
||||||
|
prepared.append({'path': path, 'original': original, 'mode': mode, 'content': c['content'], 'temp': None})
|
||||||
|
for p in prepared:
|
||||||
|
fd, p['temp'] = tempfile.mkstemp(prefix='.lms-agent-', dir=os.path.dirname(p['path']))
|
||||||
|
with os.fdopen(fd, 'w', encoding='utf-8', newline='') as fp:
|
||||||
|
fp.write(p['content'])
|
||||||
|
os.chmod(p['temp'], p['mode'])
|
||||||
|
for p in prepared:
|
||||||
|
try:
|
||||||
|
with open(p['path'], 'r', encoding='utf-8', newline='') as fp:
|
||||||
|
latest = fp.read()
|
||||||
|
except FileNotFoundError:
|
||||||
|
latest = None
|
||||||
|
if latest != p['original']:
|
||||||
|
raise ValueError('File changed immediately before apply')
|
||||||
|
for p in prepared:
|
||||||
|
os.replace(p['temp'], p['path'])
|
||||||
|
committed.append(p)
|
||||||
|
except Exception as error:
|
||||||
|
failed = []
|
||||||
|
for p in reversed(committed):
|
||||||
|
try:
|
||||||
|
if p['original'] is None:
|
||||||
|
os.unlink(p['path'])
|
||||||
|
else:
|
||||||
|
with open(p['path'], 'w', encoding='utf-8', newline='') as fp:
|
||||||
|
fp.write(p['original'])
|
||||||
|
os.chmod(p['path'], p['mode'])
|
||||||
|
except Exception:
|
||||||
|
failed.append(p['path'])
|
||||||
|
if failed:
|
||||||
|
raise ValueError('Rollback failed: ' + ', '.join(failed)) from error
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
for p in prepared:
|
||||||
|
if p['temp'] and os.path.exists(p['temp']):
|
||||||
|
os.unlink(p['temp'])
|
||||||
|
print(json.dumps({'ok': True}))
|
||||||
|
else:
|
||||||
|
root = os.path.realpath(args['root'])
|
||||||
|
req = args['request']
|
||||||
|
options = req.get('args') or {}
|
||||||
|
name = req['name']
|
||||||
|
if name == 'files.read':
|
||||||
|
path = options['path']
|
||||||
|
try:
|
||||||
|
content = read(root, path)
|
||||||
|
lines = content.split('\n')
|
||||||
|
ranged = 'startLine' in options or 'lineCount' in options or len(content) > 40000
|
||||||
|
start = options.get('startLine', 1)
|
||||||
|
selection = '\n'.join(lines[start-1:start-1+options.get('lineCount', 200)]) if ranged else content
|
||||||
|
selected = selection[:40000]
|
||||||
|
content_truncated = len(selected) < len(selection)
|
||||||
|
complete = start == 1 and selected == content
|
||||||
|
partial = not complete
|
||||||
|
end = min(len(lines), start + len(selected.split('\n')) - 1 - int(content_truncated))
|
||||||
|
data = {'path': path, 'exists': True, 'hash': digest(content), 'content': selected, 'complete': complete, 'startLine': start, 'endLine': end, 'contentTruncated': content_truncated, 'totalLines': len(lines)}
|
||||||
|
except FileNotFoundError:
|
||||||
|
checked(root, path, True)
|
||||||
|
data, partial = {'path': path, 'exists': False, 'hash': None, 'content': '', 'complete': True}, False
|
||||||
|
print(json.dumps({'data': data, 'truncated': partial}, ensure_ascii=False))
|
||||||
|
else:
|
||||||
|
prefix = options.get('path', '')
|
||||||
|
if prefix:
|
||||||
|
checked(root, prefix)
|
||||||
|
paths, matches = [], []
|
||||||
|
visited, scanned, truncated = 0, 0, False
|
||||||
|
def fail(error):
|
||||||
|
raise error
|
||||||
|
for directory, dirs, files in os.walk(root, followlinks=False, onerror=fail):
|
||||||
|
depth = len(os.path.relpath(directory, root).split('/')) if directory != root else 0
|
||||||
|
kept = []
|
||||||
|
for d in sorted(dirs):
|
||||||
|
rel = os.path.relpath(os.path.join(directory, d), root)
|
||||||
|
if prefix and rel != prefix and not rel.startswith(prefix + '/') and not prefix.startswith(rel + '/'):
|
||||||
|
continue
|
||||||
|
visited += 1
|
||||||
|
try:
|
||||||
|
safe(rel)
|
||||||
|
if not os.path.islink(os.path.join(directory, d)):
|
||||||
|
kept.append(d)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
dirs[:] = kept if depth < 10 else []
|
||||||
|
for f in sorted(files):
|
||||||
|
path = os.path.relpath(os.path.join(directory, f), root)
|
||||||
|
if prefix and path != prefix and not path.startswith(prefix + '/'):
|
||||||
|
continue
|
||||||
|
visited += 1
|
||||||
|
if visited > 5000 or len(paths) >= 250 or len(matches) >= 80 or scanned >= 4000000:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
safe(path)
|
||||||
|
if os.path.islink(os.path.join(directory, f)) or not text_pattern.search(f):
|
||||||
|
continue
|
||||||
|
if prefix and path != prefix and not path.startswith(prefix + '/'):
|
||||||
|
continue
|
||||||
|
if name == 'files.list':
|
||||||
|
paths.append(path)
|
||||||
|
continue
|
||||||
|
content = read(root, path)
|
||||||
|
except (ValueError, OSError, UnicodeError):
|
||||||
|
continue
|
||||||
|
scanned += len(content)
|
||||||
|
for number, line in enumerate(content.split('\n'), 1):
|
||||||
|
if options['query'].lower() in line.lower():
|
||||||
|
matches.append({'path': path, 'line': number, 'text': line[:500]})
|
||||||
|
if len(matches) >= 80:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
if truncated:
|
||||||
|
break
|
||||||
|
data = {'paths': paths} if name == 'files.list' else {'query': options['query'], 'matches': matches}
|
||||||
|
print(json.dumps({'data': data, 'truncated': truncated}, ensure_ascii=False))
|
||||||
|
`
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { AgentToolRequest } from '../shared/agentTools'
|
||||||
|
|
||||||
|
export const DEFAULT_AGENT_TOPICS = ['/raw/lidar', '/scan', '/lidar_filtered', '/cloud_map', '/odom', '/map', '/tf', '/tf_static']
|
||||||
|
|
||||||
|
export function agentRosProbe(topics: string[] = DEFAULT_AGENT_TOPICS): string {
|
||||||
|
const encoded = Buffer.from(JSON.stringify(topics)).toString('base64')
|
||||||
|
return `python3 -c ${shellQuote(agentRosProbePython)} ${shellQuote(encoded)}`
|
||||||
|
}
|
||||||
|
export function shellQuote(value: string): string { return `'${value.replace(/'/g, `'"'"'`)}'` }
|
||||||
|
|
||||||
|
export const agentRosProbePython = String.raw`
|
||||||
|
import base64, json, sys, time
|
||||||
|
import rclpy
|
||||||
|
from rclpy.qos import QoSProfile, ReliabilityPolicy, DurabilityPolicy
|
||||||
|
from rosidl_runtime_py.utilities import get_message
|
||||||
|
rclpy.init(args=[])
|
||||||
|
node = rclpy.create_node('lms_agent_readonly_probe_' + str(int(time.time() * 1000)))
|
||||||
|
topics = json.loads(base64.b64decode(sys.argv[1]))
|
||||||
|
samples, valid_samples, frames, edges, errors, subscriptions = {}, {}, {}, set(), {}, []
|
||||||
|
try:
|
||||||
|
deadline = time.monotonic() + 1.5
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
rclpy.spin_once(node, timeout_sec=0.1)
|
||||||
|
graph = dict(node.get_topic_names_and_types())
|
||||||
|
def callback(topic):
|
||||||
|
def receive(message):
|
||||||
|
samples[topic] = samples.get(topic, 0) + 1
|
||||||
|
if hasattr(message, 'header') and message.header.frame_id:
|
||||||
|
frame_set = frames.setdefault(topic, set())
|
||||||
|
if len(frame_set) < 8:
|
||||||
|
frame_set.add(message.header.frame_id)
|
||||||
|
valid = True
|
||||||
|
if hasattr(message, 'ranges'):
|
||||||
|
valid = len(message.ranges) > 0
|
||||||
|
elif hasattr(message, 'width') and hasattr(message, 'data'):
|
||||||
|
valid = message.width * message.height > 0 and len(message.data) > 0
|
||||||
|
elif hasattr(message, 'info') and hasattr(message, 'data'):
|
||||||
|
valid = message.info.width * message.info.height > 0 and len(message.data) > 0
|
||||||
|
if valid:
|
||||||
|
valid_samples[topic] = valid_samples.get(topic, 0) + 1
|
||||||
|
if topic in ('/tf', '/tf_static'):
|
||||||
|
for transform in message.transforms:
|
||||||
|
if len(edges) < 200:
|
||||||
|
edges.add((transform.header.frame_id, transform.child_frame_id))
|
||||||
|
return receive
|
||||||
|
for topic in topics:
|
||||||
|
if topic not in graph:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
retained = any(info.qos_profile.durability == DurabilityPolicy.TRANSIENT_LOCAL for info in node.get_publishers_info_by_topic(topic))
|
||||||
|
qos = QoSProfile(depth=1, reliability=ReliabilityPolicy.BEST_EFFORT, durability=DurabilityPolicy.TRANSIENT_LOCAL if retained else DurabilityPolicy.VOLATILE)
|
||||||
|
subscriptions.append(node.create_subscription(get_message(graph[topic][0]), topic, callback(topic), qos))
|
||||||
|
except Exception as error:
|
||||||
|
errors[topic] = str(error)[:500]
|
||||||
|
deadline = time.monotonic() + 3.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
rclpy.spin_once(node, timeout_sec=0.1)
|
||||||
|
graph = dict(node.get_topic_names_and_types())
|
||||||
|
print(json.dumps({'nodes': node.get_node_names()[:200], 'topics': [{'name': t, 'types': graph.get(t, []), 'publishers': node.count_publishers(t), 'samples': samples.get(t, 0), 'validSamples': valid_samples.get(t, 0), 'frameIds': sorted(frames.get(t, [])), 'error': errors.get(t)} for t in topics], 'tfEdges': [{'parent': p, 'child': c} for p, c in sorted(edges)], 'observationSeconds': 3.0}))
|
||||||
|
finally:
|
||||||
|
node.destroy_node()
|
||||||
|
rclpy.shutdown()
|
||||||
|
`
|
||||||
|
|
||||||
|
export async function inspectAgentRuntime(
|
||||||
|
request: AgentToolRequest,
|
||||||
|
host: (executable: string, args: string[]) => Promise<string>,
|
||||||
|
container: (command: string) => Promise<string>
|
||||||
|
): Promise<unknown> {
|
||||||
|
if (request.name === 'network.inspect') {
|
||||||
|
const interfaces = JSON.parse(await host('ip', ['-j', 'address', 'show']))
|
||||||
|
const routes = JSON.parse(await host('ip', ['-j', 'route', 'show']))
|
||||||
|
let containerNetwork: unknown
|
||||||
|
try { containerNetwork = JSON.parse(await container('ip -j address show')) } catch (error) { containerNetwork = { error: String(error).slice(0, 500) } }
|
||||||
|
return { host: { interfaces, routes }, container: containerNetwork, note: '호스트는 해당 모듈이 설치된 로컬 또는 SSH 대상 장치입니다. 라이다·공유기 자체 설정은 조회하지 않았습니다.' }
|
||||||
|
}
|
||||||
|
if (request.name === 'ros.inspect') return JSON.parse(await container(agentRosProbe(request.args?.topics)))
|
||||||
|
throw new Error('지원하지 않는 런타임 조회입니다.')
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { dirname, join } from 'path'
|
||||||
|
import { inspect } from 'util'
|
||||||
|
import { createDailyLog } from './dailyLog'
|
||||||
|
|
||||||
|
const fallback = app.getPath('logs')
|
||||||
|
export const appLog = createDailyLog([
|
||||||
|
process.env.APPIMAGE
|
||||||
|
? join(dirname(process.env.APPIMAGE), 'logs')
|
||||||
|
: app.isPackaged ? join(dirname(process.execPath), 'logs') : fallback,
|
||||||
|
fallback
|
||||||
|
])
|
||||||
|
|
||||||
|
for (const level of ['warn', 'error'] as const) {
|
||||||
|
const original = console[level].bind(console)
|
||||||
|
console[level] = (...args: unknown[]) => {
|
||||||
|
appLog.write(level, args.map((value) => typeof value === 'string' ? value : inspect(value, { depth: 4 })).join(' '))
|
||||||
|
original(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor fatal exceptions without changing Node's normal termination behavior.
|
||||||
|
process.on('uncaughtExceptionMonitor', (error, origin) => {
|
||||||
|
appLog.write(origin, error.stack ?? String(error))
|
||||||
|
})
|
||||||
|
process.on('warning', (warning) => appLog.write('warning', warning.stack ?? warning.message))
|
||||||
|
process.on('exit', (code) => appLog.write('exit', `code=${code}`))
|
||||||
|
app.on('child-process-gone', (_event, details) => appLog.write('child-process-gone', JSON.stringify(details)))
|
||||||
|
appLog.write('startup', `version=${app.getVersion()} platform=${process.platform} arch=${process.arch} electron=${process.versions.electron} executable=${process.env.APPIMAGE || process.execPath}`)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { appLog } from './appLogging'
|
||||||
|
|
||||||
|
// Load native dependencies only after the startup logger is ready.
|
||||||
|
void import('./index').catch((error: unknown) => {
|
||||||
|
appLog.write('startup-error', error instanceof Error ? error.stack ?? error.message : String(error))
|
||||||
|
app.exit(1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { bridgeArtifact, jetsonBridgeImage, localBridgeImage } from './bridgeArtifacts'
|
||||||
|
|
||||||
|
test('PC and Jetson bridges have separate pinned downloads', () => {
|
||||||
|
const pc = bridgeArtifact(localBridgeImage)!
|
||||||
|
const jetson = bridgeArtifact(jetsonBridgeImage)!
|
||||||
|
assert.equal(pc.architecture, 'amd64')
|
||||||
|
assert.equal(jetson.architecture, 'arm64')
|
||||||
|
assert.notEqual(pc.url, jetson.url)
|
||||||
|
assert.notEqual(pc.fileName, jetson.fileName)
|
||||||
|
for (const artifact of [pc, jetson]) {
|
||||||
|
assert.match(artifact.checksumSha256, /^[a-f0-9]{64}$/)
|
||||||
|
assert.equal(new URL(artifact.url).protocol, 'https:')
|
||||||
|
assert.ok(artifact.url.includes(artifact.checksumSha256.slice(0, 12)))
|
||||||
|
assert.equal(artifact.required, true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('custom images do not resolve to an unrelated built-in artifact', () => {
|
||||||
|
assert.equal(bridgeArtifact('custom-bridge:test'), undefined)
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const root = 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/slam-ros-bridge'
|
||||||
|
|
||||||
|
export const localBridgeImage = 'slam-ros-bridge:20260813-3d-map-v2'
|
||||||
|
export const jetsonBridgeImage = 'slam-ros-bridge:jetson_orin'
|
||||||
|
|
||||||
|
const artifacts = [
|
||||||
|
{
|
||||||
|
image: localBridgeImage,
|
||||||
|
architecture: 'amd64',
|
||||||
|
url: `${root}/amd64/20260915-489a3e8b370c/slam-ros-bridge-amd64.tar.zst`,
|
||||||
|
fileName: 'slam-ros-bridge-amd64-489a3e8b370c.tar.zst',
|
||||||
|
checksumSha256: '489a3e8b370c19903043a39e92935f2e32e4b4423c28210f130df1acab1a9889',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
image: jetsonBridgeImage,
|
||||||
|
architecture: 'arm64',
|
||||||
|
url: `${root}/arm64/20261013-21acdf0cbdee/slam-ros-bridge-jetson-orin.tar.zst`,
|
||||||
|
fileName: 'slam-ros-bridge-jetson-orin-21acdf0cbdee.tar.zst',
|
||||||
|
checksumSha256: '21acdf0cbdee45d8b7b9dba78c3d321d67d8d7a2467511c4af0826e36ea0c74f',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function bridgeArtifact(image: string) {
|
||||||
|
return artifacts.find((artifact) => artifact.image === image)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const image = 'future-nav2-camera-test:humble-jetson_orin'
|
||||||
|
|
||||||
|
export function cameraRuntimeArtifacts<T extends { image?: string | null }>(
|
||||||
|
folder: string,
|
||||||
|
configured: T[] | null | undefined,
|
||||||
|
): T[] | null | undefined {
|
||||||
|
if (!configured) return configured
|
||||||
|
const camera = folder.includes('lms-nav2-camera-test')
|
||||||
|
// The optional camera image must not be downloaded for other Nav2 modules.
|
||||||
|
return configured.filter((item) => (item.image?.trim() === image) === camera)
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { BrowserWindow, clipboard, Menu } from 'electron'
|
||||||
|
import type { IpcMainInvokeEvent, MenuItemConstructorOptions } from 'electron'
|
||||||
|
import type { ChatClipboardAction, ChatTextMenuAction, ChatTextMenuOptions } from '../shared/chatTextMenu'
|
||||||
|
|
||||||
|
function trustedChatFrame(event: IpcMainInvokeEvent): boolean {
|
||||||
|
if (event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return false
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
return Boolean(window && !window.isDestroyed())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readChatClipboardState(event: IpcMainInvokeEvent): { hasText: boolean } {
|
||||||
|
return { hasText: trustedChatFrame(event) && clipboard.readText().length > 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function performChatClipboardAction(event: IpcMainInvokeEvent, input: ChatClipboardAction): boolean {
|
||||||
|
if (!trustedChatFrame(event) || !input) return false
|
||||||
|
if (input.action === 'copy' && typeof input.text === 'string' && input.text.length > 0 && input.text.length <= 5_000_000) {
|
||||||
|
clipboard.writeText(input.text)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (input.action === 'paste' && clipboard.readText().length > 0) {
|
||||||
|
event.sender.paste()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showChatTextMenu(event: IpcMainInvokeEvent, input: ChatTextMenuOptions): Promise<ChatTextMenuAction> | null {
|
||||||
|
if (!input || !['message', 'input'].includes(input.kind) || event.sender.isDestroyed() || event.senderFrame !== event.sender.mainFrame) return null
|
||||||
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
|
if (!window || window.isDestroyed()) return null
|
||||||
|
const label = (value: unknown, fallback: string): string => typeof value === 'string' && value.trim() ? value.slice(0, 80) : fallback
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const items: MenuItemConstructorOptions[] = [{
|
||||||
|
label: label(input.labels?.copy, 'Copy'),
|
||||||
|
enabled: input.canCopy === true,
|
||||||
|
click: () => {
|
||||||
|
if (!event.sender.isDestroyed() && input.canCopy === true) event.sender.copy()
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
if (input.kind === 'input') {
|
||||||
|
items.push({
|
||||||
|
label: label(input.labels?.paste, 'Paste'),
|
||||||
|
enabled: input.canPaste === true && clipboard.readText().length > 0,
|
||||||
|
click: () => {
|
||||||
|
if (!event.sender.isDestroyed() && input.canPaste === true && clipboard.readText().length > 0) event.sender.paste()
|
||||||
|
resolve(null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (input.canClear !== undefined) {
|
||||||
|
items.push({ type: 'separator' }, {
|
||||||
|
label: label(input.labels?.clear, 'Delete conversation'),
|
||||||
|
enabled: input.canClear === true,
|
||||||
|
click: () => resolve(input.canClear === true ? 'clear' : null)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Menu.buildFromTemplate(items).popup({ window, callback: () => resolve(null) })
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { resolveContainerTerminal } from './containerTerminal'
|
||||||
|
|
||||||
|
const inspection = (running = true) => JSON.stringify([{
|
||||||
|
Id: 'container-id', State: { Running: running }, Mounts: [
|
||||||
|
{ Source: '/home/user/ws', Destination: '/workspace' },
|
||||||
|
{ Source: '/home/user/ws/module', Destination: '/module' }
|
||||||
|
]
|
||||||
|
}])
|
||||||
|
|
||||||
|
test('resolves module path from the most specific actual mount', () => {
|
||||||
|
assert.deepEqual(resolveContainerTerminal(inspection(), '/home/user/ws/module'), { id: 'container-id', cwd: '/module' })
|
||||||
|
assert.equal(resolveContainerTerminal(inspection(), '/home/user/ws/other').cwd, '/workspace/other')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects stopped containers and unrelated workspace paths', () => {
|
||||||
|
assert.throws(() => resolveContainerTerminal(inspection(false), '/home/user/ws/module'), /not running/)
|
||||||
|
assert.throws(() => resolveContainerTerminal(inspection(), '/home/user/ws-other/module'), /not mounted/)
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { posix } from 'node:path'
|
||||||
|
|
||||||
|
export function resolveContainerTerminal(inspectJson: string, moduleRoot: string): { id: string; cwd: string } {
|
||||||
|
const [container] = JSON.parse(inspectJson)
|
||||||
|
if (!container?.State?.Running) throw new Error('Module container is not running')
|
||||||
|
const root = posix.resolve(moduleRoot)
|
||||||
|
const mounts = (container.Mounts ?? []) as { Source: string; Destination: string }[]
|
||||||
|
const mount = mounts
|
||||||
|
.filter((item) => root === item.Source || root.startsWith(`${item.Source.replace(/\/$/, '')}/`))
|
||||||
|
.sort((a, b) => b.Source.length - a.Source.length)[0]
|
||||||
|
if (!mount) throw new Error('Module workspace is not mounted in this container')
|
||||||
|
return { id: container.Id, cwd: posix.join(mount.Destination, posix.relative(mount.Source, root)) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, unlinkSync } from 'fs'
|
||||||
|
import { join } from 'path'
|
||||||
|
|
||||||
|
export function redactLog(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/(https?:\/\/)[^\s/@]+@/gi, '$1[REDACTED]@')
|
||||||
|
.replace(/\bBearer\s+[^\s"',;]+/gi, 'Bearer [REDACTED]')
|
||||||
|
.replace(/((?:[?&]|--)(?:[^\s=&]*token|password|secret|code|key)=)[^\s&#"']+/gi, '$1[REDACTED]')
|
||||||
|
.replace(/(["']?(?:[\w-]*token|password|secret|authorization|api[_-]?key)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;}]+)/gi, '$1[REDACTED]')
|
||||||
|
}
|
||||||
|
|
||||||
|
function localDate(now: Date): string {
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDailyLog(directories: string[], now: () => Date = () => new Date()) {
|
||||||
|
let activePath = ''
|
||||||
|
let activeDay = ''
|
||||||
|
|
||||||
|
function selectFile(): string {
|
||||||
|
const day = localDate(now())
|
||||||
|
if (activePath && activeDay === day) return activePath
|
||||||
|
for (const directory of [...new Set(directories)]) {
|
||||||
|
try {
|
||||||
|
mkdirSync(directory, { recursive: true })
|
||||||
|
const path = join(directory, `lms-${day}.log`)
|
||||||
|
closeSync(openSync(path, 'a', 0o600))
|
||||||
|
activePath = path
|
||||||
|
activeDay = day
|
||||||
|
for (const name of readdirSync(directory)) {
|
||||||
|
if (/^lms-\d{4}-\d{2}-\d{2}\.log$/.test(name) && name < `lms-${day}.log`) {
|
||||||
|
try { unlinkSync(join(directory, name)) } catch { /* Retention must not block startup. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
} catch { /* Try the user-writable fallback directory. */ }
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: selectFile,
|
||||||
|
write(kind: string, message: string): void {
|
||||||
|
try {
|
||||||
|
const path = selectFile()
|
||||||
|
if (!path) return
|
||||||
|
appendFileSync(path, `[${now().toISOString()}] [${redactLog(kind)}] ${redactLog(message)}\n`, { mode: 0o600 })
|
||||||
|
} catch { /* Diagnostics must not interrupt the app. */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { classifyDockerError } from './dockerStatus'
|
||||||
|
|
||||||
|
test('missing CLI is distinguished from an unavailable daemon', () => {
|
||||||
|
assert.equal(classifyDockerError('ENOENT', 'spawn docker ENOENT'), 'missing')
|
||||||
|
assert.equal(classifyDockerError(1, 'Cannot connect to the Docker daemon'), 'unavailable')
|
||||||
|
assert.equal(classifyDockerError(1, 'dial unix /run/docker.sock: connect: connection refused'), 'unavailable')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('permission errors take precedence over connection errors', () => {
|
||||||
|
assert.equal(classifyDockerError(1, 'Cannot connect: permission denied'), 'permission')
|
||||||
|
assert.equal(classifyDockerError('EACCES', 'spawn docker EACCES'), 'permission')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('timeouts and unknown failures do not imply Docker needs installing', () => {
|
||||||
|
assert.equal(classifyDockerError(null, 'Command failed: docker info'), 'error')
|
||||||
|
assert.equal(classifyDockerError(1, 'context not found'), 'error')
|
||||||
|
})
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { execFile } from 'node:child_process'
|
||||||
|
|
||||||
|
export type DockerStatus = 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'
|
||||||
|
|
||||||
|
export function classifyDockerError(code: string | number | null | undefined, message: string): DockerStatus {
|
||||||
|
if (code === 'ENOENT') return 'missing'
|
||||||
|
if (/permission denied|access is denied|EACCES/i.test(message)) return 'permission'
|
||||||
|
if (/cannot connect|connection refused|is the docker daemon running/i.test(message)) return 'unavailable'
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkDockerStatus(): Promise<{ status: DockerStatus; platform: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('docker', ['info', '--format', '{{.ServerVersion}}'], {
|
||||||
|
timeout: 5000,
|
||||||
|
maxBuffer: 256 * 1024,
|
||||||
|
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
||||||
|
}, (error, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
status: error
|
||||||
|
? classifyDockerError(error.code, `${error.message}\n${stderr}`)
|
||||||
|
: stdout.trim() ? 'ready' : 'error',
|
||||||
|
platform: process.platform,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { readdir, readFile, realpath } from 'fs/promises'
|
||||||
|
import { resolve, sep } from 'path'
|
||||||
|
import type { LocalGitModule } from '../shared/gitModuleImport'
|
||||||
|
|
||||||
|
export async function localGitModulePath(root: string, folder: string): Promise<string> {
|
||||||
|
if (!/^\[lms-nav2-custom\][a-z][a-z0-9_]*_v[^/\\]+_ws$/.test(folder)) throw new Error('Invalid custom module folder')
|
||||||
|
const base = await realpath(root)
|
||||||
|
const target = await realpath(resolve(base, folder))
|
||||||
|
if (!target.startsWith(base + sep)) throw new Error('Module is outside the local workspace')
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listLocalGitModules(root: string): Promise<LocalGitModule[]> {
|
||||||
|
const entries = await readdir(root, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||||
|
if (error.code === 'ENOENT') return []
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
const modules: LocalGitModule[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !entry.name.startsWith('[lms-nav2-custom]')) continue
|
||||||
|
try {
|
||||||
|
const target = await localGitModulePath(root, entry.name)
|
||||||
|
const data = JSON.parse(await readFile(resolve(target, '.autonomy-studio-module.json'), 'utf8'))
|
||||||
|
if (data.sourceType !== 'CUSTOM' || !['name', 'category', 'packageName', 'launchFile'].every(key => typeof data[key] === 'string' && data[key])) continue
|
||||||
|
modules.push({ name: data.name, category: data.category, packageName: data.packageName, launchFile: data.launchFile, folderName: entry.name })
|
||||||
|
} catch { /* Ignore incomplete module folders. */ }
|
||||||
|
}
|
||||||
|
return modules.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function repositoryAddress(value: string): string {
|
||||||
|
// Do not allow local paths, Git remote helpers, or credentials in the UI.
|
||||||
|
const normalized = /^git@[^:]+:.+/.test(value) ? value.replace(/^git@([^:]+):(.+)$/, 'ssh://git@$1/$2') : value
|
||||||
|
const url = new URL(normalized)
|
||||||
|
if (!['https:', 'http:', 'ssh:'].includes(url.protocol)) throw new Error('Unsupported Git repository protocol')
|
||||||
|
url.password = ''
|
||||||
|
if (url.protocol !== 'ssh:') url.username = ''
|
||||||
|
url.search = ''
|
||||||
|
url.hash = ''
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remoteBranches(output: string): Array<{ name: string; commit: string }> {
|
||||||
|
return output.split('\n').flatMap(line => {
|
||||||
|
const match = line.trim().match(/^([a-f0-9]{40,64})\s+refs\/heads\/(.+)$/)
|
||||||
|
return match ? [{ name: match[2], commit: match[1] }] : []
|
||||||
|
}).sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
|
||||||
|
const { moduleProgress, createModuleProgressParser } = createRequire(import.meta.url)('./moduleProgress.ts') as typeof import('./moduleProgress')
|
||||||
|
|
||||||
|
test('progress parser handles split lines and ignores normal command output', () => {
|
||||||
|
const messages: string[] = []
|
||||||
|
const parse = moduleProgress.run((message) => messages.push(message), createModuleProgressParser)
|
||||||
|
parse('ordinary output\nLMS_MODULE_PRO')
|
||||||
|
parse('GRESS:download\nLMS_MODULE_PROGRESS:load\n')
|
||||||
|
assert.deepEqual(messages, ['download', 'load'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('concurrent operations retain their own progress callbacks', () => {
|
||||||
|
const first: string[] = []
|
||||||
|
const second: string[] = []
|
||||||
|
const a = moduleProgress.run((message) => first.push(message), createModuleProgressParser)
|
||||||
|
const b = moduleProgress.run((message) => second.push(message), createModuleProgressParser)
|
||||||
|
b('LMS_MODULE_PROGRESS:build\n')
|
||||||
|
a('LMS_MODULE_PROGRESS:download\n')
|
||||||
|
assert.deepEqual(first, ['download'])
|
||||||
|
assert.deepEqual(second, ['build'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('commands outside a module operation do not report progress', () => {
|
||||||
|
assert.doesNotThrow(() => createModuleProgressParser()('LMS_MODULE_PROGRESS:ignored\n'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
|
||||||
|
export const moduleProgress = new AsyncLocalStorage<(message: string) => void>()
|
||||||
|
|
||||||
|
export function reportModuleProgress(message: string): void {
|
||||||
|
moduleProgress.getStore()?.(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createModuleProgressParser(): (chunk: string) => void {
|
||||||
|
const report = moduleProgress.getStore()
|
||||||
|
let pending = ''
|
||||||
|
return (chunk) => {
|
||||||
|
if (!report) return
|
||||||
|
pending += chunk
|
||||||
|
const lines = pending.split('\n')
|
||||||
|
pending = lines.pop()!.slice(-4096)
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('LMS_MODULE_PROGRESS:')) report(line.slice('LMS_MODULE_PROGRESS:'.length).trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
type Request = { action: 'list' } | { action: 'content'; path: string } | { action: 'search'; query: string }
|
||||||
|
|
||||||
|
export function remoteModuleFilesScript(root: string, request: Request): string {
|
||||||
|
const input = Buffer.from(JSON.stringify({ root, ...request })).toString('base64')
|
||||||
|
return `python3 - '${input}' <<'PY'
|
||||||
|
import base64, json, os, stat, sys
|
||||||
|
args = json.loads(base64.b64decode(sys.argv[1]))
|
||||||
|
root = os.path.realpath(args['root'])
|
||||||
|
skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'}
|
||||||
|
extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.sdf', '.world', '.config', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'}
|
||||||
|
placeholder = 'Binary or large file preview is not available.'
|
||||||
|
def content(relative):
|
||||||
|
if os.path.isabs(relative) or '..' in relative.split('/'):
|
||||||
|
raise ValueError('Invalid module path')
|
||||||
|
path = os.path.realpath(os.path.join(root, relative))
|
||||||
|
if os.path.commonpath([root, path]) != root:
|
||||||
|
raise ValueError('Invalid module path')
|
||||||
|
info = os.stat(path)
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
raise ValueError('Not a regular file')
|
||||||
|
if os.path.splitext(path)[1].lower() not in extensions or info.st_size > 1048576:
|
||||||
|
return placeholder
|
||||||
|
with open(path, 'rb') as fp:
|
||||||
|
data = fp.read(1048577)
|
||||||
|
if len(data) > 1048576 or b'\\x00' in data:
|
||||||
|
return placeholder
|
||||||
|
return data.decode('utf-8', errors='replace')
|
||||||
|
def entries():
|
||||||
|
def fail(error):
|
||||||
|
raise error
|
||||||
|
for current, dirs, files in os.walk(root, onerror=fail, followlinks=False):
|
||||||
|
depth = len(os.path.relpath(current, root).split('/')) if current != root else 0
|
||||||
|
dirs[:] = sorted(d for d in dirs if d not in skip and not os.path.islink(os.path.join(current, d)))
|
||||||
|
if depth >= 10:
|
||||||
|
dirs[:] = []
|
||||||
|
continue
|
||||||
|
for name in dirs:
|
||||||
|
yield 'D', os.path.relpath(os.path.join(current, name), root)
|
||||||
|
for name in sorted(files):
|
||||||
|
path = os.path.join(current, name)
|
||||||
|
if not os.path.islink(path) and os.path.isfile(path):
|
||||||
|
yield 'F', os.path.relpath(path, root)
|
||||||
|
if args['action'] == 'content':
|
||||||
|
print(json.dumps({'content': content(args['path'])}))
|
||||||
|
elif args['action'] == 'list':
|
||||||
|
paths, folders = [], []
|
||||||
|
for kind, path in entries():
|
||||||
|
(folders if kind == 'D' else paths).append(path)
|
||||||
|
print(json.dumps({'filePaths': paths, 'folders': folders}))
|
||||||
|
else:
|
||||||
|
query = args['query'].strip().lower()
|
||||||
|
results = []
|
||||||
|
if len(query) >= 2:
|
||||||
|
for kind, path in entries():
|
||||||
|
if kind != 'F':
|
||||||
|
continue
|
||||||
|
text = content(path)
|
||||||
|
if text == placeholder:
|
||||||
|
continue
|
||||||
|
for number, line in enumerate(text.split('\\n'), 1):
|
||||||
|
if query in line.lower():
|
||||||
|
results.append({'file': path, 'lineNumber': number, 'lineText': (line.strip() or line)[:2000]})
|
||||||
|
if len(results) >= 200:
|
||||||
|
break
|
||||||
|
if len(results) >= 200:
|
||||||
|
break
|
||||||
|
print(json.dumps(results))
|
||||||
|
PY`
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
function quote(value: string): string {
|
||||||
|
return `'${value.replace(/'/g, `'"'"'`)}'`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSH may forward LANG/LC_* through the user's SendEnv configuration.
|
||||||
|
// Undefined entries also remove inherited keys when merged by the command runner.
|
||||||
|
export function remoteShellEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||||
|
const result = { ...env }
|
||||||
|
for (const key of Object.keys(result)) {
|
||||||
|
if (key === 'LANGUAGE' || key.startsWith('LC_')) result[key] = undefined
|
||||||
|
}
|
||||||
|
return { ...result, LANG: 'C', LC_ALL: 'C' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function remoteShellCommand(command: string): string {
|
||||||
|
const script = [
|
||||||
|
'selected_locale=C',
|
||||||
|
// Probe under C so an inherited, unavailable locale cannot break the probe.
|
||||||
|
'available_locales=$(locale -a 2>/dev/null) || available_locales=',
|
||||||
|
'for candidate in $available_locales; do',
|
||||||
|
' case "$candidate" in C.utf8|C.UTF-8) selected_locale=$candidate; break ;;',
|
||||||
|
' *utf8|*UTF-8|*utf-8) if [ "$selected_locale" = C ]; then selected_locale=$candidate; fi ;;',
|
||||||
|
' esac',
|
||||||
|
'done',
|
||||||
|
`exec env LANG="$selected_locale" LC_ALL="$selected_locale" LANGUAGE=C /bin/bash --noprofile --norc -c ${quote(command)}`,
|
||||||
|
].join('\n')
|
||||||
|
return `env LANG=C LC_ALL=C LANGUAGE=C /bin/sh -c ${quote(script)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
function loadFunction(name: string, dependencies: Record<string, unknown>) {
|
||||||
|
const declaration = source.statements.find((item) => ts.isFunctionDeclaration(item) && item.name?.text === name)
|
||||||
|
assert.ok(declaration)
|
||||||
|
const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 })
|
||||||
|
return new Function(...Object.keys(dependencies), `${code}; return ${name};`)(...Object.values(dependencies))
|
||||||
|
}
|
||||||
|
|
||||||
|
function command(executable: string, args: string[], cwd: string) {
|
||||||
|
const result = spawnSync(executable, args, {
|
||||||
|
cwd, encoding: 'utf8', timeout: 15000,
|
||||||
|
env: { ...process.env, GIT_AUTHOR_NAME: 'Test', GIT_AUTHOR_EMAIL: 'test@example.com', GIT_COMMITTER_NAME: 'Test', GIT_COMMITTER_EMAIL: 'test@example.com' },
|
||||||
|
})
|
||||||
|
assert.equal(result.status, 0, result.stderr || String(result.error))
|
||||||
|
return result.stdout.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'lms-preserve-'))
|
||||||
|
const template = join(root, 'template')
|
||||||
|
mkdirSync(template)
|
||||||
|
command('git', ['init', '-b', 'main'], template)
|
||||||
|
writeFileSync(join(template, 'node.py'), 'template source\n')
|
||||||
|
writeFileSync(join(template, '.gitignore'), 'build/\ninstall/\nlog/\n')
|
||||||
|
command('git', ['add', '.'], template)
|
||||||
|
command('git', ['commit', '-m', 'template'], template)
|
||||||
|
const install = loadFunction('installRemoteWorkspaceModule', {
|
||||||
|
normalizeRemoteTarget: (target: unknown) => target,
|
||||||
|
remoteRepositoryUrl: (url: string) => url,
|
||||||
|
withGitCredential: (url: string) => url,
|
||||||
|
remotePathJoin: join,
|
||||||
|
safeArtifactFileName: () => 'unused.tar',
|
||||||
|
quoteShellSingle: (value: string) => "'" + value.replaceAll("'", "'\\''") + "'",
|
||||||
|
encodeBase64: (value: string) => Buffer.from(value).toString('base64'),
|
||||||
|
expandRemotePathVar: () => [],
|
||||||
|
runSsh: async (_target: unknown, script: string) => command('bash', ['-c', script], root),
|
||||||
|
parseModuleFolderName: () => ({ name: 'Test', category: 'Control' }),
|
||||||
|
})
|
||||||
|
const target = { workspaceRoot: join(root, 'workspace') }
|
||||||
|
return { root, template, target, install, modulePath: join(target.workspaceRoot, 'module') }
|
||||||
|
}
|
||||||
|
|
||||||
|
test('reinstall preserves saved source, local commits, untracked files and build outputs despite changed repository URLs', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
try {
|
||||||
|
await f.install(f.target, 'Test', 'module', f.template)
|
||||||
|
writeFileSync(join(f.modulePath, 'node.py'), 'student commit\n')
|
||||||
|
command('git', ['add', 'node.py'], f.modulePath)
|
||||||
|
command('git', ['commit', '-m', 'student work'], f.modulePath)
|
||||||
|
const head = command('git', ['rev-parse', 'HEAD'], f.modulePath)
|
||||||
|
writeFileSync(join(f.modulePath, 'node.py'), 'saved but not committed\n')
|
||||||
|
writeFileSync(join(f.modulePath, 'new.py'), 'new student file\n')
|
||||||
|
for (const dir of ['build', 'install', 'log']) {
|
||||||
|
mkdirSync(join(f.modulePath, dir))
|
||||||
|
writeFileSync(join(f.modulePath, dir, 'keep'), dir)
|
||||||
|
}
|
||||||
|
const status = command('git', ['status', '--porcelain'], f.modulePath)
|
||||||
|
await f.install(f.target, 'Test', 'module', '/missing/new-template', '/missing/new-student', {
|
||||||
|
prebuiltArtifacts: [{ url: 'https://invalid.example/overwrite.tar', required: true }],
|
||||||
|
})
|
||||||
|
assert.equal(command('git', ['rev-parse', 'HEAD'], f.modulePath), head)
|
||||||
|
assert.equal(command('git', ['status', '--porcelain'], f.modulePath), status)
|
||||||
|
assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'saved but not committed\n')
|
||||||
|
assert.equal(readFileSync(join(f.modulePath, 'new.py'), 'utf8'), 'new student file\n')
|
||||||
|
for (const dir of ['build', 'install', 'log']) assert.equal(readFileSync(join(f.modulePath, dir, 'keep'), 'utf8'), dir)
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('first install restores the student repository instead of replacing it with template source', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
try {
|
||||||
|
const student = join(f.root, 'student')
|
||||||
|
command('git', ['clone', f.template, student], f.root)
|
||||||
|
writeFileSync(join(student, 'node.py'), 'submitted student work\n')
|
||||||
|
command('git', ['add', '.'], student)
|
||||||
|
command('git', ['commit', '-m', 'submission'], student)
|
||||||
|
await f.install(f.target, 'Test', 'module', f.template, student)
|
||||||
|
assert.equal(readFileSync(join(f.modulePath, 'node.py'), 'utf8'), 'submitted student work\n')
|
||||||
|
assert.equal(command('git', ['branch', '--show-current'], f.modulePath), 'student-work')
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('first install initializes an empty student repository', async () => {
|
||||||
|
const f = fixture()
|
||||||
|
try {
|
||||||
|
const student = join(f.root, 'student.git')
|
||||||
|
command('git', ['init', '--bare', student], f.root)
|
||||||
|
await f.install(f.target, 'Test', 'module', f.template, student)
|
||||||
|
assert.equal(command('git', ['--git-dir', student, 'show', 'main:node.py'], f.root), 'template source')
|
||||||
|
} finally { rmSync(f.root, { recursive: true, force: true }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legacy session reset only stops modules and returns stop failures', async () => {
|
||||||
|
const target = { workspaceRoot: '/student/workspace' }
|
||||||
|
const modules = [{ moduleName: 'Test', folderName: 'module', templateRepositoryUrl: '/template' }]
|
||||||
|
const result = { ok: false, message: 'stop failed' }
|
||||||
|
let calls = 0
|
||||||
|
const reset = loadFunction('resetRemoteWorkspaceSession', {
|
||||||
|
stopRemoteWorkspaceModules: async (actualTarget: unknown, actualModules: unknown) => {
|
||||||
|
calls++
|
||||||
|
assert.equal(actualTarget, target)
|
||||||
|
assert.equal(actualModules, modules)
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
})
|
||||||
|
assert.equal(await reset(target, modules), result)
|
||||||
|
assert.equal(calls, 1)
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||||
|
|
||||||
|
export const visualizationProgress = new AsyncLocalStorage<(message: string) => void>()
|
||||||
|
|
||||||
|
export function reportVisualizationProgress(message: string): void {
|
||||||
|
visualizationProgress.getStore()?.(message)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { strict as assert } from 'node:assert'
|
||||||
|
import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import { join } from 'node:path'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import ts from 'typescript'
|
||||||
|
const { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } = createRequire(import.meta.url)('./yahboomRuntime.ts') as typeof import('./yahboomRuntime')
|
||||||
|
|
||||||
|
test('simulation is an explicit opt-in and excludes physical device modules', () => {
|
||||||
|
for (const role of ['vehicle', 'slam', 'map', 'localization', 'planning', 'control']) {
|
||||||
|
const folder = `[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`
|
||||||
|
assert.equal(yahboomSimulation(folder, ''), false)
|
||||||
|
assert.equal(yahboomSimulation(folder, 'sim_mode:=false'), false)
|
||||||
|
assert.equal(yahboomSimulation(folder, 'foo:=bar sim_mode:=true'), true)
|
||||||
|
}
|
||||||
|
for (const role of ['agent', 'camera']) {
|
||||||
|
assert.equal(yahboomSimulation(`[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`, 'sim_mode:=true'), false)
|
||||||
|
}
|
||||||
|
assert.equal(yahboomSimulation('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', 'sim_mode:=true'), false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local simulation overrides ARM metadata and downloads only the PC image', async () => {
|
||||||
|
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const declaration = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === 'resolveLocalModuleRuntime')
|
||||||
|
assert.ok(declaration)
|
||||||
|
const artifact = { image: 'future-nav2-gazebo:humble', url: 'https://example.com/gazebo.tar.zst' }
|
||||||
|
const resolver = new Function('yahboomSimulation', 'defaultNav2GazeboRuntimeImage', 'resolveCustomModuleRuntimeImage',
|
||||||
|
ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 }) + '; return resolveLocalModuleRuntime;'
|
||||||
|
)(yahboomSimulation, artifact, () => { throw new Error('ARM metadata must not override simulation') })
|
||||||
|
const result = await resolver('/module', '[lms-nav2-yahboom-vehicle]yahboom_vehicle_v1.0.0_ws', 'sim_mode:=true',
|
||||||
|
[{ image: 'future-nav2:humble-jetson_orin', url: 'https://example.com/arm.tar.zst' }])
|
||||||
|
assert.deepEqual(result, { image: artifact.image, artifacts: [artifact] })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('remote Yahboom downloads only its own image and preserves explicit overrides', () => {
|
||||||
|
const configured = [{ image: 'future-nav2:humble-jetson_orin', url: 'https://example.com/jetson.tar.zst' }]
|
||||||
|
assert.equal(yahboomRuntimeArtifacts('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', configured[0].image, configured), configured)
|
||||||
|
const folder = '[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws'
|
||||||
|
const image = 'future-nav2:humble-yahboom-agent'
|
||||||
|
const selected = yahboomRuntimeArtifacts(folder, image, configured)
|
||||||
|
assert.equal(selected?.length, 1)
|
||||||
|
assert.equal(selected?.[0].image, image)
|
||||||
|
assert.match(selected?.[0].checksumSha256 ?? '', /^[a-f0-9]{64}$/)
|
||||||
|
const override = { image, url: 'https://example.com/override.tar.zst' }
|
||||||
|
assert.deepEqual(yahboomRuntimeArtifacts(folder, image, [...configured, override]), [override])
|
||||||
|
assert.deepEqual(yahboomRuntimeArtifacts(folder, 'custom:tag', configured), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
function runtimeScript(name: string): string {
|
||||||
|
const source = ts.createSourceFile('index.ts', readFileSync(new URL('./index.ts', import.meta.url), 'utf8'), ts.ScriptTarget.Latest, true)
|
||||||
|
const declaration = source.statements.find((item): item is ts.FunctionDeclaration => ts.isFunctionDeclaration(item) && item.name?.text === name)
|
||||||
|
assert.ok(declaration)
|
||||||
|
const code = ts.transpile(declaration.getText(source), { target: ts.ScriptTarget.ES2022 })
|
||||||
|
return new Function('yahboomModuleCases', 'yahboomDevicePreflight', `${code}; return ${name}();`)(yahboomModuleCases, yahboomDevicePreflight)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('Yahboom image routing does not change MKMini, Gazebo or custom modules', () => {
|
||||||
|
assert.equal(yahboomRuntimeImage('[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws'), 'future-nav2:humble-yahboom-agent')
|
||||||
|
assert.equal(yahboomRuntimeImage('[lms-nav2-yahboom-slam]yahboom_slam_v1.0.0_ws'), 'future-nav2:humble-yahboom')
|
||||||
|
for (const name of ['[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', '[lms-nav2-gazebo-map]nav2-map_v1.0.0_ws', '[lms-nav2-custom]yahboom_agent_v1.0.0_ws', '[lms-nav2-yahboom-agent]other_v1.0.0_ws']) {
|
||||||
|
assert.equal(yahboomRuntimeImage(name), null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('all eight templates map to their own package in both runtime scripts', () => {
|
||||||
|
for (const build of [false, true]) {
|
||||||
|
for (const role of ['agent', 'vehicle', 'slam', 'map', 'localization', 'planning', 'control', 'camera']) {
|
||||||
|
const script = ['case "$1" in', ...yahboomModuleCases(build), 'esac', build ? 'printf "%s" "$TARGET"' : 'printf "%s %s" "$PACKAGE" "$LAUNCH"'].join('\n')
|
||||||
|
const result = spawnSync('bash', ['-c', script, '_', `[lms-nav2-yahboom-${role}]yahboom_${role}_v1.0.0_ws`], { encoding: 'utf8' })
|
||||||
|
assert.equal(result.status, 0, result.stderr)
|
||||||
|
assert.equal(result.stdout, build ? `yahboom_${role}` : `yahboom_${role} ${role}.launch.py`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('generated build and run shell scripts are syntactically valid', () => {
|
||||||
|
for (const name of ['buildNav2DockerRunScript', 'buildNav2DockerBuildScript']) {
|
||||||
|
const result = spawnSync('bash', ['-n'], { input: runtimeScript(name), encoding: 'utf8' })
|
||||||
|
assert.equal(result.status, 0, result.stderr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('invalid hardware path fails before Docker is called; MKMini still launches without extra device', () => {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'yahboom-runtime-'))
|
||||||
|
try {
|
||||||
|
const bin = join(root, 'bin')
|
||||||
|
mkdirSync(bin)
|
||||||
|
writeFileSync(join(bin, 'docker'), '#!/bin/bash\nprintf "%s\\n" "$@"\n', { mode: 0o755 })
|
||||||
|
const script = join(root, 'docker_run_nav2.sh')
|
||||||
|
writeFileSync(script, runtimeScript('buildNav2DockerRunScript'))
|
||||||
|
for (const [folder, parameter] of [
|
||||||
|
['[lms-nav2-yahboom-agent]yahboom_agent_v1.0.0_ws', 'serial_device:=/etc/passwd'],
|
||||||
|
['[lms-nav2-yahboom-camera]yahboom_camera_v1.0.0_ws', 'video_device:=/dev/../etc/passwd'],
|
||||||
|
['[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', ''],
|
||||||
|
]) {
|
||||||
|
mkdirSync(join(root, folder, 'install'), { recursive: true })
|
||||||
|
writeFileSync(join(root, folder, 'install/setup.bash'), '')
|
||||||
|
const result = spawnSync('bash', [script, 'test-container', root, folder, 'test-image', '77', 'false', parameter], {
|
||||||
|
encoding: 'utf8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, DISPLAY: '', FASTDDS_CONFIG_PATH: '' },
|
||||||
|
})
|
||||||
|
if (parameter) {
|
||||||
|
assert.equal(result.status, 64)
|
||||||
|
assert.equal(result.stdout, '')
|
||||||
|
} else {
|
||||||
|
assert.equal(result.status, 0, result.stderr)
|
||||||
|
assert.match(result.stdout, /vehicle_pkg\nvehicle.launch.py/)
|
||||||
|
assert.doesNotMatch(result.stdout, /ttyUSB|video0|privileged/)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
rmSync(root, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
const roles = ['agent', 'vehicle', 'slam', 'map', 'localization', 'planning', 'control', 'camera'] as const
|
||||||
|
|
||||||
|
export function yahboomSimulation(folderName: string | null | undefined, rosParams: string): boolean {
|
||||||
|
return Boolean(yahboomRuntimeImage(folderName))
|
||||||
|
&& !/yahboom-(agent|camera)\]/.test(folderName ?? '')
|
||||||
|
&& /(?:^|\s)sim_mode:=true(?:\s|$)/.test(rosParams)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeArtifact = {
|
||||||
|
image?: string | null
|
||||||
|
url?: string | null
|
||||||
|
fileName?: string | null
|
||||||
|
checksumSha256?: string | null
|
||||||
|
required?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const artifactRoot = 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/yahboom/arm64'
|
||||||
|
const artifacts: RuntimeArtifact[] = [
|
||||||
|
['future-nav2:humble-yahboom', 'future-nav2-humble-yahboom.tar.zst', 'b2f168c45b961ab96badeeb7df68d1c997996c75dcb5e0cc677b0b6669b1ffd2'],
|
||||||
|
['future-nav2:humble-yahboom-agent', 'future-nav2-humble-yahboom-agent.tar.zst', 'e30404506c320af1d53c8733177eaf27997dcb2864019b8fcc6888874f02d191'],
|
||||||
|
].map(([image, fileName, checksumSha256]) => ({
|
||||||
|
image, fileName, checksumSha256, required: true,
|
||||||
|
url: `${artifactRoot}/${checksumSha256.slice(0, 16)}/${fileName}`,
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function yahboomRuntimeArtifacts(
|
||||||
|
folderName: string,
|
||||||
|
runtimeImage: string | null,
|
||||||
|
configured?: RuntimeArtifact[] | null,
|
||||||
|
): RuntimeArtifact[] | null | undefined {
|
||||||
|
if (!yahboomRuntimeImage(folderName)) return configured
|
||||||
|
const selected = configured?.find((item) => item.image?.trim() === runtimeImage && item.url?.trim())
|
||||||
|
?? artifacts.find((item) => item.image === runtimeImage)
|
||||||
|
// Yahboom must not download unrelated Jetson images from an existing profile.
|
||||||
|
return selected ? [selected] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yahboomRuntimeImage(folderName?: string | null): string | null {
|
||||||
|
const role = folderName?.match(/^\[lms-nav2-yahboom-([a-z]+)\]yahboom_\1_v[^/]+_ws$/)?.[1]
|
||||||
|
if (!roles.some((value) => value === role)) return null
|
||||||
|
return role === 'agent' ? 'future-nav2:humble-yahboom-agent' : 'future-nav2:humble-yahboom'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yahboomModuleCases(build = false): string[] {
|
||||||
|
return roles.map((role) => (
|
||||||
|
` "[lms-nav2-yahboom-${role}]yahboom_${role}_v"*_ws) ${build ? `TARGET=yahboom_${role}` : `PACKAGE=yahboom_${role}; LAUNCH=${role}.launch.py`} ;;`
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep hardware exposure limited to the two hardware-facing Yahboom templates.
|
||||||
|
export function yahboomDevicePreflight(): string[] {
|
||||||
|
return [
|
||||||
|
'YAHBOOM_DEVICE=""',
|
||||||
|
'case "$MODULE_FOLDER" in',
|
||||||
|
' "[lms-nav2-yahboom-agent]yahboom_agent_v"*_ws)',
|
||||||
|
' YAHBOOM_DEVICE=/dev/ttyUSB0',
|
||||||
|
' for arg in "$@"; do case "$arg" in serial_device:=*) YAHBOOM_DEVICE=${arg#serial_device:=} ;; esac; done',
|
||||||
|
' case "$YAHBOOM_DEVICE" in /dev/ttyUSB*|/dev/ttyACM*|/dev/serial/by-id/*) ;; *) echo "Invalid Yahboom serial device" >&2; exit 64 ;; esac',
|
||||||
|
' ;;',
|
||||||
|
' "[lms-nav2-yahboom-camera]yahboom_camera_v"*_ws)',
|
||||||
|
' YAHBOOM_DEVICE=/dev/video0',
|
||||||
|
' for arg in "$@"; do case "$arg" in video_device:=*) YAHBOOM_DEVICE=${arg#video_device:=} ;; esac; done',
|
||||||
|
' [[ "$YAHBOOM_DEVICE" =~ ^/dev/video[0-9]+$ ]] || { echo "Invalid Yahboom camera device" >&2; exit 64; }',
|
||||||
|
' ;;',
|
||||||
|
'esac',
|
||||||
|
'if [ -n "$YAHBOOM_DEVICE" ]; then',
|
||||||
|
' [[ "$YAHBOOM_DEVICE" != *".."* ]] && [ -c "$YAHBOOM_DEVICE" ] || { echo "Yahboom device not found: $YAHBOOM_DEVICE" >&2; exit 1; }',
|
||||||
|
'fi',
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -2,6 +2,20 @@ import { contextBridge, ipcRenderer } from 'electron'
|
|||||||
import { electronAPI } from '@electron-toolkit/preload'
|
import { electronAPI } from '@electron-toolkit/preload'
|
||||||
import { readFileSync, rmSync } from 'fs'
|
import { readFileSync, rmSync } from 'fs'
|
||||||
|
|
||||||
|
let visualizationRequestId = 0
|
||||||
|
async function invokeVisualizationStart(channel: string, args: unknown[], onProgress?: (message: string) => void) {
|
||||||
|
const requestId = `${Date.now()}:${++visualizationRequestId}`
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, id: string, message: string) => {
|
||||||
|
if (id === requestId) onProgress?.(message)
|
||||||
|
}
|
||||||
|
ipcRenderer.on('visualization:progress', listener)
|
||||||
|
try {
|
||||||
|
return await ipcRenderer.invoke(channel, ...args, requestId)
|
||||||
|
} finally {
|
||||||
|
ipcRenderer.removeListener('visualization:progress', listener)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type LauncherAuthPayload = {
|
type LauncherAuthPayload = {
|
||||||
appUrl?: string | null
|
appUrl?: string | null
|
||||||
handoffToken?: string | null
|
handoffToken?: string | null
|
||||||
@@ -40,6 +54,7 @@ type ModuleInstallOptions = {
|
|||||||
|
|
||||||
type RosNetworkConfig = {
|
type RosNetworkConfig = {
|
||||||
enabled?: boolean | null
|
enabled?: boolean | null
|
||||||
|
allowExternal?: boolean
|
||||||
peer?: RemoteWorkspaceTarget | null
|
peer?: RemoteWorkspaceTarget | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,6 +117,11 @@ console.info('[lms-preload] launcher auth resolved', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('electron', electronAPI)
|
contextBridge.exposeInMainWorld('electron', electronAPI)
|
||||||
|
contextBridge.exposeInMainWorld('chatTextMenu', {
|
||||||
|
show: (options: import('../shared/chatTextMenu').ChatTextMenuOptions) => ipcRenderer.invoke('chat-text:context-menu', options),
|
||||||
|
readClipboardState: () => ipcRenderer.invoke('chat-text:clipboard-state'),
|
||||||
|
perform: (action: import('../shared/chatTextMenu').ChatClipboardAction) => ipcRenderer.invoke('chat-text:clipboard-action', action)
|
||||||
|
})
|
||||||
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
|
||||||
contextBridge.exposeInMainWorld('lmsBootSplash', {
|
contextBridge.exposeInMainWorld('lmsBootSplash', {
|
||||||
status: (message: string) => ipcRenderer.send('lms-boot-status', message),
|
status: (message: string) => ipcRenderer.send('lms-boot-status', message),
|
||||||
@@ -182,6 +202,15 @@ contextBridge.exposeInMainWorld('visualization', {
|
|||||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('localWorkspace', {
|
contextBridge.exposeInMainWorld('localWorkspace', {
|
||||||
|
onModuleProgress: (callback: (folderName: string, message: string) => void) => {
|
||||||
|
const listener = (_event: Electron.IpcRendererEvent, folderName: string, message: string) => callback(folderName, message)
|
||||||
|
ipcRenderer.on('module:progress', listener)
|
||||||
|
return () => ipcRenderer.removeListener('module:progress', listener)
|
||||||
|
},
|
||||||
|
docker: {
|
||||||
|
status: () => ipcRenderer.invoke('local-workspace:docker:status'),
|
||||||
|
openGuide: () => ipcRenderer.invoke('local-workspace:docker:guide'),
|
||||||
|
},
|
||||||
moduleFiles: {
|
moduleFiles: {
|
||||||
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName),
|
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName),
|
||||||
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:image', moduleName, path, workspaceRoot, folderName),
|
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:image', moduleName, path, workspaceRoot, folderName),
|
||||||
@@ -191,9 +220,16 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:rename', moduleName, fromPath, toPath, workspaceRoot, folderName),
|
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:rename', moduleName, fromPath, toPath, workspaceRoot, folderName),
|
||||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
|
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:delete', moduleName, path, workspaceRoot, folderName)
|
||||||
},
|
},
|
||||||
|
agent: {
|
||||||
|
readTool: (request: import('../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-tool:read', request, workspaceRoot, folderName),
|
||||||
|
applyFiles: (changes: import('../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:agent-files:apply', changes, workspaceRoot),
|
||||||
|
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:agent-command:run', moduleName, command, workspaceRoot, folderName)
|
||||||
|
},
|
||||||
modules: {
|
modules: {
|
||||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
|
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
|
||||||
listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'),
|
listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'),
|
||||||
|
listGitSources: (root: string) => ipcRenderer.invoke('local-workspace:modules:git-sources', root),
|
||||||
|
inspectGitSource: (root: string, folder: string) => ipcRenderer.invoke('local-workspace:modules:git-source', root, folder),
|
||||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input),
|
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input),
|
||||||
selectImportSource: () => ipcRenderer.invoke('local-workspace:modules:select-import-source'),
|
selectImportSource: () => ipcRenderer.invoke('local-workspace:modules:select-import-source'),
|
||||||
@@ -228,7 +264,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
},
|
},
|
||||||
visualization: {
|
visualization: {
|
||||||
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath),
|
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, onProgress?: (message: string) => void) => invokeVisualizationStart('local-workspace:visualization:start', [stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath, savedMapWorldLayoutPath], onProgress),
|
||||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
||||||
},
|
},
|
||||||
git: {
|
git: {
|
||||||
@@ -241,7 +277,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
|
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName),
|
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string, mode?: 'host' | 'container') => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName, mode),
|
||||||
resize: (cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('local-workspace:terminal:resize', cols, rows, sessionId),
|
resize: (cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('local-workspace:terminal:resize', cols, rows, sessionId),
|
||||||
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:change-directory', moduleName, sessionId, workspaceRoot, folderName),
|
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:change-directory', moduleName, sessionId, workspaceRoot, folderName),
|
||||||
write: (data: string, sessionId?: string) => ipcRenderer.send('local-workspace:terminal:write', data, sessionId),
|
write: (data: string, sessionId?: string) => ipcRenderer.send('local-workspace:terminal:write', data, sessionId),
|
||||||
@@ -265,6 +301,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
contextBridge.exposeInMainWorld('remoteWorkspace', {
|
contextBridge.exposeInMainWorld('remoteWorkspace', {
|
||||||
moduleFiles: {
|
moduleFiles: {
|
||||||
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName),
|
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName),
|
||||||
|
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:content', target, moduleName, path, folderName),
|
||||||
|
search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:search', target, moduleName, query, folderName),
|
||||||
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:image', target, moduleName, path, folderName),
|
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:image', target, moduleName, path, folderName),
|
||||||
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName),
|
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName),
|
||||||
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName),
|
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName),
|
||||||
@@ -272,14 +310,22 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
|||||||
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName),
|
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:rename', target, moduleName, fromPath, toPath, folderName),
|
||||||
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
|
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:delete', target, moduleName, path, folderName)
|
||||||
},
|
},
|
||||||
|
agent: {
|
||||||
|
readTool: (target: RemoteWorkspaceTarget, request: import('../shared/agentTools').AgentToolRequest, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-tool:read', target, request, folderName),
|
||||||
|
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../shared/agentTools').AgentCheckedChange[]) => ipcRenderer.invoke('remote-workspace:agent-files:apply', target, changes),
|
||||||
|
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:agent-command:run', target, moduleName, command, folderName)
|
||||||
|
},
|
||||||
maps: {
|
maps: {
|
||||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
|
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
|
||||||
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
|
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
|
||||||
|
uploadRtabmapDatabase: (target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => ipcRenderer.invoke('remote-workspace:maps:upload-rtabmap-database', target, uploadUrl, databasePath),
|
||||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
|
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
|
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules),
|
||||||
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
|
listDockerImages: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:modules:docker-images', target),
|
||||||
|
importGit: (target: RemoteWorkspaceTarget, root: string, input: import('../shared/gitModuleImport').GitModuleImportInput & { folderName: string }) => ipcRenderer.invoke('remote-workspace:modules:import-git', target, root, input),
|
||||||
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input),
|
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('remote-workspace:modules:create-custom', target, input),
|
||||||
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:transfer', target, sourceWorkspaceRoot, moduleName, folderName),
|
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:transfer', target, sourceWorkspaceRoot, moduleName, folderName),
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork),
|
||||||
@@ -298,7 +344,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
|||||||
},
|
},
|
||||||
visualization: {
|
visualization: {
|
||||||
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
|
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
|
||||||
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile, rosNetwork),
|
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, onProgress?: (message: string) => void) => invokeVisualizationStart('remote-workspace:visualization:start', [target, stack, rosDomainId, environmentProfile, rosNetwork], onProgress),
|
||||||
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
|
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
|
||||||
},
|
},
|
||||||
git: {
|
git: {
|
||||||
@@ -311,7 +357,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
|||||||
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash)
|
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash)
|
||||||
},
|
},
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName),
|
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string, mode?: 'host' | 'container') => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName, mode),
|
||||||
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('remote-workspace:terminal:resize', target, cols, rows, sessionId),
|
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('remote-workspace:terminal:resize', target, cols, rows, sessionId),
|
||||||
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:change-directory', target, moduleName, sessionId, folderName),
|
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:change-directory', target, moduleName, sessionId, folderName),
|
||||||
write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId),
|
write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId),
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.5 MiB After Width: | Height: | Size: 2.4 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 961 KiB |
|
Before Width: | Height: | Size: 961 KiB After Width: | Height: | Size: 628 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
export function createCameraPreview({ root, baseUrl, getState, onAvailable }) {
|
||||||
|
const cameras = new Map();
|
||||||
|
let enabled = false;
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function sources() {
|
||||||
|
const { state, receivedAt } = getState();
|
||||||
|
if (!enabled || Date.now() - receivedAt >= 3000) return [];
|
||||||
|
const list = Array.isArray(state?.cameras) ? state.cameras
|
||||||
|
: state?.topic_status?.camera_image?.available ? [{ id: 'legacy', name: 'Camera', available: true }] : [];
|
||||||
|
const seen = new Set();
|
||||||
|
return list.filter(item => {
|
||||||
|
if (!item?.available || !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || seen.has(item.id)) return false;
|
||||||
|
seen.add(item.id);
|
||||||
|
return true;
|
||||||
|
}).slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFrame(camera) {
|
||||||
|
if (camera.url) URL.revokeObjectURL(camera.url);
|
||||||
|
camera.url = null;
|
||||||
|
camera.image.removeAttribute('src');
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(id, camera) {
|
||||||
|
cameras.delete(id);
|
||||||
|
camera.controller?.abort();
|
||||||
|
clearFrame(camera);
|
||||||
|
camera.element.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function frame(id, camera) {
|
||||||
|
if (camera.controller || Date.now() < camera.retryAt || document.hidden) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
camera.controller = controller;
|
||||||
|
const timeout = window.setTimeout(() => controller.abort(), 2000);
|
||||||
|
try {
|
||||||
|
const query = id === 'legacy' ? '' : `?camera_id=${encodeURIComponent(id)}`;
|
||||||
|
const response = await fetch(`${baseUrl}/camera-frame${query}`, { cache: 'no-store', signal: controller.signal });
|
||||||
|
if (!response.ok) throw new Error('Camera frame unavailable');
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (controller.signal.aborted || document.hidden || cameras.get(id) !== camera || !sources().some(item => item.id === id)) return;
|
||||||
|
const previous = camera.url;
|
||||||
|
camera.url = URL.createObjectURL(blob);
|
||||||
|
camera.image.src = camera.url;
|
||||||
|
camera.retryAt = 0;
|
||||||
|
if (previous) URL.revokeObjectURL(previous);
|
||||||
|
} catch (_) {
|
||||||
|
if (cameras.get(id) === camera) {
|
||||||
|
camera.retryAt = Date.now() + 5000;
|
||||||
|
clearFrame(camera);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
if (camera.controller === controller) camera.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
const live = sources();
|
||||||
|
const ids = new Set(live.map(item => item.id));
|
||||||
|
for (const [id, camera] of cameras) if (!ids.has(id)) remove(id, camera);
|
||||||
|
for (const item of live) {
|
||||||
|
let camera = cameras.get(item.id);
|
||||||
|
if (!camera) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = 'camera-preview-item';
|
||||||
|
element.dataset.cameraId = item.id;
|
||||||
|
const label = document.createElement('div');
|
||||||
|
label.className = 'camera-preview-label';
|
||||||
|
const image = document.createElement('img');
|
||||||
|
image.className = 'camera-preview-image';
|
||||||
|
element.append(label, image);
|
||||||
|
root.append(element);
|
||||||
|
camera = { element, label, image, url: null, controller: null, retryAt: 0 };
|
||||||
|
image.addEventListener('error', () => {
|
||||||
|
if (cameras.get(item.id) === camera) {
|
||||||
|
clearFrame(camera);
|
||||||
|
camera.retryAt = Date.now() + 5000;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
cameras.set(item.id, camera);
|
||||||
|
}
|
||||||
|
camera.label.textContent = item.name || item.id;
|
||||||
|
camera.image.alt = item.name || item.id;
|
||||||
|
void frame(item.id, camera);
|
||||||
|
}
|
||||||
|
onAvailable(enabled && cameras.size > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibilityChanged() {
|
||||||
|
if (document.hidden) for (const camera of cameras.values()) camera.controller?.abort();
|
||||||
|
else refresh();
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', visibilityChanged);
|
||||||
|
|
||||||
|
function setEnabled(value) {
|
||||||
|
enabled = value;
|
||||||
|
if (timer) window.clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
refresh();
|
||||||
|
if (enabled) timer = window.setInterval(refresh, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setEnabled,
|
||||||
|
refresh,
|
||||||
|
dispose() {
|
||||||
|
setEnabled(false);
|
||||||
|
document.removeEventListener('visibilitychange', visibilityChanged);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -24,11 +24,12 @@
|
|||||||
#miniMapToggle {
|
#miniMapToggle {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
right:16px;
|
right:16px;
|
||||||
bottom:240px;
|
bottom:calc(16px + 220px + 2px);
|
||||||
z-index:11;
|
z-index:11;
|
||||||
min-height:28px;
|
min-height:28px;
|
||||||
padding:0 10px;
|
padding:0 10px;
|
||||||
border:1px solid rgba(255,255,255,.22);
|
border:1px solid rgba(255,255,255,.22);
|
||||||
|
border-radius:6px 6px 0 0;
|
||||||
background:rgba(17,24,39,.42);
|
background:rgba(17,24,39,.42);
|
||||||
color:rgba(255,255,255,.88);
|
color:rgba(255,255,255,.88);
|
||||||
font:12px sans-serif;
|
font:12px sans-serif;
|
||||||
@@ -48,35 +49,52 @@
|
|||||||
}
|
}
|
||||||
#cameraPreviewWrap {
|
#cameraPreviewWrap {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
right:16px;
|
left:16px;
|
||||||
bottom:256px;
|
top:60px;
|
||||||
z-index:10;
|
z-index:10;
|
||||||
display:none;
|
display:none;
|
||||||
width:260px;
|
width:min(260px, calc(100vw - 32px));
|
||||||
border:1px solid rgba(0,0,0,.45);
|
border:1px solid rgba(0,0,0,.45);
|
||||||
background:#111520;
|
background:#111520;
|
||||||
box-shadow:0 10px 26px rgba(0,0,0,.16);
|
box-shadow:0 10px 26px rgba(0,0,0,.16);
|
||||||
}
|
}
|
||||||
#cameraPreviewWrap.is-visible { display:block; }
|
#cameraPreviewWrap.is-visible { display:block; }
|
||||||
|
#cameraToggle { display:grid; place-items:center; width:34px; flex:0 0 34px; padding:0; }
|
||||||
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
|
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
|
||||||
#cameraPreview {
|
#cameraPreviewList {
|
||||||
|
max-height:min(60vh, 600px);
|
||||||
|
overflow-y:auto;
|
||||||
|
scrollbar-width:thin;
|
||||||
|
scrollbar-color:rgba(148,163,184,.65) transparent;
|
||||||
|
}
|
||||||
|
.camera-preview-item + .camera-preview-item { border-top:1px solid rgba(255,255,255,.18); }
|
||||||
|
.camera-preview-label {
|
||||||
|
padding:5px 8px;
|
||||||
|
overflow:hidden;
|
||||||
|
text-overflow:ellipsis;
|
||||||
|
white-space:nowrap;
|
||||||
|
color:rgba(255,255,255,.85);
|
||||||
|
font:11px sans-serif;
|
||||||
|
}
|
||||||
|
.camera-preview-image {
|
||||||
display:block;
|
display:block;
|
||||||
width:260px;
|
width:100%;
|
||||||
aspect-ratio:16 / 9;
|
aspect-ratio:16 / 9;
|
||||||
object-fit:cover;
|
object-fit:contain;
|
||||||
background:#111520;
|
background:#111520;
|
||||||
}
|
}
|
||||||
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
|
#cameraPreviewWrap.is-collapsed #cameraPreviewList { display:none; }
|
||||||
#cameraPreviewToggle {
|
#cameraPreviewToggle {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
right:0;
|
left:0;
|
||||||
top:-28px;
|
top:100%;
|
||||||
display:grid;
|
display:grid;
|
||||||
width:30px;
|
width:30px;
|
||||||
height:28px;
|
height:28px;
|
||||||
place-items:center;
|
place-items:center;
|
||||||
padding:0;
|
padding:0;
|
||||||
border:1px solid rgba(255,255,255,.22);
|
border:1px solid rgba(255,255,255,.22);
|
||||||
|
border-radius:0 0 6px 6px;
|
||||||
background:rgba(17,24,39,.42);
|
background:rgba(17,24,39,.42);
|
||||||
color:rgba(255,255,255,.9);
|
color:rgba(255,255,255,.9);
|
||||||
font:11px sans-serif;
|
font:11px sans-serif;
|
||||||
@@ -86,8 +104,8 @@
|
|||||||
box-shadow:0 10px 28px rgba(0,0,0,.16);
|
box-shadow:0 10px 28px rgba(0,0,0,.16);
|
||||||
}
|
}
|
||||||
#cameraPreviewToggle:hover { color:#fff; background:rgba(255,255,255,.06); }
|
#cameraPreviewToggle:hover { color:#fff; background:rgba(255,255,255,.06); }
|
||||||
#cameraPreviewChevron { font-size:13px; line-height:1; }
|
#cameraPreviewChevron { font-size:13px; line-height:1; transform:rotate(180deg); }
|
||||||
#cameraPreviewWrap.is-collapsed #cameraPreviewChevron { transform:rotate(180deg); }
|
#cameraPreviewWrap.is-collapsed #cameraPreviewChevron { transform:rotate(0deg); }
|
||||||
#topRightControls {
|
#topRightControls {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
right:16px;
|
right:16px;
|
||||||
@@ -96,6 +114,9 @@
|
|||||||
display:flex;
|
display:flex;
|
||||||
gap:7px;
|
gap:7px;
|
||||||
align-items:flex-start;
|
align-items:flex-start;
|
||||||
|
max-width:calc(100% - 32px);
|
||||||
|
flex-wrap:wrap;
|
||||||
|
justify-content:flex-end;
|
||||||
}
|
}
|
||||||
#topLeftControls {
|
#topLeftControls {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
@@ -105,11 +126,14 @@
|
|||||||
display:none;
|
display:none;
|
||||||
}
|
}
|
||||||
#keyGuide {
|
#keyGuide {
|
||||||
|
display:none;
|
||||||
position:absolute;
|
position:absolute;
|
||||||
left:16px;
|
left:16px;
|
||||||
bottom:16px;
|
bottom:16px;
|
||||||
z-index:10;
|
z-index:10;
|
||||||
padding:7px 9px;
|
padding:7px 9px;
|
||||||
|
max-width:calc(100% - 32px);
|
||||||
|
box-sizing:border-box;
|
||||||
border:1px solid rgba(255,255,255,.24);
|
border:1px solid rgba(255,255,255,.24);
|
||||||
background:rgba(17,24,39,.34);
|
background:rgba(17,24,39,.34);
|
||||||
color:rgba(255,255,255,.82);
|
color:rgba(255,255,255,.82);
|
||||||
@@ -121,7 +145,7 @@
|
|||||||
#rosTopicStatusWrap {
|
#rosTopicStatusWrap {
|
||||||
position:absolute;
|
position:absolute;
|
||||||
left:16px;
|
left:16px;
|
||||||
bottom:54px;
|
bottom:16px;
|
||||||
z-index:10;
|
z-index:10;
|
||||||
width:178px;
|
width:178px;
|
||||||
}
|
}
|
||||||
@@ -144,6 +168,7 @@
|
|||||||
place-items:center;
|
place-items:center;
|
||||||
padding:0;
|
padding:0;
|
||||||
border:1px solid rgba(255,255,255,.22);
|
border:1px solid rgba(255,255,255,.22);
|
||||||
|
border-radius:6px 6px 0 0;
|
||||||
background:rgba(17,24,39,.42);
|
background:rgba(17,24,39,.42);
|
||||||
color:rgba(255,255,255,.9);
|
color:rgba(255,255,255,.9);
|
||||||
font:11px sans-serif;
|
font:11px sans-serif;
|
||||||
@@ -239,12 +264,14 @@
|
|||||||
#downloadMap,
|
#downloadMap,
|
||||||
#pathToggle,
|
#pathToggle,
|
||||||
#sensorToggle,
|
#sensorToggle,
|
||||||
|
#cameraToggle,
|
||||||
#poseToggle,
|
#poseToggle,
|
||||||
#goalToggle,
|
#goalToggle,
|
||||||
#obstacleToggle,
|
#obstacleToggle,
|
||||||
#cameraPresetSelect {
|
#cameraPresetSelect {
|
||||||
min-height:32px;
|
min-height:32px;
|
||||||
border:1px solid rgba(255,255,255,.22);
|
border:1px solid rgba(255,255,255,.22);
|
||||||
|
border-radius:6px;
|
||||||
background:rgba(17,24,39,.42);
|
background:rgba(17,24,39,.42);
|
||||||
color:rgba(255,255,255,.88);
|
color:rgba(255,255,255,.88);
|
||||||
font:12px sans-serif;
|
font:12px sans-serif;
|
||||||
@@ -254,32 +281,47 @@
|
|||||||
box-shadow:0 10px 26px rgba(0,0,0,.16);
|
box-shadow:0 10px 26px rgba(0,0,0,.16);
|
||||||
transition:background .15s ease, border-color .15s ease, color .15s ease;
|
transition:background .15s ease, border-color .15s ease, color .15s ease;
|
||||||
}
|
}
|
||||||
#downloadMap,
|
.viewer-icon-button {
|
||||||
#pathToggle,
|
display:grid;
|
||||||
#sensorToggle,
|
place-items:center;
|
||||||
#poseToggle,
|
width:34px;
|
||||||
#goalToggle,
|
height:32px;
|
||||||
#obstacleToggle {
|
flex:0 0 34px;
|
||||||
padding:0 11px;
|
padding:0;
|
||||||
}
|
}
|
||||||
#downloadMap {
|
.camera-preset-wrap {
|
||||||
display:inline-flex;
|
position:relative;
|
||||||
align-items:center;
|
width:34px;
|
||||||
gap:6px;
|
height:32px;
|
||||||
|
flex:0 0 34px;
|
||||||
|
}
|
||||||
|
.camera-preset-wrap > svg {
|
||||||
|
position:absolute;
|
||||||
|
inset:0;
|
||||||
|
margin:auto;
|
||||||
|
color:rgba(255,255,255,.88);
|
||||||
|
pointer-events:none;
|
||||||
}
|
}
|
||||||
#cameraPresetSelect {
|
#cameraPresetSelect {
|
||||||
min-width:86px;
|
width:100%;
|
||||||
padding:0 24px 0 9px;
|
height:100%;
|
||||||
outline:none;
|
padding:0;
|
||||||
|
color:transparent;
|
||||||
appearance:none;
|
appearance:none;
|
||||||
background-image:linear-gradient(45deg, transparent 50%, rgba(255,255,255,.7) 50%), linear-gradient(135deg, rgba(255,255,255,.7) 50%, transparent 50%);
|
}
|
||||||
background-position:calc(100% - 13px) 14px, calc(100% - 8px) 14px;
|
#cameraPresetSelect option {
|
||||||
background-size:5px 5px, 5px 5px;
|
color:rgba(255,255,255,.88);
|
||||||
background-repeat:no-repeat;
|
background:#202020;
|
||||||
|
}
|
||||||
|
.viewer-icon-button:focus-visible,
|
||||||
|
#cameraPresetSelect:focus-visible {
|
||||||
|
outline:2px solid #93c5fd;
|
||||||
|
outline-offset:2px;
|
||||||
}
|
}
|
||||||
#downloadMap:hover,
|
#downloadMap:hover,
|
||||||
#pathToggle:hover,
|
#pathToggle:hover,
|
||||||
#sensorToggle:hover,
|
#sensorToggle:hover,
|
||||||
|
#cameraToggle:hover,
|
||||||
#poseToggle:hover,
|
#poseToggle:hover,
|
||||||
#goalToggle:hover,
|
#goalToggle:hover,
|
||||||
#obstacleToggle:hover,
|
#obstacleToggle:hover,
|
||||||
@@ -288,6 +330,7 @@
|
|||||||
border-color:rgba(255,255,255,.34);
|
border-color:rgba(255,255,255,.34);
|
||||||
color:#fff;
|
color:#fff;
|
||||||
}
|
}
|
||||||
|
#cameraPresetSelect:hover { color:transparent; }
|
||||||
#downloadMap:disabled {
|
#downloadMap:disabled {
|
||||||
opacity:.45;
|
opacity:.45;
|
||||||
cursor:not-allowed;
|
cursor:not-allowed;
|
||||||
@@ -305,6 +348,7 @@
|
|||||||
border-color:rgba(147,197,253,.55);
|
border-color:rgba(147,197,253,.55);
|
||||||
color:#fff;
|
color:#fff;
|
||||||
}
|
}
|
||||||
|
#cameraToggle.active,
|
||||||
#sensorToggle.active {
|
#sensorToggle.active {
|
||||||
background:rgba(14,116,144,.62);
|
background:rgba(14,116,144,.62);
|
||||||
border-color:rgba(103,232,249,.55);
|
border-color:rgba(103,232,249,.55);
|
||||||
@@ -365,58 +409,62 @@
|
|||||||
.nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; }
|
.nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; }
|
||||||
.nav2-navigation #downloadWrap { display:none; }
|
.nav2-navigation #downloadWrap { display:none; }
|
||||||
.nav2-navigation #topLeftControls { display:block; }
|
.nav2-navigation #topLeftControls { display:block; }
|
||||||
.nav2-navigation #keyGuide { display:none; }
|
|
||||||
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
|
|
||||||
.real-runtime #topLeftControls { display:none; }
|
.real-runtime #topLeftControls { display:none; }
|
||||||
.real-runtime #keyGuide { display:none; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="topLeftControls">
|
<div id="topLeftControls">
|
||||||
<button id="obstacleToggle" title="벽 배치" aria-label="벽 배치" aria-pressed="false">Wall</button>
|
<button id="obstacleToggle" class="viewer-icon-button" title="벽 배치" data-i18n-title="벽 배치" aria-label="벽 배치" data-i18n-aria-label="벽 배치" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-brick-wall" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M12 9v6M16 15v6M16 3v6M3 15h18M3 9h18M8 15v6M8 3v6"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="topRightControls">
|
<div id="topRightControls">
|
||||||
<div id="downloadWrap">
|
<div id="downloadWrap">
|
||||||
<button id="downloadMap" title="맵 저장" aria-label="맵 저장" disabled><span>SAVE</span></button>
|
<button id="downloadMap" class="viewer-icon-button" title="맵 저장" data-i18n-title="맵 저장" aria-label="맵 저장" data-i18n-aria-label="맵 저장" disabled><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save" aria-hidden="true"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7M7 3v4a1 1 0 0 0 1 1h7"/></svg></button>
|
||||||
</div>
|
</div>
|
||||||
<button id="pathToggle" title="로봇 경로 표시" aria-label="로봇 경로 표시">Path</button>
|
<button id="pathToggle" class="viewer-icon-button" title="로봇 경로 표시" data-i18n-title="로봇 경로 표시" aria-label="로봇 경로 표시" data-i18n-aria-label="로봇 경로 표시"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-route" aria-hidden="true"><circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></svg></button>
|
||||||
<button id="sensorToggle" title="센서 표시" aria-label="센서 표시" aria-pressed="false">Sensor</button>
|
<button id="sensorToggle" class="viewer-icon-button" title="센서 표시" data-i18n-title="센서 표시" aria-label="센서 표시" data-i18n-aria-label="센서 표시" aria-pressed="false"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-radar" aria-hidden="true"><path d="M19.07 4.93A10 10 0 0 0 6.99 3.34M4 6h.01M2.29 9.62A10 10 0 1 0 21.31 8.35M16.24 7.76A6 6 0 1 0 8.23 16.67M12 18h.01M17.99 11.66A6 6 0 0 1 15.77 16.67"/><circle cx="12" cy="12" r="2"/><path d="m13.41 10.59 5.66-5.66"/></svg></button>
|
||||||
<button id="poseToggle" title="초기 위치 지정" aria-label="초기 위치 지정">Pose</button>
|
<button id="cameraToggle" class="viewer-icon-button" type="button" title="카메라 영상 표시" data-i18n-title="카메라 영상 표시" aria-label="카메라 영상 표시" data-i18n-aria-label="카메라 영상 표시" aria-pressed="true" aria-controls="cameraPreviewWrap">
|
||||||
<button id="goalToggle" title="목표 위치 지정" aria-label="목표 위치 지정">Goal</button>
|
<!-- Lucide Camera icon. -->
|
||||||
<select id="cameraPresetSelect" title="카메라 시점" aria-label="카메라 시점">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M13.997 4a2 2 0 0 1 1.76 1.05l.486.9A2 2 0 0 0 18.003 7H20a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2h1.997a2 2 0 0 0 1.759-1.048l.489-.904A2 2 0 0 1 10.004 4z"/><circle cx="12" cy="13" r="3"/></svg>
|
||||||
|
</button>
|
||||||
|
<button id="poseToggle" class="viewer-icon-button" title="초기 위치 지정" data-i18n-title="초기 위치 지정" aria-label="초기 위치 지정" data-i18n-aria-label="초기 위치 지정"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-locate-fixed" aria-hidden="true"><path d="M2 12h3M19 12h3M12 2v3M12 19v3"/><circle cx="12" cy="12" r="7"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||||
|
<button id="goalToggle" class="viewer-icon-button" title="목표 위치 지정" data-i18n-title="목표 위치 지정" aria-label="목표 위치 지정" data-i18n-aria-label="목표 위치 지정"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-flag" aria-hidden="true"><path d="M4 22V4a1 1 0 0 1 .4-.8A6 6 0 0 1 8 2c3 0 5 2 7.333 2q2 0 3.067-.8A1 1 0 0 1 20 4v10a1 1 0 0 1-.4.8A6 6 0 0 1 16 16c-3 0-5-2-8-2a6 6 0 0 0-4 1.528"/></svg></button>
|
||||||
|
<div class="camera-preset-wrap">
|
||||||
|
<select id="cameraPresetSelect" title="카메라 시점" data-i18n-title="카메라 시점" aria-label="카메라 시점" data-i18n-aria-label="카메라 시점">
|
||||||
<option value="default">Default</option>
|
<option value="default">Default</option>
|
||||||
<option value="top">Top</option>
|
<option value="top">Top</option>
|
||||||
<option value="follow">Follow</option>
|
<option value="follow">Follow</option>
|
||||||
</select>
|
</select>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-panels-top-left" aria-hidden="true"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<canvas id="miniMap" width="260" height="220"></canvas>
|
<canvas id="miniMap" width="260" height="220"></canvas>
|
||||||
<button id="miniMapToggle" title="미니맵 접기/펼치기" aria-label="미니맵 접기">▾</button>
|
<button id="miniMapToggle" title="미니맵 접기/펼치기" data-i18n-title="미니맵 접기/펼치기" aria-label="미니맵 접기" data-i18n-aria-label="미니맵 접기">▾</button>
|
||||||
<div id="cameraPreviewWrap">
|
<div id="cameraPreviewWrap">
|
||||||
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
|
<button id="cameraPreviewToggle" type="button" title="카메라 화면 접기/펼치기" data-i18n-title="카메라 화면 접기/펼치기" aria-label="카메라 화면 접기" data-i18n-aria-label="카메라 화면 접기" aria-expanded="true"><span id="cameraPreviewChevron" aria-hidden="true">▾</span></button>
|
||||||
<img id="cameraPreview" alt="실시간 카메라 화면" />
|
<div id="cameraPreviewList"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="keyGuide">
|
<div id="keyGuide">
|
||||||
<div>i 전진 · , 후진 · j/l 회전 · k 정지</div>
|
<div data-i18n="i 전진 · , 후진 · j/l 회전 · k 정지">i 전진 · , 후진 · j/l 회전 · k 정지</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="rosTopicStatusWrap">
|
<div id="rosTopicStatusWrap">
|
||||||
<button id="rosTopicStatusToggle" type="button" title="ROS 상태 접기/펼치기" aria-label="ROS 상태 접기" aria-expanded="true">
|
<button id="rosTopicStatusToggle" type="button" title="ROS 상태 접기/펼치기" data-i18n-title="ROS 상태 접기/펼치기" aria-label="ROS 상태 접기" data-i18n-aria-label="ROS 상태 접기" aria-expanded="true">
|
||||||
<span id="rosTopicStatusChevron" aria-hidden="true">▾</span>
|
<span id="rosTopicStatusChevron" aria-hidden="true">▾</span>
|
||||||
</button>
|
</button>
|
||||||
<aside id="rosTopicStatus" aria-label="ROS 상태">
|
<aside id="rosTopicStatus" aria-label="ROS 상태" data-i18n-aria-label="ROS 상태">
|
||||||
<div id="rosTopicStatusTitle">ROS 상태</div>
|
<div id="rosTopicStatusTitle" data-i18n="ROS 상태">ROS 상태</div>
|
||||||
<div id="rosTopicStatusList"></div>
|
<div id="rosTopicStatusList"></div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
<div id="responseWaitOverlay" role="status" aria-live="polite">
|
<div id="responseWaitOverlay" role="status" aria-live="polite">
|
||||||
<div class="response-wait-dialog">
|
<div class="response-wait-dialog">
|
||||||
<span class="response-wait-spinner" aria-hidden="true"></span>
|
<span class="response-wait-spinner" aria-hidden="true"></span>
|
||||||
<span>응답을 기다리는 중입니다.</span>
|
<span data-i18n="응답을 기다리는 중입니다.">응답을 기다리는 중입니다.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="mapSaveOverlay" role="status" aria-live="polite" aria-hidden="true">
|
<div id="mapSaveOverlay" role="status" aria-live="polite" aria-hidden="true">
|
||||||
<div class="response-wait-dialog">
|
<div class="response-wait-dialog">
|
||||||
<span class="response-wait-spinner" aria-hidden="true"></span>
|
<span class="response-wait-spinner" aria-hidden="true"></span>
|
||||||
<span>맵을 저장하고 있습니다.</span>
|
<span data-i18n="맵을 저장하고 있습니다.">맵을 저장하고 있습니다.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
@@ -427,7 +475,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
<script type="module" src="./locale.js"></script>
|
||||||
<script type="module">
|
<script type="module">
|
||||||
|
import { t, setText } from './locale.js';
|
||||||
|
import { createCameraPreview } from './camera-preview.js';
|
||||||
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
||||||
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
import { TransformControls } from 'three/addons/controls/TransformControls.js';
|
||||||
@@ -441,12 +493,13 @@ const REAL_RUNTIME = params.get('runtime') === 'real';
|
|||||||
const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
|
const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
|
||||||
// Keep the prior real-platform default when an older LMS URL has no platform.
|
// Keep the prior real-platform default when an older LMS URL has no platform.
|
||||||
const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
|
const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
|
||||||
|
const USE_YAHBOOM_MODEL = ROBOT_PLATFORM === 'yahboom';
|
||||||
|
const REAL_MKMINI_TELEOP = REAL_RUNTIME && USE_MKMINI_MODEL;
|
||||||
|
const REAL_TELEOP = REAL_RUNTIME && (USE_YAHBOOM_MODEL || USE_MKMINI_MODEL);
|
||||||
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
|
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
|
||||||
const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD;
|
const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD;
|
||||||
const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
|
const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
|
||||||
// Camera preview is kept for the RGB test module, but is paused for now so
|
const ENABLE_CAMERA_PREVIEW = params.get('editor') !== '1';
|
||||||
// regular visualization sessions never poll `/camera-frame`.
|
|
||||||
const ENABLE_CAMERA_PREVIEW = false;
|
|
||||||
const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom';
|
const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom';
|
||||||
const SAVED_GAZEBO_MAP_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-saved-gazebo-map';
|
const SAVED_GAZEBO_MAP_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-saved-gazebo-map';
|
||||||
const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world';
|
const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world';
|
||||||
@@ -465,8 +518,13 @@ const miniMap = document.getElementById('miniMap');
|
|||||||
const miniCtx = miniMap.getContext('2d');
|
const miniCtx = miniMap.getContext('2d');
|
||||||
const miniMapToggle = document.getElementById('miniMapToggle');
|
const miniMapToggle = document.getElementById('miniMapToggle');
|
||||||
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
|
const cameraPreviewWrap = document.getElementById('cameraPreviewWrap');
|
||||||
const cameraPreview = document.getElementById('cameraPreview');
|
const cameraPreviewList = document.getElementById('cameraPreviewList');
|
||||||
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
|
const cameraPreviewToggle = document.getElementById('cameraPreviewToggle');
|
||||||
|
const cameraToggleButton = document.getElementById('cameraToggle');
|
||||||
|
const topRightControls = document.getElementById('topRightControls');
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
cameraPreviewWrap.style.top = `${topRightControls.offsetTop + topRightControls.offsetHeight + 12}px`;
|
||||||
|
}).observe(topRightControls);
|
||||||
const responseWaitOverlay = document.getElementById('responseWaitOverlay');
|
const responseWaitOverlay = document.getElementById('responseWaitOverlay');
|
||||||
const mapSaveOverlay = document.getElementById('mapSaveOverlay');
|
const mapSaveOverlay = document.getElementById('mapSaveOverlay');
|
||||||
const downloadMapButton = document.getElementById('downloadMap');
|
const downloadMapButton = document.getElementById('downloadMap');
|
||||||
@@ -493,6 +551,12 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList');
|
|||||||
if (EDITOR_MODE) document.body.classList.add('editor-mode');
|
if (EDITOR_MODE) document.body.classList.add('editor-mode');
|
||||||
if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`);
|
if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`);
|
||||||
if (REAL_RUNTIME) document.body.classList.add('real-runtime');
|
if (REAL_RUNTIME) document.body.classList.add('real-runtime');
|
||||||
|
if (REAL_TELEOP && !EDITOR_MODE) {
|
||||||
|
document.body.classList.add('real-teleop');
|
||||||
|
if (REAL_MKMINI_TELEOP) {
|
||||||
|
setText(document.querySelector('#keyGuide div'), 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
|
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
|
||||||
|
|
||||||
let visualizationResponseReceived = EDITOR_MODE;
|
let visualizationResponseReceived = EDITOR_MODE;
|
||||||
@@ -543,7 +607,7 @@ function updateRosTopicStatus(topicStatus = {}) {
|
|||||||
rosTopicStatusToggle?.addEventListener('click', () => {
|
rosTopicStatusToggle?.addEventListener('click', () => {
|
||||||
const collapsed = rosTopicStatusWrap?.classList.toggle('is-collapsed') ?? false;
|
const collapsed = rosTopicStatusWrap?.classList.toggle('is-collapsed') ?? false;
|
||||||
rosTopicStatusToggle.setAttribute('aria-expanded', String(!collapsed));
|
rosTopicStatusToggle.setAttribute('aria-expanded', String(!collapsed));
|
||||||
rosTopicStatusToggle.setAttribute('aria-label', collapsed ? 'ROS 상태 펼치기' : 'ROS 상태 접기');
|
rosTopicStatusToggle.setAttribute('aria-label', collapsed ? t("ROS 상태 펼치기") : t("ROS 상태 접기"));
|
||||||
});
|
});
|
||||||
updateRosTopicStatus();
|
updateRosTopicStatus();
|
||||||
|
|
||||||
@@ -847,7 +911,7 @@ window.addEventListener('message', (event) => {
|
|||||||
pendingSimulatorPoseRequests.delete(payload.requestId);
|
pendingSimulatorPoseRequests.delete(payload.requestId);
|
||||||
window.clearTimeout(request.timeout);
|
window.clearTimeout(request.timeout);
|
||||||
if (payload.ok) request.resolve();
|
if (payload.ok) request.resolve();
|
||||||
else request.reject(new Error(payload.message || '가제보 로봇 위치를 적용하지 못했습니다.'));
|
else request.reject(new Error(payload.message || t("가제보 로봇 위치를 적용하지 못했습니다.")));
|
||||||
});
|
});
|
||||||
|
|
||||||
let robotModel = null;
|
let robotModel = null;
|
||||||
@@ -868,6 +932,7 @@ let laserFrontBeam = null;
|
|||||||
let slamSocket = null;
|
let slamSocket = null;
|
||||||
let controlSocket = null;
|
let controlSocket = null;
|
||||||
let latestSlamState = null;
|
let latestSlamState = null;
|
||||||
|
let latestSlamStateReceivedAt = 0;
|
||||||
let currentRobotPose = { x:-2.0, y:-0.5, z:0.01, roll:0, pitch:0, yaw:0 };
|
let currentRobotPose = { x:-2.0, y:-0.5, z:0.01, roll:0, pitch:0, yaw:0 };
|
||||||
let targetRobotPose = { ...currentRobotPose };
|
let targetRobotPose = { ...currentRobotPose };
|
||||||
let hasRobotPose = false;
|
let hasRobotPose = false;
|
||||||
@@ -888,9 +953,7 @@ let pointCloudRefreshTimer = null;
|
|||||||
let savedMapPointCloudLoaded = false;
|
let savedMapPointCloudLoaded = false;
|
||||||
let sensorPointCloudRequestInFlight = false;
|
let sensorPointCloudRequestInFlight = false;
|
||||||
let sensorPointCloudRefreshTimer = null;
|
let sensorPointCloudRefreshTimer = null;
|
||||||
let cameraFrameRequestInFlight = false;
|
let cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW;
|
||||||
let cameraFrameRefreshTimer = null;
|
|
||||||
let cameraFrameObjectUrl = null;
|
|
||||||
let pathVisible = false;
|
let pathVisible = false;
|
||||||
// SLAM always renders its live scan, including the forward beam. Navigation
|
// SLAM always renders its live scan, including the forward beam. Navigation
|
||||||
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
||||||
@@ -952,8 +1015,8 @@ function syncGoalToggleAvailability() {
|
|||||||
goalToggleButton.disabled = goalDisabled;
|
goalToggleButton.disabled = goalDisabled;
|
||||||
poseToggleButton.disabled = poseDisabled;
|
poseToggleButton.disabled = poseDisabled;
|
||||||
poseToggleButton.title = poseDisabled
|
poseToggleButton.title = poseDisabled
|
||||||
? '주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.'
|
? t("주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.")
|
||||||
: '로봇 초기 위치 설정';
|
: t("로봇 초기 위치 설정");
|
||||||
if (goalDisabled) {
|
if (goalDisabled) {
|
||||||
navigationGoalMode = false;
|
navigationGoalMode = false;
|
||||||
goalToggleButton.classList.remove('active');
|
goalToggleButton.classList.remove('active');
|
||||||
@@ -1385,6 +1448,42 @@ async function loadMkMiniModel(sdfPose = { x:0, y:0, z:0, roll:0, pitch:0, yaw:0
|
|||||||
sdfRoot.add(posePreviewModel);
|
sdfRoot.add(posePreviewModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadYahboomModel(sdfPose) {
|
||||||
|
const gltf = await new GLTFLoader().loadAsync('./assets/yahboom-pi5.glb');
|
||||||
|
const robotGroup = new THREE.Group();
|
||||||
|
robotGroup.name = 'yahboom';
|
||||||
|
applySdfPose(robotGroup, sdfPose);
|
||||||
|
// GLB is meter-scaled Y-up; the viewer's SDF root expects ROS Z-up.
|
||||||
|
// The exported origin is already base_footprint, so do not recenter it.
|
||||||
|
gltf.scene.rotation.x = Math.PI / 2;
|
||||||
|
gltf.scene.traverse((child) => {
|
||||||
|
if (!child.isMesh) return;
|
||||||
|
child.castShadow = true;
|
||||||
|
// The world-scale shadow map causes self-shadow acne on small CAD parts.
|
||||||
|
child.receiveShadow = false;
|
||||||
|
});
|
||||||
|
robotGroup.add(gltf.scene);
|
||||||
|
sdfRoot.add(robotGroup);
|
||||||
|
robotModel = robotGroup;
|
||||||
|
robotModel.visible = shouldRenderRobot();
|
||||||
|
posePreviewModel = robotGroup.clone(true);
|
||||||
|
posePreviewModel.name = 'yahboom_pose_preview';
|
||||||
|
posePreviewModel.traverse((child) => {
|
||||||
|
if (!child.isMesh) return;
|
||||||
|
child.castShadow = false;
|
||||||
|
child.material = Array.isArray(child.material)
|
||||||
|
? child.material.map((material) => material.clone()) : child.material.clone();
|
||||||
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
|
for (const material of materials) {
|
||||||
|
material.transparent = true;
|
||||||
|
material.opacity = 0.38;
|
||||||
|
material.depthWrite = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
posePreviewModel.visible = false;
|
||||||
|
sdfRoot.add(posePreviewModel);
|
||||||
|
}
|
||||||
|
|
||||||
function createMkMiniFrontWheelPivots(model) {
|
function createMkMiniFrontWheelPivots(model) {
|
||||||
const pivots = [];
|
const pivots = [];
|
||||||
model.updateMatrixWorld(true);
|
model.updateMatrixWorld(true);
|
||||||
@@ -1411,10 +1510,10 @@ const LASER_HOLE_R = 0.14;
|
|||||||
const LASER_RENDER_INTERVAL_MS = 100;
|
const LASER_RENDER_INTERVAL_MS = 100;
|
||||||
const LASER_OPEN_SPACE_RAY_STRIDE = 4;
|
const LASER_OPEN_SPACE_RAY_STRIDE = 4;
|
||||||
const MINI_MAP_RENDER_INTERVAL_MS = 100;
|
const MINI_MAP_RENDER_INTERVAL_MS = 100;
|
||||||
const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.15 : -0.032;
|
const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.15 : USE_YAHBOOM_MODEL ? 0 : -0.032;
|
||||||
const LASER_OFFSET_Y = 0;
|
const LASER_OFFSET_Y = 0;
|
||||||
// Fallback height when an explicit ROS scan pose is unavailable.
|
// Fallback height when an explicit ROS scan pose is unavailable.
|
||||||
const LASER_Z = USE_MKMINI_MODEL ? 0.5 : 0.181;
|
const LASER_Z = USE_MKMINI_MODEL ? 0.5 : USE_YAHBOOM_MODEL ? 0.124183557 : 0.181;
|
||||||
const MKMINI_LASER_RENDER_BACKWARD_OFFSET = 0.03;
|
const MKMINI_LASER_RENDER_BACKWARD_OFFSET = 0.03;
|
||||||
|
|
||||||
function setRobotPose(pose) {
|
function setRobotPose(pose) {
|
||||||
@@ -1999,38 +2098,28 @@ async function refreshSensorPointCloud() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCameraPreviewAvailable(available) {
|
const cameraPreviewController = createCameraPreview({
|
||||||
cameraPreviewWrap?.classList.toggle('is-visible', available);
|
root: cameraPreviewList,
|
||||||
if (available) return;
|
baseUrl: BACKEND_URL,
|
||||||
if (cameraFrameObjectUrl) {
|
getState: () => ({ state: latestSlamState, receivedAt: latestSlamStateReceivedAt }),
|
||||||
URL.revokeObjectURL(cameraFrameObjectUrl);
|
onAvailable: available => cameraPreviewWrap.classList.toggle('is-visible', available),
|
||||||
cameraFrameObjectUrl = null;
|
});
|
||||||
}
|
|
||||||
if (cameraPreview) cameraPreview.removeAttribute('src');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshCameraFrame() {
|
function setCameraPreviewEnabled(enabled) {
|
||||||
if (!ENABLE_CAMERA_PREVIEW || !REAL_RUNTIME || cameraFrameRequestInFlight) return;
|
cameraPreviewEnabled = ENABLE_CAMERA_PREVIEW && enabled;
|
||||||
cameraFrameRequestInFlight = true;
|
cameraToggleButton.classList.toggle('active', cameraPreviewEnabled);
|
||||||
try {
|
cameraToggleButton.setAttribute('aria-pressed', String(cameraPreviewEnabled));
|
||||||
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store' });
|
cameraPreviewController.setEnabled(cameraPreviewEnabled);
|
||||||
if (!response.ok) {
|
if (!cameraPreviewEnabled) {
|
||||||
setCameraPreviewAvailable(false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextUrl = URL.createObjectURL(await response.blob());
|
cameraPreviewWrap.classList.remove('is-collapsed');
|
||||||
const previousUrl = cameraFrameObjectUrl;
|
cameraPreviewToggle.setAttribute('aria-expanded', 'true');
|
||||||
cameraFrameObjectUrl = nextUrl;
|
cameraPreviewToggle.setAttribute('aria-label', t('카메라 화면 접기'));
|
||||||
if (cameraPreview) cameraPreview.src = nextUrl;
|
|
||||||
if (previousUrl) URL.revokeObjectURL(previousUrl);
|
|
||||||
setCameraPreviewAvailable(true);
|
|
||||||
} catch (_) {
|
|
||||||
setCameraPreviewAvailable(false);
|
|
||||||
} finally {
|
|
||||||
cameraFrameRequestInFlight = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cameraToggleButton.addEventListener('click', () => setCameraPreviewEnabled(!cameraPreviewEnabled));
|
||||||
|
|
||||||
function drawMiniMap(state) {
|
function drawMiniMap(state) {
|
||||||
pendingMiniMapState = state;
|
pendingMiniMapState = state;
|
||||||
if (miniMapRenderTimer) return;
|
if (miniMapRenderTimer) return;
|
||||||
@@ -2194,8 +2283,8 @@ function drawMiniMapNow(state) {
|
|||||||
miniCtx.textBaseline = 'middle';
|
miniCtx.textBaseline = 'middle';
|
||||||
miniCtx.fillText(
|
miniCtx.fillText(
|
||||||
SHOW_POINT_CLOUD && state?.point_cloud
|
SHOW_POINT_CLOUD && state?.point_cloud
|
||||||
? '3D 포인트클라우드를 표시 중입니다.'
|
? t("3D 포인트클라우드를 표시 중입니다.")
|
||||||
: '모듈을 연결해주세요.',
|
: t("모듈을 연결해주세요."),
|
||||||
w / 2,
|
w / 2,
|
||||||
h / 2,
|
h / 2,
|
||||||
);
|
);
|
||||||
@@ -2522,10 +2611,10 @@ downloadMapButton.addEventListener('click', async () => {
|
|||||||
body: JSON.stringify({})
|
body: JSON.stringify({})
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
if (!response.ok) throw new Error(payload.detail || payload.message || '맵 저장에 실패했습니다.');
|
if (!response.ok) throw new Error(payload.detail || payload.message || t("맵 저장에 실패했습니다."));
|
||||||
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-saved', map: payload.map }, '*');
|
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-saved', map: payload.map }, '*');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-save-failed', message: error instanceof Error ? error.message : '맵 저장에 실패했습니다.' }, '*');
|
window.parent.postMessage({ source: 'autonomy-slam-viewer', type: 'map-save-failed', message: error instanceof Error ? error.message : t("맵 저장에 실패했습니다.") }, '*');
|
||||||
} finally {
|
} finally {
|
||||||
setMapSavePending(false);
|
setMapSavePending(false);
|
||||||
downloadMapButton.disabled = !latestMiniMapGrid?.data?.length;
|
downloadMapButton.disabled = !latestMiniMapGrid?.data?.length;
|
||||||
@@ -2546,7 +2635,7 @@ sensorToggleButton.addEventListener('click', () => {
|
|||||||
cameraPreviewToggle?.addEventListener('click', () => {
|
cameraPreviewToggle?.addEventListener('click', () => {
|
||||||
const collapsed = cameraPreviewWrap?.classList.toggle('is-collapsed') ?? false;
|
const collapsed = cameraPreviewWrap?.classList.toggle('is-collapsed') ?? false;
|
||||||
cameraPreviewToggle.setAttribute('aria-expanded', String(!collapsed));
|
cameraPreviewToggle.setAttribute('aria-expanded', String(!collapsed));
|
||||||
cameraPreviewToggle.setAttribute('aria-label', collapsed ? '카메라 화면 펼치기' : '카메라 화면 접기');
|
cameraPreviewToggle.setAttribute('aria-label', collapsed ? t("카메라 화면 펼치기") : t("카메라 화면 접기"));
|
||||||
});
|
});
|
||||||
|
|
||||||
goalToggleButton.addEventListener('click', () => {
|
goalToggleButton.addEventListener('click', () => {
|
||||||
@@ -2607,7 +2696,7 @@ obstacleToggleButton.addEventListener('click', () => {
|
|||||||
miniMapToggle.addEventListener('click', () => {
|
miniMapToggle.addEventListener('click', () => {
|
||||||
const hidden = miniMap.classList.toggle('hidden');
|
const hidden = miniMap.classList.toggle('hidden');
|
||||||
miniMapToggle.textContent = hidden ? '▴' : '▾';
|
miniMapToggle.textContent = hidden ? '▴' : '▾';
|
||||||
miniMapToggle.setAttribute('aria-label', hidden ? '미니맵 펼치기' : '미니맵 접기');
|
miniMapToggle.setAttribute('aria-label', hidden ? t("미니맵 펼치기") : t("미니맵 접기"));
|
||||||
focusViewer();
|
focusViewer();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2841,7 +2930,7 @@ async function sendNavigationGoal(goal) {
|
|||||||
const cancelResponse = await fetch(`${BACKEND_URL}/cancel_navigation`, { method:'POST' });
|
const cancelResponse = await fetch(`${BACKEND_URL}/cancel_navigation`, { method:'POST' });
|
||||||
if (!cancelResponse.ok) {
|
if (!cancelResponse.ok) {
|
||||||
const message = await cancelResponse.text().catch(() => '');
|
const message = await cancelResponse.text().catch(() => '');
|
||||||
throw new Error(message || '기존 목표를 취소하지 못했습니다.');
|
throw new Error(message || t("기존 목표를 취소하지 못했습니다."));
|
||||||
}
|
}
|
||||||
navigationGoalId = null;
|
navigationGoalId = null;
|
||||||
placeNavigationGoalMarker(goal.x, goal.y, goal.yaw);
|
placeNavigationGoalMarker(goal.x, goal.y, goal.yaw);
|
||||||
@@ -2864,7 +2953,7 @@ function moveLocalSimulatorToInitialPose(pose) {
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const timeout = window.setTimeout(() => {
|
const timeout = window.setTimeout(() => {
|
||||||
pendingSimulatorPoseRequests.delete(requestId);
|
pendingSimulatorPoseRequests.delete(requestId);
|
||||||
reject(new Error('가제보 로봇 위치 적용 시간이 초과되었습니다.'));
|
reject(new Error(t("가제보 로봇 위치 적용 시간이 초과되었습니다.")));
|
||||||
}, 12_000);
|
}, 12_000);
|
||||||
pendingSimulatorPoseRequests.set(requestId, { resolve, reject, timeout });
|
pendingSimulatorPoseRequests.set(requestId, { resolve, reject, timeout });
|
||||||
window.parent.postMessage({
|
window.parent.postMessage({
|
||||||
@@ -3062,7 +3151,11 @@ renderer.domElement.addEventListener('click', async (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function applySlamState(state) {
|
function applySlamState(state) {
|
||||||
|
// Any valid state payload confirms that the bridge is responding. Missing
|
||||||
|
// ROS data is reported separately by the topic status indicators.
|
||||||
|
markVisualizationResponseReceived();
|
||||||
latestSlamState = state;
|
latestSlamState = state;
|
||||||
|
latestSlamStateReceivedAt = Date.now();
|
||||||
resetNavigationPoseForNewSession(state);
|
resetNavigationPoseForNewSession(state);
|
||||||
restoreNavigationPoseFromState(state);
|
restoreNavigationPoseFromState(state);
|
||||||
updateNavigationLocalizationState(state);
|
updateNavigationLocalizationState(state);
|
||||||
@@ -3076,7 +3169,6 @@ function applySlamState(state) {
|
|||||||
clearNavigationGoalMarker();
|
clearNavigationGoalMarker();
|
||||||
navigationGoalId = null;
|
navigationGoalId = null;
|
||||||
}
|
}
|
||||||
if (state?.pose || state?.pose_3d || state?.scan || state?.map || state?.point_cloud || state?.sensor_point_cloud) markVisualizationResponseReceived();
|
|
||||||
if (NAV2_VIEW_MODE === 'navigation' && state?.tf?.map_to_odom) {
|
if (NAV2_VIEW_MODE === 'navigation' && state?.tf?.map_to_odom) {
|
||||||
navigationMapToOdom = state.tf.map_to_odom;
|
navigationMapToOdom = state.tf.map_to_odom;
|
||||||
updateNavigationMapTransform();
|
updateNavigationMapTransform();
|
||||||
@@ -3155,23 +3247,23 @@ if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
|
|||||||
void refreshSensorPointCloud();
|
void refreshSensorPointCloud();
|
||||||
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
|
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
|
||||||
}
|
}
|
||||||
if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) {
|
setCameraPreviewEnabled(ENABLE_CAMERA_PREVIEW);
|
||||||
void refreshCameraFrame();
|
|
||||||
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
|
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
|
||||||
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
|
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
|
||||||
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
|
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
|
||||||
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
|
cameraPreviewController.dispose();
|
||||||
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function connectControlSocket() {
|
function connectControlSocket() {
|
||||||
if (!CONTROL_WS) return;
|
if (!CONTROL_WS) return;
|
||||||
controlSocket = new WebSocket(CONTROL_WS);
|
controlSocket = new WebSocket(CONTROL_WS);
|
||||||
controlSocket.addEventListener('close', () => {
|
controlSocket.addEventListener('close', () => {
|
||||||
|
if (REAL_TELEOP) {
|
||||||
|
activeMoveKey = null;
|
||||||
|
stopRobot();
|
||||||
|
}
|
||||||
setTimeout(connectControlSocket, 1000);
|
setTimeout(connectControlSocket, 1000);
|
||||||
});
|
});
|
||||||
controlSocket.addEventListener('error', () => {
|
controlSocket.addEventListener('error', () => {
|
||||||
@@ -3179,8 +3271,16 @@ function connectControlSocket() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let maxLinear = 0.22;
|
let maxLinear = REAL_TELEOP ? 0.1 : 0.22;
|
||||||
let maxAngular = 2.84;
|
let maxAngular = REAL_TELEOP ? 0.5 : 2.84;
|
||||||
|
const mkMiniHeldKeys = new Set();
|
||||||
|
const MKMINI_REAL_ANGULAR_SPEED = 0.08;
|
||||||
|
function teleopKey(event) {
|
||||||
|
const key = event.key.toLowerCase();
|
||||||
|
return REAL_MKMINI_TELEOP
|
||||||
|
? ({ arrowup: 'i', arrowdown: ',', arrowleft: 'j', arrowright: 'l', ' ': 'k' }[key] ?? key)
|
||||||
|
: key;
|
||||||
|
}
|
||||||
let activeMoveKey = null;
|
let activeMoveKey = null;
|
||||||
let teleopInterval = null;
|
let teleopInterval = null;
|
||||||
let singleMoveStopTimer = null;
|
let singleMoveStopTimer = null;
|
||||||
@@ -3204,12 +3304,19 @@ const speedBindings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function canUseTeleop(event) {
|
function canUseTeleop(event) {
|
||||||
if (REAL_RUNTIME) return false;
|
if (EDITOR_MODE || (REAL_RUNTIME && (!REAL_TELEOP || !realTeleopReady()))) return false;
|
||||||
if (!document.hasFocus()) return false;
|
if (!document.hasFocus()) return false;
|
||||||
|
if (document.hidden || event.ctrlKey || event.altKey || event.metaKey || event.target?.isContentEditable) return false;
|
||||||
const tag = event.target?.tagName?.toLowerCase();
|
const tag = event.target?.tagName?.toLowerCase();
|
||||||
return !['input', 'textarea', 'select', 'button'].includes(tag);
|
return !['input', 'textarea', 'select', 'button'].includes(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function realTeleopReady() {
|
||||||
|
return latestSlamState?.teleop_deadman === true
|
||||||
|
&& Date.now() - latestSlamStateReceivedAt < 1000
|
||||||
|
&& controlSocket?.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
|
||||||
function flushQueuedCmdVel() {
|
function flushQueuedCmdVel() {
|
||||||
if (cmdVelRequestInFlight || !queuedCmdVel) return;
|
if (cmdVelRequestInFlight || !queuedCmdVel) return;
|
||||||
const command = queuedCmdVel;
|
const command = queuedCmdVel;
|
||||||
@@ -3236,6 +3343,20 @@ function flushQueuedCmdVel() {
|
|||||||
|
|
||||||
function sendCmdVel(linear, angular) {
|
function sendCmdVel(linear, angular) {
|
||||||
const payload = { type:'cmd_vel', linear, angular };
|
const payload = { type:'cmd_vel', linear, angular };
|
||||||
|
if (REAL_TELEOP) {
|
||||||
|
// Never queue physical motion for replay after a connection recovers.
|
||||||
|
if (!controlSocket || controlSocket.readyState !== WebSocket.OPEN) return;
|
||||||
|
if ((linear || angular) && !realTeleopReady()) return;
|
||||||
|
payload.deadman = true;
|
||||||
|
payload.linear = Math.max(-0.1, Math.min(0.1, linear));
|
||||||
|
payload.angular = Math.max(-0.5, Math.min(0.5, angular));
|
||||||
|
if (controlSocket.bufferedAmount > 0) {
|
||||||
|
controlSocket.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controlSocket.send(JSON.stringify(payload));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (controlSocket?.readyState === WebSocket.OPEN) {
|
if (controlSocket?.readyState === WebSocket.OPEN) {
|
||||||
controlSocket.send(JSON.stringify(payload));
|
controlSocket.send(JSON.stringify(payload));
|
||||||
return;
|
return;
|
||||||
@@ -3276,6 +3397,7 @@ function setSimulatorMkMiniSteering(angle) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function stopRobot() {
|
function stopRobot() {
|
||||||
|
mkMiniHeldKeys.clear();
|
||||||
if (teleopInterval) {
|
if (teleopInterval) {
|
||||||
window.clearInterval(teleopInterval);
|
window.clearInterval(teleopInterval);
|
||||||
teleopInterval = null;
|
teleopInterval = null;
|
||||||
@@ -3289,8 +3411,25 @@ function stopRobot() {
|
|||||||
|
|
||||||
function sendActiveMoveCommand() {
|
function sendActiveMoveCommand() {
|
||||||
if (!activeMoveKey) return;
|
if (!activeMoveKey) return;
|
||||||
|
if (REAL_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) {
|
||||||
|
activeMoveKey = null;
|
||||||
|
stopRobot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const [linearSign, angularSign] = moveBindings[activeMoveKey];
|
const [linearSign, angularSign] = moveBindings[activeMoveKey];
|
||||||
if (linearSign === 0 && angularSign === 0) return;
|
if (linearSign === 0 && angularSign === 0) return;
|
||||||
|
if (REAL_MKMINI_TELEOP) {
|
||||||
|
let drive = 0;
|
||||||
|
let steering = 0;
|
||||||
|
for (const key of mkMiniHeldKeys) {
|
||||||
|
drive += moveBindings[key][0];
|
||||||
|
steering += moveBindings[key][1];
|
||||||
|
}
|
||||||
|
// The TOD adapter uses abs(linear) for steering: keep wheel direction
|
||||||
|
// consistent in reverse, and never turn a steering-only key into motion.
|
||||||
|
sendCmdVel(Math.sign(drive) * maxLinear, drive ? Math.sign(steering) * MKMINI_REAL_ANGULAR_SPEED : 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (USE_MKMINI_MODEL && !REAL_RUNTIME) {
|
if (USE_MKMINI_MODEL && !REAL_RUNTIME) {
|
||||||
if (activeMoveKey === 'j' || activeMoveKey === 'l') {
|
if (activeMoveKey === 'j' || activeMoveKey === 'l') {
|
||||||
@@ -3314,6 +3453,7 @@ function sendActiveMoveCommand() {
|
|||||||
|
|
||||||
function startActiveMoveCommand() {
|
function startActiveMoveCommand() {
|
||||||
sendActiveMoveCommand();
|
sendActiveMoveCommand();
|
||||||
|
if (!activeMoveKey) return;
|
||||||
if (teleopInterval) return;
|
if (teleopInterval) return;
|
||||||
teleopInterval = window.setInterval(sendActiveMoveCommand, TELEOP_COMMAND_INTERVAL_MS);
|
teleopInterval = window.setInterval(sendActiveMoveCommand, TELEOP_COMMAND_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
@@ -3331,7 +3471,7 @@ function sendSingleMoveCommand(key) {
|
|||||||
|
|
||||||
window.addEventListener('keydown', (event) => {
|
window.addEventListener('keydown', (event) => {
|
||||||
if (!canUseTeleop(event)) return;
|
if (!canUseTeleop(event)) return;
|
||||||
const key = event.key.toLowerCase();
|
const key = teleopKey(event);
|
||||||
|
|
||||||
if (event.repeat) {
|
if (event.repeat) {
|
||||||
if (moveBindings[key]) event.preventDefault();
|
if (moveBindings[key]) event.preventDefault();
|
||||||
@@ -3340,6 +3480,7 @@ window.addEventListener('keydown', (event) => {
|
|||||||
|
|
||||||
if (speedBindings[key]) {
|
if (speedBindings[key]) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
if (REAL_TELEOP) return;
|
||||||
const [linearScale, angularScale] = speedBindings[key];
|
const [linearScale, angularScale] = speedBindings[key];
|
||||||
maxLinear = Math.max(0.01, maxLinear * linearScale);
|
maxLinear = Math.max(0.01, maxLinear * linearScale);
|
||||||
maxAngular = Math.max(0.1, maxAngular * angularScale);
|
maxAngular = Math.max(0.1, maxAngular * angularScale);
|
||||||
@@ -3355,6 +3496,12 @@ window.addEventListener('keydown', (event) => {
|
|||||||
stopRobot();
|
stopRobot();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (REAL_TELEOP) {
|
||||||
|
if (REAL_MKMINI_TELEOP) mkMiniHeldKeys.add(key);
|
||||||
|
activeMoveKey = key;
|
||||||
|
startActiveMoveCommand();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (USE_MKMINI_MODEL && !REAL_RUNTIME && (key === 'j' || key === 'l')) {
|
if (USE_MKMINI_MODEL && !REAL_RUNTIME && (key === 'j' || key === 'l')) {
|
||||||
activeMoveKey = key;
|
activeMoveKey = key;
|
||||||
sendActiveMoveCommand();
|
sendActiveMoveCommand();
|
||||||
@@ -3370,6 +3517,11 @@ window.addEventListener('keydown', (event) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener('keyup', (event) => {
|
window.addEventListener('keyup', (event) => {
|
||||||
|
if (REAL_TELEOP && (teleopKey(event) === activeMoveKey || (REAL_MKMINI_TELEOP && mkMiniHeldKeys.has(teleopKey(event))))) {
|
||||||
|
activeMoveKey = null;
|
||||||
|
stopRobot();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!canUseTeleop(event)) return;
|
if (!canUseTeleop(event)) return;
|
||||||
const key = event.key.toLowerCase();
|
const key = event.key.toLowerCase();
|
||||||
if (moveBindings[key]) event.preventDefault();
|
if (moveBindings[key]) event.preventDefault();
|
||||||
@@ -3381,6 +3533,14 @@ window.addEventListener('blur', () => {
|
|||||||
stopRobot();
|
stopRobot();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function releaseRealTeleop() {
|
||||||
|
if (!REAL_TELEOP || !activeMoveKey) return;
|
||||||
|
activeMoveKey = null;
|
||||||
|
stopRobot();
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange', () => { if (document.hidden) releaseRealTeleop(); });
|
||||||
|
window.addEventListener('pagehide', releaseRealTeleop);
|
||||||
|
|
||||||
// A static Gazebo model can contain assets that the browser cannot render.
|
// A static Gazebo model can contain assets that the browser cannot render.
|
||||||
// Keep the ROS stream available even when the decorative world mesh fails.
|
// Keep the ROS stream available even when the decorative world mesh fails.
|
||||||
const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME
|
const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME
|
||||||
@@ -3389,7 +3549,7 @@ const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME
|
|||||||
const remoteNavigationSceneTasks = [];
|
const remoteNavigationSceneTasks = [];
|
||||||
Promise.all(EDITOR_MODE
|
Promise.all(EDITOR_MODE
|
||||||
? worldSceneTasks
|
? worldSceneTasks
|
||||||
: [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })]
|
: [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_YAHBOOM_MODEL ? loadYahboomModel : USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })]
|
||||||
).then(() => {
|
).then(() => {
|
||||||
if (EDITOR_MODE) {
|
if (EDITOR_MODE) {
|
||||||
window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*');
|
window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*');
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const storageKey = 'autonomy-studio.lms.language';
|
||||||
|
let language = 'ko';
|
||||||
|
try { language = localStorage.getItem(storageKey) === 'en' ? 'en' : 'ko'; } catch {}
|
||||||
|
|
||||||
|
const english = {
|
||||||
|
'벽 배치': 'Place walls',
|
||||||
|
'맵 저장': 'Save map',
|
||||||
|
'로봇 경로 표시': 'Show robot path',
|
||||||
|
'센서 표시': 'Show sensors',
|
||||||
|
'카메라 영상 표시': 'Show camera preview',
|
||||||
|
'초기 위치 지정': 'Set initial pose',
|
||||||
|
'목표 위치 지정': 'Set goal',
|
||||||
|
'카메라 시점': 'Camera view',
|
||||||
|
'미니맵 접기/펼치기': 'Toggle minimap',
|
||||||
|
'미니맵 접기': 'Collapse minimap',
|
||||||
|
'미니맵 펼치기': 'Expand minimap',
|
||||||
|
'카메라 화면 접기/펼치기': 'Toggle camera preview',
|
||||||
|
'카메라 화면 접기': 'Collapse camera preview',
|
||||||
|
'카메라 화면 펼치기': 'Expand camera preview',
|
||||||
|
'실시간 카메라 화면': 'Live camera preview',
|
||||||
|
'i 전진 · , 후진 · j/l 회전 · k 정지': 'i Forward · , Reverse · j/l Turn · k Stop',
|
||||||
|
'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지': 'i Forward · , Reverse · j/l Steer while moving · k Stop',
|
||||||
|
'ROS 상태 접기/펼치기': 'Toggle ROS status',
|
||||||
|
'ROS 상태 접기': 'Collapse ROS status',
|
||||||
|
'ROS 상태 펼치기': 'Expand ROS status',
|
||||||
|
'ROS 상태': 'ROS status',
|
||||||
|
'응답을 기다리는 중입니다.': 'Waiting for a response.',
|
||||||
|
'맵을 저장하고 있습니다.': 'Saving the map.',
|
||||||
|
'가제보 로봇 위치를 적용하지 못했습니다.': 'Failed to set the Gazebo robot pose.',
|
||||||
|
'주행 시뮬레이션에서는 초기 위치를 한 번만 설정할 수 있습니다.': 'The initial pose can only be set once during navigation simulation.',
|
||||||
|
'로봇 초기 위치 설정': 'Set initial robot pose',
|
||||||
|
'3D 포인트클라우드를 표시 중입니다.': 'Displaying the 3D point cloud.',
|
||||||
|
'모듈을 연결해주세요.': 'Connect your modules.',
|
||||||
|
'맵 저장에 실패했습니다.': 'Failed to save the map.',
|
||||||
|
'기존 목표를 취소하지 못했습니다.': 'Failed to cancel the previous goal.',
|
||||||
|
'가제보 로봇 위치 적용 시간이 초과되었습니다.': 'Setting the Gazebo robot pose timed out.'
|
||||||
|
};
|
||||||
|
|
||||||
|
export function t(key) { return language === 'en' ? (english[key] ?? key) : key; }
|
||||||
|
|
||||||
|
// Only explicitly marked viewer UI is translated. Topic names and telemetry stay intact.
|
||||||
|
export function setText(element, key) {
|
||||||
|
element.dataset.i18n = key;
|
||||||
|
element.textContent = t(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
document.documentElement.lang = language;
|
||||||
|
document.querySelectorAll('[data-i18n]').forEach(element => {
|
||||||
|
element.textContent = t(element.dataset.i18n);
|
||||||
|
});
|
||||||
|
for (const attribute of ['title', 'aria-label', 'alt']) {
|
||||||
|
document.querySelectorAll(`[data-i18n-${attribute}]`).forEach(element => {
|
||||||
|
element.setAttribute(attribute, t(element.getAttribute(`data-i18n-${attribute}`)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('storage', event => {
|
||||||
|
if (event.key !== storageKey) return;
|
||||||
|
language = event.newValue === 'en' ? 'en' : 'ko';
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
|
||||||
|
// Embedded viewers may not share storage with the app (for example file URLs).
|
||||||
|
window.addEventListener('message', event => {
|
||||||
|
if (window.parent === window || event.source !== window.parent) return;
|
||||||
|
if (event.data?.type !== 'lms:language' || !['ko', 'en'].includes(event.data.language)) return;
|
||||||
|
language = event.data.language;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
if (window.parent !== window) window.parent.postMessage({ type: 'lms:language:ready' }, '*');
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #07111f;
|
background: #ffffff;
|
||||||
font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: NanumSquare, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: radial-gradient(circle at 50% 35%, #11243e 0%, #07111f 54%, #03070d 100%);
|
background: #ffffff;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,22 +31,53 @@
|
|||||||
-webkit-user-drag: none;
|
-webkit-user-drag: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fallback-title {
|
.brand-logo {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 45%;
|
top: 116px;
|
||||||
|
left: calc(50% - 3px);
|
||||||
|
z-index: 1;
|
||||||
|
width: 80px;
|
||||||
|
height: 70px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
object-fit: contain;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
position: absolute;
|
||||||
|
top: 187px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translate(-50%, -50%);
|
z-index: 1;
|
||||||
color: #4d5d76;
|
width: 290px;
|
||||||
text-shadow: 0 1px 12px rgba(255, 255, 255, 0.28);
|
transform: translateX(-50%);
|
||||||
font-size: 28px;
|
text-align: center;
|
||||||
font-weight: 800;
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
color: #252d39;
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1;
|
||||||
letter-spacing: 6px;
|
letter-spacing: 6px;
|
||||||
|
text-indent: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-subtitle {
|
||||||
|
margin-top: 9px;
|
||||||
|
color: #7a8495;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading {
|
.loading {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 26px;
|
right: 26px;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
|
z-index: 2;
|
||||||
color: #64738a;
|
color: #64738a;
|
||||||
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24);
|
text-shadow: 0 1px 10px rgba(255, 255, 255, 0.24);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -58,6 +89,7 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
|
z-index: 2;
|
||||||
color: #7a8495;
|
color: #7a8495;
|
||||||
text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2);
|
text-shadow: 0 1px 8px rgba(255, 255, 255, 0.2);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -68,21 +100,35 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="splash">
|
<div class="splash">
|
||||||
<div class="fallback-title">LMS</div>
|
<img class="splash-image" src="./assets/splash.png" alt="" />
|
||||||
<img class="splash-image" id="splash-image" alt="" />
|
<img class="brand-logo" src="./assets/splash-logo.png" alt="" />
|
||||||
|
<div class="brand-copy">
|
||||||
|
<div class="brand-title">LMS</div>
|
||||||
|
<div class="brand-subtitle">Learning Management System</div>
|
||||||
|
</div>
|
||||||
<div class="copyright">© 2026 Autonomy Studio. All rights reserved.</div>
|
<div class="copyright">© 2026 Autonomy Studio. All rights reserved.</div>
|
||||||
<div class="loading" id="loading-text">학습 환경 준비 중...</div>
|
<div class="loading" id="loading-text">학습 환경 준비 중...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.getElementById('splash-image').src = './assets/splash.png'
|
|
||||||
|
|
||||||
const el = document.getElementById('loading-text')
|
const el = document.getElementById('loading-text')
|
||||||
|
const translations = {
|
||||||
|
'학습 환경 준비 중...': 'Preparing your learning environment...',
|
||||||
|
'로그인 세션 확인 중...': 'Checking your session...',
|
||||||
|
'강좌 정보 불러오는 중...': 'Loading courses...',
|
||||||
|
'커리큘럼 및 플랫폼 정보 가져오는 중...': 'Loading curriculum and platforms...'
|
||||||
|
}
|
||||||
|
let language = 'ko'
|
||||||
|
try {
|
||||||
|
language = localStorage.getItem('autonomy-studio.lms.language') === 'en' ? 'en' : 'ko'
|
||||||
|
} catch {}
|
||||||
|
document.documentElement.lang = language
|
||||||
window.setSplashStatus = (message) => {
|
window.setSplashStatus = (message) => {
|
||||||
if (typeof message === 'string' && message.trim()) {
|
if (typeof message === 'string' && message.trim()) {
|
||||||
el.textContent = message
|
el.textContent = language === 'en' ? (translations[message] ?? message) : message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.setSplashStatus(el.textContent)
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { FileCode2, Loader2 } from 'lucide-react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
import type { AgentCodeReference, AgentCodeReferenceState } from '../features/workspace/useAiAgent'
|
||||||
|
|
||||||
|
export function AgentCodeReferences({ references, states, dark, busy, onOpen }: {
|
||||||
|
references: AgentCodeReference[]
|
||||||
|
states?: Record<string, AgentCodeReferenceState>
|
||||||
|
dark: boolean
|
||||||
|
busy?: boolean
|
||||||
|
onOpen?: (id: string) => void
|
||||||
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
return <div className="grid min-w-0 gap-3">
|
||||||
|
{references.map(reference => {
|
||||||
|
const state = states?.[reference.id]
|
||||||
|
const opening = state?.status === 'opening'
|
||||||
|
return <div key={reference.id} data-agent-code-reference={reference.id} className={['min-w-0 border-t pt-2', dark ? 'border-white/10' : 'border-[#ddd6fe]'].join(' ')}>
|
||||||
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
|
<FileCode2 size={14} className="mt-0.5 shrink-0 text-[#8b5cf6]" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="break-all font-bold">{reference.moduleName}/{reference.path}</p>
|
||||||
|
<p className={dark ? 'text-white/48' : 'text-[#64748b]'}>{t('{{start}}~{{end}}줄', { start: reference.startLine, end: reference.endLine })}</p>
|
||||||
|
<p className="mt-1 break-words">{reference.reason}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{state?.error && <p role="alert" className="mt-2 break-words text-[11px] text-[#ef4444]">{t(state.error)}</p>}
|
||||||
|
<div className="mt-2 flex min-w-0 items-center justify-end gap-2">
|
||||||
|
<button type="button" disabled={busy || opening || !onOpen} onClick={() => onOpen?.(reference.id)} className="inline-flex h-8 min-w-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-[#7c3aed] px-3 text-[11px] font-bold leading-none text-white hover:bg-[#6d28d9] disabled:cursor-not-allowed disabled:opacity-40">{opening ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <FileCode2 size={13} className="shrink-0" />}<span className="relative top-px leading-none">{opening ? t('파일 여는 중...') : t('코드에서 보기')}</span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { useEffect, useId, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { Bot, Check } from 'lucide-react'
|
||||||
|
import { t } from '../i18n'
|
||||||
|
import { agentModelLabel, type AgentModelOption } from '../features/workspace/agentModels'
|
||||||
|
|
||||||
|
export function AgentModelSelector({ models, provider, disabled, dark, onChange }: {
|
||||||
|
models: AgentModelOption[]
|
||||||
|
provider: string | null
|
||||||
|
disabled: boolean
|
||||||
|
dark: boolean
|
||||||
|
onChange: (provider: string) => void
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const selected = models.find(model => model.provider === provider)
|
||||||
|
const blocked = disabled || models.length === 0
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [position, setPosition] = useState({ left: 8, bottom: 8, width: 280, maxHeight: 320 })
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const menuId = useId()
|
||||||
|
const visible = open && !blocked
|
||||||
|
const close = (restoreFocus = false): void => {
|
||||||
|
setOpen(false)
|
||||||
|
if (restoreFocus) buttonRef.current?.focus({ preventScroll: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (blocked) setOpen(false)
|
||||||
|
}, [blocked])
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!visible || !buttonRef.current) return
|
||||||
|
const bounds = buttonRef.current.getBoundingClientRect()
|
||||||
|
const width = Math.min(320, window.innerWidth - 16)
|
||||||
|
setPosition({
|
||||||
|
left: Math.max(8, Math.min(bounds.left, window.innerWidth - width - 8)),
|
||||||
|
bottom: window.innerHeight - bounds.top + 6,
|
||||||
|
width,
|
||||||
|
maxHeight: Math.max(0, Math.min(320, bounds.top - 14))
|
||||||
|
})
|
||||||
|
const items = menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]')
|
||||||
|
const index = Math.max(0, models.findIndex(model => model.provider === provider))
|
||||||
|
items?.[index]?.focus({ preventScroll: true })
|
||||||
|
}, [visible, models, provider])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return
|
||||||
|
const outside = (event: Event): void => {
|
||||||
|
const target = event.target as Node
|
||||||
|
if (!menuRef.current?.contains(target) && !buttonRef.current?.contains(target)) setOpen(false)
|
||||||
|
}
|
||||||
|
const dismiss = (): void => setOpen(false)
|
||||||
|
window.addEventListener('pointerdown', outside, true)
|
||||||
|
window.addEventListener('contextmenu', outside)
|
||||||
|
window.addEventListener('scroll', outside, true)
|
||||||
|
window.addEventListener('resize', dismiss)
|
||||||
|
window.addEventListener('blur', dismiss)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('pointerdown', outside, true)
|
||||||
|
window.removeEventListener('contextmenu', outside)
|
||||||
|
window.removeEventListener('scroll', outside, true)
|
||||||
|
window.removeEventListener('resize', dismiss)
|
||||||
|
window.removeEventListener('blur', dismiss)
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
type="button"
|
||||||
|
aria-label={t('에이전트 모델 선택')}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={visible}
|
||||||
|
aria-controls={visible ? menuId : undefined}
|
||||||
|
title={selected ? agentModelLabel(selected) : t('사용 가능한 모델 없음')}
|
||||||
|
disabled={blocked}
|
||||||
|
onClick={() => setOpen(current => !current)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { event.preventDefault(); setOpen(true) }
|
||||||
|
else if (event.key === 'Escape') close()
|
||||||
|
}}
|
||||||
|
className={['grid size-7 shrink-0 cursor-pointer place-items-center rounded outline-none transition focus-visible:ring-1 focus-visible:ring-[#8b5cf6] disabled:cursor-default disabled:opacity-35',
|
||||||
|
dark ? 'text-white/60 hover:bg-white/10 hover:text-white/90' : 'text-[#64748b] hover:bg-[#eef1f7] hover:text-[#111520]',
|
||||||
|
visible ? dark ? 'bg-white/10 text-white/90' : 'bg-[#eef1f7] text-[#111520]' : ''].join(' ')}
|
||||||
|
>
|
||||||
|
<Bot size={15} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
{visible && createPortal(
|
||||||
|
<div ref={menuRef} id={menuId} role="menu" aria-label={t('에이전트 모델 선택')}
|
||||||
|
className={['fixed z-[160] overflow-y-auto overscroll-contain rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
|
||||||
|
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'].join(' ')}
|
||||||
|
style={position}
|
||||||
|
onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(true); return }
|
||||||
|
if (event.key === 'Tab') { close(true); return }
|
||||||
|
if (!['ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)) return
|
||||||
|
event.preventDefault()
|
||||||
|
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitemradio"]') ?? [])
|
||||||
|
const current = items.indexOf(document.activeElement as HTMLButtonElement)
|
||||||
|
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1
|
||||||
|
: (current + (event.key === 'ArrowDown' ? 1 : -1) + items.length) % items.length
|
||||||
|
items[next]?.focus()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{models.map(model => <button key={model.provider} type="button" role="menuitemradio" aria-checked={model.provider === provider}
|
||||||
|
title={agentModelLabel(model)} tabIndex={-1}
|
||||||
|
onClick={() => { if (!blocked) { onChange(model.provider); close(true) } }}
|
||||||
|
className={['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left outline-none transition',
|
||||||
|
dark ? 'hover:bg-[#37373d] hover:text-white focus:bg-[#37373d] focus:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520] focus:bg-[#eef1f7] focus:text-[#111520]'].join(' ')}>
|
||||||
|
<Check size={13} className={['shrink-0', model.provider === provider ? '' : 'opacity-0'].join(' ')} aria-hidden="true" />
|
||||||
|
<span className="min-w-0 truncate">{agentModelLabel(model)}</span>
|
||||||
|
</button>)}
|
||||||
|
</div>, document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { CheckCircle2, CircleAlert, XCircle } from 'lucide-react'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
import type { AgentProgressStep } from '../features/workspace/agentContext'
|
||||||
|
import { AgentWorkDuration } from './AgentWorkDuration'
|
||||||
|
|
||||||
|
function stepLabel(step: AgentProgressStep): string {
|
||||||
|
if (step.kind === 'analysis') return [
|
||||||
|
t('{{round}}차 분석 완료', { round: step.round ?? 1 }),
|
||||||
|
step.requests ? t('추가 조회 {{count}}건', { count: step.requests }) : ''
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
if (step.kind === 'image') return `${t('이미지 업로드 완료')} · ${step.name}`
|
||||||
|
const tools: Record<string, string> = {
|
||||||
|
'modules.list': t('모듈 목록'),
|
||||||
|
'files.list': t('파일 목록'),
|
||||||
|
'files.search': t('파일 검색'),
|
||||||
|
'files.read': t('파일 읽기'),
|
||||||
|
'logs.read': t('로그 조회'),
|
||||||
|
'network.inspect': t('네트워크 조회'),
|
||||||
|
'ros.inspect': t('ROS 상태 조회'),
|
||||||
|
'practice.inspect': t('실습 구성 조회')
|
||||||
|
}
|
||||||
|
const status = step.status === 'error' ? t('조회 실패') : step.status === 'partial' ? t('일부 결과') : step.status === 'empty' ? t('조회 결과 없음') : step.status === 'missing' ? t('대상 없음') : t('조회 완료')
|
||||||
|
return [
|
||||||
|
step.moduleName,
|
||||||
|
step.kind === 'files' ? t('소스 파일') : step.kind === 'logs' ? t('로그 조회') : tools[step.name ?? ''] ?? step.name,
|
||||||
|
step.path,
|
||||||
|
status,
|
||||||
|
step.count !== undefined ? t('{{count}}건', { count: step.count }) : ''
|
||||||
|
].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentProgressBubble({ dark, steps, activity, startedAt }: { dark: boolean; steps: AgentProgressStep[]; activity?: string; startedAt?: number | null }): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
const listRef = useRef<HTMLOListElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
const list = listRef.current
|
||||||
|
if (list) list.scrollTop = list.scrollHeight
|
||||||
|
}, [steps.length])
|
||||||
|
return (
|
||||||
|
<div data-agent-progress="true" className={['min-w-0 flex-1 overflow-hidden rounded-lg border px-3 py-3 text-[12px] leading-relaxed', dark ? 'border-white/10 bg-[#303030] text-white/75' : 'border-[#c4b5fd] bg-[#faf7ff] text-[#334155]'].join(' ')}>
|
||||||
|
<AgentWorkDuration dark={dark} startedAt={startedAt} />
|
||||||
|
<p className="font-bold">{t('진행 상황')}</p>
|
||||||
|
{steps.length > 0 && <ol ref={listRef} role="log" aria-label={t('진행 상황')} aria-live="polite" aria-relevant="additions" className="thin-scrollbar mt-2 grid max-h-64 gap-2 overflow-y-auto">
|
||||||
|
{steps.map(step => <li key={step.id} data-agent-progress-step={step.kind} className="agent-response-fade flex min-w-0 items-start gap-2">
|
||||||
|
{step.status === 'error' ? <XCircle size={13} className="mt-0.5 shrink-0 text-[#ef4444]" /> : step.status !== 'ok' ? <CircleAlert size={13} className="mt-0.5 shrink-0 text-[#d97706]" /> : <CheckCircle2 size={13} className="mt-0.5 shrink-0 text-[#22c55e]" />}
|
||||||
|
<span className="min-w-0 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{stepLabel(step)}</span>
|
||||||
|
</li>)}
|
||||||
|
</ol>}
|
||||||
|
<p role="status" className={['mt-2 break-words text-[11px]', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>{activity ? t(activity) : t('요청 분석 중')}</p>
|
||||||
|
<div className="mt-3 grid w-[220px] max-w-full animate-pulse gap-2 motion-reduce:animate-none" aria-hidden="true">
|
||||||
|
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
|
||||||
|
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
|
||||||
|
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
|
||||||
|
export function AgentWorkDuration({ startedAt, durationMs, dark }: { startedAt?: number | null; durationMs?: number; dark: boolean }): React.JSX.Element | null {
|
||||||
|
useLocaleRender()
|
||||||
|
const [now, setNow] = useState(Date.now)
|
||||||
|
const completed = typeof durationMs === 'number' && Number.isFinite(durationMs) && durationMs >= 0
|
||||||
|
const running = !completed && typeof startedAt === 'number' && Number.isFinite(startedAt)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!running) return
|
||||||
|
const update = (): void => setNow(Date.now())
|
||||||
|
update()
|
||||||
|
const timer = window.setInterval(update, 1000)
|
||||||
|
document.addEventListener('visibilitychange', update)
|
||||||
|
window.addEventListener('focus', update)
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer)
|
||||||
|
document.removeEventListener('visibilitychange', update)
|
||||||
|
window.removeEventListener('focus', update)
|
||||||
|
}
|
||||||
|
}, [running, startedAt])
|
||||||
|
if (!running && !completed) return null
|
||||||
|
const seconds = Math.floor(Math.max(0, completed ? durationMs : now - startedAt!) / 1000)
|
||||||
|
return <p data-agent-work-duration={completed ? 'completed' : 'running'} aria-live="off" className={['mb-2 text-left text-[11px] font-normal leading-relaxed tabular-nums', dark ? 'text-white/45' : 'text-[#64748b]'].join(' ')}>
|
||||||
|
{completed ? t('{{seconds}}초 동안 작업함', { seconds }) : t('{{seconds}}초 동안 작업 중입니다', { seconds })}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
import { AlertCircle, CheckCircle2, Clock3, LogOut, Power, Trash2 } from 'lucide-react'
|
import { AlertCircle, CheckCircle2, Clock3, LogOut, Power, Trash2 } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ function strongClass(dark: boolean): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dimClass(dark: boolean): string {
|
function dimClass(dark: boolean): string {
|
||||||
return dark ? 'text-white/45' : 'text-[#727b8c]'
|
return dark ? 'text-white/45' : 'text-[#475569]'
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelButtonClass(dark: boolean): string {
|
function cancelButtonClass(dark: boolean): string {
|
||||||
@@ -73,30 +74,30 @@ export function StatusNoticeModal({
|
|||||||
onAction?: () => void
|
onAction?: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
const hasAction = Boolean(actionLabel && onAction)
|
||||||
return (
|
return (
|
||||||
<div className={overlayClassName}>
|
<div className={overlayClassName}>
|
||||||
<section className={modalClass(dark)}>
|
<section className={modalClass(dark)}>
|
||||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||||
{toneIcon(tone)}
|
{toneIcon(tone)}
|
||||||
<strong className="text-[13px] font-bold">{title}</strong>
|
<strong className="text-[13px] font-bold">{t(title)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{message}</p>
|
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{t(message)}</p>
|
||||||
{children}
|
{children}
|
||||||
{detail && (
|
{detail && (
|
||||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
||||||
{detail}
|
{t(detail)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<button type="button" onClick={onConfirm} className={hasAction ? cancelButtonClass(dark) : confirmButtonClass('primary')}>{t("확인")}</button>
|
||||||
{actionLabel && onAction && (
|
{actionLabel && onAction && (
|
||||||
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}>
|
<button type="button" onClick={onAction} className={confirmButtonClass('primary')}>
|
||||||
{actionLabel}
|
{t(actionLabel)}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>
|
|
||||||
확인
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -117,29 +118,28 @@ export function WeekSwitchBlockedModal({
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<div className={overlayClassName}>
|
<div className={overlayClassName}>
|
||||||
<section className={modalClass(dark, true)}>
|
<section className={modalClass(dark, true)}>
|
||||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||||
<AlertCircle size={15} className="text-[#f59e0b]" />
|
<AlertCircle size={15} className="text-[#f59e0b]" />
|
||||||
<strong className="text-[13px] font-bold">이미 실행 중인 스튜디오가 있습니다</strong>
|
<strong className="text-[13px] font-bold">{t("이미 실행 중인 스튜디오가 있습니다")}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
{[
|
{[
|
||||||
['실행 중', runningLabel],
|
[t("실행 중"), runningLabel],
|
||||||
['새로 시작', nextLabel]
|
[t("새로 시작"), nextLabel]
|
||||||
].map(([label, value], index) => (
|
].map(([label, value], index) => (
|
||||||
<div key={label} className={[index ? 'mt-2' : '', 'rounded-md border px-3 py-2', dark ? 'border-white/10 bg-white/5' : 'border-[#e5e8ef] bg-[#f8fafc]'].join(' ')}>
|
<div key={label} className={[index ? 'mt-2' : '', 'rounded-md border px-3 py-2', dark ? 'border-white/10 bg-white/5' : 'border-[#e5e8ef] bg-[#f8fafc]'].join(' ')}>
|
||||||
<p className={['text-[11px] font-bold', dimClass(dark)].join(' ')}>{label}</p>
|
<p className={['text-[11px] font-bold', dimClass(dark)].join(' ')}>{label}</p>
|
||||||
<p className={['mt-1 text-[13px] font-bold', strongClass(dark)].join(' ')}>{value}</p>
|
<p className={['mt-1 text-[13px] font-bold', strongClass(dark)].join(' ')}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<p className={['mt-3 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
<p className={['mt-3 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>{t("현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.")}</p>
|
||||||
현재 실행 중인 개발 스튜디오를 종료한 뒤 선택한 주차를 새로 시작할 수 있습니다.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
|
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{t("취소")}</button>
|
||||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>종료하고 새로 시작</button>
|
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{t("종료하고 새로 시작")}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -158,15 +158,16 @@ export function RunningPlatformCloseModal({
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<ConfirmStatusModal
|
<ConfirmStatusModal
|
||||||
overlayClassName={overlayClassName}
|
overlayClassName={overlayClassName}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
icon={<Power size={15} className="text-[#e87979]" />}
|
icon={<Power size={15} className="text-[#e87979]" />}
|
||||||
title="개발 스튜디오 종료"
|
title={t("개발 스튜디오 종료")}
|
||||||
message={`${platformTitle} 개발 스튜디오를 종료할까요?`}
|
message={t("{{v0}} 개발 스튜디오를 종료할까요?", { v0: platformTitle === '로컬 PC' ? t('로컬 PC') : platformTitle })}
|
||||||
detail="현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다."
|
detail={t("현재 열린 개발 스튜디오 세션만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.")}
|
||||||
confirmLabel="종료"
|
confirmLabel={t("확인")}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
@@ -186,18 +187,19 @@ export function SessionConfirmModal({
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const release = action === 'release'
|
const release = action === 'release'
|
||||||
return (
|
return (
|
||||||
<ConfirmStatusModal
|
<ConfirmStatusModal
|
||||||
overlayClassName={overlayClassName}
|
overlayClassName={overlayClassName}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
icon={release ? <Trash2 size={15} className="text-[#e87979]" /> : <LogOut size={15} className="text-[#e87979]" />}
|
icon={release ? <Trash2 size={15} className="text-[#e87979]" /> : <LogOut size={15} className="text-[#e87979]" />}
|
||||||
title={release ? '반납' : '연결종료'}
|
title={release ? t("반납") : t("연결종료")}
|
||||||
message={release ? '제어권을 반납할까요?' : '연결을 종료할까요?'}
|
message={release ? t("제어권을 반납할까요?") : t("연결을 종료할까요?")}
|
||||||
detail={release
|
detail={release
|
||||||
? `${platformName} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.`
|
? t("{{v0}} 제어권을 반납하면 대기 중인 다음 수강생에게 제어권이 넘어갈 수 있습니다.", { v0: platformName })
|
||||||
: `${platformName} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.`}
|
: t("{{v0}} 개발 스튜디오 연결만 종료합니다. 플랫폼 제어권은 반납되지 않습니다.", { v0: platformName === '로컬 PC' ? t('로컬 PC') : platformName })}
|
||||||
confirmLabel={release ? '반납' : '연결종료'}
|
confirmLabel={release ? t("반납") : t("연결종료")}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
@@ -217,22 +219,23 @@ export function ModuleShutdownConfirmModal({
|
|||||||
onKeepRunning: () => void
|
onKeepRunning: () => void
|
||||||
onStopModules: () => void
|
onStopModules: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const release = action === 'release'
|
const release = action === 'release'
|
||||||
return (
|
return (
|
||||||
<ConfirmStatusModal
|
<ConfirmStatusModal
|
||||||
overlayClassName={overlayClassName}
|
overlayClassName={overlayClassName}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
icon={<AlertCircle size={15} className="text-[#f59e0b]" />}
|
icon={<AlertCircle size={15} className="text-[#f59e0b]" />}
|
||||||
title="실행 중인 모듈"
|
title={t("실행 중인 모듈")}
|
||||||
message={`${moduleNames.length}개의 모듈이 실행 중입니다. 모듈을 종료할까요?`}
|
message={t("{{v0}}개의 모듈이 실행 중입니다. 모듈을 종료할까요?", { v0: moduleNames.length })}
|
||||||
detail={moduleNames.join(', ')}
|
detail={moduleNames.join(', ')}
|
||||||
cancelLabel="아니오"
|
cancelLabel={t("아니오")}
|
||||||
confirmLabel="예"
|
confirmLabel={t("예")}
|
||||||
onCancel={onKeepRunning}
|
onCancel={onKeepRunning}
|
||||||
onConfirm={onStopModules}
|
onConfirm={onStopModules}
|
||||||
>
|
>
|
||||||
<p className={['mt-3 text-[11px] font-medium', dimClass(dark)].join(' ')}>
|
<p className={['mt-3 text-[11px] font-medium', dimClass(dark)].join(' ')}>
|
||||||
{release ? '아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.' : '아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.'}
|
{release ? t("아니오를 선택하면 모듈은 플랫폼에서 계속 실행됩니다.") : t("아니오를 선택하면 연결 종료 후에도 모듈은 계속 실행됩니다.")}
|
||||||
</p>
|
</p>
|
||||||
</ConfirmStatusModal>
|
</ConfirmStatusModal>
|
||||||
)
|
)
|
||||||
@@ -251,22 +254,21 @@ export function ControlExtensionConfirmModal({
|
|||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<div className={overlayClassName}>
|
<div className={overlayClassName}>
|
||||||
<section className={modalClass(dark)}>
|
<section className={modalClass(dark)}>
|
||||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||||
<Clock3 size={15} className="text-[#f59e0b]" />
|
<Clock3 size={15} className="text-[#f59e0b]" />
|
||||||
<strong className="text-[13px] font-bold">제어 시간 연장</strong>
|
<strong className="text-[13px] font-bold">{t("제어 시간 연장")}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName} 제어 시간을 연장할까요?</p>
|
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName}{t("제어 시간을 연장할까요?")}</p>
|
||||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>{t("대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.")}</p>
|
||||||
대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<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={onCancel} className={cancelButtonClass(dark)}>{t("취소")}</button>
|
||||||
<button type="button" disabled={submitting} onClick={onConfirm} className={confirmButtonClass('primary')}>
|
<button type="button" disabled={submitting} onClick={onConfirm} className={confirmButtonClass('primary')}>
|
||||||
{submitting ? '연장 중' : '연장'}
|
{submitting ? t("연장 중") : t("연장")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -275,7 +277,7 @@ export function ControlExtensionConfirmModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfirmStatusModal({
|
export function ConfirmStatusModal({
|
||||||
overlayClassName,
|
overlayClassName,
|
||||||
dark,
|
dark,
|
||||||
icon,
|
icon,
|
||||||
@@ -298,22 +300,23 @@ function ConfirmStatusModal({
|
|||||||
onConfirm: () => void
|
onConfirm: () => void
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<div className={overlayClassName}>
|
<div className={overlayClassName}>
|
||||||
<section className={modalClass(dark)}>
|
<section className={modalClass(dark)}>
|
||||||
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
|
||||||
{icon}
|
{icon}
|
||||||
<strong className="text-[13px] font-bold">{title}</strong>
|
<strong className="text-[13px] font-bold">{t(title)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{message}</p>
|
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{t(message)}</p>
|
||||||
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
|
||||||
{detail}
|
{t(detail)}
|
||||||
</p>
|
</p>
|
||||||
{children}
|
{children}
|
||||||
<div className="mt-4 flex justify-end gap-2">
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{cancelLabel}</button>
|
<button type="button" onClick={onCancel} className={cancelButtonClass(dark)}>{t(cancelLabel)}</button>
|
||||||
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{confirmLabel}</button>
|
<button type="button" onClick={onConfirm} className={confirmButtonClass('danger')}>{t(confirmLabel)}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Check, Copy, FileCode2, X } from 'lucide-react'
|
||||||
|
import { common, createLowlight } from 'lowlight'
|
||||||
|
import type React from 'react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
|
||||||
|
const highlighter = createLowlight(common)
|
||||||
|
const languageLabels: Record<string, string> = {
|
||||||
|
python: 'Python', py: 'Python', yaml: 'YAML', yml: 'YAML', json: 'JSON',
|
||||||
|
cpp: 'C++', 'c++': 'C++', c: 'C', javascript: 'JavaScript', js: 'JavaScript',
|
||||||
|
typescript: 'TypeScript', ts: 'TypeScript', bash: 'Bash', sh: 'Bash', shell: 'Shell',
|
||||||
|
xml: 'XML', html: 'HTML', css: 'CSS', sql: 'SQL', markdown: 'Markdown', md: 'Markdown',
|
||||||
|
}
|
||||||
|
type HighlightNode = ReturnType<typeof highlighter.highlight>['children'][number]
|
||||||
|
|
||||||
|
function renderToken(node: HighlightNode, key: string): React.ReactNode {
|
||||||
|
if (node.type === 'text') return node.value
|
||||||
|
if (node.type !== 'element') return null
|
||||||
|
const classes = node.properties.className
|
||||||
|
return <span key={key} className={Array.isArray(classes) ? classes.join(' ') : undefined}>
|
||||||
|
{node.children.map((child, index) => renderToken(child, `${key}-${index}`))}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatCodeBlock({ source, language = '', dark, fadeClass = '' }: {
|
||||||
|
source: string
|
||||||
|
language?: string
|
||||||
|
dark: boolean
|
||||||
|
fadeClass?: string
|
||||||
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
|
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'error'>('idle')
|
||||||
|
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||||
|
useEffect(() => () => clearTimeout(timer.current), [])
|
||||||
|
useEffect(() => { setCopyState('idle'); clearTimeout(timer.current) }, [source])
|
||||||
|
const normalizedLanguage = language.toLowerCase()
|
||||||
|
const tokens = useMemo(() => {
|
||||||
|
if (source.length > 50_000 || !highlighter.registered(normalizedLanguage)) return source
|
||||||
|
try { return highlighter.highlight(normalizedLanguage, source).children.map((node, index) => renderToken(node, `${index}`)) }
|
||||||
|
catch { return source }
|
||||||
|
}, [source, normalizedLanguage])
|
||||||
|
const copy = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(source)
|
||||||
|
setCopyState('copied')
|
||||||
|
} catch {
|
||||||
|
setCopyState('error')
|
||||||
|
}
|
||||||
|
clearTimeout(timer.current)
|
||||||
|
timer.current = setTimeout(() => setCopyState('idle'), 1800)
|
||||||
|
}
|
||||||
|
const copyLabel = copyState === 'copied' ? t('복사됨') : copyState === 'error' ? t('코드를 복사하지 못했습니다.') : t('코드 복사')
|
||||||
|
return <div data-chat-code-block data-code-theme={dark ? 'dark' : 'light'} className={['chat-code-block my-2 min-w-0 max-w-full overflow-hidden rounded-md border', dark ? 'border-white/12 bg-[#191919] text-[#dedede]' : 'border-[#d7dfeb] bg-[#f8fafc] text-[#253247]', fadeClass].join(' ')}>
|
||||||
|
<div className={['flex h-9 min-w-0 items-center justify-between gap-2 border-b px-2.5', dark ? 'border-white/10 bg-[#262626]' : 'border-[#e2e8f0] bg-[#eef2f7]'].join(' ')}>
|
||||||
|
<span title={language || undefined} className="flex min-w-0 items-center gap-1.5 text-[11px] font-bold"><FileCode2 size={13} className="shrink-0" /><span className="truncate">{languageLabels[normalizedLanguage] ?? (language ? language.slice(0, 40) : t('일반 텍스트'))}</span></span>
|
||||||
|
<button type="button" title={copyLabel} aria-label={copyLabel} onClick={event => { event.stopPropagation(); void copy() }} className={['grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded transition focus-visible:outline-2 focus-visible:outline-[#60a5fa]', copyState === 'error' ? 'text-[#ef4444]' : dark ? 'text-white/65 hover:bg-white/10 hover:text-white' : 'text-[#526079] hover:bg-[#dbe3ee] hover:text-[#182337]'].join(' ')}>{copyState === 'copied' ? <Check size={14} /> : copyState === 'error' ? <X size={14} /> : <Copy size={14} />}</button>
|
||||||
|
<span role="status" className="sr-only">{copyState === 'idle' ? '' : copyLabel}</span>
|
||||||
|
</div>
|
||||||
|
<pre tabIndex={0} aria-label={t('코드')} className="thin-scrollbar m-0 max-h-80 max-w-full overflow-auto p-3 text-[11px] leading-relaxed outline-offset-[-2px] [overflow-wrap:normal] [tab-size:4] [word-break:normal]">
|
||||||
|
<code className="block w-max min-w-full whitespace-pre font-mono font-normal [overflow-wrap:normal] [word-break:normal]">{tokens}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
import { displayAttachmentName } from './chatAttachments'
|
||||||
|
|
||||||
|
type ImageAttachment = { name: string; url: string }
|
||||||
|
|
||||||
|
export function ChatImageAttachments({ images, variant = 'draft', dark = true, onRemove, removeDisabled = false }: {
|
||||||
|
images: ImageAttachment[]
|
||||||
|
variant?: 'draft' | 'sent'
|
||||||
|
dark?: boolean
|
||||||
|
onRemove?: (image: ImageAttachment) => void
|
||||||
|
removeDisabled?: boolean
|
||||||
|
}): React.JSX.Element | null {
|
||||||
|
useLocaleRender()
|
||||||
|
const [preview, setPreview] = useState<ImageAttachment | null>(null)
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (preview) dialogRef.current?.showModal()
|
||||||
|
else dialogRef.current?.close()
|
||||||
|
}, [preview])
|
||||||
|
if (!images.length) return null
|
||||||
|
return <>
|
||||||
|
<div data-chat-image-attachments={variant} className={['flex min-w-0 flex-wrap gap-2', variant === 'sent' ? 'justify-end' : 'mt-2'].join(' ')}>
|
||||||
|
{images.map((source, index) => {
|
||||||
|
const image = { ...source, name: displayAttachmentName(source.name) }
|
||||||
|
return variant === 'draft' ? <div key={`${image.url}-${index}`} data-chat-image-draft="true" className={['flex h-16 w-56 min-w-0 max-w-full items-center gap-1 overflow-hidden rounded-md border p-1', dark ? 'border-white/10 bg-white/[0.03] text-white/70' : 'border-[#d8dee9] bg-[#f8f9fc] text-[#475569]'].join(' ')}>
|
||||||
|
<button type="button" title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="flex h-full min-w-0 flex-1 cursor-zoom-in items-center gap-2 overflow-hidden text-left">
|
||||||
|
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-14 shrink-0 rounded bg-black/10 object-contain" />
|
||||||
|
<span className="min-w-0 truncate text-[11px]">{image.name}</span>
|
||||||
|
</button>
|
||||||
|
{onRemove && <button type="button" disabled={removeDisabled} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${image.name}`} onClick={() => onRemove(image)} className={['grid size-6 shrink-0 cursor-pointer place-items-center rounded disabled:cursor-default disabled:opacity-40', dark ? 'hover:bg-white/10' : 'hover:bg-black/5'].join(' ')}><X size={12} /></button>}
|
||||||
|
</div> : <button type="button" key={`${image.url}-${index}`} title={image.name} aria-label={`${t('이미지 확대')}: ${image.name}`} onClick={() => setPreview(image)} className="h-16 w-20 shrink-0 cursor-zoom-in overflow-hidden rounded-md border border-current/20 bg-black/10">
|
||||||
|
<img src={image.url} alt={image.name} loading="lazy" referrerPolicy="no-referrer" className="h-full w-full object-contain" />
|
||||||
|
</button>
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{preview && createPortal(<dialog ref={dialogRef} aria-label={preview.name} onCancel={() => setPreview(null)} onClick={event => { if (event.target === event.currentTarget) setPreview(null) }} className="fixed inset-0 m-auto max-h-[90vh] max-w-[94vw] overflow-auto rounded-lg border border-white/15 bg-[#202020] p-3 text-white shadow-xl backdrop:bg-black/75">
|
||||||
|
<div className="mb-2 flex min-w-0 items-center justify-between gap-3"><span className="min-w-0 break-all text-[12px]">{preview.name}</span><button type="button" title={t('닫기')} aria-label={t('닫기')} onClick={() => setPreview(null)} className="grid size-8 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10"><X size={16} /></button></div>
|
||||||
|
<img src={preview.url} alt={preview.name} referrerPolicy="no-referrer" className="max-h-[78vh] max-w-full object-contain" />
|
||||||
|
</dialog>, document.body)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { ClipboardPaste, Copy, Trash2 } from 'lucide-react'
|
||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
|
||||||
|
export type ChatTextContextMenuState = {
|
||||||
|
kind: 'message' | 'input'
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
text: string
|
||||||
|
input?: HTMLTextAreaElement
|
||||||
|
selectionStart?: number
|
||||||
|
selectionEnd?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatTextContextMenu({ dark, menu, canClear = false, onClose, onClear, onPasteText }: {
|
||||||
|
dark: boolean
|
||||||
|
menu: ChatTextContextMenuState | null
|
||||||
|
canClear?: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onClear?: () => void
|
||||||
|
onPasteText?: (text: string, menu: ChatTextContextMenuState) => void
|
||||||
|
}): React.JSX.Element | null {
|
||||||
|
useLocaleRender()
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [clipboardState, setClipboardState] = useState<{ menu: ChatTextContextMenuState; hasText: boolean } | null>(null)
|
||||||
|
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||||
|
const closeRef = useRef(onClose)
|
||||||
|
closeRef.current = onClose
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!menu || !menuRef.current) return
|
||||||
|
const bounds = menuRef.current.getBoundingClientRect()
|
||||||
|
setPosition({ x: Math.max(8, Math.min(menu.x, window.innerWidth - bounds.width - 8)), y: Math.max(8, Math.min(menu.y, window.innerHeight - bounds.height - 8)) })
|
||||||
|
}, [menu])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!menu) return
|
||||||
|
let disposed = false
|
||||||
|
if (menu.kind === 'input') {
|
||||||
|
const read = Promise.resolve().then(() => window.chatTextMenu?.readClipboardState
|
||||||
|
? window.chatTextMenu.readClipboardState()
|
||||||
|
: navigator.clipboard.readText().then(text => ({ hasText: text.length > 0 })))
|
||||||
|
void read.then(state => { if (!disposed) setClipboardState({ menu, hasText: state.hasText }) })
|
||||||
|
.catch(() => { if (!disposed) setClipboardState({ menu, hasText: false }) })
|
||||||
|
}
|
||||||
|
const outside = (event: Event): void => {
|
||||||
|
if (!menuRef.current?.contains(event.target as Node)) closeRef.current()
|
||||||
|
}
|
||||||
|
const keydown = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape') { event.preventDefault(); closeRef.current() }
|
||||||
|
}
|
||||||
|
const close = (): void => closeRef.current()
|
||||||
|
window.addEventListener('pointerdown', outside, true)
|
||||||
|
window.addEventListener('contextmenu', outside)
|
||||||
|
window.addEventListener('scroll', outside, true)
|
||||||
|
window.addEventListener('resize', close)
|
||||||
|
window.addEventListener('blur', close)
|
||||||
|
window.addEventListener('keydown', keydown)
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
window.removeEventListener('pointerdown', outside, true)
|
||||||
|
window.removeEventListener('contextmenu', outside)
|
||||||
|
window.removeEventListener('scroll', outside, true)
|
||||||
|
window.removeEventListener('resize', close)
|
||||||
|
window.removeEventListener('blur', close)
|
||||||
|
window.removeEventListener('keydown', keydown)
|
||||||
|
}
|
||||||
|
}, [menu])
|
||||||
|
|
||||||
|
if (!menu) return null
|
||||||
|
const pasteEnabled = menu.kind === 'input' && !menu.input?.disabled && !menu.input?.readOnly && clipboardState?.menu === menu && clipboardState.hasText
|
||||||
|
const itemClass = ['flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition disabled:cursor-default disabled:opacity-35', dark ? 'enabled:hover:bg-[#37373d] enabled:hover:text-white' : 'enabled:hover:bg-[#eef1f7] enabled:hover:text-[#111520]'].join(' ')
|
||||||
|
const copy = async (): Promise<void> => {
|
||||||
|
if (!menu.text) return
|
||||||
|
onClose()
|
||||||
|
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'copy', text: menu.text })
|
||||||
|
else await navigator.clipboard.writeText(menu.text)
|
||||||
|
}
|
||||||
|
const paste = async (): Promise<void> => {
|
||||||
|
if (!pasteEnabled || !menu.input?.isConnected || menu.input.disabled || menu.input.readOnly) return
|
||||||
|
const input = menu.input
|
||||||
|
input.focus({ preventScroll: true })
|
||||||
|
input.setSelectionRange(menu.selectionStart ?? input.selectionStart, menu.selectionEnd ?? input.selectionEnd)
|
||||||
|
onClose()
|
||||||
|
if (window.chatTextMenu?.perform) await window.chatTextMenu.perform({ action: 'paste' })
|
||||||
|
else {
|
||||||
|
const text = await navigator.clipboard.readText()
|
||||||
|
if (text) onPasteText?.(text, menu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return createPortal(
|
||||||
|
<div ref={menuRef} role="menu" aria-label={t('질의응답')} className={[
|
||||||
|
'fixed z-[160] w-[160px] overflow-hidden rounded-md border py-1 text-[12px] font-medium shadow-[0_14px_32px_rgba(0,0,0,0.32)]',
|
||||||
|
dark ? 'border-white/10 bg-[#252526] text-white/80' : 'border-[#d8dee9] bg-white text-[#374151]'
|
||||||
|
].join(' ')} style={{ left: position.x, top: position.y }} onMouseDown={event => event.preventDefault()} onContextMenu={event => { event.preventDefault(); event.stopPropagation() }}>
|
||||||
|
<button type="button" role="menuitem" disabled={!menu.text} onClick={() => void copy().catch(error => console.error('[chat] copy failed', error))} className={itemClass}><Copy size={13} />{t('복사')}</button>
|
||||||
|
{menu.kind === 'input' ? (
|
||||||
|
<button type="button" role="menuitem" disabled={!pasteEnabled} onClick={() => void paste().catch(error => console.error('[chat] paste failed', error))} className={itemClass}><ClipboardPaste size={13} />{t('붙여넣기')}</button>
|
||||||
|
) : onClear ? <>
|
||||||
|
<div role="separator" className={['my-1 border-t', dark ? 'border-white/10' : 'border-[#d8dee9]'].join(' ')} />
|
||||||
|
<button type="button" role="menuitem" disabled={!canClear} onClick={() => { onClose(); onClear() }} className={itemClass}><Trash2 size={13} />{t('대화내역 삭제')}</button>
|
||||||
|
</> : null}
|
||||||
|
</div>, document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { ChevronDown, Send, Square } from 'lucide-react'
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
import { ChevronDown, Paperclip, Send, Square, Trash2, X } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { memo, useEffect, useState } from 'react'
|
import { memo, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
import { CHAT_ATTACHMENT_ACCEPT, CHAT_IMAGE_ACCEPT, readChatAttachments, type ChatAttachment } from './chatAttachments'
|
||||||
|
import { ChatImageAttachments } from './ChatImageAttachments'
|
||||||
|
import { ChatCodeBlock } from './ChatCodeBlock'
|
||||||
import ReactMarkdown from 'react-markdown'
|
import ReactMarkdown from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import { ChatTextContextMenu, type ChatTextContextMenuState } from './ChatTextContextMenu'
|
||||||
|
|
||||||
export function ChatAvatar({
|
export function ChatAvatar({
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -15,6 +20,7 @@ export function ChatAvatar({
|
|||||||
fallback: React.JSX.Element
|
fallback: React.JSX.Element
|
||||||
className: string
|
className: string
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const [avatarSource, setAvatarSource] = useState<string | null>(null)
|
const [avatarSource, setAvatarSource] = useState<string | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,22 +62,23 @@ export function ChatAvatar({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MarkdownMessage({ content, dark }: { content: string; dark: boolean }): React.JSX.Element {
|
export function MarkdownMessage({ content, dark, fadeFrom }: { content: string; dark: boolean; fadeFrom?: number }): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const textColor = dark ? 'text-white/76' : 'text-[#4f596b]'
|
const textColor = dark ? 'text-white/76' : 'text-[#4f596b]'
|
||||||
const headingColor = dark ? 'text-white/92' : 'text-[#111827]'
|
const headingColor = dark ? 'text-white/92' : 'text-[#111827]'
|
||||||
const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]'
|
const mutedBorder = dark ? 'border-white/10' : 'border-[#d7dfeb]'
|
||||||
const codeBg = dark ? 'bg-[#171717] text-white/82' : 'bg-[#f1f5f9] text-[#334155]'
|
const fadeClass = (offset?: number): string => fadeFrom !== undefined && offset !== undefined && offset >= fadeFrom ? ' agent-response-fade' : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [overflow-wrap:anywhere] [word-break:break-word] ${textColor}`}>
|
<div className={`min-w-0 max-w-full overflow-hidden break-words text-[12px] leading-relaxed [overflow-wrap:anywhere] [word-break:break-word] ${textColor}`}>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
h1: ({ children }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h1>,
|
h1: ({ children, node }) => <h1 className={`mb-2 mt-1 break-words text-[15px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h1>,
|
||||||
h2: ({ children }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h2>,
|
h2: ({ children, node }) => <h2 className={`mb-2 mt-3 break-words text-[14px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h2>,
|
||||||
h3: ({ children }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h3>,
|
h3: ({ children, node }) => <h3 className={`mb-1.5 mt-3 break-words text-[13px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h3>,
|
||||||
h4: ({ children }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}`}>{children}</h4>,
|
h4: ({ children, node }) => <h4 className={`mb-1 mt-2.5 break-words text-[12px] font-black leading-snug [overflow-wrap:anywhere] ${headingColor}${fadeClass(node?.position?.start.offset)}`}>{children}</h4>,
|
||||||
p: ({ children }) => <p className="my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]">{children}</p>,
|
p: ({ children, node }) => <p className={`my-1 max-w-full whitespace-pre-wrap break-words leading-relaxed [overflow-wrap:anywhere] [word-break:break-word]${fadeClass(node?.position?.start.offset)}`}>{children}</p>,
|
||||||
strong: ({ children }) => <strong className={`font-black ${headingColor}`}>{children}</strong>,
|
strong: ({ children }) => <strong className={`font-black ${headingColor}`}>{children}</strong>,
|
||||||
em: ({ children }) => <em className="italic">{children}</em>,
|
em: ({ children }) => <em className="italic">{children}</em>,
|
||||||
a: ({ children, href }) => (
|
a: ({ children, href }) => (
|
||||||
@@ -85,30 +92,37 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
|
|||||||
{children}
|
{children}
|
||||||
</a>
|
</a>
|
||||||
),
|
),
|
||||||
ul: ({ children }) => <ul className="my-2 grid gap-1 pl-4">{children}</ul>,
|
ul: ({ children, className }) => <ul className={`my-2 grid gap-1 pl-5 ${className?.includes('contains-task-list') ? 'contains-task-list list-none' : 'list-disc [&_ul]:list-[circle]'}`}>{children}</ul>,
|
||||||
ol: ({ children }) => <ol className="my-2 grid gap-1 pl-4">{children}</ol>,
|
ol: ({ children, start }) => <ol start={start} className="my-2 grid list-decimal gap-1 pl-5">{children}</ol>,
|
||||||
li: ({ children }) => <li className="max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word]">{children}</li>,
|
li: ({ children, node, className }) => <li className={`max-w-full break-words pl-1 leading-relaxed marker:font-bold [overflow-wrap:anywhere] [word-break:break-word] ${className?.includes('task-list-item') ? 'task-list-item list-none' : ''}${fadeClass(node?.position?.start.offset)}`}>{children}</li>,
|
||||||
blockquote: ({ children }) => (
|
blockquote: ({ children, node }) => (
|
||||||
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}`}>
|
<blockquote className={`my-2 border-l-2 pl-3 ${mutedBorder} ${dark ? 'text-white/58' : 'text-[#64748b]'}${fadeClass(node?.position?.start.offset)}`}>
|
||||||
{children}
|
{children}
|
||||||
</blockquote>
|
</blockquote>
|
||||||
),
|
),
|
||||||
hr: () => <hr className={`my-3 ${mutedBorder}`} />,
|
hr: ({ node }) => <hr className={`my-3 ${mutedBorder}${fadeClass(node?.position?.start.offset)}`} />,
|
||||||
pre: ({ children }) => <pre className={`my-2 max-w-full overflow-hidden whitespace-pre-wrap break-words rounded-md p-2 font-mono text-[11px] font-medium leading-relaxed [overflow-wrap:anywhere] ${codeBg}`}>{children}</pre>,
|
pre: ({ children, node }) => {
|
||||||
|
const code = node?.children.find(child => child.type === 'element' && child.tagName === 'code')
|
||||||
|
if (code?.type !== 'element') return <pre className="my-2 max-w-full overflow-auto whitespace-pre font-mono text-[11px]">{children}</pre>
|
||||||
|
const source = code.children.map(child => child.type === 'text' ? child.value : '').join('')
|
||||||
|
const classes = code.properties.className
|
||||||
|
const language = Array.isArray(classes) ? String(classes.find(value => String(value).startsWith('language-')) ?? '').slice(9) : ''
|
||||||
|
return <ChatCodeBlock source={source} language={language} dark={dark} fadeClass={fadeClass(node?.position?.start.offset)} />
|
||||||
|
},
|
||||||
code: ({ children, className }) => {
|
code: ({ children, className }) => {
|
||||||
if (className) {
|
if (className) {
|
||||||
return <code className="whitespace-pre-wrap break-words font-mono [overflow-wrap:anywhere]">{children}</code>
|
return <code className="whitespace-pre-wrap break-words font-mono [overflow-wrap:anywhere]">{children}</code>
|
||||||
}
|
}
|
||||||
return <code className={`rounded px-1 py-0.5 font-mono text-[11px] ${dark ? 'bg-white/10 text-[#dbeafe]' : 'bg-[#eef2ff] text-[#1d4ed8]'}`}>{children}</code>
|
return <code className={`chat-inline-code rounded px-1 py-0.5 font-mono text-[11px] ${dark ? 'bg-white/10 text-[#dbeafe]' : 'bg-[#eef2ff] text-[#1d4ed8]'}`}>{children}</code>
|
||||||
},
|
},
|
||||||
table: ({ children }) => (
|
table: ({ children, node }) => (
|
||||||
<div className="my-2 max-w-full overflow-hidden">
|
<div data-chat-table tabIndex={0} role="region" aria-label={t('표')} className={`thin-x-scrollbar my-2 max-w-full overflow-x-auto rounded-md${fadeClass(node?.position?.start.offset)}`}>
|
||||||
<table className={`w-full table-fixed border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
|
<table className={`w-full table-auto border-collapse text-left text-[11px] ${dark ? 'text-white/72' : 'text-[#475569]'}`}>{children}</table>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
thead: ({ children }) => <thead className={dark ? 'bg-white/8 text-white/86' : 'bg-[#eef2f7] text-[#1e293b]'}>{children}</thead>,
|
thead: ({ children }) => <thead className={dark ? 'bg-white/8 text-white/86' : 'bg-[#eef2f7] text-[#1e293b]'}>{children}</thead>,
|
||||||
th: ({ children }) => <th className={`break-words border px-2 py-1.5 font-black [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</th>,
|
th: ({ children, style }) => <th style={style} className={`min-w-[96px] max-w-[320px] break-words border px-2 py-1.5 font-black [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</th>,
|
||||||
td: ({ children }) => <td className={`break-words border px-2 py-1.5 align-top [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</td>
|
td: ({ children, style }) => <td style={style} className={`min-w-[96px] max-w-[320px] break-words border px-2 py-1.5 align-top [overflow-wrap:anywhere] ${mutedBorder}`}>{children}</td>
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
@@ -117,6 +131,33 @@ export function MarkdownMessage({ content, dark }: { content: string; dark: bool
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ChatEmptyState({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div className={['flex h-full min-h-[160px] items-center justify-center text-center text-[12px] font-semibold leading-relaxed', dark ? 'text-white/45' : 'text-[#687284]'].join(' ')}>
|
||||||
|
<div className={['relative mx-4 min-w-0 max-w-full break-words rounded-lg border px-8 py-5', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')}>
|
||||||
|
<span>{children}</span>
|
||||||
|
<span aria-hidden="true" className={['absolute -bottom-[5px] left-7 size-2 rotate-45 border-b border-r', dark ? 'border-white/12 bg-[#272727]' : 'border-[#d8dee9] bg-[#f8fafc]'].join(' ')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatResponseSkeleton({ dark, label }: { dark: boolean; label: string }): React.JSX.Element {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label={label}
|
||||||
|
className={['w-[220px] max-w-[calc(100%-38px)] rounded-lg border px-3 py-3', dark ? 'border-white/10 bg-[#303030]' : 'border-[#c4b5fd] bg-[#faf7ff]'].join(' ')}
|
||||||
|
>
|
||||||
|
<div className="grid animate-pulse gap-2 motion-reduce:animate-none" aria-hidden="true">
|
||||||
|
<span className={['h-2 w-full rounded-sm', dark ? 'bg-white/18' : 'bg-[#c4b5fd]/65'].join(' ')} />
|
||||||
|
<span className={['h-2 w-[76%] rounded-sm', dark ? 'bg-white/14' : 'bg-[#c4b5fd]/50'].join(' ')} />
|
||||||
|
<span className={['h-2 w-[52%] rounded-sm', dark ? 'bg-white/10' : 'bg-[#c4b5fd]/40'].join(' ')} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function ExpandableMarkdownMessage({
|
export function ExpandableMarkdownMessage({
|
||||||
content,
|
content,
|
||||||
dark,
|
dark,
|
||||||
@@ -130,6 +171,7 @@ export function ExpandableMarkdownMessage({
|
|||||||
collapseSignal?: number
|
collapseSignal?: number
|
||||||
defaultCollapsed?: boolean
|
defaultCollapsed?: boolean
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const [expanded, setExpanded] = useState(!defaultCollapsed)
|
const [expanded, setExpanded] = useState(!defaultCollapsed)
|
||||||
const lineCount = content.split('\n').length
|
const lineCount = content.split('\n').length
|
||||||
const collapsible = content.length > 700 || lineCount > 12
|
const collapsible = content.length > 700 || lineCount > 12
|
||||||
@@ -161,8 +203,9 @@ export function ExpandableMarkdownMessage({
|
|||||||
<div className={['relative z-10', collapsed ? '-mt-7' : 'mt-2'].join(' ')}>
|
<div className={['relative z-10', collapsed ? '-mt-7' : 'mt-2'].join(' ')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={expanded ? '접기' : '자세히 보기'}
|
aria-label={expanded ? t("접기") : t("자세히 보기")}
|
||||||
title={expanded ? '접기' : '자세히 보기'}
|
aria-expanded={expanded}
|
||||||
|
title={expanded ? t("접기") : t("자세히 보기")}
|
||||||
onClick={() => setExpanded((value) => !value)}
|
onClick={() => setExpanded((value) => !value)}
|
||||||
className={['grid h-7 w-full cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white/80' : 'text-[#64748b] hover:bg-[#e2e8f0]/70 hover:text-[#2563eb]'].join(' ')}
|
className={['grid h-7 w-full cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/55 hover:bg-white/8 hover:text-white/80' : 'text-[#64748b] hover:bg-[#e2e8f0]/70 hover:text-[#2563eb]'].join(' ')}
|
||||||
>
|
>
|
||||||
@@ -183,6 +226,7 @@ export function ExpandablePlainMessage({
|
|||||||
collapseSignal?: number
|
collapseSignal?: number
|
||||||
content: string
|
content: string
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
const lineCount = content.split('\n').length
|
const lineCount = content.split('\n').length
|
||||||
const collapsible = content.length > 450 || lineCount > 8
|
const collapsible = content.length > 450 || lineCount > 8
|
||||||
@@ -216,8 +260,8 @@ export function ExpandablePlainMessage({
|
|||||||
<div className={['relative z-10', collapsed ? '-mt-6' : 'mt-2'].join(' ')}>
|
<div className={['relative z-10', collapsed ? '-mt-6' : 'mt-2'].join(' ')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={expanded ? '접기' : '자세히 보기'}
|
aria-label={expanded ? t("접기") : t("자세히 보기")}
|
||||||
title={expanded ? '접기' : '자세히 보기'}
|
title={expanded ? t("접기") : t("자세히 보기")}
|
||||||
onClick={() => setExpanded((value) => !value)}
|
onClick={() => setExpanded((value) => !value)}
|
||||||
className="grid h-7 w-full cursor-pointer place-items-center rounded-md text-white/70 transition hover:bg-white/10 hover:text-white"
|
className="grid h-7 w-full cursor-pointer place-items-center rounded-md text-white/70 transition hover:bg-white/10 hover:text-white"
|
||||||
>
|
>
|
||||||
@@ -229,11 +273,32 @@ export function ExpandablePlainMessage({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resizeQaInput(input: HTMLTextAreaElement): void {
|
||||||
|
const styles = window.getComputedStyle(input)
|
||||||
|
const lineHeight = Number.parseFloat(styles.lineHeight)
|
||||||
|
const padding = Number.parseFloat(styles.paddingTop) + Number.parseFloat(styles.paddingBottom)
|
||||||
|
const border = Number.parseFloat(styles.borderTopWidth) + Number.parseFloat(styles.borderBottomWidth)
|
||||||
|
const minHeight = Number.parseFloat(styles.minHeight)
|
||||||
|
const maxHeight = Math.ceil(lineHeight * 8 + padding + border)
|
||||||
|
|
||||||
|
input.style.height = 'auto'
|
||||||
|
input.style.overflowY = 'hidden'
|
||||||
|
const contentHeight = input.scrollHeight + border
|
||||||
|
input.style.maxHeight = `${maxHeight}px`
|
||||||
|
input.style.height = `${Math.min(Math.max(contentHeight, minHeight), maxHeight)}px`
|
||||||
|
input.style.overflowY = contentHeight > maxHeight ? 'auto' : 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
export const QaInputBox = memo(function QaInputBox({
|
export const QaInputBox = memo(function QaInputBox({
|
||||||
dark,
|
dark,
|
||||||
busy,
|
busy,
|
||||||
disabled,
|
disabled,
|
||||||
accent = 'blue',
|
accent = 'blue',
|
||||||
|
attachmentsEnabled = false,
|
||||||
|
imagesEnabled = false,
|
||||||
|
onClearConversation,
|
||||||
|
clearDisabled = false,
|
||||||
|
footerAction,
|
||||||
onStop,
|
onStop,
|
||||||
onSubmit
|
onSubmit
|
||||||
}: {
|
}: {
|
||||||
@@ -241,44 +306,127 @@ export const QaInputBox = memo(function QaInputBox({
|
|||||||
busy: boolean
|
busy: boolean
|
||||||
disabled: boolean
|
disabled: boolean
|
||||||
accent?: 'blue' | 'cyan'
|
accent?: 'blue' | 'cyan'
|
||||||
|
attachmentsEnabled?: boolean
|
||||||
|
imagesEnabled?: boolean
|
||||||
|
onClearConversation?: () => void
|
||||||
|
clearDisabled?: boolean
|
||||||
|
footerAction?: React.ReactNode
|
||||||
onStop: () => void
|
onStop: () => void
|
||||||
onSubmit: (content: string) => void
|
onSubmit: (content: string, attachments?: ChatAttachment[]) => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const [draft, setDraft] = useState('')
|
const [draft, setDraft] = useState('')
|
||||||
const canSubmit = busy || (Boolean(draft.trim()) && !disabled)
|
const [attachments, setAttachments] = useState<ChatAttachment[]>([])
|
||||||
|
const [attachmentError, setAttachmentError] = useState<string | null>(null)
|
||||||
|
const [readingAttachments, setReadingAttachments] = useState(false)
|
||||||
|
const [contextMenu, setContextMenu] = useState<ChatTextContextMenuState | null>(null)
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const attachmentReadRef = useRef(0)
|
||||||
|
const attachmentUrlsRef = useRef<string[]>([])
|
||||||
|
const canSubmit = busy || ((Boolean(draft.trim()) || attachments.some(file => file.kind === 'image')) && !disabled && !readingAttachments)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const urls = attachments.flatMap(file => file.kind === 'image' ? [file.previewUrl] : [])
|
||||||
|
attachmentUrlsRef.current.filter(url => !urls.includes(url)).forEach(url => URL.revokeObjectURL(url))
|
||||||
|
attachmentUrlsRef.current = urls
|
||||||
|
}, [attachments])
|
||||||
|
useEffect(() => () => attachmentUrlsRef.current.forEach(url => URL.revokeObjectURL(url)), [])
|
||||||
|
|
||||||
|
const attachFiles = (files: File[]): void => {
|
||||||
|
if (!attachmentsEnabled || disabled || busy || readingAttachments || !files.length) return
|
||||||
|
const readId = ++attachmentReadRef.current
|
||||||
|
setReadingAttachments(true)
|
||||||
|
setAttachmentError(null)
|
||||||
|
void readChatAttachments(files, attachments, { imagesEnabled }).then(files => {
|
||||||
|
if (attachmentReadRef.current === readId) setAttachments(files)
|
||||||
|
else files.forEach(file => { if (file.kind === 'image' && !attachments.includes(file)) URL.revokeObjectURL(file.previewUrl) })
|
||||||
|
}).catch(error => {
|
||||||
|
if (attachmentReadRef.current === readId) setAttachmentError(error instanceof Error ? error.message : '파일을 읽지 못했습니다.')
|
||||||
|
}).finally(() => { if (attachmentReadRef.current === readId) setReadingAttachments(false) })
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (textareaRef.current) resizeQaInput(textareaRef.current)
|
||||||
|
}, [draft])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const input = textareaRef.current
|
||||||
|
if (!input) return
|
||||||
|
let width = input.getBoundingClientRect().width
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
if (!entry || entry.contentRect.width === width) return
|
||||||
|
width = entry.contentRect.width
|
||||||
|
resizeQaInput(input)
|
||||||
|
})
|
||||||
|
observer.observe(input)
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
attachmentReadRef.current += 1
|
||||||
|
setReadingAttachments(false)
|
||||||
|
if (!attachmentsEnabled) {
|
||||||
|
setAttachments([])
|
||||||
|
setAttachmentError(null)
|
||||||
|
setReadingAttachments(false)
|
||||||
|
}
|
||||||
|
else if (!imagesEnabled) setAttachments(current => current.filter(file => file.kind !== 'image'))
|
||||||
|
return () => { attachmentReadRef.current += 1 }
|
||||||
|
}, [attachmentsEnabled, imagesEnabled])
|
||||||
|
|
||||||
const submit = (): void => {
|
const submit = (): void => {
|
||||||
const content = draft.trim()
|
const content = draft.trim() || (attachments.some(file => file.kind === 'image') ? t('첨부 이미지를 분석해주세요.') : '')
|
||||||
if (busy) {
|
if (busy) {
|
||||||
onStop()
|
onStop()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!content || disabled) return
|
if (!content || disabled || readingAttachments) return
|
||||||
setDraft('')
|
setDraft('')
|
||||||
onSubmit(content)
|
onSubmit(content, attachmentsEnabled ? attachments : undefined)
|
||||||
|
setAttachments([])
|
||||||
|
setAttachmentError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={['m-3 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')}>
|
<div className={['m-3 shrink-0 rounded-lg border p-2', dark ? 'border-white/10 bg-[#202020]' : 'border-[#d8dee9] bg-white'].join(' ')} onDragOver={event => { if (attachmentsEnabled && event.dataTransfer.types.includes('Files')) event.preventDefault() }} onDrop={event => { if (!attachmentsEnabled) return; event.preventDefault(); attachFiles(Array.from(event.dataTransfer.files)) }}>
|
||||||
<div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}>
|
<div className={['flex min-h-[56px] items-stretch overflow-hidden rounded-md pl-3 text-[12px]', dark ? 'bg-[#252525]' : 'bg-[#f8f9fc]'].join(' ')}>
|
||||||
<textarea
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
rows={2}
|
rows={2}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCorrect="off"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => setDraft(event.target.value)}
|
onChange={(event) => setDraft(event.target.value)}
|
||||||
|
onPaste={event => { if (attachmentsEnabled && event.clipboardData.files.length) { event.preventDefault(); attachFiles(Array.from(event.clipboardData.files)) } }}
|
||||||
|
onContextMenu={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
const input = event.currentTarget
|
||||||
|
setContextMenu({
|
||||||
|
kind: 'input',
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
text: input.value.slice(input.selectionStart, input.selectionEnd),
|
||||||
|
input,
|
||||||
|
selectionStart: input.selectionStart,
|
||||||
|
selectionEnd: input.selectionEnd
|
||||||
|
})
|
||||||
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' && !event.shiftKey) {
|
if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
submit()
|
submit()
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="질문하세요..."
|
placeholder={t("질문하세요...")}
|
||||||
className={[
|
className={[
|
||||||
'min-h-[56px] min-w-0 flex-1 resize-none bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium',
|
'box-border min-h-[56px] min-w-0 flex-1 self-start resize-none overflow-y-hidden bg-transparent py-2.5 pr-3 text-[12px] font-medium leading-relaxed outline-none placeholder:font-medium',
|
||||||
dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]'
|
dark ? 'text-white/80 placeholder:text-white/40' : 'text-[#111520] placeholder:text-[#727b8c]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-label={busy ? t('중지') : t('전송')}
|
||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
onClick={busy ? onStop : submit}
|
onClick={busy ? onStop : submit}
|
||||||
className={[
|
className={[
|
||||||
@@ -291,6 +439,43 @@ export const QaInputBox = memo(function QaInputBox({
|
|||||||
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
|
{busy ? <Square size={13} fill="currentColor" /> : <Send size={14} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<ChatTextContextMenu dark={dark} menu={contextMenu} onClose={() => setContextMenu(null)} onPasteText={(text, menu) => {
|
||||||
|
const start = menu.selectionStart ?? draft.length
|
||||||
|
const end = menu.selectionEnd ?? start
|
||||||
|
setDraft(current => current.slice(0, start) + text + current.slice(end))
|
||||||
|
window.requestAnimationFrame(() => textareaRef.current?.setSelectionRange(start + text.length, start + text.length))
|
||||||
|
}} />
|
||||||
|
{attachmentsEnabled && attachments.some(file => file.kind !== 'image') && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 pt-2">
|
||||||
|
{attachments.map((file, index) => file.kind === 'image' ? null : (
|
||||||
|
<span key={`${file.name}-${index}`} className={['inline-flex min-w-0 max-w-full items-center gap-1 rounded border px-2 py-1 text-[11px]', dark ? 'border-white/10 text-white/70' : 'border-[#d8dee9] text-[#475569]'].join(' ')}>
|
||||||
|
<Paperclip size={12} className="shrink-0" />
|
||||||
|
<span className="min-w-0 truncate" title={file.name}>{file.name}</span>
|
||||||
|
<button type="button" disabled={busy || readingAttachments} title={t('첨부 제거')} aria-label={`${t('첨부 제거')}: ${file.name}`} onClick={() => setAttachments((current) => current.filter((_, item) => item !== index))} className="grid size-5 shrink-0 cursor-pointer place-items-center rounded hover:bg-white/10 disabled:cursor-default disabled:opacity-40"><X size={12} /></button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ChatImageAttachments dark={dark} images={attachments.flatMap(file => file.kind === 'image' ? [{ name: file.name, url: file.previewUrl }] : [])} removeDisabled={busy || readingAttachments} onRemove={image => setAttachments(current => current.filter(file => file.kind !== 'image' || file.previewUrl !== image.url))} />
|
||||||
|
{attachmentError && <p role="alert" className="break-words pt-2 text-[11px] text-[#ef4444]">{t(attachmentError)}</p>}
|
||||||
|
{(attachmentsEnabled || onClearConversation || footerAction) && (
|
||||||
|
<div className={['flex h-9 min-w-0 items-center gap-1 pt-1', dark ? 'text-white/50' : 'text-[#64748b]'].join(' ')}>
|
||||||
|
{footerAction}
|
||||||
|
{attachmentsEnabled && (
|
||||||
|
<>
|
||||||
|
<input ref={fileInputRef} type="file" multiple accept={[CHAT_ATTACHMENT_ACCEPT, ...(imagesEnabled ? [CHAT_IMAGE_ACCEPT] : [])].join(',')} className="hidden" aria-label={t('파일 첨부')} onChange={(event) => {
|
||||||
|
const files = Array.from(event.currentTarget.files ?? [])
|
||||||
|
event.currentTarget.value = ''
|
||||||
|
attachFiles(files)
|
||||||
|
}} />
|
||||||
|
<button type="button" title={t('파일 첨부')} aria-label={t('파일 첨부')} disabled={disabled || busy || readingAttachments} onClick={() => fileInputRef.current?.click()} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 disabled:cursor-default disabled:opacity-35"><Paperclip size={15} className={readingAttachments ? 'animate-pulse' : ''} /></button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{onClearConversation && (
|
||||||
|
<button type="button" title={t('대화내역 삭제')} aria-label={t('대화내역 삭제')} disabled={clearDisabled || busy || readingAttachments} onClick={onClearConversation} className="grid size-7 cursor-pointer place-items-center rounded transition hover:bg-white/10 hover:text-[#ef4444] disabled:cursor-default disabled:opacity-35"><Trash2 size={15} /></button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
|
import { Check, Copy, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
type DockerResult = Awaited<ReturnType<Window['localWorkspace']['docker']['status']>>
|
||||||
|
|
||||||
|
const DOCKER_NOTICE_SEEN_KEY = 'lms:docker-setup-notice-seen'
|
||||||
|
|
||||||
|
const messages = {
|
||||||
|
missing: 'Docker 명령을 찾을 수 없습니다.',
|
||||||
|
permission: '현재 사용자의 Docker 접근 권한을 확인해주세요.',
|
||||||
|
unavailable: 'Docker 데몬에 연결할 수 없습니다.',
|
||||||
|
error: 'Docker 상태를 확인하지 못했습니다.',
|
||||||
|
ready: 'Docker를 사용할 수 있습니다.',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DockerSetupNotice(): React.JSX.Element | null {
|
||||||
|
useLocaleRender()
|
||||||
|
const [result, setResult] = useState<DockerResult | null>(null)
|
||||||
|
const [dismissed, setDismissed] = useState(() => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(DOCKER_NOTICE_SEEN_KEY) === 'true'
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [actionError, setActionError] = useState('')
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||||
|
const retryPending = useRef(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dismissed) return
|
||||||
|
const docker = window.localWorkspace?.docker
|
||||||
|
if (!docker) return
|
||||||
|
let active = true
|
||||||
|
void docker.status().then((value) => {
|
||||||
|
if (active) setResult(value)
|
||||||
|
}).catch(() => {
|
||||||
|
if (active) setResult({ status: 'error', platform: '' })
|
||||||
|
})
|
||||||
|
return () => { active = false }
|
||||||
|
}, [dismissed])
|
||||||
|
|
||||||
|
const visible = Boolean(result && result.status !== 'ready' && !dismissed)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible || !dialogRef.current) return
|
||||||
|
dialogRef.current.showModal()
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DOCKER_NOTICE_SEEN_KEY, 'true')
|
||||||
|
} catch {
|
||||||
|
// Keep the notice dismissible when browser storage is unavailable.
|
||||||
|
}
|
||||||
|
}, [visible])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!copied) return
|
||||||
|
const timer = window.setTimeout(() => setCopied(false), 1500)
|
||||||
|
return () => window.clearTimeout(timer)
|
||||||
|
}, [copied])
|
||||||
|
|
||||||
|
if (!visible || !result) return null
|
||||||
|
const linux = result.platform === 'linux'
|
||||||
|
const command = linux && result.status === 'permission'
|
||||||
|
? 'sudo groupadd -f docker\nsudo usermod -aG docker "$(id -un)"'
|
||||||
|
: linux && result.status === 'unavailable'
|
||||||
|
? 'sudo systemctl start docker\ndocker info'
|
||||||
|
: 'docker --version\ndocker context show\ndocker info'
|
||||||
|
|
||||||
|
async function retry(): Promise<void> {
|
||||||
|
if (retryPending.current) return
|
||||||
|
retryPending.current = true
|
||||||
|
setBusy(true)
|
||||||
|
setActionError('')
|
||||||
|
setCopied(false)
|
||||||
|
try {
|
||||||
|
setResult(await window.localWorkspace.docker.status())
|
||||||
|
} catch {
|
||||||
|
setResult({ status: 'error', platform: result?.platform ?? '' })
|
||||||
|
} finally {
|
||||||
|
retryPending.current = false
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<dialog ref={dialogRef} aria-labelledby="docker-setup-title" onCancel={() => setDismissed(true)}
|
||||||
|
className="fixed inset-0 m-auto max-h-[calc(100dvh-32px)] w-[520px] max-w-[calc(100vw-32px)] overflow-y-auto rounded-lg border border-white/10 bg-[#252525] p-0 text-white shadow-2xl backdrop:bg-black/60">
|
||||||
|
<header className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
||||||
|
<h2 id="docker-setup-title" className="text-[14px] font-bold">{t("로컬 Docker 설정")}</h2>
|
||||||
|
<button type="button" autoFocus title={t("나중에")} aria-label={t("나중에")} onClick={() => setDismissed(true)} className="flex h-7 w-7 cursor-pointer items-center justify-center rounded hover:bg-white/10"><X size={16} /></button>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-4 px-5 py-4 text-[13px] leading-relaxed">
|
||||||
|
<p className="font-semibold">{t(messages[result.status])}</p>
|
||||||
|
<p className="text-white/65">{t("Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.")}</p>
|
||||||
|
{result.status === 'missing' ? (
|
||||||
|
<p>{t("공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.")}</p>
|
||||||
|
) : <>
|
||||||
|
{linux && result.status === 'permission' && <p>{t("일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.")}</p>}
|
||||||
|
{linux && result.status === 'unavailable' && <p>{t("일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.")}</p>}
|
||||||
|
<div className="relative rounded-md bg-black/25 p-3 pr-12">
|
||||||
|
<pre className="whitespace-pre-wrap break-all text-[12px]">{command}</pre>
|
||||||
|
<button type="button" title={copied ? t("복사됨") : t("명령어 복사")} aria-label={copied ? t("복사됨") : t("명령어 복사")}
|
||||||
|
className="absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded hover:bg-white/10"
|
||||||
|
onClick={() => { void navigator.clipboard.writeText(command).then(() => { setCopied(true); setActionError('') }).catch(() => setActionError(t("복사하지 못했습니다. 명령어를 직접 선택해 복사해주세요."))) }}>
|
||||||
|
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>}
|
||||||
|
<button type="button" className="inline-flex cursor-pointer items-center gap-2 text-[#9dbdff] hover:underline"
|
||||||
|
onClick={() => { void window.localWorkspace.docker.openGuide().catch(() => setActionError(t("설치 안내: https://docs.docker.com/engine/install/"))) }}>
|
||||||
|
<ExternalLink size={14} />{t("Docker 공식 설치 안내")}</button>
|
||||||
|
{actionError && <p role="status" className="break-words text-[#f2b86b]">{t(actionError || '')}</p>}
|
||||||
|
</div>
|
||||||
|
<footer className="flex justify-end gap-2 border-t border-white/10 px-5 py-3">
|
||||||
|
<button type="button" onClick={() => setDismissed(true)} className="h-8 cursor-pointer rounded-md border border-white/20 px-3 text-[12px] hover:bg-white/10">{t("나중에")}</button>
|
||||||
|
<button type="button" disabled={busy} onClick={() => void retry()} className="flex h-8 cursor-pointer items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[12px] disabled:cursor-wait disabled:opacity-50 hover:bg-[#1d4ed8]">
|
||||||
|
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />{busy ? t("확인 중") : t("다시 확인")}
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</dialog>, document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
type PlatformLoadingOverlayProps = {
|
type PlatformLoadingOverlayProps = {
|
||||||
@@ -11,14 +12,15 @@ export default function PlatformLoadingOverlay({
|
|||||||
description,
|
description,
|
||||||
className = 'absolute inset-0 z-[60]'
|
className = 'absolute inset-0 z-[60]'
|
||||||
}: PlatformLoadingOverlayProps): React.JSX.Element {
|
}: PlatformLoadingOverlayProps): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
return (
|
return (
|
||||||
<div className={`${className} grid place-items-center bg-[#080a10]/55 p-6 backdrop-blur-[1px]`}>
|
<div className={`${className} grid place-items-center bg-[#080a10]/55 p-6 backdrop-blur-[1px]`}>
|
||||||
<div className="w-[390px] rounded-xl border border-white/10 bg-[#171a22] px-9 py-8 text-center shadow-[0_20px_50px_rgba(8,10,16,0.35)]">
|
<div className="w-[390px] rounded-xl border border-white/10 bg-[#171a22] px-9 py-8 text-center shadow-[0_20px_50px_rgba(8,10,16,0.35)]">
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-4 border-white/25 border-t-white" />
|
<div className="mx-auto h-10 w-10 animate-spin rounded-full border-4 border-white/25 border-t-white" />
|
||||||
</div>
|
</div>
|
||||||
<strong className="mt-5 block text-[16px] font-bold text-white">{title}</strong>
|
<strong className="mt-5 block text-[16px] font-bold text-white">{t(title)}</strong>
|
||||||
<p className="mt-3 text-[13px] font-medium leading-relaxed text-white/60">{description}</p>
|
<p className="mt-3 text-[13px] font-medium leading-relaxed text-white/60">{t(description)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type React from 'react'
|
||||||
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
import { ExpandableMarkdownMessage, MarkdownMessage } from './ChatWidgets'
|
||||||
|
import { AGENT_REVEAL_INTERVAL, agentRevealFrame, createAgentRevealDocument } from './agentResponseReveal'
|
||||||
|
|
||||||
|
type ResponseMessage = {
|
||||||
|
content: string
|
||||||
|
revealStartedAt?: number
|
||||||
|
plan?: { summary: string; explanation: string }
|
||||||
|
provider?: string
|
||||||
|
model?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProgressiveChatResponse({ message, dark, children, footerAction, collapseOnSignal = false, collapseSignal = 0, defaultCollapsed = message.revealStartedAt === undefined }: {
|
||||||
|
message: ResponseMessage
|
||||||
|
dark: boolean
|
||||||
|
children?: React.ReactNode
|
||||||
|
footerAction?: React.ReactNode
|
||||||
|
collapseOnSignal?: boolean
|
||||||
|
collapseSignal?: number
|
||||||
|
defaultCollapsed?: boolean
|
||||||
|
}): React.JSX.Element {
|
||||||
|
const content = message.plan?.explanation ?? message.content
|
||||||
|
const modelLabel = message.provider === 'A_COPILOT' ? 'A-Copilot' : [message.provider, message.model].filter(Boolean).join(' · ')
|
||||||
|
const document = useMemo(() => createAgentRevealDocument(content), [content])
|
||||||
|
const [now, setNow] = useState(Date.now)
|
||||||
|
const [reducedMotion, setReducedMotion] = useState(() => typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
||||||
|
const frame = agentRevealFrame(document, message.revealStartedAt, now, reducedMotion)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const media = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
|
const update = (): void => setReducedMotion(media.matches)
|
||||||
|
media.addEventListener('change', update)
|
||||||
|
return () => media.removeEventListener('change', update)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (message.revealStartedAt === undefined || reducedMotion) return
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
const startedAt = message.revealStartedAt
|
||||||
|
const tick = (): void => {
|
||||||
|
const current = Date.now()
|
||||||
|
setNow(current)
|
||||||
|
const remaining = frame.duration - (current - startedAt)
|
||||||
|
if (remaining > 0) timer = setTimeout(tick, Math.min(AGENT_REVEAL_INTERVAL, remaining))
|
||||||
|
}
|
||||||
|
tick()
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [frame.duration, message.revealStartedAt, reducedMotion])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid min-w-0 max-w-full gap-3" aria-busy={!frame.complete}>
|
||||||
|
<div className="min-w-0">
|
||||||
|
{message.plan && (
|
||||||
|
<div className={['font-black', frame.fadeFrom === 0 ? 'agent-response-fade' : ''].join(' ')}>
|
||||||
|
<MarkdownMessage content={message.plan.summary} dark={dark} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={message.plan ? 'mt-1 min-w-0' : 'min-w-0'}>
|
||||||
|
{frame.complete && frame.fadeFrom === undefined ? (
|
||||||
|
<ExpandableMarkdownMessage
|
||||||
|
content={content}
|
||||||
|
dark={dark}
|
||||||
|
defaultCollapsed={defaultCollapsed}
|
||||||
|
collapseOnSignal={collapseOnSignal}
|
||||||
|
collapseSignal={collapseSignal}
|
||||||
|
/>
|
||||||
|
) : <MarkdownMessage content={frame.content} dark={dark} fadeFrom={frame.fadeFrom} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{frame.complete ? children : null}
|
||||||
|
{frame.complete && (modelLabel || footerAction) ? (
|
||||||
|
<div className="order-10 flex min-h-6 min-w-0 items-center justify-between gap-2">
|
||||||
|
<p title={modelLabel || undefined} className={['min-w-0 flex-1 truncate text-[10px]', dark ? 'text-white/35' : 'text-[#94a3b8]'].join(' ')}>{modelLabel}</p>
|
||||||
|
{footerAction}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react'
|
import { Activity, BookOpen, Bot, LayoutDashboard, Menu, Power } from 'lucide-react'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export default function Sidebar({
|
|||||||
onDisconnectSession?: (sessionId?: string) => void
|
onDisconnectSession?: (sessionId?: string) => void
|
||||||
onReleaseControl?: () => void
|
onReleaseControl?: () => void
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false)
|
const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false)
|
||||||
const sessionPopoverRef = useRef<HTMLDivElement>(null)
|
const sessionPopoverRef = useRef<HTMLDivElement>(null)
|
||||||
const visibleRunningSessions = sessionStatus === 'running' && runningSessions.length ? runningSessions : []
|
const visibleRunningSessions = sessionStatus === 'running' && runningSessions.length ? runningSessions : []
|
||||||
@@ -110,7 +112,7 @@ export default function Sidebar({
|
|||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
{collapsed && (
|
{collapsed && (
|
||||||
<span className="pointer-events-none absolute left-[46px] top-1/2 z-50 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-white/10 bg-[#303030] px-2 py-1 text-[11px] font-semibold text-white/78 shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block">
|
<span className="pointer-events-none absolute left-[46px] top-1/2 z-50 hidden -translate-y-1/2 whitespace-nowrap rounded-md border border-white/10 bg-[#303030] px-2 py-1 text-[11px] font-semibold text-white/78 shadow-[0_10px_24px_rgba(0,0,0,0.24)] group-hover:block">
|
||||||
{disabled ? '강좌 선택 후 이동 가능' : item.label}
|
{disabled ? t("강좌 선택 후 이동 가능") : t(item.label)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
@@ -119,7 +121,7 @@ export default function Sidebar({
|
|||||||
collapsed ? 'pointer-events-none w-0 opacity-0' : 'w-[132px] opacity-100 delay-100'
|
collapsed ? 'pointer-events-none w-0 opacity-0' : 'w-[132px] opacity-100 delay-100'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{item.label}
|
{t(item.label)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
@@ -148,13 +150,13 @@ export default function Sidebar({
|
|||||||
{(session.startedAt || session.endsAt) && (
|
{(session.startedAt || session.endsAt) && (
|
||||||
<span className="mt-1 block truncate text-[11px] text-white/38">
|
<span className="mt-1 block truncate text-[11px] text-white/38">
|
||||||
{[
|
{[
|
||||||
session.startedAt ? `시작 ${session.startedAt}` : null,
|
session.startedAt ? t("시작 {{v0}}", { v0: session.startedAt }) : null,
|
||||||
session.endsAt ? `종료 ${session.endsAt}` : null
|
session.endsAt ? t("종료 {{v0}}", { v0: session.endsAt }) : null
|
||||||
].filter(Boolean).join(' · ')}
|
].filter(Boolean).join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{session.runningModuleCount !== undefined && (
|
{session.runningModuleCount !== undefined && (
|
||||||
<span className="mt-0.5 block truncate text-[11px] text-white/38">실행 중인 모듈 {session.runningModuleCount}개</span>
|
<span className="mt-0.5 block truncate text-[11px] text-white/38">{t('실행 중인 모듈 {{count}}개', { count: session.runningModuleCount })}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -166,8 +168,8 @@ export default function Sidebar({
|
|||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
onDisconnectSession?.(session.id)
|
onDisconnectSession?.(session.id)
|
||||||
}}
|
}}
|
||||||
aria-label="세션 연결종료"
|
aria-label={t("세션 연결종료")}
|
||||||
title="연결종료"
|
title={t("연결종료")}
|
||||||
>
|
>
|
||||||
<Power size={15} />
|
<Power size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -187,7 +189,7 @@ export default function Sidebar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{collapsed && sessionPopoverOpen && (
|
{collapsed && sessionPopoverOpen && (
|
||||||
<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="absolute bottom-0 left-[62px] z-50 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) => (
|
{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">
|
||||||
@@ -207,13 +209,13 @@ export default function Sidebar({
|
|||||||
{(session.startedAt || session.endsAt) && (
|
{(session.startedAt || session.endsAt) && (
|
||||||
<span className="mt-1 block truncate text-[11px] text-white/38">
|
<span className="mt-1 block truncate text-[11px] text-white/38">
|
||||||
{[
|
{[
|
||||||
session.startedAt ? `시작 ${session.startedAt}` : null,
|
session.startedAt ? t("시작 {{v0}}", { v0: session.startedAt }) : null,
|
||||||
session.endsAt ? `종료 ${session.endsAt}` : null
|
session.endsAt ? t("종료 {{v0}}", { v0: session.endsAt }) : null
|
||||||
].filter(Boolean).join(' · ')}
|
].filter(Boolean).join(' · ')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{session.runningModuleCount !== undefined && (
|
{session.runningModuleCount !== undefined && (
|
||||||
<span className="mt-0.5 block truncate text-[11px] text-white/38">실행 중인 모듈 {session.runningModuleCount}개</span>
|
<span className="mt-0.5 block truncate text-[11px] text-white/38">{t('실행 중인 모듈 {{count}}개', { count: session.runningModuleCount })}</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -226,8 +228,8 @@ export default function Sidebar({
|
|||||||
setSessionPopoverOpen(false)
|
setSessionPopoverOpen(false)
|
||||||
onDisconnectSession?.(session.id)
|
onDisconnectSession?.(session.id)
|
||||||
}}
|
}}
|
||||||
aria-label="세션 연결종료"
|
aria-label={t("세션 연결종료")}
|
||||||
title="연결종료"
|
title={t("연결종료")}
|
||||||
>
|
>
|
||||||
<Power size={15} />
|
<Power size={15} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { t, useLocaleRender } from '../i18n'
|
||||||
import icon from '/assets/icon.png'
|
import icon from '/assets/icon.png'
|
||||||
import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, Clipboard, HelpCircle, LogOut, Minus, RotateCw, ScrollText, Search, Square, Wrench, X } from 'lucide-react'
|
import { Bell, BookOpen, Bug, Building2, CheckCheck, ChevronLeft, ChevronRight, Clipboard, HelpCircle, LogOut, Minus, RotateCw, ScrollText, Search, Square, Wrench, X } from 'lucide-react'
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
@@ -127,6 +128,7 @@ const normalizeNotification = (notification: NotificationMessage): NotificationM
|
|||||||
})
|
})
|
||||||
|
|
||||||
export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCourseSelection, transientNotice = null }: TitleBarProps): React.JSX.Element {
|
export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCourseSelection, transientNotice = null }: TitleBarProps): React.JSX.Element {
|
||||||
|
useLocaleRender()
|
||||||
const [notificationOpen, setNotificationOpen] = useState(false)
|
const [notificationOpen, setNotificationOpen] = useState(false)
|
||||||
const [accountOpen, setAccountOpen] = useState(false)
|
const [accountOpen, setAccountOpen] = useState(false)
|
||||||
const [toolsOpen, setToolsOpen] = useState(false)
|
const [toolsOpen, setToolsOpen] = useState(false)
|
||||||
@@ -524,7 +526,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header
|
<header
|
||||||
className="relative z-[10020] flex h-[38px] shrink-0 items-center justify-between rounded-t-[18px] bg-[#111520] px-3 pl-5"
|
className="relative z-[10020] flex h-[38px] shrink-0 items-center justify-between bg-[#111520] px-3 pl-5"
|
||||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||||
onContextMenuCapture={suppressTitlebarContextMenu}
|
onContextMenuCapture={suppressTitlebarContextMenu}
|
||||||
onContextMenu={suppressTitlebarContextMenu}
|
onContextMenu={suppressTitlebarContextMenu}
|
||||||
@@ -555,19 +557,19 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
<span className="block whitespace-nowrap px-3 text-[11px] font-bold">
|
<span className="block whitespace-nowrap px-3 text-[11px] font-bold">
|
||||||
{activeTransientNotice?.title}
|
{t(activeTransientNotice?.title ?? '')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip label="도움말">
|
<Tooltip label={t("도움말")}>
|
||||||
<button type="button" aria-label="Help" onClick={onHelpClick} className={winButton}>
|
<button type="button" aria-label="Help" onClick={onHelpClick} className={winButton}>
|
||||||
<HelpCircle size={16} />
|
<HelpCircle size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<div className="relative" ref={toolsRef}>
|
<div className="relative" ref={toolsRef}>
|
||||||
<Tooltip label="도구 보기" disabled={toolsOpen}>
|
<Tooltip label={t("도구 보기")} disabled={toolsOpen}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="도구 보기"
|
aria-label={t("도구 보기")}
|
||||||
aria-expanded={toolsOpen}
|
aria-expanded={toolsOpen}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setToolsOpen((current) => !current)
|
setToolsOpen((current) => !current)
|
||||||
@@ -586,9 +588,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
|
||||||
onClick={() => window.location.reload()}
|
onClick={() => window.location.reload()}
|
||||||
>
|
>
|
||||||
<RotateCw size={15} />
|
<RotateCw size={15} />{t("새로고침")}</button>
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
|
||||||
@@ -598,9 +598,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
void loadAppLogs()
|
void loadAppLogs()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ScrollText size={15} />
|
<ScrollText size={15} />{t("앱 로그")}</button>
|
||||||
앱 로그
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
|
className="flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left text-[12px] font-medium text-white/75 transition hover:bg-white/[0.08] hover:text-white"
|
||||||
@@ -611,14 +609,12 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
}).catch((error) => console.error('[titlebar] open DevTools request failed:', error))
|
}).catch((error) => console.error('[titlebar] open DevTools request failed:', error))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Bug size={15} />
|
<Bug size={15} />{t("개발자 도구")}</button>
|
||||||
개발자 도구
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="relative" ref={notificationRef}>
|
<div className="relative" ref={notificationRef}>
|
||||||
<Tooltip label="알림" disabled={notificationOpen}>
|
<Tooltip label={t("알림")} disabled={notificationOpen}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Notifications"
|
aria-label="Notifications"
|
||||||
@@ -635,10 +631,10 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
<div className="absolute right-0 top-[34px] z-50 w-[360px] overflow-hidden rounded-xl border border-white/10 bg-[rgba(18,23,35,0.94)] text-white shadow-[0_22px_70px_rgba(0,0,0,0.48)] backdrop-blur-xl">
|
<div className="absolute right-0 top-[34px] z-50 w-[360px] overflow-hidden rounded-xl border border-white/10 bg-[rgba(18,23,35,0.94)] text-white shadow-[0_22px_70px_rgba(0,0,0,0.48)] backdrop-blur-xl">
|
||||||
<div className="border-b border-white/10 px-4 py-3">
|
<div className="border-b border-white/10 px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-bold text-white">알림</p>
|
<p className="text-[13px] font-bold text-white">{t("알림")}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 flex items-center justify-between gap-3">
|
<div className="mt-3 flex items-center justify-between gap-3">
|
||||||
<span className="text-[11px] font-semibold text-white/45">안읽음 {unreadCount}건</span>
|
<span className="text-[11px] font-semibold text-white/45">{t('안읽음 {{count}}건', { count: unreadCount })}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -646,16 +642,12 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
disabled={unreadCount === 0}
|
disabled={unreadCount === 0}
|
||||||
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-2.5 text-[11px] font-semibold text-white/65 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
|
className="inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-2.5 text-[11px] font-semibold text-white/65 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
|
||||||
>
|
>
|
||||||
<CheckCheck size={13} />
|
<CheckCheck size={13} />{t("모두 읽음")}</button>
|
||||||
모두 읽음
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleOpenNotificationModal}
|
onClick={handleOpenNotificationModal}
|
||||||
className="h-7 cursor-pointer rounded-md border border-[#3b7ffa]/30 bg-[#3b7ffa]/12 px-2.5 text-[11px] font-semibold text-[#9ec2ff] transition-colors hover:bg-[#3b7ffa]/20 hover:text-white"
|
className="h-7 cursor-pointer rounded-md border border-[#3b7ffa]/30 bg-[#3b7ffa]/12 px-2.5 text-[11px] font-semibold text-[#9ec2ff] transition-colors hover:bg-[#3b7ffa]/20 hover:text-white"
|
||||||
>
|
>{t("전체 보기")}</button>
|
||||||
전체 보기
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -680,7 +672,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
notification.read ? 'bg-white/[0.025] text-white/24' : 'bg-[#2563eb]/15 text-[#7db3ff]'
|
notification.read ? 'bg-white/[0.025] text-white/24' : 'bg-[#2563eb]/15 text-[#7db3ff]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{notification.categoryLabel ?? notification.category}
|
{t(notification.categoryLabel ?? notification.category)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`shrink-0 text-[11px] font-semibold ${notification.read ? 'text-white/22' : 'text-white/45'}`}>
|
<span className={`shrink-0 text-[11px] font-semibold ${notification.read ? 'text-white/22' : 'text-white/45'}`}>
|
||||||
@@ -688,16 +680,16 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className={`text-[13px] leading-5 ${notification.read ? 'font-medium text-white/30' : 'font-bold text-white/90'}`}>
|
<p className={`text-[13px] leading-5 ${notification.read ? 'font-medium text-white/30' : 'font-bold text-white/90'}`}>
|
||||||
{notification.title}
|
{notification.id.startsWith('local-') ? t(notification.title) : notification.title}
|
||||||
</p>
|
</p>
|
||||||
<p className={`mt-1 text-[12px] font-medium leading-5 ${notification.read ? 'text-white/24' : 'text-white/58'}`}>
|
<p className={`mt-1 text-[12px] font-medium leading-5 ${notification.read ? 'text-white/24' : 'text-white/58'}`}>
|
||||||
{notification.body}
|
{notification.id.startsWith('local-') ? t(notification.body) : notification.body}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="px-4 py-8 text-center">
|
<div className="px-4 py-8 text-center">
|
||||||
<p className="text-[13px] font-semibold text-white/50">표시할 알림이 없습니다.</p>
|
<p className="text-[13px] font-semibold text-white/50">{t("표시할 알림이 없습니다.")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -763,18 +755,14 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
onClick={handleOpenCourseSelection}
|
onClick={handleOpenCourseSelection}
|
||||||
className="flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-[12px] text-white/62 transition-colors hover:bg-white/[0.06] hover:text-white"
|
className="flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-[12px] text-white/62 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||||
>
|
>
|
||||||
<BookOpen size={14} />
|
<BookOpen size={14} />{t("강좌 선택")}</button>
|
||||||
강좌 선택
|
|
||||||
</button>
|
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className="flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-[12px] text-white/62 transition-colors hover:bg-white/[0.06] hover:text-white"
|
className="flex w-full cursor-pointer items-center gap-2 px-4 py-2.5 text-left text-[12px] text-white/62 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||||
>
|
>
|
||||||
<LogOut size={14} />
|
<LogOut size={14} />{t("종료")}</button>
|
||||||
종료
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -849,14 +837,14 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
<section className="flex h-[540px] w-full max-w-[680px] flex-col overflow-hidden rounded-xl border border-[rgba(255,255,255,0.15)] bg-[rgba(14,18,28,0.88)] text-white shadow-[0_24px_80px_rgba(0,0,0,0.56),inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-2xl">
|
<section className="flex h-[540px] w-full max-w-[680px] flex-col overflow-hidden rounded-xl border border-[rgba(255,255,255,0.15)] bg-[rgba(14,18,28,0.88)] text-white shadow-[0_24px_80px_rgba(0,0,0,0.56),inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur-2xl">
|
||||||
<header className="flex h-[70px] shrink-0 items-center justify-between px-5">
|
<header className="flex h-[70px] shrink-0 items-center justify-between px-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-[16px] font-bold text-white/90">전체 알림</h2>
|
<h2 className="text-[16px] font-bold text-white/90">{t("전체 알림")}</h2>
|
||||||
<p className="mt-1 text-[11px] font-medium text-white/42">알림은 최대 하루동안만 저장됩니다.</p>
|
<p className="mt-1 text-[11px] font-medium text-white/42">{t("알림은 최대 하루동안만 저장됩니다.")}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNotificationModalOpen(false)}
|
onClick={() => setNotificationModalOpen(false)}
|
||||||
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white"
|
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||||
aria-label="닫기"
|
aria-label={t("닫기")}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -869,7 +857,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
<input
|
<input
|
||||||
value={notificationQuery}
|
value={notificationQuery}
|
||||||
onChange={(event) => setNotificationQuery(event.target.value)}
|
onChange={(event) => setNotificationQuery(event.target.value)}
|
||||||
placeholder="알림 검색"
|
placeholder={t("알림 검색")}
|
||||||
className="h-9 w-full rounded-md border border-white/10 bg-white/[0.045] pl-9 pr-3 text-[13px] font-medium text-white outline-none placeholder:text-white/28 transition-colors focus:border-[#3b7ffa]/55 focus:bg-white/[0.065]"
|
className="h-9 w-full rounded-md border border-white/10 bg-white/[0.045] pl-9 pr-3 text-[13px] font-medium text-white outline-none placeholder:text-white/28 transition-colors focus:border-[#3b7ffa]/55 focus:bg-white/[0.065]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -879,9 +867,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
disabled={unreadCount === 0}
|
disabled={unreadCount === 0}
|
||||||
className="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-3 text-[12px] font-semibold text-white/68 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/68"
|
className="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-white/10 px-3 text-[12px] font-semibold text-white/68 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/68"
|
||||||
>
|
>
|
||||||
<CheckCheck size={14} />
|
<CheckCheck size={14} />{t("모두 읽음")}</button>
|
||||||
모두 읽음
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center justify-between">
|
<div className="mt-3 flex items-center justify-between">
|
||||||
@@ -896,11 +882,11 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
notificationTab === tab ? 'bg-[#3b7ffa]/22 text-[#b9d4ff]' : 'text-white/45 hover:bg-white/[0.05] hover:text-white/70'
|
notificationTab === tab ? 'bg-[#3b7ffa]/22 text-[#b9d4ff]' : 'text-white/45 hover:bg-white/[0.05] hover:text-white/70'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{tab === 'unread' ? `안 읽음 ${sortedNotifications.filter((notification) => !notification.read).length}` : `읽음 ${sortedNotifications.filter((notification) => notification.read).length}`}
|
{tab === 'unread' ? t("안 읽음 {{v0}}", { v0: sortedNotifications.filter((notification) => !notification.read).length }) : t("읽음 {{v0}}", { v0: sortedNotifications.filter((notification) => notification.read).length })}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[11px] font-semibold text-white/32">{filteredModalNotifications.length}건</span>
|
<span className="text-[11px] font-semibold text-white/32">{filteredModalNotifications.length}{t("건")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -926,7 +912,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
notification.read ? 'bg-white/[0.025] text-white/24' : 'bg-[#2563eb]/15 text-[#7db3ff]'
|
notification.read ? 'bg-white/[0.025] text-white/24' : 'bg-[#2563eb]/15 text-[#7db3ff]'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{notification.categoryLabel ?? notification.category}
|
{t(notification.categoryLabel ?? notification.category)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={`shrink-0 text-[11px] font-semibold ${notification.read ? 'text-white/22' : 'text-white/45'}`}>
|
<span className={`shrink-0 text-[11px] font-semibold ${notification.read ? 'text-white/22' : 'text-white/45'}`}>
|
||||||
@@ -934,17 +920,17 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h3 className={`text-[13px] leading-5 ${notification.read ? 'font-medium text-white/30' : 'font-bold text-white/90'}`}>
|
<h3 className={`text-[13px] leading-5 ${notification.read ? 'font-medium text-white/30' : 'font-bold text-white/90'}`}>
|
||||||
{notification.title}
|
{notification.id.startsWith('local-') ? t(notification.title) : notification.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`mt-1 text-[12px] font-medium leading-5 ${notification.read ? 'text-white/24' : 'text-white/58'}`}>
|
<p className={`mt-1 text-[12px] font-medium leading-5 ${notification.read ? 'text-white/24' : 'text-white/58'}`}>
|
||||||
{notification.body}
|
{notification.id.startsWith('local-') ? t(notification.body) : notification.body}
|
||||||
</p>
|
</p>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center rounded-lg border border-white/10 bg-white/[0.025]">
|
<div className="flex h-full items-center justify-center rounded-lg border border-white/10 bg-white/[0.025]">
|
||||||
<p className="text-[13px] font-semibold text-white/48">표시할 알림이 없습니다.</p>
|
<p className="text-[13px] font-semibold text-white/48">{t("표시할 알림이 없습니다.")}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -959,7 +945,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
onClick={() => setNotificationPage((page) => Math.max(1, page - 1))}
|
onClick={() => setNotificationPage((page) => Math.max(1, page - 1))}
|
||||||
disabled={notificationPage <= 1}
|
disabled={notificationPage <= 1}
|
||||||
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-white/10 text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/55"
|
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-white/10 text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/55"
|
||||||
aria-label="이전 페이지"
|
aria-label={t("이전 페이지")}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -968,7 +954,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
onClick={() => setNotificationPage((page) => Math.min(modalTotalPages, page + 1))}
|
onClick={() => setNotificationPage((page) => Math.min(modalTotalPages, page + 1))}
|
||||||
disabled={notificationPage >= modalTotalPages}
|
disabled={notificationPage >= modalTotalPages}
|
||||||
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-white/10 text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/55"
|
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md border border-white/10 text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/55"
|
||||||
aria-label="다음 페이지"
|
aria-label={t("다음 페이지")}
|
||||||
>
|
>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -986,29 +972,27 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
<section className="flex h-[min(680px,calc(100vh-100px))] w-[min(920px,calc(100vw-80px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#171a21] shadow-[0_28px_90px_rgba(0,0,0,0.58)]">
|
<section className="flex h-[min(680px,calc(100vh-100px))] w-[min(920px,calc(100vw-80px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#171a21] shadow-[0_28px_90px_rgba(0,0,0,0.58)]">
|
||||||
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-white/10 px-5">
|
<header className="flex h-[52px] shrink-0 items-center justify-between border-b border-white/10 px-5">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="text-[14px] font-bold text-white/92">앱 로그</h2>
|
<h2 className="text-[14px] font-bold text-white/92">{t("앱 로그")}</h2>
|
||||||
<p className="mt-0.5 truncate text-[11px] font-medium text-white/42">{appLogPath || '로그 파일을 불러오는 중입니다.'}</p>
|
<p className="mt-0.5 truncate text-[11px] font-medium text-white/42">{appLogPath || t("로그 파일을 불러오는 중입니다.")}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAppLogsOpen(false)}
|
onClick={() => setAppLogsOpen(false)}
|
||||||
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white"
|
className="grid h-8 w-8 cursor-pointer place-items-center rounded-md text-white/55 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||||
aria-label="앱 로그 닫기"
|
aria-label={t("앱 로그 닫기")}
|
||||||
>
|
>
|
||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="min-h-0 flex-1 bg-[#101217] p-4">
|
<div className="min-h-0 flex-1 bg-[#101217] p-4">
|
||||||
{appLogsLoading ? (
|
{appLogsLoading ? (
|
||||||
<div className="flex h-full items-center justify-center text-[13px] font-medium text-white/48">로그를 불러오는 중입니다.</div>
|
<div className="flex h-full items-center justify-center text-[13px] font-medium text-white/48">{t("로그를 불러오는 중입니다.")}</div>
|
||||||
) : appLogContent ? (
|
) : appLogContent ? (
|
||||||
<pre className="h-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-white/[0.07] bg-black/20 p-3 font-mono text-[11px] leading-5 text-white/70">
|
<pre className="h-full overflow-auto whitespace-pre-wrap break-words rounded-md border border-white/[0.07] bg-black/20 p-3 font-mono text-[11px] leading-5 text-white/70">
|
||||||
{appLogDisplayContent}
|
{appLogDisplayContent}
|
||||||
</pre>
|
</pre>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full items-center justify-center rounded-md border border-dashed border-white/10 text-[13px] font-medium text-white/42">
|
<div className="flex h-full items-center justify-center rounded-md border border-dashed border-white/10 text-[13px] font-medium text-white/42">{t("아직 기록된 앱 로그가 없습니다.")}</div>
|
||||||
아직 기록된 앱 로그가 없습니다.
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<footer className="flex h-[58px] shrink-0 items-center justify-end gap-2 border-t border-white/10 px-5">
|
<footer className="flex h-[58px] shrink-0 items-center justify-end gap-2 border-t border-white/10 px-5">
|
||||||
@@ -1019,7 +1003,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
|
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
|
||||||
>
|
>
|
||||||
<Clipboard size={14} />
|
<Clipboard size={14} />
|
||||||
{appLogsCopied ? '복사됨' : '복사'}
|
{appLogsCopied ? t("복사됨") : t("복사")}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -1027,9 +1011,7 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
disabled={appLogsLoading}
|
disabled={appLogsLoading}
|
||||||
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
|
className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-semibold text-white/65 transition hover:bg-white/[0.06] hover:text-white disabled:cursor-default disabled:opacity-35 disabled:hover:bg-transparent disabled:hover:text-white/65"
|
||||||
>
|
>
|
||||||
<RotateCw size={14} className={appLogsLoading ? 'animate-spin' : ''} />
|
<RotateCw size={14} className={appLogsLoading ? 'animate-spin' : ''} />{t("새로고침")}</button>
|
||||||
새로고침
|
|
||||||
</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -1052,23 +1034,19 @@ export default function TitleBar({ user, accessToken = '', onHelpClick, onOpenCo
|
|||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div className="px-5 py-5">
|
<div className="px-5 py-5">
|
||||||
<p className="text-[13px] leading-relaxed text-white/62">앱을 종료하시겠습니까?</p>
|
<p className="text-[13px] leading-relaxed text-white/62">{t("앱을 종료하시겠습니까?")}</p>
|
||||||
</div>
|
</div>
|
||||||
<footer className="flex h-[58px] items-center justify-end gap-2 px-5">
|
<footer className="flex h-[58px] items-center justify-end gap-2 px-5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowCloseConfirm(false)}
|
onClick={() => setShowCloseConfirm(false)}
|
||||||
className="cursor-pointer rounded-md border border-[rgba(255,255,255,0.12)] px-5 py-2 text-[12px] font-semibold text-white/62 transition-colors hover:bg-white/[0.06] hover:text-white"
|
className="cursor-pointer rounded-md border border-[rgba(255,255,255,0.12)] px-5 py-2 text-[12px] font-semibold text-white/62 transition-colors hover:bg-white/[0.06] hover:text-white"
|
||||||
>
|
>{t("취소")}</button>
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleConfirmClose}
|
onClick={handleConfirmClose}
|
||||||
className="cursor-pointer rounded-md border border-red-400/35 bg-red-500/15 px-5 py-2 text-[12px] font-bold text-red-100 transition-colors hover:border-red-400/50 hover:bg-red-500/24"
|
className="cursor-pointer rounded-md border border-red-400/35 bg-red-500/15 px-5 py-2 text-[12px] font-bold text-red-100 transition-colors hover:border-red-400/50 hover:bg-red-500/24"
|
||||||
>
|
>{t("앱 종료")}</button>
|
||||||
종료
|
|
||||||
</button>
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { unified } from 'unified'
|
||||||
|
import remarkParse from 'remark-parse'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
|
||||||
|
const parser = unified().use(remarkParse).use(remarkGfm)
|
||||||
|
export const AGENT_REVEAL_INTERVAL = 240
|
||||||
|
export const AGENT_REVEAL_FADE_DURATION = 220
|
||||||
|
|
||||||
|
export function createAgentRevealDocument(content: string) {
|
||||||
|
const tree = parser.parse(content)
|
||||||
|
const offsets: number[] = []
|
||||||
|
const definitions: string[] = []
|
||||||
|
for (const node of tree.children) {
|
||||||
|
if (node.type === 'definition' || node.type === 'footnoteDefinition') {
|
||||||
|
definitions.push(content.slice(node.position?.start.offset, node.position?.end.offset))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const blocks = node.type === 'list' ? node.children : [node]
|
||||||
|
for (const block of blocks) {
|
||||||
|
if (block.position?.end.offset !== undefined) offsets.push(block.position.end.offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Group unusually long answers without splitting Markdown blocks or list items.
|
||||||
|
const groupSize = Math.max(1, Math.ceil(offsets.length / 20))
|
||||||
|
const boundaries = offsets.filter((_, index) => (index + 1) % groupSize === 0 || index === offsets.length - 1)
|
||||||
|
if (boundaries.length) boundaries[boundaries.length - 1] = content.length
|
||||||
|
else boundaries.push(content.length)
|
||||||
|
return { content, boundaries, definitions: definitions.join('\n\n') }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function agentRevealFrame(document: ReturnType<typeof createAgentRevealDocument>, startedAt: number | undefined, now: number, reducedMotion = false) {
|
||||||
|
const elapsed = startedAt === undefined ? Infinity : Math.max(0, now - startedAt)
|
||||||
|
const visible = reducedMotion ? document.boundaries.length : Math.min(document.boundaries.length, 1 + Math.floor(elapsed / AGENT_REVEAL_INTERVAL))
|
||||||
|
const complete = visible === document.boundaries.length
|
||||||
|
const end = document.boundaries[visible - 1]
|
||||||
|
const fadeFrom = !reducedMotion && elapsed < (visible - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
|
||||||
|
? (visible > 1 ? document.boundaries[visible - 2] : 0)
|
||||||
|
: undefined
|
||||||
|
return {
|
||||||
|
content: complete ? document.content : document.content.slice(0, end) + (document.definitions ? `\n\n${document.definitions}` : ''),
|
||||||
|
fadeFrom,
|
||||||
|
complete,
|
||||||
|
duration: (document.boundaries.length - 1) * AGENT_REVEAL_INTERVAL + AGENT_REVEAL_FADE_DURATION
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
export type ChatTextAttachment = { name: string; content: string; kind?: 'text' }
|
||||||
|
export type ChatImageDraft = { kind: 'image'; name: string; file: File; previewUrl: string }
|
||||||
|
export type ChatAttachment = ChatTextAttachment | ChatImageDraft
|
||||||
|
export type AgentImageReference = { token: string; name: string; mimeType: string; size: number; url: string }
|
||||||
|
|
||||||
|
export const CHAT_IMAGE_ACCEPT = '.png,.jpg,.jpeg,.webp'
|
||||||
|
|
||||||
|
export function displayAttachmentName(name: string): string {
|
||||||
|
// Older multipart uploads can leave Latin-1-decoded UTF-8 in cached image references.
|
||||||
|
if (!/^[\u0000-\u00ff]*$/.test(name)) return name
|
||||||
|
try {
|
||||||
|
return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(name, character => character.charCodeAt(0)))
|
||||||
|
} catch {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAT_ATTACHMENT_ACCEPT = '.txt,.log,.md,.json,.yaml,.yml,.xml,.py,.js,.jsx,.ts,.tsx,.c,.cc,.cpp,.h,.hpp,.sh,.bash,.cfg,.ini,.toml,.csv,.urdf,.xacro,.launch,.rviz,.cmake'
|
||||||
|
|
||||||
|
export async function readChatAttachments(files: File[], existing: ChatAttachment[], options: { imagesEnabled?: boolean } = {}): Promise<ChatAttachment[]> {
|
||||||
|
if (files.length + existing.length > 5) throw new Error('파일은 최대 5개까지 첨부할 수 있습니다.')
|
||||||
|
const result = [...existing]
|
||||||
|
const createdUrls: string[] = []
|
||||||
|
try { for (const file of files) {
|
||||||
|
const name = file.name
|
||||||
|
const extension = `.${name.split('.').pop()?.toLowerCase()}`
|
||||||
|
if (CHAT_IMAGE_ACCEPT.split(',').includes(extension)) {
|
||||||
|
if (!options.imagesEnabled) throw new Error('현재 모델은 이미지 첨부를 지원하지 않습니다. Qwen 3.7 Plus·Flash 또는 3.8 Max·Flash를 선택해주세요.')
|
||||||
|
if (result.filter(item => item.kind === 'image').length >= 3) throw new Error('이미지는 최대 3장까지 첨부할 수 있습니다.')
|
||||||
|
if (name.length > 255 || !file.size || file.size > 5 * 1024 * 1024) throw new Error('이미지는 장당 5MB 이하여야 합니다.')
|
||||||
|
let bitmap: ImageBitmap
|
||||||
|
try { bitmap = await createImageBitmap(file) } catch { throw new Error('이미지 파일을 읽지 못했습니다.') }
|
||||||
|
const pixels = bitmap.width * bitmap.height
|
||||||
|
bitmap.close()
|
||||||
|
if (!pixels || pixels > 16_000_000) throw new Error('이미지는 1600만 픽셀 이하여야 합니다.')
|
||||||
|
const previewUrl = URL.createObjectURL(file)
|
||||||
|
createdUrls.push(previewUrl)
|
||||||
|
result.push({ kind: 'image', name, file, previewUrl })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!CHAT_ATTACHMENT_ACCEPT.split(',').includes(extension) && !['dockerfile', 'cmakelists.txt'].includes(name.toLowerCase())) {
|
||||||
|
throw new Error('코드·설정·로그 또는 PNG·JPEG·WebP 이미지를 첨부해주세요. PDF는 지원하지 않습니다.')
|
||||||
|
}
|
||||||
|
if (name.length > 255 || file.size > 100_000) throw new Error('파일 이름은 255자, 파일 크기는 100KB 이하여야 합니다.')
|
||||||
|
let content: string
|
||||||
|
try {
|
||||||
|
content = new TextDecoder('utf-8', { fatal: true }).decode(await file.arrayBuffer())
|
||||||
|
} catch {
|
||||||
|
throw new Error('UTF-8 텍스트 파일만 첨부할 수 있습니다.')
|
||||||
|
}
|
||||||
|
if (content.includes('\0')) throw new Error('바이너리 파일은 첨부할 수 없습니다.')
|
||||||
|
if (content.length > 50_000) throw new Error('파일 하나의 내용은 5만 자 이하여야 합니다.')
|
||||||
|
result.push({ name, content })
|
||||||
|
}
|
||||||
|
if (result.reduce((sum, file) => sum + (file.kind === 'image' ? 0 : file.name.length + file.content.length), 0) > 100_000) {
|
||||||
|
throw new Error('첨부 파일의 전체 내용은 10만 자 이하여야 합니다.')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
} catch (error) {
|
||||||
|
createdUrls.forEach(url => URL.revokeObjectURL(url))
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ type ModuleInstallOptions = {
|
|||||||
|
|
||||||
type RosNetworkConfig = {
|
type RosNetworkConfig = {
|
||||||
enabled?: boolean | null
|
enabled?: boolean | null
|
||||||
|
allowExternal?: boolean
|
||||||
peer?: RemoteWorkspaceTarget | null
|
peer?: RemoteWorkspaceTarget | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +67,11 @@ interface Window {
|
|||||||
openDevTools: () => Promise<{ opened: boolean; message?: string }>
|
openDevTools: () => Promise<{ opened: boolean; message?: string }>
|
||||||
close: () => void
|
close: () => void
|
||||||
}
|
}
|
||||||
|
chatTextMenu?: {
|
||||||
|
show: (options: import('../../shared/chatTextMenu').ChatTextMenuOptions) => Promise<import('../../shared/chatTextMenu').ChatTextMenuAction>
|
||||||
|
readClipboardState: () => Promise<{ hasText: boolean }>
|
||||||
|
perform: (action: import('../../shared/chatTextMenu').ChatClipboardAction) => Promise<boolean>
|
||||||
|
}
|
||||||
appDiagnostics?: {
|
appDiagnostics?: {
|
||||||
read: () => Promise<{ path: string; content: string }>
|
read: () => Promise<{ path: string; content: string }>
|
||||||
}
|
}
|
||||||
@@ -80,6 +86,7 @@ interface Window {
|
|||||||
root: string | null
|
root: string | null
|
||||||
absoluteRoot: string | null
|
absoluteRoot: string | null
|
||||||
files: Record<string, string>
|
files: Record<string, string>
|
||||||
|
filePaths?: string[]
|
||||||
folders: string[]
|
folders: string[]
|
||||||
}>
|
}>
|
||||||
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }>
|
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }>
|
||||||
@@ -89,6 +96,16 @@ interface Window {
|
|||||||
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
rename: (moduleName: string, fromPath: string, toPath: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||||
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
|
agent: {
|
||||||
|
readTool: (request: import('../../shared/agentTools').AgentToolRequest, workspaceRoot?: string, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
|
||||||
|
applyFiles: (changes: import('../../shared/agentTools').AgentCheckedChange[], workspaceRoot?: string) => Promise<{ ok: boolean }>
|
||||||
|
runCommand: (moduleName: string, command: string, workspaceRoot?: string, folderName?: string) => Promise<{
|
||||||
|
ok: boolean
|
||||||
|
output: string
|
||||||
|
exitCode: number | null
|
||||||
|
message?: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
modules: {
|
modules: {
|
||||||
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{
|
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{
|
||||||
cacheRoot: string
|
cacheRoot: string
|
||||||
@@ -109,6 +126,8 @@ interface Window {
|
|||||||
}>
|
}>
|
||||||
}>
|
}>
|
||||||
listDockerImages: () => Promise<{ images: string[]; message?: string }>
|
listDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||||
|
listGitSources: (root: string) => Promise<import('../../shared/gitModuleImport').LocalGitModule[]>
|
||||||
|
inspectGitSource: (root: string, folder: string) => Promise<import('../../shared/gitModuleImport').GitModuleSource>
|
||||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
|
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
module: {
|
module: {
|
||||||
@@ -228,7 +247,7 @@ interface Window {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
terminal: {
|
terminal: {
|
||||||
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string, mode?: 'host' | 'container') => Promise<boolean>
|
||||||
resize: (cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
|
resize: (cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
|
||||||
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
||||||
write: (data: string, sessionId?: string) => void
|
write: (data: string, sessionId?: string) => void
|
||||||
@@ -243,7 +262,7 @@ interface Window {
|
|||||||
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null) => Promise<{
|
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, savedMapWorldLayoutPath?: string | null, onProgress?: (message: string) => void) => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
||||||
@@ -257,7 +276,13 @@ interface Window {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
localWorkspace: {
|
localWorkspace: {
|
||||||
|
onModuleProgress?: (callback: (folderName: string, message: string) => void) => () => void
|
||||||
|
docker: {
|
||||||
|
status: () => Promise<{ status: 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'; platform: string }>
|
||||||
|
openGuide: () => Promise<void>
|
||||||
|
}
|
||||||
moduleFiles: Window['moduleFiles']
|
moduleFiles: Window['moduleFiles']
|
||||||
|
agent: Window['agent']
|
||||||
modules: Window['modules']
|
modules: Window['modules']
|
||||||
rosGraph: {
|
rosGraph: {
|
||||||
snapshot: () => Promise<{
|
snapshot: () => Promise<{
|
||||||
@@ -292,6 +317,8 @@ 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']>
|
||||||
|
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => Promise<{ content: string }>
|
||||||
|
search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => Promise<Array<{ file: string; lineNumber: number; lineText: string }>>
|
||||||
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['image']>
|
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']>
|
||||||
@@ -299,9 +326,15 @@ interface Window {
|
|||||||
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']>
|
rename: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, folderName?: string) => ReturnType<Window['moduleFiles']['rename']>
|
||||||
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
|
delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
|
||||||
}
|
}
|
||||||
|
agent: {
|
||||||
|
readTool: (target: RemoteWorkspaceTarget, request: import('../../shared/agentTools').AgentToolRequest, folderName?: string) => Promise<import('../../shared/agentTools').AgentToolResult>
|
||||||
|
applyFiles: (target: RemoteWorkspaceTarget, changes: import('../../shared/agentTools').AgentCheckedChange[]) => Promise<{ ok: boolean }>
|
||||||
|
runCommand: (target: RemoteWorkspaceTarget, moduleName: string, command: string, folderName?: string) => ReturnType<Window['agent']['runCommand']>
|
||||||
|
}
|
||||||
maps: {
|
maps: {
|
||||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
||||||
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
|
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
|
||||||
|
uploadRtabmapDatabase: (target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => Promise<{ ok: boolean }>
|
||||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
modules: {
|
modules: {
|
||||||
@@ -323,6 +356,8 @@ interface Window {
|
|||||||
runtime: string
|
runtime: string
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
|
listDockerImages: (target: RemoteWorkspaceTarget) => Promise<{ images: string[]; message?: string }>
|
||||||
|
importGit: (target: RemoteWorkspaceTarget, root: string, input: import('../../shared/gitModuleImport').GitModuleImportInput & { folderName: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||||
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
createCustom: (target: RemoteWorkspaceTarget, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }>
|
||||||
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => Promise<{ ok: boolean; workspacePath: string }>
|
transfer: (target: RemoteWorkspaceTarget, sourceWorkspaceRoot: string | undefined, moduleName: string, folderName?: string) => Promise<{ ok: boolean; workspacePath: string }>
|
||||||
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
|
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }>
|
||||||
@@ -349,7 +384,7 @@ interface Window {
|
|||||||
}
|
}
|
||||||
visualization: {
|
visualization: {
|
||||||
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
||||||
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, onProgress?: (message: string) => void) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
||||||
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
||||||
}
|
}
|
||||||
git: {
|
git: {
|
||||||
@@ -362,7 +397,7 @@ interface Window {
|
|||||||
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType<Window['git']['commitDiff']>
|
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType<Window['git']['commitDiff']>
|
||||||
}
|
}
|
||||||
terminal: {
|
terminal: {
|
||||||
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, mode?: 'host' | 'container') => 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: (data: string, sessionId?: string) => void
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
[data-ai-guide-theme='light'] {
|
||||||
|
background: linear-gradient(110deg, #6548ff, #4776ff);
|
||||||
|
color: #202d43;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] > header {
|
||||||
|
background: linear-gradient(115deg, #8370ff, #607dff 55%, #57a6ff);
|
||||||
|
border-color: #8ea7ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] aside {
|
||||||
|
background: #f5f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] main {
|
||||||
|
background: #f5f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keep these overrides inside the guide; shared chat widgets retain their own themes. */
|
||||||
|
[data-ai-guide-theme='light'] :is(aside, main, section) [class*='text-white/'] {
|
||||||
|
color: #647497;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] :is(aside, main) :is(strong, h2)[class*='text-white/'] {
|
||||||
|
color: #203654;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class*='border-white/'] {
|
||||||
|
border-color: #b8c7e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class*='text-violet-'],
|
||||||
|
[data-ai-guide-theme='light'] [class*='text-cyan-'] {
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class*='bg-cyan-400/'],
|
||||||
|
[data-ai-guide-theme='light'] [class*='bg-violet-400/'] {
|
||||||
|
background: #edf1ff;
|
||||||
|
border-color: #bdcbed;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] aside [class*='bg-cyan-400/10'] {
|
||||||
|
background: linear-gradient(110deg, #6548ff, #4776ff);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] aside [class*='bg-cyan-400/10'] [class*='text-white/'] {
|
||||||
|
color: #dce8fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] :is(aside, section) button[class*='hover:bg-white/']:hover {
|
||||||
|
background: #d6e2f5;
|
||||||
|
color: #153b7c;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
||||||
|
background: #faf8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class~='bg-[#5b4aa8]'] {
|
||||||
|
background: linear-gradient(110deg, #6548ff, #4776ff);
|
||||||
|
box-shadow: 0 4px 12px rgb(37 99 235 / 12%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class~='bg-[#10131b]/78'] {
|
||||||
|
background: rgb(248 250 255 / 82%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class~='bg-[#252238]'],
|
||||||
|
[data-ai-guide-theme='light'] section[class~='bg-[#1b2a42]'] {
|
||||||
|
background: #fff;
|
||||||
|
color: #203654;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] section[class~='bg-[#1b2a42]'] p.text-white {
|
||||||
|
color: #203654;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class~='bg-[#0e7490]'] {
|
||||||
|
background: linear-gradient(110deg, #6548ff, #4776ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class~='bg-[#0e7490]']:enabled:hover {
|
||||||
|
background: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] > header strong {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] > header button {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] > div {
|
||||||
|
padding: 18px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] > div > div:first-child {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] > div > button {
|
||||||
|
min-height: 44px;
|
||||||
|
margin-top: 18px;
|
||||||
|
border: 1px solid #b8c8e8;
|
||||||
|
color: #4056bb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class~='bg-[#1d2230]'] {
|
||||||
|
border-color: #c2cfe6;
|
||||||
|
background: #faf8ff;
|
||||||
|
padding: 22px 24px;
|
||||||
|
box-shadow: 0 5px 18px rgb(49 81 170 / 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [class~='bg-[#5b4aa8]'] {
|
||||||
|
padding: 13px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-questions'] > div:first-child {
|
||||||
|
margin: 0 14px;
|
||||||
|
padding: 0;
|
||||||
|
height: 46px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] main .pointer-events-auto > div {
|
||||||
|
border-color: #b8c8e8;
|
||||||
|
box-shadow: 0 4px 16px rgb(49 81 170 / 6%);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] button:not([title]):not([class*='bg-cyan-400/10']),
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group:not([class*='bg-cyan-400/10']),
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-questions'] button:not([class*='bg-cyan-400/10']) {
|
||||||
|
border: 1px solid #d5ddef;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] button:not([title]):not([class*='bg-cyan-400/10']):hover,
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group:not([class*='bg-cyan-400/10']):hover,
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-questions'] button:not([class*='bg-cyan-400/10']):hover {
|
||||||
|
background: #f2f5fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-ai-guide-theme='light'] [data-help='ai-guide-context'] div.group > button:first-child {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||