mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-14 22:40:59 +00:00
Merge remote-tracking branch 'origin/master' into feature/itchat-adapter
# Conflicts: # src/langbot/pkg/api/http/controller/groups/platform/adapters.py # src/langbot/pkg/plugin/connector.py # uv.lock # web/src/app/home/bots/BotDetailContent.tsx # web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx # web/src/app/home/components/qrcode-login/QrCodeLoginDialog.tsx
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "node --test tests/unit/*.test.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
@@ -31,6 +32,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fingerprintjs/fingerprintjs": "^4.6.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
@@ -46,6 +48,7 @@
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.4.7",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.11",
|
||||
|
||||
Generated
+216
@@ -24,6 +24,9 @@ dependencies:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.1)
|
||||
'@fingerprintjs/fingerprintjs':
|
||||
specifier: ^4.6.2
|
||||
version: 4.6.2
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.0.1
|
||||
version: 5.2.2(react-hook-form@7.71.1)
|
||||
@@ -69,6 +72,9 @@ dependencies:
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||
'@radix-ui/react-slider':
|
||||
specifier: ^1.4.7
|
||||
version: 1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.3
|
||||
version: 1.2.4(@types/react@19.2.10)(react@19.2.1)
|
||||
@@ -422,6 +428,12 @@ packages:
|
||||
levn: 0.4.1
|
||||
dev: true
|
||||
|
||||
/@fingerprintjs/fingerprintjs@4.6.2:
|
||||
resolution: {integrity: sha512-g8mXuqcFKbgH2CZKwPfVtsUJDHyvcgIABQI7Y0tzWEFXpGxJaXuAuzlifT2oTakjDBLTK4Gaa9/5PERDhqUjtw==}
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
|
||||
/@floating-ui/core@1.7.4:
|
||||
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
||||
dependencies:
|
||||
@@ -546,10 +558,18 @@ packages:
|
||||
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
|
||||
dev: false
|
||||
|
||||
/@radix-ui/number@1.1.3:
|
||||
resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==}
|
||||
dev: false
|
||||
|
||||
/@radix-ui/primitive@1.1.3:
|
||||
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
|
||||
dev: false
|
||||
|
||||
/@radix-ui/primitive@1.1.7:
|
||||
resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==}
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
|
||||
peerDependencies:
|
||||
@@ -673,6 +693,29 @@ packages:
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1)
|
||||
'@types/react': 19.2.10
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.10)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
|
||||
peerDependencies:
|
||||
@@ -709,6 +752,19 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-context-menu@2.2.16(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==}
|
||||
peerDependencies:
|
||||
@@ -760,6 +816,19 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-context@1.2.2(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-dialog@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
||||
peerDependencies:
|
||||
@@ -806,6 +875,19 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-direction@1.1.4(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
|
||||
peerDependencies:
|
||||
@@ -1095,6 +1177,26 @@ packages:
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.3.3(@types/react@19.2.10)(react@19.2.1)
|
||||
'@types/react': 19.2.10
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.10)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-primitive@2.1.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
|
||||
peerDependencies:
|
||||
@@ -1272,6 +1374,36 @@ packages:
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-slider@1.4.7(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-mTSLf1GC/C0moWjTbvCM6Qn/gBjvlFt1azuWF2v7MN5C3Zq2U2J2lN3ZEYkpujuOU5Ro7A28wkviSxaKnG0BYg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.3
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-context': 1.2.2(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-direction': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1)
|
||||
'@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-use-previous': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-use-size': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||
'@types/react': 19.2.10
|
||||
'@types/react-dom': 19.2.3(@types/react@19.2.10)
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1(react@19.2.1)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-slot@1.2.3(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
|
||||
peerDependencies:
|
||||
@@ -1300,6 +1432,20 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-slot@1.3.3(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.10)(react@19.2.1)
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-switch@1.2.6(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||
peerDependencies:
|
||||
@@ -1460,6 +1606,22 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.7
|
||||
'@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.10)(react@19.2.1)
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
|
||||
peerDependencies:
|
||||
@@ -1474,6 +1636,20 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-escape-keydown@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
|
||||
peerDependencies:
|
||||
@@ -1515,6 +1691,19 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-previous@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
|
||||
peerDependencies:
|
||||
@@ -1528,6 +1717,19 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-previous@1.1.4(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-XoSLhbRbqxFtgJoi2fNHA3C6pDlY34x508vUpUGoFZfvePfHXHbE1lC4FYFMnJWgiCRroSTw6fOsXQoVS9RwZg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-rect@1.1.1(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
|
||||
peerDependencies:
|
||||
@@ -1556,6 +1758,20 @@ packages:
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-size@1.1.4(@types/react@19.2.10)(react@19.2.1):
|
||||
resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.10)(react@19.2.1)
|
||||
'@types/react': 19.2.10
|
||||
react: 19.2.1
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3)(@types/react@19.2.10)(react-dom@19.2.1)(react@19.2.1):
|
||||
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
|
||||
peerDependencies:
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { useEffect, useState, useCallback, Suspense, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
beginAuthenticatedSession,
|
||||
beginSupportAdminSession,
|
||||
bootstrapWorkspaceSession,
|
||||
clearPendingInvitationToken,
|
||||
getPendingInvitationToken,
|
||||
} from '@/app/infra/http';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -22,7 +29,10 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
|
||||
type SpaceOAuthLoginResult = {
|
||||
token: string;
|
||||
user: string;
|
||||
user?: string;
|
||||
workspace_uuid?: string;
|
||||
principal_type?: 'account' | 'support_admin';
|
||||
actor_account_uuid?: string;
|
||||
};
|
||||
|
||||
const pendingSpaceOAuthLogins = new Map<
|
||||
@@ -32,19 +42,23 @@ const pendingSpaceOAuthLogins = new Map<
|
||||
|
||||
function getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode: string,
|
||||
state: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
): Promise<SpaceOAuthLoginResult> {
|
||||
const pendingRequest = pendingSpaceOAuthLogins.get(authCode);
|
||||
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
|
||||
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
const requestPromise = httpClient
|
||||
.exchangeSpaceOAuthCode(authCode)
|
||||
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
|
||||
.finally(() => {
|
||||
pendingSpaceOAuthLogins.delete(authCode);
|
||||
pendingSpaceOAuthLogins.delete(requestKey);
|
||||
});
|
||||
|
||||
pendingSpaceOAuthLogins.set(authCode, requestPromise);
|
||||
pendingSpaceOAuthLogins.set(requestKey, requestPromise);
|
||||
return requestPromise;
|
||||
}
|
||||
|
||||
@@ -53,34 +67,99 @@ function SpaceOAuthCallbackContent() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const isMountedRef = useRef(true);
|
||||
const directLaunchFragmentRef = useRef<{
|
||||
workspaceUuid: string | null;
|
||||
launchAssertion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
'loading' | 'confirm' | 'success' | 'error'
|
||||
>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
const [terminalErrorCode, setTerminalErrorCode] = useState<
|
||||
'space_account_not_registered' | 'space_account_binding_required' | null
|
||||
>(null);
|
||||
const [isBindMode, setIsBindMode] = useState(false);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [localEmail, setLocalEmail] = useState<string>('');
|
||||
|
||||
const handleOAuthCallback = useCallback(
|
||||
async (authCode: string) => {
|
||||
async (
|
||||
authCode: string,
|
||||
state: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
) => {
|
||||
try {
|
||||
const response = await getOrCreateSpaceOAuthLoginPromise(authCode);
|
||||
const response = await getOrCreateSpaceOAuthLoginPromise(
|
||||
authCode,
|
||||
state,
|
||||
workspaceUuid,
|
||||
launchAssertion,
|
||||
);
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('token', response.token);
|
||||
if (response.user) {
|
||||
localStorage.setItem('userEmail', response.user);
|
||||
if (response.principal_type === 'support_admin') {
|
||||
if (!response.workspace_uuid) {
|
||||
throw new Error('Support admin launch did not include a Workspace');
|
||||
}
|
||||
beginSupportAdminSession(response.token, response.workspace_uuid);
|
||||
await bootstrapWorkspaceSession();
|
||||
navigate('/home', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
beginAuthenticatedSession(response.token, response.user);
|
||||
const invitationToken = getPendingInvitationToken();
|
||||
if (invitationToken) {
|
||||
let invitation;
|
||||
try {
|
||||
invitation =
|
||||
await httpClient.acceptWorkspaceInvitation(invitationToken);
|
||||
} catch (error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
const path = code
|
||||
? `/invitations/accept?error=${encodeURIComponent(code)}`
|
||||
: '/invitations/accept';
|
||||
navigate(path, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
beginAuthenticatedSession(invitation.token, response.user);
|
||||
clearPendingInvitationToken();
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid: invitation.workspace_uuid,
|
||||
});
|
||||
if (workspaceResult.status === 'unavailable') {
|
||||
navigate('/workspace-unavailable', { replace: true });
|
||||
return;
|
||||
}
|
||||
navigate('/home', { replace: true });
|
||||
return;
|
||||
}
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid: response.workspace_uuid,
|
||||
});
|
||||
if (workspaceResult.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
if (response.workspace_uuid) {
|
||||
navigate('/home', { replace: true });
|
||||
return;
|
||||
}
|
||||
setStatus('success');
|
||||
toast.success(t('common.spaceLoginSuccess'));
|
||||
|
||||
// If wizard state exists, redirect back to wizard instead of home
|
||||
const wizardState = localStorage.getItem('langbot_wizard_state');
|
||||
const redirectTo = wizardState ? '/wizard' : '/home';
|
||||
const destination = wizardState ? '/wizard' : '/home';
|
||||
const redirectTo =
|
||||
workspaceResult.status === 'selection-required'
|
||||
? `/workspaces/select?returnTo=${encodeURIComponent(destination)}`
|
||||
: destination;
|
||||
setTimeout(() => {
|
||||
navigate(redirectTo);
|
||||
}, 1000);
|
||||
@@ -90,7 +169,15 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
const errorObj = err as { msg?: string };
|
||||
const errorObj = err as { code?: string; msg?: string };
|
||||
if (
|
||||
errorObj.code === 'space_account_not_registered' ||
|
||||
errorObj.code === 'space_account_binding_required'
|
||||
) {
|
||||
setTerminalErrorCode(errorObj.code);
|
||||
setErrorMessage(t(`account.${errorObj.code}`));
|
||||
return;
|
||||
}
|
||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||
if (errMsg.includes('account email mismatch')) {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
@@ -113,14 +200,19 @@ function SpaceOAuthCallbackContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
localStorage.setItem('token', response.token);
|
||||
if (response.user) {
|
||||
localStorage.setItem('userEmail', response.user);
|
||||
beginAuthenticatedSession(response.token, response.user);
|
||||
const workspaceResult = await bootstrapWorkspaceSession();
|
||||
if (workspaceResult.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
setStatus('success');
|
||||
toast.success(t('account.bindSpaceSuccess'));
|
||||
const redirectTo =
|
||||
workspaceResult.status === 'selection-required'
|
||||
? '/workspaces/select?returnTo=%2Fhome'
|
||||
: '/home';
|
||||
setTimeout(() => {
|
||||
navigate('/home');
|
||||
navigate(redirectTo);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
if (!isMountedRef.current) {
|
||||
@@ -128,7 +220,11 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
|
||||
setStatus('error');
|
||||
const errorObj = err as { msg?: string };
|
||||
const errorObj = err as { code?: string; msg?: string };
|
||||
if (errorObj.code === 'space_account_email_mismatch') {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
return;
|
||||
}
|
||||
const errMsg = (errorObj?.msg || '').toLowerCase();
|
||||
if (errMsg.includes('account email mismatch')) {
|
||||
setErrorMessage(t('account.spaceEmailMismatch'));
|
||||
@@ -152,6 +248,28 @@ function SpaceOAuthCallbackContent() {
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
const mode = searchParams.get('mode');
|
||||
const state = searchParams.get('state');
|
||||
if (directLaunchFragmentRef.current === null) {
|
||||
const fragmentParams = new URLSearchParams(
|
||||
window.location.hash.startsWith('#')
|
||||
? window.location.hash.slice(1)
|
||||
: window.location.hash,
|
||||
);
|
||||
directLaunchFragmentRef.current = {
|
||||
workspaceUuid: fragmentParams.get('workspace_uuid'),
|
||||
launchAssertion: fragmentParams.get('launch_assertion'),
|
||||
};
|
||||
if (window.location.hash) {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const workspaceUuid =
|
||||
directLaunchFragmentRef.current.workspaceUuid ??
|
||||
searchParams.get('workspace_uuid');
|
||||
const launchAssertion = directLaunchFragmentRef.current.launchAssertion;
|
||||
|
||||
if (error) {
|
||||
setStatus('error');
|
||||
@@ -161,15 +279,13 @@ function SpaceOAuthCallbackContent() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authCode) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginNoCode'));
|
||||
return;
|
||||
}
|
||||
|
||||
setCode(authCode);
|
||||
|
||||
if (mode === 'bind') {
|
||||
if (!authCode) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginNoCode'));
|
||||
return;
|
||||
}
|
||||
setCode(authCode);
|
||||
// Bind mode - verify state (token) exists
|
||||
if (!state) {
|
||||
setStatus('error');
|
||||
@@ -180,9 +296,31 @@ function SpaceOAuthCallbackContent() {
|
||||
setIsBindMode(true);
|
||||
setLocalEmail(localStorage.getItem('userEmail') || '');
|
||||
setStatus('confirm');
|
||||
} else if (workspaceUuid || launchAssertion) {
|
||||
if (!workspaceUuid || !launchAssertion) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginFailed'));
|
||||
return;
|
||||
}
|
||||
handleOAuthCallback(
|
||||
authCode ?? '',
|
||||
state ?? '',
|
||||
workspaceUuid,
|
||||
launchAssertion,
|
||||
);
|
||||
} else {
|
||||
// Normal login/register mode
|
||||
handleOAuthCallback(authCode);
|
||||
if (!authCode) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginNoCode'));
|
||||
return;
|
||||
}
|
||||
setCode(authCode);
|
||||
if (!state) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('common.spaceLoginFailed'));
|
||||
return;
|
||||
}
|
||||
handleOAuthCallback(authCode, state);
|
||||
}
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
@@ -216,9 +354,11 @@ function SpaceOAuthCallbackContent() {
|
||||
? t('account.bindSpaceSuccess')
|
||||
: t('common.spaceLoginSuccess'))}
|
||||
{status === 'error' &&
|
||||
(isBindMode
|
||||
? t('account.bindSpaceFailed')
|
||||
: t('common.spaceLoginError'))}
|
||||
(terminalErrorCode
|
||||
? t(`account.${terminalErrorCode}Title`)
|
||||
: isBindMode
|
||||
? t('account.bindSpaceFailed')
|
||||
: t('common.spaceLoginError'))}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status === 'loading' &&
|
||||
|
||||
@@ -49,6 +49,8 @@ import type {
|
||||
} from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
import SkillZipPreviewPanel from '@/app/home/skills/components/SkillZipPreviewPanel';
|
||||
import PluginLocalPreviewPanel from '@/app/home/plugins/components/PluginLocalPreviewPanel';
|
||||
import { useWorkspaceQuotaStatus } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
|
||||
type PopoverView = 'menu' | 'mcp' | 'github';
|
||||
|
||||
@@ -154,6 +156,12 @@ function AddExtensionContent() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||
const { extensions: extensionQuota, extensionsReached } =
|
||||
useWorkspaceQuotaStatus();
|
||||
const extensionQuotaTooltip = t('limitation.createDisabledTooltip', {
|
||||
resource: t('sidebar.extensions'),
|
||||
max: extensionQuota.max,
|
||||
});
|
||||
|
||||
// Localized label for an extension type, used in the install dialog.
|
||||
const extensionTypeLabel = (type: string) =>
|
||||
@@ -344,23 +352,28 @@ function AddExtensionContent() {
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||
plugin_description: extractI18nObject(plugin.description) || '',
|
||||
plugin_icon: plugin.icon || '',
|
||||
});
|
||||
setInstallExtensionType(plugin.type || 'plugin');
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setInstallIconFailed(false);
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
const handleInstallPlugin = useCallback(
|
||||
async (plugin: PluginV4) => {
|
||||
if (extensionsReached) return;
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||
plugin_description: extractI18nObject(plugin.description) || '',
|
||||
plugin_icon: plugin.icon || '',
|
||||
});
|
||||
setInstallExtensionType(plugin.type || 'plugin');
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setInstallIconFailed(false);
|
||||
setModalOpen(true);
|
||||
},
|
||||
[extensionsReached],
|
||||
);
|
||||
|
||||
function handleModalConfirm() {
|
||||
if (extensionsReached) return;
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||
httpClient
|
||||
@@ -402,6 +415,7 @@ function AddExtensionContent() {
|
||||
|
||||
const uploadFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (extensionsReached) return;
|
||||
if (!validateFileType(file)) {
|
||||
toast.error(t('addExtension.unsupportedFileType'));
|
||||
return;
|
||||
@@ -421,14 +435,15 @@ function AddExtensionContent() {
|
||||
setSkillUploadPreviewOpen(true);
|
||||
}
|
||||
},
|
||||
[t, setSelectedTaskId],
|
||||
[extensionsReached, t, setSelectedTaskId],
|
||||
);
|
||||
|
||||
const handleFileSelect = useCallback(() => {
|
||||
if (extensionsReached) return;
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
}, []);
|
||||
}, [extensionsReached]);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
@@ -455,12 +470,13 @@ function AddExtensionContent() {
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (extensionsReached) return;
|
||||
const files = Array.from(event.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
uploadFile(files[0]);
|
||||
}
|
||||
},
|
||||
[uploadFile],
|
||||
[extensionsReached, uploadFile],
|
||||
);
|
||||
|
||||
function handleMCPCreated(_serverName: string) {
|
||||
@@ -490,7 +506,8 @@ function AddExtensionContent() {
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, let backend handle it
|
||||
toast.error(t('limitation.quotaCheckFailed'));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -630,13 +647,16 @@ function AddExtensionContent() {
|
||||
|
||||
async function handleGithubConfirm() {
|
||||
if (!selectedAsset || !selectedRelease) return;
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
|
||||
setGithubInstallStatus(GithubInstallStatus.INSTALLING);
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
setGithubInstallStatus(GithubInstallStatus.ASK_CONFIRM);
|
||||
return;
|
||||
}
|
||||
const pluginDisplayName = `${githubOwner}/${githubRepo}`;
|
||||
httpClient
|
||||
.installPluginFromGithub(
|
||||
selectedAsset.download_url,
|
||||
selectedAsset.id,
|
||||
selectedRelease.id,
|
||||
githubOwner,
|
||||
githubRepo,
|
||||
selectedRelease.tag_name,
|
||||
@@ -663,9 +683,11 @@ function AddExtensionContent() {
|
||||
|
||||
async function handleGithubSkillConfirm() {
|
||||
if (!githubSkillInfo) return;
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
|
||||
setGithubInstallStatus(GithubInstallStatus.SKILL_INSTALLING);
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
setGithubInstallStatus(GithubInstallStatus.SKILL_PREVIEW);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await httpClient.installSkillFromGithub(
|
||||
githubURL.trim(),
|
||||
@@ -725,17 +747,24 @@ function AddExtensionContent() {
|
||||
setPopoverOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span className="whitespace-nowrap">
|
||||
{t('addExtension.manualAdd')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={extensionQuota}
|
||||
resource={t('sidebar.extensions')}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
disabled={extensionsReached}
|
||||
aria-disabled={extensionsReached}
|
||||
className="px-3 sm:px-4 py-2 cursor-pointer flex-shrink-0 disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground disabled:opacity-100"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
<span className="whitespace-nowrap">
|
||||
{t('addExtension.manualAdd')}
|
||||
</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</WorkspaceQuotaTooltip>
|
||||
<PopoverContent
|
||||
forceMount
|
||||
className={`${getPopoverWidth()} max-h-[min(720px,80vh)] overflow-hidden p-0`}
|
||||
@@ -744,9 +773,19 @@ function AddExtensionContent() {
|
||||
{/* ===== Menu View ===== */}
|
||||
{popoverView === 'menu' && (
|
||||
<div className="space-y-4 p-4">
|
||||
{extensionsReached && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||
{extensionQuotaTooltip}
|
||||
</div>
|
||||
)}
|
||||
{/* File upload area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-colors ${
|
||||
aria-disabled={extensionsReached}
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
extensionsReached
|
||||
? 'cursor-not-allowed opacity-50'
|
||||
: 'cursor-pointer'
|
||||
} ${
|
||||
isDragOver
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-muted-foreground/25 hover:border-primary/50'
|
||||
@@ -776,7 +815,8 @@ function AddExtensionContent() {
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setPopoverView('mcp')}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
@@ -795,7 +835,8 @@ function AddExtensionContent() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={() => setPopoverView('github')}
|
||||
>
|
||||
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-background text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
@@ -814,7 +855,8 @@ function AddExtensionContent() {
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
disabled={extensionsReached}
|
||||
className="group flex w-full items-center gap-3 rounded-md bg-muted/30 p-3 text-left transition-colors outline-none hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setPopoverOpen(false);
|
||||
@@ -881,6 +923,7 @@ function AddExtensionContent() {
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
size="sm"
|
||||
disabled={extensionsReached}
|
||||
onClick={async (e) => {
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
e.preventDefault();
|
||||
@@ -945,6 +988,7 @@ function AddExtensionContent() {
|
||||
className="w-full"
|
||||
onClick={handleGithubAddressSubmit}
|
||||
disabled={
|
||||
extensionsReached ||
|
||||
!githubURL.trim() ||
|
||||
fetchingReleases ||
|
||||
fetchingSkillPreview
|
||||
@@ -1101,7 +1145,11 @@ function AddExtensionContent() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button className="w-full" onClick={handleGithubConfirm}>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGithubConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -1183,6 +1231,7 @@ function AddExtensionContent() {
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={handleGithubSkillConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
@@ -1239,6 +1288,8 @@ function AddExtensionContent() {
|
||||
<MarketPage
|
||||
installPlugin={handleInstallPlugin}
|
||||
headerActions={extensionActions}
|
||||
installDisabled={extensionsReached}
|
||||
installDisabledTooltip={extensionQuotaTooltip}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1324,9 +1375,17 @@ function AddExtensionContent() {
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleModalConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={extensionQuota}
|
||||
resource={t('sidebar.extensions')}
|
||||
>
|
||||
<Button
|
||||
onClick={handleModalConfirm}
|
||||
disabled={extensionsReached}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
</>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
@@ -1358,6 +1417,8 @@ function AddExtensionContent() {
|
||||
{pluginUploadPreviewFile && (
|
||||
<PluginLocalPreviewPanel
|
||||
file={pluginUploadPreviewFile}
|
||||
quota={extensionQuota}
|
||||
quotaResource={t('sidebar.extensions')}
|
||||
onCancel={() => {
|
||||
setPluginUploadPreviewOpen(false);
|
||||
setPluginUploadPreviewFile(null);
|
||||
@@ -1391,6 +1452,8 @@ function AddExtensionContent() {
|
||||
{skillUploadPreviewFile && (
|
||||
<SkillZipPreviewPanel
|
||||
file={skillUploadPreviewFile}
|
||||
quota={extensionQuota}
|
||||
quotaResource={t('sidebar.extensions')}
|
||||
onCancel={() => {
|
||||
setSkillUploadPreviewOpen(false);
|
||||
setSkillUploadPreviewFile(null);
|
||||
|
||||
@@ -100,11 +100,17 @@ function RuntimeStatusBadge({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function BotDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canViewMonitoring =
|
||||
currentWorkspace?.permissions.includes('resource.view') ?? false;
|
||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
@@ -208,19 +214,23 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -241,6 +251,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
id="bot-enable-switch"
|
||||
checked={botEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bot-enable-switch"
|
||||
@@ -252,14 +263,16 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
)}
|
||||
<RuntimeStatusBadge status={getBotRuntimeStatus(botDetail)} />
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
@@ -269,45 +282,51 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="config" className="gap-1.5">
|
||||
<Settings className="size-3.5" />
|
||||
{t('bots.configuration')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('bots.logs')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{t('bots.sessionMonitor.title')}
|
||||
{activeTab === 'sessions' && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center ml-0.5"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (isRefreshingSessions) return;
|
||||
setIsRefreshingSessions(true);
|
||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
||||
Promise.all([
|
||||
sessionMonitorRef.current?.refreshSessions(),
|
||||
minDelay,
|
||||
]).finally(() => setIsRefreshingSessions(false));
|
||||
}}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground hover:text-foreground transition-colors',
|
||||
isRefreshingSessions && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="config" className="gap-1.5">
|
||||
<Settings className="size-3.5" />
|
||||
{t('bots.configuration')}
|
||||
</TabsTrigger>
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="logs" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('bots.logs')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="sessions" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{t('bots.sessionMonitor.title')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
{activeTab === 'sessions' && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('bots.sessionMonitor.refresh')}
|
||||
title={t('bots.sessionMonitor.refresh')}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
||||
disabled={isRefreshingSessions}
|
||||
onClick={() => {
|
||||
if (isRefreshingSessions) return;
|
||||
setIsRefreshingSessions(true);
|
||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
||||
Promise.all([
|
||||
sessionMonitorRef.current?.refreshSessions(),
|
||||
minDelay,
|
||||
]).finally(() => setIsRefreshingSessions(false));
|
||||
}}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'size-3.5',
|
||||
isRefreshingSessions && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab: Configuration */}
|
||||
<TabsContent
|
||||
@@ -315,60 +334,68 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<BotForm
|
||||
initBotId={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<BotForm
|
||||
initBotId={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Card: Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('bots.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('bots.deleteBotAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('bots.deleteBotHint')}
|
||||
</p>
|
||||
{canManage && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('bots.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('bots.deleteBotAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('bots.deleteBotHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Logs */}
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<BotLogListComponent botId={id} />
|
||||
</TabsContent>
|
||||
{canViewMonitoring && (
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<BotLogListComponent botId={id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Tab: Sessions */}
|
||||
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
|
||||
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
|
||||
</TabsContent>
|
||||
{canViewMonitoring && (
|
||||
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
|
||||
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ export default function BotForm({
|
||||
// Track whether initial data loading is complete.
|
||||
// setValue calls during init should NOT mark the form as dirty.
|
||||
const isInitializing = useRef(true);
|
||||
const initializationSequence = useRef(0);
|
||||
|
||||
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
|
||||
useState(new Map<string, IDynamicFormItemSchema[]>());
|
||||
@@ -141,7 +142,7 @@ export default function BotForm({
|
||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||
IDynamicFormItemSchema[]
|
||||
>([]);
|
||||
const [, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||
|
||||
@@ -162,50 +163,55 @@ export default function BotForm({
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
void setBotFormValues();
|
||||
return () => {
|
||||
// React StrictMode may start the effect twice. Invalidate the older
|
||||
// request so it cannot reset fields after the user starts editing.
|
||||
initializationSequence.current += 1;
|
||||
};
|
||||
}, [initBotId]);
|
||||
|
||||
function setBotFormValues() {
|
||||
async function setBotFormValues() {
|
||||
const sequence = ++initializationSequence.current;
|
||||
isInitializing.current = true;
|
||||
initBotFormComponent().then(() => {
|
||||
if (initBotId) {
|
||||
getBotConfig(initBotId)
|
||||
.then((val) => {
|
||||
// Use form.reset() to set values AND update the dirty baseline,
|
||||
// so isDirty stays false after initial load.
|
||||
form.reset({
|
||||
name: val.name,
|
||||
description: val.description,
|
||||
adapter: val.adapter,
|
||||
adapter_config: val.adapter_config,
|
||||
enable: val.enable,
|
||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
||||
});
|
||||
handleAdapterSelect(val.adapter);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await initBotFormComponent();
|
||||
if (sequence !== initializationSequence.current) return;
|
||||
|
||||
if (val.webhook_full_url) {
|
||||
setWebhookUrl(val.webhook_full_url);
|
||||
} else {
|
||||
setWebhookUrl('');
|
||||
}
|
||||
setExtraWebhookUrl(val.extra_webhook_full_url || '');
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
t('bots.getBotConfigError') + (err as CustomApiError).msg,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
isInitializing.current = false;
|
||||
});
|
||||
if (initBotId) {
|
||||
const val = await getBotConfig(initBotId);
|
||||
if (sequence !== initializationSequence.current) return;
|
||||
|
||||
// Use form.reset() to set values AND update the dirty baseline,
|
||||
// so isDirty stays false after initial load.
|
||||
form.reset({
|
||||
name: val.name,
|
||||
description: val.description,
|
||||
adapter: val.adapter,
|
||||
adapter_config: val.adapter_config,
|
||||
enable: val.enable,
|
||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
||||
});
|
||||
handleAdapterSelect(val.adapter);
|
||||
setWebhookUrl(val.webhook_full_url || '');
|
||||
setExtraWebhookUrl(val.extra_webhook_full_url || '');
|
||||
} else {
|
||||
form.reset();
|
||||
setWebhookUrl('');
|
||||
setExtraWebhookUrl('');
|
||||
isInitializing.current = false;
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (sequence === initializationSequence.current) {
|
||||
toast.error(t('bots.getBotConfigError') + (err as CustomApiError).msg);
|
||||
}
|
||||
} finally {
|
||||
if (sequence === initializationSequence.current) {
|
||||
isInitializing.current = false;
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initBotFormComponent() {
|
||||
@@ -269,6 +275,10 @@ export default function BotForm({
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
url: item.url,
|
||||
download_filename: item.download_filename,
|
||||
help_links: item.help_links,
|
||||
help_label: item.help_label,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -369,10 +379,6 @@ export default function BotForm({
|
||||
.createBot(newBot)
|
||||
.then((res) => {
|
||||
toast.success(t('bots.createSuccess'));
|
||||
initBotId = res.uuid;
|
||||
|
||||
setBotFormValues();
|
||||
|
||||
onNewBotCreated(res.uuid);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -390,244 +396,256 @@ export default function BotForm({
|
||||
<form
|
||||
id="bot-form"
|
||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||
className="space-y-6"
|
||||
aria-busy={isLoading}
|
||||
>
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
<CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botName')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Pipeline Binding (edit mode only) */}
|
||||
{initBotId && (
|
||||
<fieldset className="space-y-6" disabled={isLoading}>
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.routingConnection')}</CardTitle>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.routingConnectionDescription')}
|
||||
{t('bots.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="use_pipeline_uuid"
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
||||
<FormLabel>
|
||||
{t('bots.botName')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} {...field}>
|
||||
<SelectTrigger>
|
||||
{field.value ? (
|
||||
(() => {
|
||||
const pipeline = pipelineNameList.find(
|
||||
(p) => p.value === field.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{pipeline?.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{pipeline.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span>{pipeline?.label ?? field.value}</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectPipeline')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{pipelineNameList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{item.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Pipeline Routing Rules */}
|
||||
<RoutingRulesEditor
|
||||
form={form}
|
||||
pipelineNameList={pipelineNameList}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Adapter Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.adapterConfigDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="adapter"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.platformAdapter')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleAdapterSelect(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
{field.value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(field.value)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
{/* Card 2: Pipeline Binding (edit mode only) */}
|
||||
{initBotId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.routingConnection')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.routingConnectionDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="use_pipeline_uuid"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} {...field}>
|
||||
<SelectTrigger>
|
||||
{field.value ? (
|
||||
(() => {
|
||||
const pipeline = pipelineNameList.find(
|
||||
(p) => p.value === field.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{pipeline?.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{pipeline.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{pipeline?.label ?? field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectPipeline')}
|
||||
/>
|
||||
<span>
|
||||
{adapterNameList.find(
|
||||
(a) => a.value === field.value,
|
||||
)?.label ?? field.value}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectAdapter')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedAdapters.map((group) => (
|
||||
<SelectGroup
|
||||
key={group.categoryId ?? 'uncategorized'}
|
||||
>
|
||||
{group.categoryId && (
|
||||
<SelectLabel>
|
||||
{getCategoryLabel(t, group.categoryId)}
|
||||
</SelectLabel>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{pipelineNameList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
item.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
{item.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{item.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentAdapter &&
|
||||
(() => {
|
||||
const docUrl = getAdapterDocUrl(
|
||||
adapterHelpLinks[currentAdapter],
|
||||
i18n.language,
|
||||
);
|
||||
return docUrl ? (
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t('bots.viewAdapterDocs')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</FormControl>
|
||||
{currentAdapter && adapterDescriptionList[currentAdapter] && (
|
||||
<FormDescription>
|
||||
{adapterDescriptionList[currentAdapter]}
|
||||
</FormDescription>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
/>
|
||||
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}}
|
||||
{/* Pipeline Routing Rules */}
|
||||
<RoutingRulesEditor
|
||||
form={form}
|
||||
pipelineNameList={pipelineNameList}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Adapter Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.adapterConfigDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="adapter"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.platformAdapter')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleAdapterSelect(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
{field.value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
field.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>
|
||||
{adapterNameList.find(
|
||||
(a) => a.value === field.value,
|
||||
)?.label ?? field.value}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectAdapter')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedAdapters.map((group) => (
|
||||
<SelectGroup
|
||||
key={group.categoryId ?? 'uncategorized'}
|
||||
>
|
||||
{group.categoryId && (
|
||||
<SelectLabel>
|
||||
{getCategoryLabel(t, group.categoryId)}
|
||||
</SelectLabel>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
item.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentAdapter &&
|
||||
(() => {
|
||||
const docUrl = getAdapterDocUrl(
|
||||
adapterHelpLinks[currentAdapter],
|
||||
i18n.language,
|
||||
);
|
||||
return docUrl ? (
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t('bots.viewAdapterDocs')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</FormControl>
|
||||
{currentAdapter &&
|
||||
adapterDescriptionList[currentAdapter] && (
|
||||
<FormDescription>
|
||||
{adapterDescriptionList[currentAdapter]}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
outbound_ips: systemInfo.outbound_ips,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
@@ -15,11 +16,14 @@ import {
|
||||
Bot,
|
||||
Copy,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Workflow,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import BotAdminsDialog, {
|
||||
@@ -76,6 +80,35 @@ interface SessionFeedback {
|
||||
stream_id?: string | null;
|
||||
}
|
||||
|
||||
interface SessionToolCall {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
tool_name: string;
|
||||
tool_source: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
message_id?: string | null;
|
||||
arguments?: string | null;
|
||||
result?: string | null;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
type SessionTimelineItem =
|
||||
| {
|
||||
id: string;
|
||||
type: 'message';
|
||||
timestamp: number;
|
||||
order: number;
|
||||
message: SessionMessage;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: 'tool';
|
||||
timestamp: number;
|
||||
order: number;
|
||||
toolCall: SessionToolCall;
|
||||
};
|
||||
|
||||
export interface BotSessionMonitorHandle {
|
||||
refreshSessions: () => Promise<void>;
|
||||
}
|
||||
@@ -100,6 +133,10 @@ const BotSessionMonitor = forwardRef<
|
||||
const [feedbackMap, setFeedbackMap] = useState<
|
||||
Record<string, SessionFeedback>
|
||||
>({});
|
||||
const [toolCalls, setToolCalls] = useState<SessionToolCall[]>([]);
|
||||
const [expandedToolCallIds, setExpandedToolCallIds] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
|
||||
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
||||
@@ -189,6 +226,7 @@ const BotSessionMonitor = forwardRef<
|
||||
const loadMessages = useCallback(
|
||||
async (sessionId: string) => {
|
||||
setLoadingMessages(true);
|
||||
setExpandedToolCallIds({});
|
||||
try {
|
||||
const messagesRes = await httpClient.getSessionMessages(sessionId);
|
||||
const sorted = (messagesRes.messages ?? []).sort(
|
||||
@@ -197,6 +235,18 @@ const BotSessionMonitor = forwardRef<
|
||||
);
|
||||
setMessages(sorted);
|
||||
|
||||
try {
|
||||
const analysisRes = await httpClient.get<{
|
||||
tool_calls?: SessionToolCall[];
|
||||
}>(
|
||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
|
||||
);
|
||||
setToolCalls(analysisRes?.tool_calls ?? []);
|
||||
} catch (analysisError) {
|
||||
console.error('Failed to load session tool calls:', analysisError);
|
||||
setToolCalls([]);
|
||||
}
|
||||
|
||||
// Collect user message IDs for feedback matching
|
||||
const userMsgIds = new Set(
|
||||
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
|
||||
@@ -240,11 +290,14 @@ const BotSessionMonitor = forwardRef<
|
||||
loadMessages(selectedSessionId);
|
||||
} else {
|
||||
setMessages([]);
|
||||
setToolCalls([]);
|
||||
setExpandedToolCallIds({});
|
||||
setFeedbackMap({});
|
||||
}
|
||||
}, [selectedSessionId, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
if (messages.length === 0 && toolCalls.length === 0) return;
|
||||
// Wait for DOM to render the new messages before scrolling
|
||||
requestAnimationFrame(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
@@ -256,7 +309,7 @@ const BotSessionMonitor = forwardRef<
|
||||
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, [messages]);
|
||||
}, [messages, toolCalls]);
|
||||
|
||||
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
||||
try {
|
||||
@@ -431,6 +484,71 @@ const BotSessionMonitor = forwardRef<
|
||||
return `${diffDays}d`;
|
||||
};
|
||||
|
||||
const formatDuration = (durationMs: number): string => {
|
||||
if (!durationMs) return '0ms';
|
||||
if (durationMs < 1000) return `${durationMs}ms`;
|
||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
const truncateToolDetail = (value?: string | null): string => {
|
||||
if (!value) return '';
|
||||
return value.length > 600 ? `${value.slice(0, 600)}...` : value;
|
||||
};
|
||||
|
||||
const toggleToolCallDetails = (toolCallId: string) => {
|
||||
setExpandedToolCallIds((previous) => ({
|
||||
...previous,
|
||||
[toolCallId]: !previous[toolCallId],
|
||||
}));
|
||||
};
|
||||
|
||||
const feedbackByMessageId = useMemo(() => {
|
||||
const map: Record<string, SessionFeedback> = {};
|
||||
|
||||
for (let index = 0; index < messages.length; index++) {
|
||||
const msg = messages[index];
|
||||
if (isUserMessage(msg)) continue;
|
||||
|
||||
for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) {
|
||||
const previousMessage = messages[previousIndex];
|
||||
if (isUserMessage(previousMessage)) {
|
||||
const feedback = feedbackMap[previousMessage.id];
|
||||
if (feedback) {
|
||||
map[msg.id] = feedback;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}, [feedbackMap, messages]);
|
||||
|
||||
const timelineItems = useMemo<SessionTimelineItem[]>(() => {
|
||||
const messageItems: SessionTimelineItem[] = messages.map(
|
||||
(message, index) => ({
|
||||
id: `message-${message.id}`,
|
||||
type: 'message',
|
||||
timestamp: parseTimestamp(message.timestamp).getTime(),
|
||||
order: index * 2,
|
||||
message,
|
||||
}),
|
||||
);
|
||||
const toolItems: SessionTimelineItem[] = toolCalls.map(
|
||||
(toolCall, index) => ({
|
||||
id: `tool-${toolCall.id}`,
|
||||
type: 'tool',
|
||||
timestamp: parseTimestamp(toolCall.timestamp).getTime(),
|
||||
order: index * 2 + 1,
|
||||
toolCall,
|
||||
}),
|
||||
);
|
||||
|
||||
return [...messageItems, ...toolItems].sort(
|
||||
(a, b) => a.timestamp - b.timestamp || a.order - b.order,
|
||||
);
|
||||
}, [messages, toolCalls]);
|
||||
|
||||
const selectedSession = sessions.find(
|
||||
(s) => s.session_id === selectedSessionId,
|
||||
);
|
||||
@@ -612,29 +730,162 @@ const BotSessionMonitor = forwardRef<
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
) : timelineItems.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg, msgIndex) => {
|
||||
timelineItems.map((item) => {
|
||||
if (item.type === 'tool') {
|
||||
const call = item.toolCall;
|
||||
const hasToolDetails = Boolean(
|
||||
call.arguments || call.result || call.error_message,
|
||||
);
|
||||
const expandedToolCall = Boolean(
|
||||
expandedToolCallIds[call.id],
|
||||
);
|
||||
const detailsId = `tool-call-details-${call.id}`;
|
||||
return (
|
||||
<div key={item.id} className="flex justify-start">
|
||||
<div className="max-w-2xl rounded-xl rounded-bl-sm border border-border/60 bg-muted/25 px-2.5 py-1.5 text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 rounded-md text-left outline-none transition-colors',
|
||||
hasToolDetails &&
|
||||
'cursor-pointer hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
aria-expanded={
|
||||
hasToolDetails ? expandedToolCall : undefined
|
||||
}
|
||||
aria-controls={
|
||||
hasToolDetails ? detailsId : undefined
|
||||
}
|
||||
aria-disabled={!hasToolDetails}
|
||||
onClick={() =>
|
||||
hasToolDetails &&
|
||||
toggleToolCallDetails(call.id)
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{hasToolDetails &&
|
||||
(expandedToolCall ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
|
||||
))}
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
|
||||
<span className="min-w-0 max-w-[18rem] truncate text-[13px] font-medium text-foreground/75">
|
||||
{call.tool_name}
|
||||
</span>
|
||||
<span className="rounded border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
|
||||
{call.tool_source}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded px-1.5 py-0.5 text-[10px] font-medium leading-none',
|
||||
call.status === 'success'
|
||||
? 'bg-green-100/70 text-green-700 dark:bg-green-950/60 dark:text-green-300'
|
||||
: 'bg-red-100/70 text-red-700 dark:bg-red-950/60 dark:text-red-300',
|
||||
)}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground/80">
|
||||
{formatDuration(call.duration)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{hasToolDetails && expandedToolCall && (
|
||||
<div
|
||||
id={detailsId}
|
||||
className="mt-2 space-y-1.5"
|
||||
>
|
||||
{(call.arguments || call.result) && (
|
||||
<div className="space-y-1.5">
|
||||
{call.arguments && (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.toolCalls.arguments',
|
||||
{
|
||||
defaultValue: '参数',
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
|
||||
{truncateToolDetail(call.arguments)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{call.result && (
|
||||
<div>
|
||||
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
|
||||
{t('monitoring.toolCalls.result', {
|
||||
defaultValue: '结果',
|
||||
})}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
|
||||
{truncateToolDetail(call.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{call.error_message && (
|
||||
<div className="whitespace-pre-wrap break-words rounded bg-red-50 p-2 text-[11px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
|
||||
{call.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span>
|
||||
{t('monitoring.toolCalls.title', {
|
||||
defaultValue: '工具调用',
|
||||
})}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTime(call.timestamp)}
|
||||
</span>
|
||||
{hasToolDetails && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>
|
||||
{expandedToolCall
|
||||
? t(
|
||||
'monitoring.toolCalls.hideDetails',
|
||||
{
|
||||
defaultValue: '隐藏详情',
|
||||
},
|
||||
)
|
||||
: t(
|
||||
'monitoring.toolCalls.showDetails',
|
||||
{
|
||||
defaultValue: '查看详情',
|
||||
},
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const msg = item.message;
|
||||
const isUser = isUserMessage(msg);
|
||||
const isDiscarded =
|
||||
msg.status === 'discarded' ||
|
||||
msg.pipeline_id === PIPELINE_DISCARD;
|
||||
// For bot replies, find feedback linked to the preceding user message
|
||||
let msgFeedback: SessionFeedback | undefined;
|
||||
if (!isUser) {
|
||||
for (let i = msgIndex - 1; i >= 0; i--) {
|
||||
if (isUserMessage(messages[i])) {
|
||||
msgFeedback = feedbackMap[messages[i].id];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const msgFeedback = feedbackByMessageId[msg.id];
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'flex',
|
||||
isUser ? 'justify-end' : 'justify-start',
|
||||
|
||||
@@ -58,19 +58,9 @@ export default function AccountSettingsPanel({
|
||||
const handleBindSpace = async () => {
|
||||
setSpaceBindLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
setSpaceBindLoading(false);
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
// Pass token as state for security verification
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
|
||||
@@ -40,12 +40,20 @@ import { PanelToolbar } from '../settings-dialog/panel-layout';
|
||||
|
||||
interface ApiKey {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
key: string;
|
||||
description: string;
|
||||
scopes: string[];
|
||||
status: 'active' | 'revoked';
|
||||
secret_available: false;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
type CreatedApiKey = Omit<ApiKey, 'secret_available'> & {
|
||||
key: string;
|
||||
secret_available: true;
|
||||
};
|
||||
|
||||
interface Webhook {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -71,7 +79,7 @@ export default function ApiIntegrationPanel({
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [newKeyName, setNewKeyName] = useState('');
|
||||
const [newKeyDescription, setNewKeyDescription] = useState('');
|
||||
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
|
||||
const [createdKey, setCreatedKey] = useState<CreatedApiKey | null>(null);
|
||||
const [deleteKeyId, setDeleteKeyId] = useState<number | null>(null);
|
||||
|
||||
// Webhook state
|
||||
@@ -135,7 +143,7 @@ export default function ApiIntegrationPanel({
|
||||
const response = (await backendClient.post('/api/v1/apikeys', {
|
||||
name: newKeyName,
|
||||
description: newKeyDescription,
|
||||
})) as { key: ApiKey };
|
||||
})) as { key: CreatedApiKey };
|
||||
|
||||
setCreatedKey(response.key);
|
||||
toast.success(t('common.apiKeyCreated'));
|
||||
@@ -184,11 +192,6 @@ export default function ApiIntegrationPanel({
|
||||
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
const maskApiKey = (key: string) => {
|
||||
if (key.length <= 8) return key;
|
||||
return `${key.substring(0, 8)}...${key.substring(key.length - 4)}`;
|
||||
};
|
||||
|
||||
// Webhook methods
|
||||
const loadWebhooks = async () => {
|
||||
setLoading(true);
|
||||
@@ -337,25 +340,12 @@ export default function ApiIntegrationPanel({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
</code>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('common.apiKeyStoredSecurely')}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import QrCodeLoginDialog, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
@@ -24,7 +25,15 @@ import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Globe, Info, QrCode } from 'lucide-react';
|
||||
import {
|
||||
Copy,
|
||||
Check,
|
||||
Globe,
|
||||
Info,
|
||||
QrCode,
|
||||
Download,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -33,6 +42,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
|
||||
/**
|
||||
* Resolve the value referenced by a `show_if.field` string.
|
||||
@@ -134,6 +144,7 @@ function getValueSchema(spec: DynamicFormValueSpec) {
|
||||
return z.object({
|
||||
primary: z.string(),
|
||||
fallbacks: z.array(z.string()),
|
||||
reasoning: z.record(z.string()),
|
||||
});
|
||||
case DynamicFormItemType.PROMPT_EDITOR:
|
||||
return z.array(
|
||||
@@ -291,6 +302,52 @@ function WebhookUrlField({
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadLinkField({
|
||||
label,
|
||||
description,
|
||||
url,
|
||||
filename,
|
||||
helpUrl,
|
||||
helpLabel,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
filename?: string;
|
||||
helpUrl?: string | null;
|
||||
helpLabel: string;
|
||||
}) {
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const downloadUrl = url.startsWith('http') ? url : `${baseUrl}${url}`;
|
||||
|
||||
return (
|
||||
<FormItem className="min-w-0">
|
||||
<FormLabel className="break-words">{label}</FormLabel>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={downloadUrl} download={filename}>
|
||||
<Download className="h-4 w-4" />
|
||||
{label}
|
||||
</a>
|
||||
</Button>
|
||||
{helpUrl && (
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<a href={helpUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
{helpLabel}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="max-w-2xl text-sm break-words text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only component for `__system.*` fields (e.g. the deployment's
|
||||
* outbound IPs that the operator must add to a platform's trusted-IP list).
|
||||
@@ -405,7 +462,7 @@ export default function DynamicFormComponent({
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
// Normalize a form value according to its field type.
|
||||
// This ensures legacy/malformed data (e.g. a plain string for
|
||||
@@ -432,12 +489,24 @@ export default function DynamicFormComponent({
|
||||
(v): v is string => typeof v === 'string',
|
||||
)
|
||||
: [],
|
||||
reasoning:
|
||||
obj.reasoning != null &&
|
||||
typeof obj.reasoning === 'object' &&
|
||||
!Array.isArray(obj.reasoning)
|
||||
? Object.fromEntries(
|
||||
Object.entries(obj.reasoning).filter(
|
||||
(entry): entry is [string, string] =>
|
||||
typeof entry[1] === 'string',
|
||||
),
|
||||
)
|
||||
: {},
|
||||
};
|
||||
}
|
||||
// Legacy string format or any other unexpected type
|
||||
return {
|
||||
primary: typeof value === 'string' ? value : '',
|
||||
fallbacks: [],
|
||||
reasoning: {},
|
||||
};
|
||||
}
|
||||
if (item.type === 'prompt-editor') {
|
||||
@@ -460,6 +529,7 @@ export default function DynamicFormComponent({
|
||||
item.type !== 'webhook-url' &&
|
||||
item.type !== 'embed-code' &&
|
||||
item.type !== 'qr-code-login' &&
|
||||
item.type !== 'download-link' &&
|
||||
!item.name.startsWith(SYSTEM_FIELD_PREFIX),
|
||||
),
|
||||
[itemConfigList],
|
||||
@@ -575,12 +645,9 @@ export default function DynamicFormComponent({
|
||||
// even if the user saves without modifying any field.
|
||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||
const formValues = form.getValues();
|
||||
const initialFinalValues = editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
const initialFinalValues = normalizeDynamicFormValuesForSave(
|
||||
editableValueSpecs,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
onSubmitRef.current?.(initialFinalValues);
|
||||
|
||||
@@ -595,12 +662,9 @@ export default function DynamicFormComponent({
|
||||
|
||||
const subscription = form.watch(() => {
|
||||
const formValues = form.getValues();
|
||||
const finalValues = editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
const finalValues = normalizeDynamicFormValuesForSave(
|
||||
editableValueSpecs,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
onSubmitRef.current?.(finalValues);
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
@@ -780,6 +844,30 @@ export default function DynamicFormComponent({
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === 'download-link') {
|
||||
if (!config.url) return null;
|
||||
|
||||
return (
|
||||
<DownloadLinkField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={config.url}
|
||||
filename={config.download_filename}
|
||||
helpUrl={getAdapterDocUrl(config.help_links, i18n.language)}
|
||||
helpLabel={
|
||||
config.help_label
|
||||
? extractI18nObject(config.help_label)
|
||||
: t('bots.viewAdapterDocs')
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
|
||||
if (config.type === 'qr-code-login') {
|
||||
return (
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
EmbeddingModel,
|
||||
RerankModel,
|
||||
PluginTool,
|
||||
ReasoningLevel,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -66,17 +67,18 @@ import SettingsDialog, {
|
||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
|
||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
|
||||
import ReasoningLevelPicker, {
|
||||
REASONING_LEVELS,
|
||||
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
|
||||
|
||||
const EMPTY_SELECT_ITEM_VALUE = '__langbot_empty_select_item_value__';
|
||||
|
||||
function toSelectValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value : '';
|
||||
}
|
||||
|
||||
function hasNonEmptyUuid<T extends { uuid?: string | null }>(
|
||||
function hasUsableUuid<T extends { uuid?: string | null }>(
|
||||
item: T,
|
||||
): item is T & { uuid: string } {
|
||||
return typeof item.uuid === 'string' && item.uuid.length > 0;
|
||||
return typeof item.uuid === 'string' && item.uuid.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasUsableOptionName(option: { name?: string | null }): boolean {
|
||||
return typeof option.name === 'string' && option.name.trim().length > 0;
|
||||
}
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
@@ -116,7 +118,7 @@ export default function DynamicFormItemComponent({
|
||||
httpClient
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
setLlmModels(resp.models);
|
||||
setLlmModels(resp.models.filter(hasUsableUuid));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('models.getModelListError') + err.msg);
|
||||
@@ -127,7 +129,7 @@ export default function DynamicFormItemComponent({
|
||||
httpClient
|
||||
.getProviderEmbeddingModels()
|
||||
.then((resp) => {
|
||||
setEmbeddingModels(resp.models);
|
||||
setEmbeddingModels(resp.models.filter(hasUsableUuid));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('embedding.getModelListError') + err.msg);
|
||||
@@ -138,7 +140,7 @@ export default function DynamicFormItemComponent({
|
||||
httpClient
|
||||
.getProviderRerankModels()
|
||||
.then((resp) => {
|
||||
setRerankModels(resp.models);
|
||||
setRerankModels(resp.models.filter(hasUsableUuid));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to load rerank models: ' + err.msg);
|
||||
@@ -190,15 +192,10 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
const handleSpaceLogin = () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
httpClient
|
||||
.getSpaceAuthorizeUrl(redirectUri, token)
|
||||
.getSpaceBindAuthorizeUrl(redirectUri)
|
||||
.then((response) => {
|
||||
window.location.href = response.authorize_url;
|
||||
})
|
||||
@@ -242,7 +239,7 @@ export default function DynamicFormItemComponent({
|
||||
httpClient
|
||||
.getKnowledgeBases()
|
||||
.then((resp) => {
|
||||
setKnowledgeBases(resp.bases);
|
||||
setKnowledgeBases(resp.bases.filter(hasUsableUuid));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
|
||||
@@ -255,7 +252,7 @@ export default function DynamicFormItemComponent({
|
||||
httpClient
|
||||
.getBots()
|
||||
.then((resp) => {
|
||||
setBots(resp.bots);
|
||||
setBots(resp.bots.filter(hasUsableUuid));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('bots.getBotListError') + err.msg);
|
||||
@@ -392,33 +389,19 @@ export default function DynamicFormItemComponent({
|
||||
</div>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.SELECT: {
|
||||
const hasEmptyOption =
|
||||
config.options?.some((option) => option.name === '') ?? false;
|
||||
const selectValue =
|
||||
hasEmptyOption && field.value === ''
|
||||
? EMPTY_SELECT_ITEM_VALUE
|
||||
: toSelectValue(field.value);
|
||||
|
||||
case DynamicFormItemType.SELECT:
|
||||
return (
|
||||
<Select
|
||||
value={selectValue}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value === EMPTY_SELECT_ITEM_VALUE ? '' : value)
|
||||
}
|
||||
>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="w-full max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{config.options?.map((option, index) => (
|
||||
{config.options?.filter(hasUsableOptionName).map((option) => (
|
||||
<SelectItem
|
||||
key={`${option.name}-${index}`}
|
||||
value={
|
||||
option.name === '' ? EMPTY_SELECT_ITEM_VALUE : option.name
|
||||
}
|
||||
description={option.name || undefined}
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
description={option.name}
|
||||
>
|
||||
{extractI18nObject(option.label)}
|
||||
</SelectItem>
|
||||
@@ -427,7 +410,6 @@ export default function DynamicFormItemComponent({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
||||
// Separate space models from regular models
|
||||
@@ -482,7 +464,7 @@ export default function DynamicFormItemComponent({
|
||||
{Object.entries(groupedModels).map(([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
@@ -586,7 +568,7 @@ export default function DynamicFormItemComponent({
|
||||
{providerName}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
@@ -683,7 +665,7 @@ export default function DynamicFormItemComponent({
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
@@ -775,7 +757,7 @@ export default function DynamicFormItemComponent({
|
||||
{providerName}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
@@ -840,7 +822,7 @@ export default function DynamicFormItemComponent({
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
@@ -896,7 +878,11 @@ export default function DynamicFormItemComponent({
|
||||
];
|
||||
|
||||
const rawModelValue = field.value;
|
||||
const modelValue: { primary: string; fallbacks: string[] } =
|
||||
const modelValue: {
|
||||
primary: string;
|
||||
fallbacks: string[];
|
||||
reasoning: Record<string, ReasoningLevel>;
|
||||
} =
|
||||
rawModelValue != null &&
|
||||
typeof rawModelValue === 'object' &&
|
||||
!Array.isArray(rawModelValue)
|
||||
@@ -915,10 +901,29 @@ export default function DynamicFormItemComponent({
|
||||
.fallbacks as unknown[]
|
||||
).filter((v): v is string => typeof v === 'string')
|
||||
: [],
|
||||
reasoning:
|
||||
(rawModelValue as Record<string, unknown>).reasoning != null &&
|
||||
typeof (rawModelValue as Record<string, unknown>).reasoning ===
|
||||
'object' &&
|
||||
!Array.isArray(
|
||||
(rawModelValue as Record<string, unknown>).reasoning,
|
||||
)
|
||||
? (Object.fromEntries(
|
||||
Object.entries(
|
||||
(rawModelValue as Record<string, unknown>)
|
||||
.reasoning as Record<string, unknown>,
|
||||
).filter(
|
||||
(entry): entry is [string, ReasoningLevel] =>
|
||||
typeof entry[1] === 'string' &&
|
||||
REASONING_LEVELS.includes(entry[1] as ReasoningLevel),
|
||||
),
|
||||
) as Record<string, ReasoningLevel>)
|
||||
: {},
|
||||
}
|
||||
: {
|
||||
primary: typeof rawModelValue === 'string' ? rawModelValue : '',
|
||||
fallbacks: [],
|
||||
reasoning: {},
|
||||
};
|
||||
|
||||
const renderModelSelect = (
|
||||
@@ -935,7 +940,7 @@ export default function DynamicFormItemComponent({
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
@@ -1040,7 +1045,7 @@ export default function DynamicFormItemComponent({
|
||||
{providerName}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.filter(hasNonEmptyUuid).map((model) => (
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
@@ -1065,20 +1070,79 @@ export default function DynamicFormItemComponent({
|
||||
field.onChange({ ...modelValue, ...patch });
|
||||
};
|
||||
|
||||
const updateModelReasoning = (
|
||||
modelUuid: string,
|
||||
level: ReasoningLevel,
|
||||
) => {
|
||||
if (!modelUuid) return;
|
||||
const updated = { ...modelValue.reasoning };
|
||||
if (level === 'provider_default') {
|
||||
delete updated[modelUuid];
|
||||
} else {
|
||||
updated[modelUuid] = level;
|
||||
}
|
||||
updateValue({ reasoning: updated });
|
||||
};
|
||||
|
||||
const replaceModel = (
|
||||
currentUuid: string,
|
||||
nextUuid: string,
|
||||
patch: Partial<typeof modelValue>,
|
||||
) => {
|
||||
const nextValue = { ...modelValue, ...patch };
|
||||
const updatedReasoning = { ...modelValue.reasoning };
|
||||
const currentModelStillSelected =
|
||||
nextValue.primary === currentUuid ||
|
||||
nextValue.fallbacks.includes(currentUuid);
|
||||
if (
|
||||
currentUuid &&
|
||||
currentUuid !== nextUuid &&
|
||||
!currentModelStillSelected
|
||||
) {
|
||||
delete updatedReasoning[currentUuid];
|
||||
}
|
||||
updateValue({ ...nextValue, reasoning: updatedReasoning });
|
||||
};
|
||||
|
||||
const renderReasoningPicker = (modelUuid: string) => {
|
||||
if (!modelUuid) return null;
|
||||
const model = llmModels.find(
|
||||
(candidate) => candidate.uuid === modelUuid,
|
||||
);
|
||||
const currentLevel =
|
||||
modelValue.reasoning[modelUuid] || 'provider_default';
|
||||
const availableLevels = model?.reasoning_capabilities?.levels || [
|
||||
'provider_default',
|
||||
];
|
||||
const levels = REASONING_LEVELS.filter(
|
||||
(level) => availableLevels.includes(level) || level === currentLevel,
|
||||
);
|
||||
|
||||
return (
|
||||
<ReasoningLevelPicker
|
||||
value={currentLevel}
|
||||
levels={levels}
|
||||
onChange={(level) => updateModelReasoning(modelUuid, level)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const addFallbackModel = () => {
|
||||
updateValue({ fallbacks: [...modelValue.fallbacks, ''] });
|
||||
};
|
||||
|
||||
const updateFallbackModel = (index: number, value: string) => {
|
||||
const updated = [...modelValue.fallbacks];
|
||||
const currentUuid = updated[index];
|
||||
updated[index] = value;
|
||||
updateValue({ fallbacks: updated });
|
||||
replaceModel(currentUuid, value, { fallbacks: updated });
|
||||
};
|
||||
|
||||
const removeFallbackModel = (index: number) => {
|
||||
const updated = [...modelValue.fallbacks];
|
||||
const removedUuid = updated[index];
|
||||
updated.splice(index, 1);
|
||||
updateValue({ fallbacks: updated });
|
||||
replaceModel(removedUuid, '', { fallbacks: updated });
|
||||
};
|
||||
|
||||
const moveFallbackModel = (index: number, direction: 'up' | 'down') => {
|
||||
@@ -1103,10 +1167,12 @@ export default function DynamicFormItemComponent({
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderModelSelect(
|
||||
modelValue.primary,
|
||||
(val) => updateValue({ primary: val }),
|
||||
(val) =>
|
||||
replaceModel(modelValue.primary, val, { primary: val }),
|
||||
t('models.selectModel'),
|
||||
)}
|
||||
</div>
|
||||
{renderReasoningPicker(modelValue.primary)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -1140,15 +1206,18 @@ export default function DynamicFormItemComponent({
|
||||
</p>
|
||||
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
|
||||
<div key={index} className="flex min-w-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground w-4 shrink-0">
|
||||
<span className="w-4 shrink-0 text-xs text-muted-foreground">
|
||||
{index + 1}.
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderModelSelect(
|
||||
fbUuid,
|
||||
(val) => updateFallbackModel(index, val),
|
||||
t('models.selectModel'),
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
{renderModelSelect(
|
||||
fbUuid,
|
||||
(val) => updateFallbackModel(index, val),
|
||||
t('models.selectModel'),
|
||||
)}
|
||||
</div>
|
||||
{renderReasoningPicker(fbUuid)}
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
@@ -1203,7 +1272,8 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
||||
// Group KBs by Knowledge Engine name
|
||||
const kbsByEngine = knowledgeBases.reduce(
|
||||
const validKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
|
||||
const kbsByEngine = validKnowledgeBases.reduce(
|
||||
(acc, kb) => {
|
||||
const engineName = kb.knowledge_engine?.name
|
||||
? extractI18nObject(kb.knowledge_engine.name)
|
||||
@@ -1214,7 +1284,7 @@ export default function DynamicFormItemComponent({
|
||||
acc[engineName].push(kb);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof knowledgeBases>,
|
||||
{} as Record<string, typeof validKnowledgeBases>,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1222,7 +1292,7 @@ export default function DynamicFormItemComponent({
|
||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
{field.value && field.value !== '__none__' ? (
|
||||
(() => {
|
||||
const selectedKb = knowledgeBases.find(
|
||||
const selectedKb = validKnowledgeBases.find(
|
||||
(kb) => kb.uuid === field.value,
|
||||
);
|
||||
return (
|
||||
@@ -1250,7 +1320,7 @@ export default function DynamicFormItemComponent({
|
||||
{Object.entries(kbsByEngine).map(([engineName, kbs]) => (
|
||||
<SelectGroup key={engineName}>
|
||||
<SelectLabel>{engineName}</SelectLabel>
|
||||
{kbs.filter(hasNonEmptyUuid).map((base) => (
|
||||
{kbs.map((base) => (
|
||||
<SelectItem key={base.uuid} value={base.uuid}>
|
||||
<div className="flex items-center gap-2">
|
||||
{base.emoji && (
|
||||
@@ -1268,7 +1338,8 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
|
||||
// Group KBs by Knowledge Engine name for multi-selector
|
||||
const multiKbsByEngine = knowledgeBases.reduce(
|
||||
const validMultiKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
|
||||
const multiKbsByEngine = validMultiKnowledgeBases.reduce(
|
||||
(acc, kb) => {
|
||||
const engineName = kb.knowledge_engine?.name
|
||||
? extractI18nObject(kb.knowledge_engine.name)
|
||||
@@ -1279,7 +1350,7 @@ export default function DynamicFormItemComponent({
|
||||
acc[engineName].push(kb);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof knowledgeBases>,
|
||||
{} as Record<string, typeof validMultiKnowledgeBases>,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1288,7 +1359,7 @@ export default function DynamicFormItemComponent({
|
||||
{field.value && field.value.length > 0 ? (
|
||||
<div className="min-w-0 space-y-2">
|
||||
{field.value.map((kbId: string) => {
|
||||
const currentKb = knowledgeBases.find(
|
||||
const currentKb = validMultiKnowledgeBases.find(
|
||||
(base) => base.uuid === kbId,
|
||||
);
|
||||
if (!currentKb) return null;
|
||||
@@ -1374,15 +1445,13 @@ export default function DynamicFormItemComponent({
|
||||
{engineName}
|
||||
</div>
|
||||
{kbs.map((base) => {
|
||||
const isSelected = tempSelectedKBIds.includes(
|
||||
base.uuid ?? '',
|
||||
);
|
||||
const isSelected = tempSelectedKBIds.includes(base.uuid);
|
||||
return (
|
||||
<div
|
||||
key={base.uuid}
|
||||
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
||||
onClick={() => {
|
||||
const kbId = base.uuid ?? '';
|
||||
const kbId = base.uuid;
|
||||
setTempSelectedKBIds((prev) =>
|
||||
prev.includes(kbId)
|
||||
? prev.filter((id) => id !== kbId)
|
||||
@@ -1444,7 +1513,7 @@ export default function DynamicFormItemComponent({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{bots.filter(hasNonEmptyUuid).map((bot) => (
|
||||
{bots.filter(hasUsableUuid).map((bot) => (
|
||||
<SelectItem key={bot.uuid} value={bot.uuid}>
|
||||
{bot.name}
|
||||
</SelectItem>
|
||||
|
||||
@@ -18,6 +18,10 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
options?: IDynamicFormItemOption[];
|
||||
show_if?: IShowIfCondition;
|
||||
login_platform?: string;
|
||||
url?: string;
|
||||
download_filename?: string;
|
||||
help_links?: Record<string, string>;
|
||||
help_label?: I18nObject;
|
||||
|
||||
constructor(params: IDynamicFormItemSchema) {
|
||||
this.id = params.id;
|
||||
@@ -30,6 +34,10 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
|
||||
this.options = params.options;
|
||||
this.show_if = params.show_if;
|
||||
this.login_platform = params.login_platform;
|
||||
this.url = params.url;
|
||||
this.download_filename = params.download_filename;
|
||||
this.help_links = params.help_links;
|
||||
this.help_label = params.help_label;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
|
||||
export type DynamicFormSaveValueSpec = Pick<
|
||||
IDynamicFormItemSchema,
|
||||
'default' | 'name' | 'type'
|
||||
>;
|
||||
|
||||
const reasoningLevels = new Set([
|
||||
'disabled',
|
||||
'enabled',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
]);
|
||||
|
||||
function normalizeModelFallbackValue(value: unknown): {
|
||||
primary: string;
|
||||
fallbacks: string[];
|
||||
reasoning: Record<string, string>;
|
||||
} {
|
||||
const raw =
|
||||
value != null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
const primary =
|
||||
typeof raw.primary === 'string'
|
||||
? raw.primary
|
||||
: typeof value === 'string'
|
||||
? value
|
||||
: '';
|
||||
const fallbacks = Array.isArray(raw.fallbacks)
|
||||
? raw.fallbacks.filter(
|
||||
(fallback): fallback is string => typeof fallback === 'string',
|
||||
)
|
||||
: [];
|
||||
const selectedModels = new Set([primary, ...fallbacks].filter(Boolean));
|
||||
const rawReasoning =
|
||||
raw.reasoning != null &&
|
||||
typeof raw.reasoning === 'object' &&
|
||||
!Array.isArray(raw.reasoning)
|
||||
? (raw.reasoning as Record<string, unknown>)
|
||||
: {};
|
||||
const reasoning = Object.fromEntries(
|
||||
Object.entries(rawReasoning).filter(
|
||||
([modelUuid, level]) =>
|
||||
selectedModels.has(modelUuid) &&
|
||||
typeof level === 'string' &&
|
||||
reasoningLevels.has(level),
|
||||
),
|
||||
) as Record<string, string>;
|
||||
|
||||
return { primary, fallbacks, reasoning };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the value snapshot emitted to parent forms for persistence.
|
||||
* Only single-line string fields trim surrounding whitespace; multiline text
|
||||
* and every other dynamic form field type preserve their original values.
|
||||
*/
|
||||
export function normalizeDynamicFormValuesForSave(
|
||||
specs: readonly DynamicFormSaveValueSpec[],
|
||||
formValues: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return specs.reduce<Record<string, unknown>>((values, spec) => {
|
||||
const value = formValues[spec.name] ?? spec.default;
|
||||
if (spec.type === 'model-fallback-selector') {
|
||||
values[spec.name] = normalizeModelFallbackValue(value);
|
||||
} else {
|
||||
values[spec.name] =
|
||||
spec.type === 'string' && typeof value === 'string'
|
||||
? value.trim()
|
||||
: value;
|
||||
}
|
||||
return values;
|
||||
}, {});
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@/components/ui/form';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
@@ -150,12 +151,9 @@ export default function N8nAuthFormComponent({
|
||||
// Emit initial form values on mount so the parent form's
|
||||
// initializedStagesRef registers this stage (matches DynamicFormComponent).
|
||||
const formValues = form.getValues();
|
||||
const initialFinalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
const initialFinalValues = normalizeDynamicFormValuesForSave(
|
||||
itemConfigList,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
onSubmitRef.current?.(initialFinalValues);
|
||||
previousInitialValues.current = initialFinalValues as Record<
|
||||
@@ -171,12 +169,9 @@ export default function N8nAuthFormComponent({
|
||||
|
||||
// 获取完整的表单值,确保包含所有默认值
|
||||
const formValues = form.getValues();
|
||||
const finalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
const finalValues = normalizeDynamicFormValuesForSave(
|
||||
itemConfigList,
|
||||
formValues as Record<string, unknown>,
|
||||
);
|
||||
|
||||
onSubmitRef.current?.(finalValues);
|
||||
|
||||
@@ -4,7 +4,12 @@ import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { sidebarConfigList } from '@/app/home/components/home-sidebar/sidbarConfigList';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { systemInfo, httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import {
|
||||
clearUserInfo,
|
||||
getCloudServiceClientSync,
|
||||
useCurrentWorkspace,
|
||||
useWorkspaceBootstrap,
|
||||
} from '@/app/infra/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Moon,
|
||||
@@ -28,10 +33,10 @@ import {
|
||||
Zap,
|
||||
FilePlus2,
|
||||
Sparkles,
|
||||
HardDrive,
|
||||
Server,
|
||||
Puzzle,
|
||||
RefreshCcw,
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
@@ -57,6 +62,9 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import WorkspaceSwitcher, {
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
} from '@/app/home/components/workspace-settings/WorkspaceSwitcher';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import SettingsDialog, {
|
||||
SettingsSection,
|
||||
@@ -101,6 +109,11 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useSidebarData, SidebarEntityItem } from './SidebarDataContext';
|
||||
import { FeedbackPopoverContent } from './FeedbackPopover';
|
||||
import {
|
||||
type WorkspaceQuotaItem,
|
||||
useWorkspaceQuotaStatus,
|
||||
} from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
|
||||
// Compare two version strings, returns true if v1 > v2
|
||||
function compareVersions(v1: string, v2: string): boolean {
|
||||
@@ -264,6 +277,56 @@ function saveListExpansionState(state: SidebarListExpansionState) {
|
||||
|
||||
// Maximum number of entity sub-items visible before "More" toggle
|
||||
const MAX_VISIBLE_ITEMS = 5;
|
||||
const MCP_REFRESH_POLL_INTERVAL_MS = 1000;
|
||||
const MCP_REFRESH_TIMEOUT_MS = 60000;
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
const UNLIMITED_QUOTA: WorkspaceQuotaItem = {
|
||||
count: 0,
|
||||
max: -1,
|
||||
reached: false,
|
||||
loading: false,
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
async function waitForMCPRefreshTask(taskId: number) {
|
||||
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const task = await httpClient.getAsyncTask(taskId);
|
||||
if (task.runtime.done) return task;
|
||||
await sleep(MCP_REFRESH_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for MCP refresh task ${taskId}`);
|
||||
}
|
||||
|
||||
async function refreshEnabledMCPConnections() {
|
||||
const resp = await httpClient.getMCPServers();
|
||||
const enabledServers = resp.servers.filter((server) => server.enable);
|
||||
if (enabledServers.length === 0) return;
|
||||
|
||||
const taskResults = await Promise.allSettled(
|
||||
enabledServers.map((server) => httpClient.testMCPServer(server.name, {})),
|
||||
);
|
||||
const taskIds: number[] = [];
|
||||
|
||||
for (const result of taskResults) {
|
||||
if (
|
||||
result.status === 'fulfilled' &&
|
||||
typeof result.value.task_id === 'number'
|
||||
) {
|
||||
taskIds.push(result.value.task_id);
|
||||
} else if (result.status === 'rejected') {
|
||||
console.error('Failed to start MCP refresh task:', result.reason);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(taskIds.map(waitForMCPRefreshTask));
|
||||
}
|
||||
|
||||
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
|
||||
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
|
||||
@@ -336,8 +399,14 @@ function NavItems({
|
||||
const pathname = location.pathname;
|
||||
const [searchParams] = useSearchParams();
|
||||
const sidebarData = useSidebarData();
|
||||
const quotaStatus = useWorkspaceQuotaStatus();
|
||||
const { state: sidebarState, isMobile } = useSidebar();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManageResources =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperateRuntime =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
// Track which entity categories have their full list expanded
|
||||
const [expandedLists, setExpandedLists] = useState<SidebarListExpansionState>(
|
||||
loadListExpansionState,
|
||||
@@ -352,11 +421,19 @@ function NavItems({
|
||||
if (extRefreshing) return;
|
||||
setExtRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
const results = await Promise.allSettled([
|
||||
sidebarData.refreshPlugins(),
|
||||
sidebarData.refreshMCPServers(),
|
||||
sidebarData.refreshSkills(),
|
||||
refreshEnabledMCPConnections(),
|
||||
]);
|
||||
const mcpRefreshResult = results[2];
|
||||
if (mcpRefreshResult.status === 'rejected') {
|
||||
console.error(
|
||||
'Failed to refresh MCP connections:',
|
||||
mcpRefreshResult.reason,
|
||||
);
|
||||
}
|
||||
await sidebarData.refreshMCPServers();
|
||||
} finally {
|
||||
setExtRefreshing(false);
|
||||
}
|
||||
@@ -463,7 +540,10 @@ function NavItems({
|
||||
<>
|
||||
{sectionItems.map((config) => {
|
||||
if (!isEntityCategory(config.id)) {
|
||||
// Non-entity entries (e.g. monitoring, market, mcp) render as plain links
|
||||
if (config.id === 'add-extension' && !canManageResources) {
|
||||
return null;
|
||||
}
|
||||
// Non-entity entries (e.g. monitoring and the extension market) render as plain links.
|
||||
return (
|
||||
<SidebarMenuItem key={config.id}>
|
||||
<SidebarMenuButton
|
||||
@@ -502,12 +582,25 @@ function NavItems({
|
||||
: sidebarData[entityKey];
|
||||
const routePrefix = ENTITY_ROUTE_MAP[categoryId];
|
||||
const hasDetailPages = DETAIL_PAGE_CATEGORIES.includes(categoryId);
|
||||
const canCreate = CREATABLE_CATEGORIES.includes(categoryId);
|
||||
const canCreate =
|
||||
canManageResources && CREATABLE_CATEGORIES.includes(categoryId);
|
||||
const isCollapseOnly = COLLAPSIBLE_ONLY_CATEGORIES.includes(categoryId);
|
||||
const isPlugin = categoryId === 'plugins';
|
||||
const isSkill = categoryId === 'skills';
|
||||
const isBot = categoryId === 'bots';
|
||||
const isMCP = categoryId === 'mcp';
|
||||
const quota =
|
||||
categoryId === 'bots'
|
||||
? quotaStatus.bots
|
||||
: categoryId === 'pipelines'
|
||||
? quotaStatus.pipelines
|
||||
: categoryId === 'knowledge'
|
||||
? quotaStatus.knowledgeBases
|
||||
: categoryId === 'plugins' ||
|
||||
categoryId === 'mcp' ||
|
||||
categoryId === 'skills'
|
||||
? quotaStatus.extensions
|
||||
: UNLIMITED_QUOTA;
|
||||
|
||||
const resolveItemRoute = (item: SidebarEntityItem): string => {
|
||||
if (item.extensionType === 'mcp') {
|
||||
@@ -750,6 +843,7 @@ function NavItems({
|
||||
{itemIsPluginType && !item.debug && (
|
||||
<PluginItemMenu
|
||||
item={item}
|
||||
canManage={canManageResources}
|
||||
onUpdate={() => handlePluginUpdate(item)}
|
||||
onDelete={() => handlePluginDelete(item)}
|
||||
/>
|
||||
@@ -839,128 +933,144 @@ function NavItems({
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1 px-2">
|
||||
<span className="text-sm font-medium">{config.name}</span>
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
{canCreate && (
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={quota}
|
||||
resource={config.name}
|
||||
side="right"
|
||||
>
|
||||
{isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
onClick={() => {
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={() => {
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
setPopoverOpen((prev) => ({
|
||||
...prev,
|
||||
[config.id]: false,
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</WorkspaceQuotaTooltip>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 max-h-80 overflow-y-auto">
|
||||
{renderEntityList(true)}
|
||||
@@ -1013,7 +1123,7 @@ function NavItems({
|
||||
{config.name}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{isExtensionsCategory && (
|
||||
{isExtensionsCategory && canOperateRuntime && (
|
||||
<button
|
||||
type="button"
|
||||
title={t('common.refresh', '刷新')}
|
||||
@@ -1028,103 +1138,119 @@ function NavItems({
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
{canCreate && (
|
||||
<WorkspaceQuotaTooltip
|
||||
quota={quota}
|
||||
resource={config.name}
|
||||
side="right"
|
||||
>
|
||||
{isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension');
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : isSkill ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/skills?action=create');
|
||||
}}
|
||||
>
|
||||
<FilePlus2 className="size-4" />
|
||||
{t('skills.createManually')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('skills.uploadZip')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/add-extension?manual=1');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('skills.importFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={quota.disabled}
|
||||
aria-disabled={quota.disabled}
|
||||
aria-label={`${t('common.create')} ${config.name}`}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all disabled:pointer-events-none disabled:opacity-40"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</WorkspaceQuotaTooltip>
|
||||
)}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1280,10 +1406,12 @@ function NavItems({
|
||||
// Dropdown menu for plugin sidebar sub-items (shown on hover)
|
||||
function PluginItemMenu({
|
||||
item,
|
||||
canManage,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
item: SidebarEntityItem;
|
||||
canManage: boolean;
|
||||
onUpdate: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
@@ -1294,6 +1422,8 @@ function PluginItemMenu({
|
||||
const isGithub = item.installSource === 'github';
|
||||
const hasSourceLink = isMarketplace || isGithub;
|
||||
|
||||
if (!canManage && !hasSourceLink) return null;
|
||||
|
||||
function handleViewSource() {
|
||||
const slashIdx = item.id.indexOf('/');
|
||||
const author = slashIdx >= 0 ? item.id.substring(0, slashIdx) : '';
|
||||
@@ -1334,7 +1464,7 @@ function PluginItemMenu({
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
{isMarketplace && (
|
||||
{canManage && isMarketplace && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
@@ -1363,16 +1493,18 @@ function PluginItemMenu({
|
||||
<span>{t('plugins.viewSource')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-600 focus:text-red-600"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash className="size-4" />
|
||||
<span>{t('plugins.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
{canManage && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-red-600 focus:text-red-600"
|
||||
onClick={() => {
|
||||
onDelete();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Trash className="size-4" />
|
||||
<span>{t('plugins.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
@@ -1562,6 +1694,14 @@ export default function HomeSidebar({
|
||||
useState<Record<string, boolean>>(loadSectionState);
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaces = useWorkspaceBootstrap();
|
||||
const showWorkspaceSwitcher =
|
||||
workspaces.length > 1 ||
|
||||
currentWorkspace?.workspace.source === 'cloud_projection';
|
||||
const canViewStorageAnalysis =
|
||||
currentWorkspace?.workspace.source !== 'cloud_projection' &&
|
||||
currentWorkspace?.permissions.includes('audit.view');
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsSection, setSettingsSection] =
|
||||
useState<SettingsSection>('models');
|
||||
@@ -1605,6 +1745,19 @@ export default function HomeSidebar({
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const openWorkspaceSettings = () => openSettings('workspace');
|
||||
window.addEventListener(
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
openWorkspaceSettings,
|
||||
);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
OPEN_WORKSPACE_SETTINGS_EVENT,
|
||||
openWorkspaceSettings,
|
||||
);
|
||||
});
|
||||
|
||||
function handleSettingsSectionChange(section: SettingsSection) {
|
||||
setSettingsSection(section);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
@@ -1628,10 +1781,6 @@ export default function HomeSidebar({
|
||||
|
||||
useEffect(() => {
|
||||
initSelect();
|
||||
if (!localStorage.getItem('token')) {
|
||||
localStorage.setItem('token', 'test-token');
|
||||
localStorage.setItem('userEmail', 'test@example.com');
|
||||
}
|
||||
|
||||
const storedEmail = localStorage.getItem('userEmail');
|
||||
if (storedEmail) {
|
||||
@@ -1770,6 +1919,7 @@ export default function HomeSidebar({
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
clearUserInfo();
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
window.location.href = '/login';
|
||||
@@ -1830,6 +1980,12 @@ export default function HomeSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
{showWorkspaceSwitcher && (
|
||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
||||
<WorkspaceSwitcher className="w-full group-data-[collapsible=icon]:min-w-0 group-data-[collapsible=icon]:px-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation items grouped by section */}
|
||||
<div className="relative flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<SidebarContent ref={navigationContentRef} className="min-h-0 pb-8">
|
||||
@@ -1898,18 +2054,20 @@ export default function HomeSidebar({
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
{/* API Integration entry */}
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('apiIntegration')}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{/* API-key management is available only to authorized Workspace roles. */}
|
||||
{currentWorkspace?.permissions.includes('api_key.manage') && (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
onClick={() => openSettings('apiIntegration')}
|
||||
tooltip={t('common.apiIntegration')}
|
||||
>
|
||||
<KeyRound className="size-4 text-blue-500" />
|
||||
<span>{t('common.apiIntegration')}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)}
|
||||
|
||||
{/* User menu using sidebar-07 nav-user DropdownMenu pattern */}
|
||||
<SidebarMenu>
|
||||
@@ -2001,12 +2159,22 @@ export default function HomeSidebar({
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('storageAnalysis');
|
||||
openSettings('workspace');
|
||||
}}
|
||||
>
|
||||
<HardDrive />
|
||||
{t('storageAnalysis.title')}
|
||||
<UsersRound />
|
||||
{t('workspace.settings')}
|
||||
</DropdownMenuItem>
|
||||
{canViewStorageAnalysis && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
openSettings('storageAnalysis');
|
||||
}}
|
||||
>
|
||||
{t('storageAnalysis.title')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
@@ -48,9 +49,11 @@ export interface SidebarDataContextValue {
|
||||
pipelines: SidebarEntityItem[];
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
pluginCount: number;
|
||||
mcpServers: SidebarEntityItem[];
|
||||
skills: SidebarEntityItem[];
|
||||
pluginPages: PluginPageItem[];
|
||||
quotaDataLoaded: boolean;
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
@@ -77,9 +80,36 @@ export function SidebarDataProvider({
|
||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginCount, setPluginCount] = useState(0);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [skills, setSkills] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||
const [quotaDataLoaded, setQuotaDataLoaded] = useState(false);
|
||||
const refreshRequestIds = useRef({
|
||||
bots: 0,
|
||||
pipelines: 0,
|
||||
knowledgeBases: 0,
|
||||
plugins: 0,
|
||||
mcpServers: 0,
|
||||
skills: 0,
|
||||
});
|
||||
const quotaResourceLoaded = useRef({
|
||||
bots: false,
|
||||
pipelines: false,
|
||||
knowledgeBases: false,
|
||||
plugins: false,
|
||||
mcpServers: false,
|
||||
skills: false,
|
||||
});
|
||||
const setQuotaResourceLoaded = useCallback(
|
||||
(resource: keyof typeof quotaResourceLoaded.current, loaded: boolean) => {
|
||||
quotaResourceLoaded.current[resource] = loaded;
|
||||
setQuotaDataLoaded(
|
||||
Object.values(quotaResourceLoaded.current).every(Boolean),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [extensionsGroupByType, setExtensionsGroupByTypeState] =
|
||||
useState<boolean>(() => {
|
||||
@@ -96,8 +126,11 @@ export function SidebarDataProvider({
|
||||
}, []);
|
||||
|
||||
const refreshBots = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.bots;
|
||||
try {
|
||||
const resp = await httpClient.getBots();
|
||||
if (requestId !== refreshRequestIds.current.bots) return;
|
||||
setQuotaResourceLoaded('bots', true);
|
||||
setBots(
|
||||
resp.bots.map((bot) => ({
|
||||
id: bot.uuid || '',
|
||||
@@ -109,13 +142,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.bots) return;
|
||||
setQuotaResourceLoaded('bots', false);
|
||||
console.error('Failed to fetch bots for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshPipelines = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.pipelines;
|
||||
try {
|
||||
const resp = await httpClient.getPipelines();
|
||||
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||
setQuotaResourceLoaded('pipelines', true);
|
||||
setPipelines(
|
||||
resp.pipelines.map((p) => ({
|
||||
id: p.uuid || '',
|
||||
@@ -126,13 +164,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.pipelines) return;
|
||||
setQuotaResourceLoaded('pipelines', false);
|
||||
console.error('Failed to fetch pipelines for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshKnowledgeBases = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.knowledgeBases;
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||
setQuotaResourceLoaded('knowledgeBases', true);
|
||||
setKnowledgeBases(
|
||||
resp.bases.map((kb) => ({
|
||||
id: kb.uuid || '',
|
||||
@@ -143,11 +186,14 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.knowledgeBases) return;
|
||||
setQuotaResourceLoaded('knowledgeBases', false);
|
||||
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshPlugins = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.plugins;
|
||||
try {
|
||||
const [pluginsResp, marketplaceResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
@@ -155,6 +201,9 @@ export function SidebarDataProvider({
|
||||
.getMarketplacePlugins(1, 100)
|
||||
.catch(() => ({ plugins: [] })),
|
||||
]);
|
||||
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||
setQuotaResourceLoaded('plugins', true);
|
||||
setPluginCount(pluginsResp.plugins?.length ?? 0);
|
||||
|
||||
// Build marketplace version lookup: "author/name" -> latest_version
|
||||
const marketplaceVersions = new Map<string, string>();
|
||||
@@ -166,6 +215,19 @@ export function SidebarDataProvider({
|
||||
|
||||
// Deduplicate plugins by composite key (prefer debug over installed)
|
||||
const pluginMap = new Map<string, SidebarEntityItem>();
|
||||
const pluginIconURLs = new Map<string, string>(
|
||||
await Promise.all(
|
||||
pluginsResp.plugins.map(async (plugin) => {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const url = await httpClient
|
||||
.getAuthenticatedPluginIconURL(author, name)
|
||||
.catch(() => '');
|
||||
return [`${author}/${name}`, url] as const;
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const plugin of pluginsResp.plugins) {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
@@ -184,7 +246,7 @@ export function SidebarDataProvider({
|
||||
const item: SidebarEntityItem = {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
iconURL: pluginIconURLs.get(compositeKey) || '',
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
@@ -218,7 +280,7 @@ export function SidebarDataProvider({
|
||||
pluginAuthor: author,
|
||||
pluginName: name,
|
||||
pluginLabel: label,
|
||||
pluginIconURL: httpClient.getPluginIconURL(author, name),
|
||||
pluginIconURL: pluginIconURLs.get(`${author}/${name}`) || '',
|
||||
pageId: page.id,
|
||||
path: page.path,
|
||||
});
|
||||
@@ -228,13 +290,18 @@ export function SidebarDataProvider({
|
||||
}
|
||||
setPluginPages(pages);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.plugins) return;
|
||||
setQuotaResourceLoaded('plugins', false);
|
||||
console.error('Failed to fetch plugins for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshMCPServers = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.mcpServers;
|
||||
try {
|
||||
const resp = await httpClient.getMCPServers();
|
||||
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||
setQuotaResourceLoaded('mcpServers', true);
|
||||
setMCPServers(
|
||||
resp.servers.map((server) => ({
|
||||
id: server.name, // Keep __ for API calls
|
||||
@@ -244,13 +311,18 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.mcpServers) return;
|
||||
setQuotaResourceLoaded('mcpServers', false);
|
||||
console.error('Failed to fetch MCP servers for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshSkills = useCallback(async () => {
|
||||
const requestId = ++refreshRequestIds.current.skills;
|
||||
try {
|
||||
const resp = await httpClient.getSkills();
|
||||
if (requestId !== refreshRequestIds.current.skills) return;
|
||||
setQuotaResourceLoaded('skills', true);
|
||||
setSkills(
|
||||
resp.skills.map((skill) => ({
|
||||
id: skill.name,
|
||||
@@ -260,11 +332,22 @@ export function SidebarDataProvider({
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (requestId !== refreshRequestIds.current.skills) return;
|
||||
setQuotaResourceLoaded('skills', false);
|
||||
console.error('Failed to fetch skills for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
}, [setQuotaResourceLoaded]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
quotaResourceLoaded.current = {
|
||||
bots: false,
|
||||
pipelines: false,
|
||||
knowledgeBases: false,
|
||||
plugins: false,
|
||||
mcpServers: false,
|
||||
skills: false,
|
||||
};
|
||||
setQuotaDataLoaded(false);
|
||||
await Promise.all([
|
||||
refreshBots(),
|
||||
refreshPipelines(),
|
||||
@@ -294,9 +377,11 @@ export function SidebarDataProvider({
|
||||
pipelines,
|
||||
knowledgeBases,
|
||||
plugins,
|
||||
pluginCount,
|
||||
mcpServers,
|
||||
skills,
|
||||
pluginPages,
|
||||
quotaDataLoaded,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Boxes } from 'lucide-react';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ModelProvider } from '@/app/infra/entities/api';
|
||||
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,6 +15,7 @@ import ProviderForm from './component/provider-form/ProviderForm';
|
||||
import { ProviderCard } from './components';
|
||||
import {
|
||||
ExtraArg,
|
||||
DEFAULT_REASONING_CONFIG,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
SelectedScannedModel,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
} from './types';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { PanelBody } from '../settings-dialog/panel-layout';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
|
||||
|
||||
interface ModelsPanelProps {
|
||||
// True when this panel is the active section and the dialog is open.
|
||||
@@ -83,10 +86,13 @@ export default function ModelsPanel({
|
||||
onBlockingChange,
|
||||
}: ModelsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
|
||||
|
||||
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
||||
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
|
||||
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
|
||||
const [spaceBilling, setSpaceBilling] =
|
||||
useState<WorkspaceSpaceBilling | null>(null);
|
||||
|
||||
// Expanded providers and their models
|
||||
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
|
||||
@@ -140,7 +146,7 @@ export default function ModelsPanel({
|
||||
|
||||
useEffect(() => {
|
||||
if (active) {
|
||||
loadUserInfo();
|
||||
loadWorkspaceBilling();
|
||||
loadProviders();
|
||||
loadRequesterSupportTypes();
|
||||
}
|
||||
@@ -163,16 +169,11 @@ export default function ModelsPanel({
|
||||
}
|
||||
}, [providersLoaded, providers]);
|
||||
|
||||
async function loadUserInfo() {
|
||||
async function loadWorkspaceBilling() {
|
||||
try {
|
||||
const userInfo = await httpClient.getUserInfo();
|
||||
setAccountType(userInfo.account_type);
|
||||
if (userInfo.account_type === 'space') {
|
||||
const creditsInfo = await httpClient.getSpaceCredits();
|
||||
setSpaceCredits(creditsInfo.credits);
|
||||
}
|
||||
setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
|
||||
} catch {
|
||||
setAccountType('local');
|
||||
setSpaceBilling(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,17 +271,9 @@ export default function ModelsPanel({
|
||||
|
||||
async function handleSpaceLogin() {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
const response = await httpClient.getSpaceAuthorizeUrl(
|
||||
redirectUri,
|
||||
token,
|
||||
);
|
||||
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
|
||||
window.location.href = response.authorize_url;
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
@@ -293,6 +286,7 @@ export default function ModelsPanel({
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) {
|
||||
if (!name.trim()) {
|
||||
@@ -308,6 +302,7 @@ export default function ModelsPanel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities,
|
||||
reasoning_config: reasoningConfig,
|
||||
context_length: parseContextLength(
|
||||
contextLength,
|
||||
t('models.contextLengthInvalid'),
|
||||
@@ -369,6 +364,7 @@ export default function ModelsPanel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities: item.abilities,
|
||||
reasoning_config: DEFAULT_REASONING_CONFIG,
|
||||
context_length: item.model.context_length ?? null,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
@@ -406,6 +402,7 @@ export default function ModelsPanel({
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) {
|
||||
if (!name.trim()) {
|
||||
@@ -421,6 +418,7 @@ export default function ModelsPanel({
|
||||
name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities,
|
||||
reasoning_config: reasoningConfig,
|
||||
context_length: parseContextLength(
|
||||
contextLength,
|
||||
t('models.contextLengthInvalid'),
|
||||
@@ -477,6 +475,7 @@ export default function ModelsPanel({
|
||||
modelType: ModelType,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
) {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
@@ -499,6 +498,7 @@ export default function ModelsPanel({
|
||||
provider_uuid: '',
|
||||
provider: providerData,
|
||||
abilities,
|
||||
reasoning_config: reasoningConfig,
|
||||
extra_args: extraArgsObj,
|
||||
} as never);
|
||||
} else if (modelType === 'embedding') {
|
||||
@@ -544,13 +544,15 @@ export default function ModelsPanel({
|
||||
<ProviderCard
|
||||
key={provider.uuid}
|
||||
provider={provider}
|
||||
canManage={canManage}
|
||||
isLangBotModels={isLangBotModels}
|
||||
supportTypes={requesterSupportTypes[provider.requester]}
|
||||
isExpanded={expandedProviders.has(provider.uuid)}
|
||||
isLoading={loadingProviders.has(provider.uuid)}
|
||||
models={providerModels[provider.uuid]}
|
||||
accountType={accountType}
|
||||
spaceCredits={spaceCredits}
|
||||
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
|
||||
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
|
||||
spaceCredits={spaceBilling?.credits ?? null}
|
||||
addModelPopoverOpen={addModelPopoverOpen}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
deleteConfirmOpen={deleteConfirmOpen}
|
||||
@@ -560,13 +562,21 @@ export default function ModelsPanel({
|
||||
onSpaceLogin={handleSpaceLogin}
|
||||
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
|
||||
onCloseAddModel={() => setAddModelPopoverOpen(null)}
|
||||
onAddModel={(modelType, name, abilities, extraArgs, contextLength) =>
|
||||
onAddModel={(
|
||||
modelType,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
contextLength,
|
||||
) =>
|
||||
handleAddModel(
|
||||
provider.uuid,
|
||||
modelType,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
contextLength,
|
||||
)
|
||||
}
|
||||
@@ -582,6 +592,7 @@ export default function ModelsPanel({
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
contextLength,
|
||||
) =>
|
||||
handleUpdateModel(
|
||||
@@ -591,6 +602,7 @@ export default function ModelsPanel({
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
contextLength,
|
||||
)
|
||||
}
|
||||
@@ -599,8 +611,15 @@ export default function ModelsPanel({
|
||||
onDeleteModel={(modelId, modelType) =>
|
||||
handleDeleteModel(provider.uuid, modelId, modelType)
|
||||
}
|
||||
onTestModel={(name, modelType, abilities, extraArgs) =>
|
||||
handleTestModel(provider.uuid, name, modelType, abilities, extraArgs)
|
||||
onTestModel={(name, modelType, abilities, extraArgs, reasoningConfig) =>
|
||||
handleTestModel(
|
||||
provider.uuid,
|
||||
name,
|
||||
modelType,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
)
|
||||
}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
@@ -628,10 +647,12 @@ export default function ModelsPanel({
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ArrowUpDown,
|
||||
Eye,
|
||||
Wrench,
|
||||
BrainCircuit,
|
||||
Check,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
@@ -20,8 +21,12 @@ import {
|
||||
} from '@/components/ui/popover';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||
import {
|
||||
ReasoningConfig,
|
||||
ScannedProviderModel,
|
||||
} from '@/app/infra/entities/api';
|
||||
import {
|
||||
DEFAULT_REASONING_CONFIG,
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
ScanModelsResult,
|
||||
@@ -42,6 +47,7 @@ interface AddModelPopoverProps {
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
@@ -54,6 +60,7 @@ interface AddModelPopoverProps {
|
||||
modelType: ModelType,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
isTesting: boolean;
|
||||
@@ -143,11 +150,24 @@ export default function AddModelPopover({
|
||||
tab === 'llm' && contextLength.trim()
|
||||
? Number(contextLength.trim())
|
||||
: null;
|
||||
await onAddModel(tab, name, abilities, extraArgs, parsedContextLength);
|
||||
await onAddModel(
|
||||
tab,
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
DEFAULT_REASONING_CONFIG,
|
||||
parsedContextLength,
|
||||
);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
await onTestModel(name, tab, tab === 'llm' ? abilities : [], extraArgs);
|
||||
await onTestModel(
|
||||
name,
|
||||
tab,
|
||||
tab === 'llm' ? abilities : [],
|
||||
extraArgs,
|
||||
DEFAULT_REASONING_CONFIG,
|
||||
);
|
||||
};
|
||||
|
||||
const handleScan = async () => {
|
||||
@@ -322,7 +342,7 @@ export default function AddModelPopover({
|
||||
{tab === 'llm' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.abilities')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-vision"
|
||||
@@ -349,6 +369,19 @@ export default function AddModelPopover({
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-reasoning"
|
||||
checked={abilities.includes('reasoning')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('reasoning', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-reasoning" className="text-sm">
|
||||
<BrainCircuit className="h-3 w-3 inline mr-1" />
|
||||
{t('models.reasoningAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Trash2, Eye, Wrench, Check } from 'lucide-react';
|
||||
import { Trash2, Eye, Wrench, Check, BrainCircuit } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -11,13 +11,23 @@ import {
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LLMModel, EmbeddingModel } from '@/app/infra/entities/api';
|
||||
import { ExtraArg, ModelType, TestResult } from '../types';
|
||||
import {
|
||||
LLMModel,
|
||||
EmbeddingModel,
|
||||
ReasoningConfig,
|
||||
} from '@/app/infra/entities/api';
|
||||
import {
|
||||
DEFAULT_REASONING_CONFIG,
|
||||
ExtraArg,
|
||||
ModelType,
|
||||
TestResult,
|
||||
} from '../types';
|
||||
import ExtraArgsEditor from './ExtraArgsEditor';
|
||||
import { userInfo } from '@/app/infra/http';
|
||||
|
||||
interface ModelItemProps {
|
||||
model: LLMModel | EmbeddingModel;
|
||||
canManage: boolean;
|
||||
modelType: ModelType;
|
||||
isLangBotModels: boolean;
|
||||
editModelPopoverOpen: string | null;
|
||||
@@ -31,12 +41,14 @@ interface ModelItemProps {
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) => Promise<void>;
|
||||
onTestModel: (
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
isTesting: boolean;
|
||||
@@ -71,6 +83,7 @@ function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
|
||||
|
||||
export default function ModelItem({
|
||||
model,
|
||||
canManage,
|
||||
modelType,
|
||||
isLangBotModels,
|
||||
editModelPopoverOpen,
|
||||
@@ -101,7 +114,6 @@ export default function ModelItem({
|
||||
const [editExtraArgs, setEditExtraArgs] = useState<ExtraArg[]>(
|
||||
convertExtraArgsToArray(model.extra_args),
|
||||
);
|
||||
|
||||
const isEditOpen = editModelPopoverOpen === model.uuid;
|
||||
const isDeleteOpen = deleteConfirmOpen === model.uuid;
|
||||
|
||||
@@ -131,12 +143,20 @@ export default function ModelItem({
|
||||
editName,
|
||||
editAbilities,
|
||||
editExtraArgs,
|
||||
modelType === 'llm'
|
||||
? (model as LLMModel).reasoning_config || DEFAULT_REASONING_CONFIG
|
||||
: DEFAULT_REASONING_CONFIG,
|
||||
parsedContextLength,
|
||||
);
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
await onTestModel(editName, editAbilities, editExtraArgs);
|
||||
await onTestModel(
|
||||
editName,
|
||||
editAbilities,
|
||||
editExtraArgs,
|
||||
DEFAULT_REASONING_CONFIG,
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAbility = (ability: string, checked: boolean) => {
|
||||
@@ -147,9 +167,15 @@ export default function ModelItem({
|
||||
}
|
||||
};
|
||||
|
||||
const supportsReasoning =
|
||||
modelType === 'llm' &&
|
||||
((model as LLMModel).reasoning_capabilities?.supported === true ||
|
||||
(model as LLMModel).abilities?.includes('reasoning'));
|
||||
const canSaveModel = !isLangBotModels;
|
||||
|
||||
// Check if popover should be disabled (space models when not logged in)
|
||||
const isPopoverDisabled =
|
||||
isLangBotModels && userInfo?.account_type !== 'space';
|
||||
!canManage || (isLangBotModels && userInfo?.account_type !== 'space');
|
||||
|
||||
return (
|
||||
<Popover
|
||||
@@ -192,8 +218,14 @@ export default function ModelItem({
|
||||
<Wrench className="h-3 w-3" />
|
||||
</Badge>
|
||||
)}
|
||||
{supportsReasoning && (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<BrainCircuit className="h-3 w-3" />
|
||||
{t('models.reasoningAbility')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!isLangBotModels && (
|
||||
{canManage && !isLangBotModels && (
|
||||
<Popover
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={(open) =>
|
||||
@@ -268,7 +300,7 @@ export default function ModelItem({
|
||||
{modelType === 'llm' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.abilities')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`edit-vision-${model.uuid}`}
|
||||
@@ -303,6 +335,23 @@ export default function ModelItem({
|
||||
{t('models.functionCallAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id={`edit-reasoning-${model.uuid}`}
|
||||
checked={editAbilities.includes('reasoning')}
|
||||
disabled={isLangBotModels}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('reasoning', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`edit-reasoning-${model.uuid}`}
|
||||
className="text-sm"
|
||||
>
|
||||
<BrainCircuit className="h-3 w-3 inline mr-1" />
|
||||
{t('models.reasoningAbility')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -334,7 +383,7 @@ export default function ModelItem({
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!isLangBotModels && (
|
||||
{canSaveModel && (
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
@@ -345,7 +394,7 @@ export default function ModelItem({
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={isLangBotModels ? 'w-full' : 'flex-1'}
|
||||
className={canSaveModel ? 'flex-1' : 'w-full'}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ModelProvider } from '@/app/infra/entities/api';
|
||||
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Collapsible,
|
||||
@@ -38,12 +38,14 @@ import AddModelPopover from './AddModelPopover';
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ModelProvider;
|
||||
canManage: boolean;
|
||||
isLangBotModels?: boolean;
|
||||
supportTypes?: string[];
|
||||
isExpanded: boolean;
|
||||
isLoading: boolean;
|
||||
models?: ProviderModels;
|
||||
accountType: 'local' | 'space';
|
||||
isWorkspaceOwner: boolean;
|
||||
ownerSpaceBound: boolean;
|
||||
spaceCredits: number | null;
|
||||
// Popover states
|
||||
addModelPopoverOpen: string | null;
|
||||
@@ -61,6 +63,7 @@ interface ProviderCardProps {
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
@@ -76,6 +79,7 @@ interface ProviderCardProps {
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) => Promise<void>;
|
||||
onOpenDeleteConfirm: (modelId: string) => void;
|
||||
@@ -86,6 +90,7 @@ interface ProviderCardProps {
|
||||
modelType: ModelType,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
isTesting: boolean;
|
||||
@@ -101,12 +106,14 @@ function maskApiKey(key: string): string {
|
||||
|
||||
export default function ProviderCard({
|
||||
provider,
|
||||
canManage,
|
||||
isLangBotModels = false,
|
||||
supportTypes,
|
||||
isExpanded,
|
||||
isLoading,
|
||||
models,
|
||||
accountType,
|
||||
isWorkspaceOwner,
|
||||
ownerSpaceBound,
|
||||
spaceCredits,
|
||||
addModelPopoverOpen,
|
||||
editModelPopoverOpen,
|
||||
@@ -196,7 +203,7 @@ export default function ProviderCard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2 shrink-0">
|
||||
{isLangBotModels && accountType !== 'space' && (
|
||||
{isLangBotModels && isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -206,16 +213,15 @@ export default function ProviderCard({
|
||||
}}
|
||||
>
|
||||
<LogIn className="h-4 w-4 mr-1" />
|
||||
{t('models.loginWithSpace')}
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</Button>
|
||||
)}
|
||||
{isLangBotModels &&
|
||||
accountType === 'space' &&
|
||||
spaceCredits !== null && (
|
||||
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||
<span>
|
||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||
</span>
|
||||
{isLangBotModels && ownerSpaceBound && spaceCredits !== null && (
|
||||
<div className="flex items-center gap-1 border rounded-md px-2 h-8 text-sm mr-2">
|
||||
<span>
|
||||
{(spaceCredits / 5000).toFixed(2)} {t('models.credits')}
|
||||
</span>
|
||||
{isWorkspaceOwner && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -230,9 +236,20 @@ export default function ProviderCard({
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!isLangBotModels && (
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.usesOwnerSpaceBilling')}
|
||||
</span>
|
||||
)}
|
||||
{isLangBotModels && !isWorkspaceOwner && !ownerSpaceBound && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('models.ownerMustBindSpace')}
|
||||
</span>
|
||||
)}
|
||||
{canManage && !isLangBotModels && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -317,7 +334,7 @@ export default function ProviderCard({
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{!isLangBotModels && (
|
||||
{canManage && !isLangBotModels && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AddModelPopover
|
||||
isOpen={
|
||||
@@ -404,6 +421,7 @@ export default function ProviderCard({
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="llm"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
@@ -417,6 +435,7 @@ export default function ProviderCard({
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
contextLength,
|
||||
) =>
|
||||
onUpdateModel(
|
||||
@@ -425,11 +444,23 @@ export default function ProviderCard({
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
contextLength,
|
||||
)
|
||||
}
|
||||
onTestModel={(name, abilities, extraArgs) =>
|
||||
onTestModel(name, 'llm', abilities, extraArgs)
|
||||
onTestModel={(
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
) =>
|
||||
onTestModel(
|
||||
name,
|
||||
'llm',
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
)
|
||||
}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
@@ -441,6 +472,7 @@ export default function ProviderCard({
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="embedding"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
@@ -450,17 +482,34 @@ export default function ProviderCard({
|
||||
onOpenDeleteConfirm={onOpenDeleteConfirm}
|
||||
onCloseDeleteConfirm={onCloseDeleteConfirm}
|
||||
onDeleteModel={() => onDeleteModel(model.uuid, 'embedding')}
|
||||
onUpdateModel={(name, abilities, extraArgs) =>
|
||||
onUpdateModel={(
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
) =>
|
||||
onUpdateModel(
|
||||
model.uuid,
|
||||
'embedding',
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
)
|
||||
}
|
||||
onTestModel={(name, abilities, extraArgs) =>
|
||||
onTestModel(name, 'embedding', abilities, extraArgs)
|
||||
onTestModel={(
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
) =>
|
||||
onTestModel(
|
||||
name,
|
||||
'embedding',
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
)
|
||||
}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
@@ -472,6 +521,7 @@ export default function ProviderCard({
|
||||
<ModelItem
|
||||
key={model.uuid}
|
||||
model={model}
|
||||
canManage={canManage}
|
||||
modelType="rerank"
|
||||
isLangBotModels={isLangBotModels}
|
||||
editModelPopoverOpen={editModelPopoverOpen}
|
||||
@@ -481,17 +531,34 @@ export default function ProviderCard({
|
||||
onOpenDeleteConfirm={onOpenDeleteConfirm}
|
||||
onCloseDeleteConfirm={onCloseDeleteConfirm}
|
||||
onDeleteModel={() => onDeleteModel(model.uuid, 'rerank')}
|
||||
onUpdateModel={(name, abilities, extraArgs) =>
|
||||
onUpdateModel={(
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
) =>
|
||||
onUpdateModel(
|
||||
model.uuid,
|
||||
'rerank',
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
)
|
||||
}
|
||||
onTestModel={(name, abilities, extraArgs) =>
|
||||
onTestModel(name, 'rerank', abilities, extraArgs)
|
||||
onTestModel={(
|
||||
name,
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
) =>
|
||||
onTestModel(
|
||||
name,
|
||||
'rerank',
|
||||
abilities,
|
||||
extraArgs,
|
||||
reasoningConfig,
|
||||
)
|
||||
}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
ModelProvider,
|
||||
ProviderScanDebugInfo,
|
||||
ScannedProviderModel,
|
||||
ReasoningConfig,
|
||||
} from '@/app/infra/entities/api';
|
||||
|
||||
export type ExtraArg = {
|
||||
@@ -16,6 +17,10 @@ export type ExtraArg = {
|
||||
|
||||
export type ModelType = 'llm' | 'embedding' | 'rerank';
|
||||
|
||||
export const DEFAULT_REASONING_CONFIG: ReasoningConfig = {
|
||||
level: 'provider_default',
|
||||
};
|
||||
|
||||
export interface ProviderModels {
|
||||
llm: LLMModel[];
|
||||
embedding: EmbeddingModel[];
|
||||
@@ -53,12 +58,14 @@ export interface ModelItemProps {
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) => Promise<void>;
|
||||
onTest: (
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
isTesting: boolean;
|
||||
@@ -90,6 +97,7 @@ export interface ProviderCardProps {
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
@@ -105,6 +113,7 @@ export interface ProviderCardProps {
|
||||
name: string,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
contextLength?: number | null,
|
||||
) => Promise<void>;
|
||||
onOpenDeleteConfirm: (modelId: string) => void;
|
||||
@@ -115,6 +124,7 @@ export interface ProviderCardProps {
|
||||
modelType: ModelType,
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
reasoningConfig: ReasoningConfig,
|
||||
) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
isTesting: boolean;
|
||||
|
||||
@@ -15,13 +15,15 @@ import {
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||
|
||||
export type QrLoginPlatform =
|
||||
| 'feishu'
|
||||
| 'weixin'
|
||||
| 'dingtalk'
|
||||
| 'wecombot'
|
||||
| 'itchat';
|
||||
| 'itchat'
|
||||
| 'qqofficial';
|
||||
|
||||
interface PlatformConfig {
|
||||
titleKey: string;
|
||||
@@ -34,6 +36,7 @@ interface PlatformConfig {
|
||||
apiBase: string;
|
||||
extractSuccess: (data: Record<string, string>) => Record<string, string>;
|
||||
successNoteKey?: string;
|
||||
boundByKey?: string;
|
||||
}
|
||||
|
||||
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
@@ -54,12 +57,12 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
},
|
||||
weixin: {
|
||||
titleKey: 'weixin.scanLogin',
|
||||
connectingKey: 'feishu.connecting',
|
||||
connectingKey: 'weixin.connecting',
|
||||
scanQRCodeKey: 'weixin.scanQRCode',
|
||||
waitingKey: 'feishu.waitingForScan',
|
||||
waitingKey: 'weixin.waitingForScan',
|
||||
successKey: 'weixin.loginSuccess',
|
||||
failedKey: 'weixin.loginFailed',
|
||||
retryKey: 'feishu.retry',
|
||||
retryKey: 'weixin.retry',
|
||||
apiBase: '/api/v1/platform/adapters/weixin/login',
|
||||
extractSuccess: (data) => ({
|
||||
token: data.token,
|
||||
@@ -111,6 +114,22 @@ const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
|
||||
nickname: data.nickname || '',
|
||||
}),
|
||||
},
|
||||
qqofficial: {
|
||||
titleKey: 'qqofficial.createBinding',
|
||||
connectingKey: 'qqofficial.connecting',
|
||||
scanQRCodeKey: 'qqofficial.scanQRCode',
|
||||
waitingKey: 'qqofficial.waitingForScan',
|
||||
successKey: 'qqofficial.bindSuccess',
|
||||
failedKey: 'qqofficial.bindFailed',
|
||||
retryKey: 'qqofficial.retry',
|
||||
apiBase: '/api/v1/platform/adapters/qqofficial/bind',
|
||||
extractSuccess: (data) => ({
|
||||
appid: data.appid,
|
||||
secret: data.secret,
|
||||
}),
|
||||
successNoteKey: 'qqofficial.tokenNote',
|
||||
boundByKey: 'qqofficial.boundBy',
|
||||
},
|
||||
};
|
||||
|
||||
interface QrCodeLoginDialogProps {
|
||||
@@ -138,11 +157,14 @@ export default function QrCodeLoginDialog({
|
||||
const qrDataUrlRef = useRef('');
|
||||
const [expireIn, setExpireIn] = useState(0);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [successMeta, setSuccessMeta] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const sessionWorkspaceUuidRef = useRef<string | null>(null);
|
||||
const sessionApiBaseRef = useRef('');
|
||||
const baseUrlRef = useRef('');
|
||||
const cleanedRef = useRef(false);
|
||||
|
||||
@@ -177,18 +199,23 @@ export default function QrCodeLoginDialog({
|
||||
}
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const workspaceUuid = sessionWorkspaceUuidRef.current;
|
||||
fetch(
|
||||
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
|
||||
`${baseUrlRef.current}${sessionApiBaseRef.current}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
|
||||
},
|
||||
keepalive: true,
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
sessionWorkspaceUuidRef.current = null;
|
||||
sessionApiBaseRef.current = '';
|
||||
baseUrlRef.current = '';
|
||||
}, []);
|
||||
|
||||
const startLogin = useCallback(async () => {
|
||||
@@ -199,8 +226,10 @@ export default function QrCodeLoginDialog({
|
||||
qrDataUrlRef.current = '';
|
||||
setExpireIn(0);
|
||||
setErrorMessage('');
|
||||
setSuccessMeta('');
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const workspaceUuid = getActiveWorkspaceUuid();
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
baseUrlRef.current = baseUrl;
|
||||
const cfg = platformConfigRef.current;
|
||||
@@ -211,7 +240,10 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
@@ -222,6 +254,8 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||
sessionIdRef.current = session_id;
|
||||
sessionWorkspaceUuidRef.current = workspaceUuid;
|
||||
sessionApiBaseRef.current = cfg.apiBase;
|
||||
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
@@ -269,11 +303,19 @@ export default function QrCodeLoginDialog({
|
||||
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(workspaceUuid
|
||||
? { 'X-Workspace-Id': workspaceUuid }
|
||||
: {}),
|
||||
},
|
||||
keepalive: true,
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
sessionWorkspaceUuidRef.current = null;
|
||||
sessionApiBaseRef.current = '';
|
||||
baseUrlRef.current = '';
|
||||
}
|
||||
setState('expired');
|
||||
}
|
||||
@@ -285,7 +327,12 @@ export default function QrCodeLoginDialog({
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(workspaceUuid ? { 'X-Workspace-Id': workspaceUuid } : {}),
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
|
||||
@@ -298,6 +345,13 @@ export default function QrCodeLoginDialog({
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('success');
|
||||
// Platform may return extra audit metadata (e.g. QQ Official returns
|
||||
// the scanner's user_openid) — surface it briefly before the dialog closes.
|
||||
if (rest.user_openid && cfg.boundByKey) {
|
||||
setSuccessMeta(
|
||||
tRef.current(cfg.boundByKey, { openid: rest.user_openid }),
|
||||
);
|
||||
}
|
||||
setTimeout(() => {
|
||||
onSuccessRef.current(cfg.extractSuccess(rest));
|
||||
onOpenChangeRef.current(false);
|
||||
@@ -431,6 +485,11 @@ export default function QrCodeLoginDialog({
|
||||
<p className="text-sm text-green-600 font-medium">
|
||||
{t(platformConfig.successKey)}
|
||||
</p>
|
||||
{successMeta && (
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs break-all">
|
||||
{successMeta}
|
||||
</p>
|
||||
)}
|
||||
{platformConfig.successNoteKey && (
|
||||
<p className="text-xs text-muted-foreground text-center max-w-xs">
|
||||
{t(platformConfig.successNoteKey)}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { BrainCircuit, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReasoningLevel } from '@/app/infra/entities/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
|
||||
export const REASONING_LEVELS: ReasoningLevel[] = [
|
||||
'provider_default',
|
||||
'disabled',
|
||||
'enabled',
|
||||
'minimal',
|
||||
'low',
|
||||
'medium',
|
||||
'high',
|
||||
'xhigh',
|
||||
'max',
|
||||
];
|
||||
|
||||
export const REASONING_LEVEL_LABEL_KEYS: Record<ReasoningLevel, string> = {
|
||||
provider_default: 'models.reasoningLevels.providerDefault',
|
||||
disabled: 'models.reasoningLevels.disabled',
|
||||
enabled: 'models.reasoningLevels.enabled',
|
||||
minimal: 'models.reasoningLevels.minimal',
|
||||
low: 'models.reasoningLevels.low',
|
||||
medium: 'models.reasoningLevels.medium',
|
||||
high: 'models.reasoningLevels.high',
|
||||
xhigh: 'models.reasoningLevels.xhigh',
|
||||
max: 'models.reasoningLevels.max',
|
||||
};
|
||||
|
||||
interface ReasoningLevelPickerProps {
|
||||
value: ReasoningLevel;
|
||||
levels: ReasoningLevel[];
|
||||
disabled?: boolean;
|
||||
onChange: (value: ReasoningLevel) => void;
|
||||
}
|
||||
|
||||
export default function ReasoningLevelPicker({
|
||||
value,
|
||||
levels,
|
||||
disabled = false,
|
||||
onChange,
|
||||
}: ReasoningLevelPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const safeLevels: ReasoningLevel[] =
|
||||
levels.length > 0 ? levels : ['provider_default'];
|
||||
const safeValue: ReasoningLevel = safeLevels.includes(value)
|
||||
? value
|
||||
: safeLevels[0];
|
||||
const currentLabel = t(REASONING_LEVEL_LABEL_KEYS[safeValue]);
|
||||
const isExplicit = safeValue !== 'provider_default';
|
||||
const currentIndex = Math.max(0, safeLevels.indexOf(safeValue));
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || safeLevels.length <= 1}
|
||||
aria-label={`${t('models.reasoningLevel')}: ${currentLabel}`}
|
||||
className="h-9 w-9 shrink-0 gap-1.5 px-2.5 text-xs font-normal sm:w-auto sm:max-w-36"
|
||||
>
|
||||
<BrainCircuit
|
||||
className={`size-4 shrink-0 ${isExplicit ? 'text-primary' : 'text-muted-foreground'}`}
|
||||
/>
|
||||
<span className="hidden min-w-0 truncate sm:block">
|
||||
{currentLabel}
|
||||
</span>
|
||||
<ChevronDown className="hidden size-3.5 shrink-0 text-muted-foreground sm:block" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[272px] p-4">
|
||||
<div className="flex h-5 items-center gap-0.5 text-sm text-muted-foreground">
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronRight className="size-3.5" />
|
||||
</div>
|
||||
<Slider
|
||||
className="mt-5"
|
||||
min={0}
|
||||
max={Math.max(0, safeLevels.length - 1)}
|
||||
step={1}
|
||||
value={[currentIndex]}
|
||||
aria-label={t('models.reasoningLevel')}
|
||||
aria-valuetext={currentLabel}
|
||||
onValueChange={([index]) => onChange(safeLevels[index])}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KeyRound, Sparkles, Settings, HardDrive } from 'lucide-react';
|
||||
import {
|
||||
HardDrive,
|
||||
KeyRound,
|
||||
Settings,
|
||||
Sparkles,
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,10 +28,13 @@ import AccountSettingsPanel from '@/app/home/components/account-settings-dialog/
|
||||
import ApiIntegrationPanel from '@/app/home/components/api-integration-dialog/ApiIntegrationPanel';
|
||||
import ModelsPanel from '@/app/home/components/models-dialog/ModelsPanel';
|
||||
import StorageAnalysisPanel from '@/app/home/components/storage-analysis-dialog/StorageAnalysisPanel';
|
||||
import WorkspaceSettingsPanel from '@/app/home/components/workspace-settings/WorkspaceSettingsPanel';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
// The set of settings sections shown in the unified dialog. The string values
|
||||
// are also reused as the ?action= query param suffix so deep links keep working.
|
||||
export type SettingsSection =
|
||||
| 'workspace'
|
||||
| 'account'
|
||||
| 'apiIntegration'
|
||||
| 'models'
|
||||
@@ -35,6 +44,7 @@ export type SettingsSection =
|
||||
// (showAccountSettings, showApiIntegrationSettings, showModelSettings,
|
||||
// showStorageAnalysis) continue to resolve to the right section.
|
||||
export const SETTINGS_ACTION_BY_SECTION: Record<SettingsSection, string> = {
|
||||
workspace: 'showWorkspaceSettings',
|
||||
account: 'showAccountSettings',
|
||||
apiIntegration: 'showApiIntegrationSettings',
|
||||
models: 'showModelSettings',
|
||||
@@ -63,6 +73,7 @@ export default function SettingsDialog({
|
||||
onSectionChange,
|
||||
}: SettingsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
// A nested modal (e.g. the provider form) can request that we ignore
|
||||
// outer-close until it is dismissed.
|
||||
const [blocking, setBlocking] = useState(false);
|
||||
@@ -76,13 +87,20 @@ export default function SettingsDialog({
|
||||
}
|
||||
}, [section, open]);
|
||||
|
||||
const navItems: {
|
||||
const allNavItems: {
|
||||
id: SettingsSection;
|
||||
label: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
id: 'workspace',
|
||||
label: t('settingsDialog.nav.workspace'),
|
||||
title: t('workspace.title'),
|
||||
description: t('workspace.description'),
|
||||
icon: <UsersRound className="size-4" />,
|
||||
},
|
||||
{
|
||||
id: 'models',
|
||||
label: t('settingsDialog.nav.models'),
|
||||
@@ -112,6 +130,35 @@ export default function SettingsDialog({
|
||||
icon: <Settings className="size-4" />,
|
||||
},
|
||||
];
|
||||
const permissions = currentWorkspace?.permissions ?? [];
|
||||
const canManageApiKeys = permissions.includes('api_key.manage');
|
||||
const canViewAudit = permissions.includes('audit.view');
|
||||
const canViewStorageAnalysis =
|
||||
currentWorkspace?.workspace.source !== 'cloud_projection' && canViewAudit;
|
||||
const navItems = allNavItems.filter((item) => {
|
||||
if (item.id === 'apiIntegration') {
|
||||
return canManageApiKeys;
|
||||
}
|
||||
if (item.id === 'storageAnalysis') {
|
||||
return canViewStorageAnalysis;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const forbiddenSection =
|
||||
(section === 'apiIntegration' && !canManageApiKeys) ||
|
||||
(section === 'storageAnalysis' && !canViewStorageAnalysis);
|
||||
if (open && forbiddenSection) {
|
||||
onSectionChange('workspace');
|
||||
}
|
||||
}, [
|
||||
canManageApiKeys,
|
||||
canViewStorageAnalysis,
|
||||
open,
|
||||
section,
|
||||
onSectionChange,
|
||||
]);
|
||||
|
||||
const activeItem = navItems.find((item) => item.id === section);
|
||||
const activeLabel = activeItem?.title ?? t('settingsDialog.title');
|
||||
@@ -201,6 +248,11 @@ export default function SettingsDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{section === 'workspace' && (
|
||||
<WorkspaceSettingsPanel
|
||||
active={open && section === 'workspace'}
|
||||
/>
|
||||
)}
|
||||
{section === 'models' && (
|
||||
<ModelsPanel
|
||||
active={open && section === 'models'}
|
||||
@@ -212,7 +264,7 @@ export default function SettingsDialog({
|
||||
active={open && section === 'apiIntegration'}
|
||||
/>
|
||||
)}
|
||||
{section === 'storageAnalysis' && (
|
||||
{section === 'storageAnalysis' && canViewStorageAnalysis && (
|
||||
<StorageAnalysisPanel
|
||||
active={open && section === 'storageAnalysis'}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import type { WorkspaceQuotaItem } from './useWorkspaceQuotaStatus';
|
||||
|
||||
export function WorkspaceQuotaTooltip({
|
||||
quota,
|
||||
resource,
|
||||
children,
|
||||
side = 'top',
|
||||
}: {
|
||||
quota: WorkspaceQuotaItem;
|
||||
resource: string;
|
||||
children: ReactNode;
|
||||
side?: 'top' | 'right' | 'bottom' | 'left';
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!quota.disabled) return children;
|
||||
const message = quota.loading
|
||||
? t('limitation.quotaLoadingTooltip')
|
||||
: t('limitation.createDisabledTooltip', {
|
||||
resource,
|
||||
max: quota.max,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
aria-disabled="true"
|
||||
aria-label={message}
|
||||
className="inline-flex cursor-not-allowed rounded-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className="max-w-72 text-left">
|
||||
{message}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
|
||||
export interface WorkspaceQuotaItem {
|
||||
count: number;
|
||||
max: number;
|
||||
reached: boolean;
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export interface WorkspaceQuotaStatus {
|
||||
bots: WorkspaceQuotaItem;
|
||||
pipelines: WorkspaceQuotaItem;
|
||||
knowledgeBases: WorkspaceQuotaItem;
|
||||
extensions: WorkspaceQuotaItem;
|
||||
botsReached: boolean;
|
||||
pipelinesReached: boolean;
|
||||
knowledgeBasesReached: boolean;
|
||||
extensionsReached: boolean;
|
||||
}
|
||||
|
||||
function quotaItem(
|
||||
count: number,
|
||||
max: number | undefined,
|
||||
loaded: boolean,
|
||||
): WorkspaceQuotaItem {
|
||||
const normalizedMax = typeof max === 'number' ? max : -1;
|
||||
const reached = loaded && normalizedMax >= 0 && count >= normalizedMax;
|
||||
return {
|
||||
count,
|
||||
max: normalizedMax,
|
||||
reached,
|
||||
loading: !loaded,
|
||||
disabled: !loaded || reached,
|
||||
};
|
||||
}
|
||||
|
||||
export function useWorkspaceQuotaStatus(): WorkspaceQuotaStatus {
|
||||
const {
|
||||
bots,
|
||||
pipelines,
|
||||
knowledgeBases,
|
||||
pluginCount,
|
||||
mcpServers,
|
||||
skills,
|
||||
quotaDataLoaded,
|
||||
} = useSidebarData();
|
||||
const limitation = systemInfo.limitation;
|
||||
|
||||
const botQuota = quotaItem(
|
||||
bots.length,
|
||||
limitation?.max_bots,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const pipelineQuota = quotaItem(
|
||||
pipelines.length,
|
||||
limitation?.max_pipelines,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const knowledgeBaseQuota = quotaItem(
|
||||
knowledgeBases.length,
|
||||
limitation?.max_knowledge_bases,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
const extensionQuota = quotaItem(
|
||||
pluginCount + mcpServers.length + skills.length,
|
||||
limitation?.max_extensions,
|
||||
quotaDataLoaded,
|
||||
);
|
||||
|
||||
return {
|
||||
bots: botQuota,
|
||||
pipelines: pipelineQuota,
|
||||
knowledgeBases: knowledgeBaseQuota,
|
||||
extensions: extensionQuota,
|
||||
botsReached: botQuota.disabled,
|
||||
pipelinesReached: pipelineQuota.disabled,
|
||||
knowledgeBasesReached: knowledgeBaseQuota.disabled,
|
||||
extensionsReached: extensionQuota.disabled,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Copy,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from '@/components/ui/item';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type {
|
||||
CurrentWorkspace,
|
||||
WorkspaceInvitation,
|
||||
WorkspaceMembership,
|
||||
WorkspaceRole,
|
||||
} from '@/app/infra/entities/workspace';
|
||||
import { backendClient, systemInfo } from '@/app/infra/http';
|
||||
import {
|
||||
PanelBody,
|
||||
PanelToolbar,
|
||||
} from '@/app/home/components/settings-dialog/panel-layout';
|
||||
|
||||
interface WorkspaceSettingsPanelProps {
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
const ASSIGNABLE_ROLES: Exclude<WorkspaceRole, 'owner'>[] = [
|
||||
'admin',
|
||||
'developer',
|
||||
'operator',
|
||||
'viewer',
|
||||
];
|
||||
|
||||
export default function WorkspaceSettingsPanel({
|
||||
active,
|
||||
}: WorkspaceSettingsPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [workspaceInfo, setWorkspaceInfo] = useState<CurrentWorkspace | null>(
|
||||
null,
|
||||
);
|
||||
const [members, setMembers] = useState<WorkspaceMembership[]>([]);
|
||||
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRole, setInviteRole] =
|
||||
useState<Exclude<WorkspaceRole, 'owner'>>('viewer');
|
||||
const [inviteLoading, setInviteLoading] = useState(false);
|
||||
const [oneTimeInviteLink, setOneTimeInviteLink] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const permissions = useMemo(
|
||||
() => new Set(workspaceInfo?.permissions ?? []),
|
||||
[workspaceInfo],
|
||||
);
|
||||
const isCloudProjection =
|
||||
workspaceInfo?.workspace.source === 'cloud_projection';
|
||||
const canViewMembers = permissions.has('member.view');
|
||||
const canInvite = permissions.has('member.invite');
|
||||
const canUpdateMembers = permissions.has('member.update_role');
|
||||
const canRemoveMembers = permissions.has('member.remove');
|
||||
const cloudPortalURL = workspaceInfo
|
||||
? `${systemInfo.cloud_service_url.replace(/\/$/, '')}/cloud?workspace=${encodeURIComponent(workspaceInfo.workspace.uuid)}&step=plan`
|
||||
: '';
|
||||
|
||||
const loadWorkspace = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const current = await backendClient.getCurrentWorkspace();
|
||||
setWorkspaceInfo(current);
|
||||
|
||||
const [memberResponse, invitationResponse] = await Promise.all([
|
||||
current.permissions.includes('member.view')
|
||||
? backendClient.getWorkspaceMembers(current.workspace.uuid)
|
||||
: Promise.resolve({ members: [] }),
|
||||
current.permissions.includes('member.invite')
|
||||
? backendClient.getWorkspaceInvitations(current.workspace.uuid)
|
||||
: Promise.resolve({ invitations: [] }),
|
||||
]);
|
||||
setMembers(memberResponse.members);
|
||||
setInvitations(invitationResponse.invitations);
|
||||
} catch {
|
||||
toast.error(t('workspace.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) void loadWorkspace();
|
||||
}, [active, loadWorkspace]);
|
||||
|
||||
async function createInvitation() {
|
||||
if (!workspaceInfo || !inviteEmail.trim()) return;
|
||||
setInviteLoading(true);
|
||||
try {
|
||||
const response = await backendClient.createWorkspaceInvitation(
|
||||
workspaceInfo.workspace.uuid,
|
||||
inviteEmail.trim(),
|
||||
inviteRole,
|
||||
);
|
||||
setOneTimeInviteLink(response.link);
|
||||
setInviteEmail('');
|
||||
await loadWorkspace();
|
||||
toast.success(t(`workspace.delivery.${response.delivery.status}`));
|
||||
} catch {
|
||||
toast.error(t('workspace.invitationCreateFailed'));
|
||||
} finally {
|
||||
setInviteLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvitationLink() {
|
||||
if (!oneTimeInviteLink) return;
|
||||
await navigator.clipboard.writeText(oneTimeInviteLink);
|
||||
toast.success(t('workspace.invitationCopied'));
|
||||
}
|
||||
|
||||
async function updateMemberRole(
|
||||
member: WorkspaceMembership,
|
||||
role: WorkspaceRole,
|
||||
) {
|
||||
if (!workspaceInfo || member.role === role) return;
|
||||
try {
|
||||
await backendClient.updateWorkspaceMemberRole(
|
||||
workspaceInfo.workspace.uuid,
|
||||
member.account_uuid,
|
||||
role,
|
||||
);
|
||||
await loadWorkspace();
|
||||
toast.success(t('workspace.memberUpdated'));
|
||||
} catch {
|
||||
toast.error(t('workspace.memberUpdateFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMember(member: WorkspaceMembership) {
|
||||
if (!workspaceInfo) return;
|
||||
if (!window.confirm(t('workspace.removeMemberConfirm'))) return;
|
||||
try {
|
||||
await backendClient.removeWorkspaceMember(
|
||||
workspaceInfo.workspace.uuid,
|
||||
member.account_uuid,
|
||||
);
|
||||
await loadWorkspace();
|
||||
toast.success(t('workspace.memberRemoved'));
|
||||
} catch {
|
||||
toast.error(t('workspace.memberRemoveFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeInvitation(invitation: WorkspaceInvitation) {
|
||||
if (!workspaceInfo) return;
|
||||
try {
|
||||
await backendClient.revokeWorkspaceInvitation(
|
||||
workspaceInfo.workspace.uuid,
|
||||
invitation.uuid,
|
||||
);
|
||||
await loadWorkspace();
|
||||
toast.success(t('workspace.invitationRevoked'));
|
||||
} catch {
|
||||
toast.error(t('workspace.invitationRevokeFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !workspaceInfo) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PanelToolbar>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">
|
||||
{workspaceInfo?.workspace.name ?? t('workspace.title')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
isCloudProjection
|
||||
? 'workspace.cloudManagedDescription'
|
||||
: 'workspace.ossSingletonDescription',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{workspaceInfo && (
|
||||
<Badge variant="secondary">
|
||||
{t(`workspace.roles.${workspaceInfo.membership.role}`)}
|
||||
</Badge>
|
||||
)}
|
||||
{isCloudProjection && workspaceInfo && (
|
||||
<Button asChild size="sm">
|
||||
<a href={cloudPortalURL} target="_blank" rel="noopener noreferrer">
|
||||
{t('workspace.upgradePlan')}
|
||||
<ExternalLink className="size-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</PanelToolbar>
|
||||
|
||||
<PanelBody className="space-y-6">
|
||||
{canInvite && (
|
||||
<section className="space-y-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('workspace.inviteMember')}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('workspace.inviteDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Input
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(event) => setInviteEmail(event.target.value)}
|
||||
placeholder={t('workspace.emailPlaceholder')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={inviteRole}
|
||||
onValueChange={(value) =>
|
||||
setInviteRole(value as Exclude<WorkspaceRole, 'owner'>)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-36">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSIGNABLE_ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(`workspace.roles.${role}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={createInvitation}
|
||||
disabled={inviteLoading || !inviteEmail.trim()}
|
||||
>
|
||||
{inviteLoading ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<UserPlus className="size-4" />
|
||||
)}
|
||||
{t('workspace.createInvitation')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{oneTimeInviteLink && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{t('workspace.oneTimeLinkWarning')}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input value={oneTimeInviteLink} readOnly />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={copyInvitationLink}
|
||||
aria-label={t('workspace.copyInvitation')}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canViewMembers && (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('workspace.members')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{members.map((member) => {
|
||||
const isSelf =
|
||||
member.account_uuid ===
|
||||
workspaceInfo?.membership.account_uuid;
|
||||
return (
|
||||
<Item
|
||||
key={member.uuid}
|
||||
size="sm"
|
||||
variant="muted"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ItemMedia variant="icon">
|
||||
<Users className="size-4" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0">
|
||||
<ItemTitle>
|
||||
{member.display_name}
|
||||
{isSelf && (
|
||||
<Badge variant="outline">{t('workspace.you')}</Badge>
|
||||
)}
|
||||
</ItemTitle>
|
||||
<ItemDescription className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5">
|
||||
<span className="break-all">{member.email}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{t(`workspace.roles.${member.role}`)}</span>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions className="max-sm:basis-full max-sm:justify-end max-sm:pl-10">
|
||||
{canUpdateMembers && member.role !== 'owner' && (
|
||||
<Select
|
||||
value={member.role}
|
||||
onValueChange={(role) =>
|
||||
void updateMemberRole(member, role as WorkspaceRole)
|
||||
}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ASSIGNABLE_ROLES.map((role) => (
|
||||
<SelectItem key={role} value={role}>
|
||||
{t(`workspace.roles.${role}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{canRemoveMembers &&
|
||||
!isSelf &&
|
||||
member.role !== 'owner' && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void removeMember(member)}
|
||||
aria-label={t('workspace.removeMember')}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canInvite && invitations.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t('workspace.pendingInvitations')}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{invitations.map((invitation) => (
|
||||
<Item
|
||||
key={invitation.uuid}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-lg"
|
||||
>
|
||||
<ItemContent>
|
||||
<ItemTitle>{invitation.normalized_email}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{t(`workspace.roles.${invitation.role}`)} ·{' '}
|
||||
{t('workspace.expiresAt', {
|
||||
date: new Date(invitation.expires_at).toLocaleString(),
|
||||
})}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => void revokeInvitation(invitation)}
|
||||
aria-label={t('workspace.revokeInvitation')}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</PanelBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Building2, Check, Settings } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
switchWorkspaceAndReload,
|
||||
useCurrentWorkspace,
|
||||
useWorkspaceBootstrap,
|
||||
} from '@/app/infra/http';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const OPEN_WORKSPACE_SETTINGS_EVENT = 'langbot:open-workspace-settings';
|
||||
|
||||
export function requestWorkspaceSettings(): void {
|
||||
window.dispatchEvent(new Event(OPEN_WORKSPACE_SETTINGS_EVENT));
|
||||
}
|
||||
|
||||
export default function WorkspaceSwitcher({
|
||||
className,
|
||||
}: {
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaces = useWorkspaceBootstrap();
|
||||
|
||||
if (!currentWorkspace) return null;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('h-9 min-w-0 justify-start px-2.5 text-sm', className)}
|
||||
aria-label={t('workspace.switchWorkspace')}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
<span className="truncate">{currentWorkspace.workspace.name}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64 p-1.5">
|
||||
<DropdownMenuLabel className="px-3 py-2 text-sm">
|
||||
{t('workspace.switchWorkspace')}
|
||||
</DropdownMenuLabel>
|
||||
{workspaces.map((entry) => {
|
||||
const selected =
|
||||
entry.workspace.uuid === currentWorkspace.workspace.uuid;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={entry.workspace.uuid}
|
||||
className="min-h-11 gap-2 px-2 py-1.5"
|
||||
onClick={() => {
|
||||
if (!selected)
|
||||
void switchWorkspaceAndReload(entry.workspace.uuid);
|
||||
}}
|
||||
>
|
||||
<Building2 className="size-4 shrink-0" />
|
||||
<span className="max-w-[7rem] min-w-0 flex-1 truncate font-medium">
|
||||
{entry.workspace.name}
|
||||
</span>
|
||||
{entry.workspace.source === 'cloud_projection' && (
|
||||
<span className="rounded-md border bg-muted px-2 py-0.5 text-[11px] font-medium uppercase text-muted-foreground">
|
||||
{entry.plan_name || t('workspace.planUnavailable')}
|
||||
</span>
|
||||
)}
|
||||
{selected && <Check className="size-4" />}
|
||||
{selected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
aria-label={t('workspace.settings')}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
requestWorkspaceSettings();
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@@ -27,11 +27,15 @@ import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function KBDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
|
||||
useSidebarData();
|
||||
|
||||
@@ -119,18 +123,22 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.createKnowledgeBase')}
|
||||
</h1>
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -146,14 +154,16 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.editKnowledgeBase')}
|
||||
</h1>
|
||||
<Button
|
||||
type="submit"
|
||||
form="kb-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'metadata' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="kb-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'metadata' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
@@ -186,45 +196,49 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<KBForm
|
||||
initKbId={id}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<KBForm
|
||||
initKbId={id}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Danger Zone Card */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('knowledge.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('knowledge.deleteKbAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.deleteKbHint')}
|
||||
</p>
|
||||
{canManage && (
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('knowledge.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('knowledge.deleteKbAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.deleteKbHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -234,11 +248,13 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
value="documents"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<KBDoc
|
||||
kbId={id}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<KBDoc
|
||||
kbId={id}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import EmojiPicker from '@/components/ui/emoji-picker';
|
||||
import {
|
||||
@@ -428,12 +429,9 @@ export default function KBForm({
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
<AuthenticatedPluginIcon
|
||||
author={author}
|
||||
name={name}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>
|
||||
@@ -459,12 +457,9 @@ export default function KBForm({
|
||||
value={engine.plugin_id}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
<AuthenticatedPluginIcon
|
||||
author={author}
|
||||
name={name}
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{extractI18nObject(engine.name)}</span>
|
||||
|
||||
+67
-35
@@ -14,10 +14,11 @@ import {
|
||||
} from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import {
|
||||
userInfo,
|
||||
bootstrapWorkspaceSession,
|
||||
systemInfo,
|
||||
initializeUserInfo,
|
||||
initializeSystemInfo,
|
||||
isSupportAdminSession,
|
||||
useCurrentWorkspace,
|
||||
} from '@/app/infra/http';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
@@ -82,8 +83,6 @@ function isExtensionsRoute(pathname: string): boolean {
|
||||
}
|
||||
|
||||
const HOME_CONTENT_MAX_WIDTH = 'max-w-[1360px]';
|
||||
const BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY =
|
||||
'langbot_backend_unavailable_return_to';
|
||||
|
||||
export default function HomeLayout({
|
||||
children,
|
||||
@@ -92,45 +91,78 @@ export default function HomeLayout({
|
||||
}>) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const [identityReady, setIdentityReady] = useState(false);
|
||||
|
||||
// Initialize user info if not already initialized
|
||||
useEffect(() => {
|
||||
if (!userInfo) {
|
||||
initializeUserInfo();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-redirect to wizard on first visit (wizard not yet completed on this instance)
|
||||
// Resolve the instance, Account, and Workspace before mounting any
|
||||
// Workspace-owned page. The second system-info read uses the now-stable
|
||||
// selector and therefore returns the selected Workspace's wizard metadata.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const checkWizard = async () => {
|
||||
try {
|
||||
// Always re-fetch to ensure we have the latest wizard_status from backend
|
||||
await initializeSystemInfo({ throwOnError: true });
|
||||
if (!cancelled && systemInfo.wizard_status === 'none') {
|
||||
navigate('/wizard', { replace: true });
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
const returnTo = `${location.pathname}${location.search}${location.hash}`;
|
||||
sessionStorage.setItem(
|
||||
BACKEND_UNAVAILABLE_RETURN_TO_STORAGE_KEY,
|
||||
returnTo,
|
||||
bootstrapWorkspaceSession()
|
||||
.then(async (result) => {
|
||||
if (result.status === 'selection-required') {
|
||||
const returnTo = `${location.pathname}${location.search}`;
|
||||
navigate(
|
||||
`/workspaces/select?returnTo=${encodeURIComponent(returnTo)}`,
|
||||
{ replace: true },
|
||||
);
|
||||
navigate('/backend-unavailable', {
|
||||
replace: true,
|
||||
state: { from: returnTo },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
checkWizard();
|
||||
|
||||
if (result.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
await initializeSystemInfo({ throwOnError: true });
|
||||
return true;
|
||||
})
|
||||
.then((ready) => {
|
||||
if (!cancelled && ready) setIdentityReady(true);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) navigate('/login', { replace: true });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.hash, location.pathname, location.search, navigate]);
|
||||
}, [location.pathname, location.search, navigate]);
|
||||
|
||||
// A read-only member may view resources but must not enter mutation-only
|
||||
// routes. The backend remains the authoritative authorization boundary.
|
||||
useEffect(() => {
|
||||
if (!identityReady || !currentWorkspace) return;
|
||||
if (currentWorkspace.permissions.includes('resource.manage')) return;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
const createOnlyRoute =
|
||||
location.pathname === '/home/add-extension' ||
|
||||
params.get('id') === 'new' ||
|
||||
(location.pathname === '/home/skills' &&
|
||||
params.get('action') === 'create');
|
||||
if (createOnlyRoute) {
|
||||
const fallback =
|
||||
location.pathname === '/home/add-extension'
|
||||
? '/home/extensions'
|
||||
: location.pathname;
|
||||
navigate(fallback, { replace: true });
|
||||
}
|
||||
}, [
|
||||
currentWorkspace,
|
||||
identityReady,
|
||||
location.pathname,
|
||||
location.search,
|
||||
navigate,
|
||||
]);
|
||||
|
||||
// Auto-redirect only after the Workspace bootstrap above has loaded the
|
||||
// selected Workspace's wizard state.
|
||||
useEffect(() => {
|
||||
if (!identityReady) return;
|
||||
if (systemInfo?.wizard_status === 'none' && !isSupportAdminSession()) {
|
||||
navigate('/wizard', { replace: true });
|
||||
}
|
||||
}, [identityReady, navigate]);
|
||||
|
||||
if (!identityReady) return <div />;
|
||||
|
||||
return (
|
||||
<SidebarDataProvider>
|
||||
|
||||
@@ -26,6 +26,7 @@ import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataCo
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Server, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
type MCPRuntimeState = 'connected' | 'connecting' | 'error';
|
||||
type MCPConnectionState =
|
||||
@@ -39,6 +40,11 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperate =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
||||
useSidebarData();
|
||||
const server = mcpServers.find((s) => s.id === id);
|
||||
@@ -157,6 +163,10 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||
}
|
||||
|
||||
const handlePersistedTestComplete = useCallback(async () => {
|
||||
await refreshMCPServers();
|
||||
}, [refreshMCPServers]);
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteMCPServer(id)
|
||||
@@ -208,21 +218,25 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/home/add-extension')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
{canOperate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate('/home/add-extension')}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
@@ -239,15 +253,17 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={undefined}
|
||||
layout="split"
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={undefined}
|
||||
layout="split"
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -270,6 +286,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
id="mcp-enable-switch"
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -331,40 +348,47 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
disabled={!formDirty || saveBlockedByBox}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canOperate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
disabled={!formDirty || saveBlockedByBox}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={id}
|
||||
layout="split"
|
||||
sideHeader={enableControl}
|
||||
sideFooter={editActions}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
onRuntimeInfoChange={(runtimeInfo) =>
|
||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||
}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={id}
|
||||
layout="split"
|
||||
sideHeader={enableControl}
|
||||
sideFooter={canManage ? editActions : undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
onTestingChange={setMcpTesting}
|
||||
onSaveBlockedChange={setSaveBlockedByBox}
|
||||
onRuntimeInfoChange={(runtimeInfo) =>
|
||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||
}
|
||||
onPersistedTestComplete={handlePersistedTestComplete}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import MCPLogs from '@/app/home/mcp/components/mcp-form/MCPLogs';
|
||||
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
|
||||
import {
|
||||
MCPServerRuntimeInfo,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { useMCPStdioPolicy } from '@/app/infra/hooks/useMCPStdioPolicy';
|
||||
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
@@ -434,6 +436,10 @@ const getFormSchema = (t: TFunction) =>
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(30),
|
||||
tool_call_timeout_sec: z
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.nonnegative({ message: t('mcp.timeoutNonNegative') })
|
||||
.default(300),
|
||||
ssereadtimeout: z
|
||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
@@ -473,6 +479,7 @@ const getFormSchema = (t: TFunction) =>
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||
timeout: number;
|
||||
tool_call_timeout_sec: number;
|
||||
ssereadtimeout: number;
|
||||
};
|
||||
|
||||
@@ -487,6 +494,7 @@ interface MCPFormProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onTestingChange?: (testing: boolean) => void;
|
||||
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
||||
onPersistedTestComplete?: (serverName: string) => void | Promise<void>;
|
||||
/** Reported when the form cannot be saved because the current mode is
|
||||
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
|
||||
* render the Save button outside this component should disable it. */
|
||||
@@ -511,6 +519,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
onDirtyChange,
|
||||
onTestingChange,
|
||||
onRuntimeInfoChange,
|
||||
onPersistedTestComplete,
|
||||
onSaveBlockedChange,
|
||||
layout = 'stacked',
|
||||
sideHeader,
|
||||
@@ -532,6 +541,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
...initialDraftRef.current,
|
||||
@@ -557,11 +567,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
hint: boxHint,
|
||||
reason: boxReason,
|
||||
} = useBoxStatus();
|
||||
const { enabled: mcpStdioEnabled } = useMCPStdioPolicy();
|
||||
// stdio mode requires the Box sandbox at runtime. If the user picks
|
||||
// stdio while Box is disabled / unreachable, the server would refuse
|
||||
// to start anyway — block creation upfront so they aren't surprised
|
||||
// by an immediate "Connection failed" on the detail page.
|
||||
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
|
||||
const stdioBlockedByPolicy = watchMode === 'stdio' && !mcpStdioEnabled;
|
||||
const stdioBlockedByBox =
|
||||
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
|
||||
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
|
||||
|
||||
const { isDirty } = form.formState;
|
||||
useEffect(() => {
|
||||
@@ -569,8 +583,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onSaveBlockedChange?.(stdioBlockedByBox);
|
||||
}, [stdioBlockedByBox, onSaveBlockedChange]);
|
||||
onSaveBlockedChange?.(stdioBlocked);
|
||||
}, [stdioBlocked, onSaveBlockedChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onTestingChange?.(mcpTesting);
|
||||
@@ -586,10 +600,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
testMcp: () => testMcp(),
|
||||
isTesting: mcpTesting,
|
||||
}),
|
||||
// testMcp now reads everything via form.getValues(), so it does not need
|
||||
// the latest stdioArgs/extraArgs closure — but keep mcpTesting so the
|
||||
// exposed isTesting flag stays accurate.
|
||||
[mcpTesting],
|
||||
// Form values are read through form.getValues(); policy and runtime health
|
||||
// remain closure values and must refresh the imperative handler.
|
||||
[mcpTesting, mcpStdioEnabled, boxAvailable],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -606,6 +619,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
...initialDraftRef.current,
|
||||
@@ -684,10 +698,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
};
|
||||
|
||||
if (typeof server.extra_args.tool_call_timeout_sec === 'number') {
|
||||
formValues.tool_call_timeout_sec =
|
||||
server.extra_args.tool_call_timeout_sec;
|
||||
}
|
||||
|
||||
let newExtraArgs: {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
@@ -744,12 +764,18 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
// Belt-and-suspenders: even though the Save button is disabled when
|
||||
// stdio is unselectable, intercept programmatic submits too.
|
||||
if (value.mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
return;
|
||||
}
|
||||
if (value.mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
let serverConfig: MCPServer;
|
||||
const serverName =
|
||||
isEditMode && initServerName ? initServerName : value.name;
|
||||
|
||||
if (value.mode === 'remote') {
|
||||
const headers: Record<string, string> = {};
|
||||
@@ -758,13 +784,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
});
|
||||
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
name: serverName,
|
||||
mode: 'remote',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers,
|
||||
timeout: value.timeout,
|
||||
tool_call_timeout_sec: value.tool_call_timeout_sec,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
@@ -774,13 +801,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
});
|
||||
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
name: serverName,
|
||||
mode: 'stdio',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
command: value.command!,
|
||||
args: value.args?.map((arg) => arg.value) || [],
|
||||
env,
|
||||
tool_call_timeout_sec: value.tool_call_timeout_sec,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -809,6 +837,16 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
if (mode === 'stdio' && !mcpStdioEnabled) {
|
||||
toast.error(t('mcp.stdioDisabledByPolicy'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
if (mode === 'stdio' && !boxAvailable) {
|
||||
toast.error(t('mcp.stdioBlockedByBoxToast'));
|
||||
setMcpTesting(false);
|
||||
return;
|
||||
}
|
||||
// Read every field via form.getValues() rather than the captured
|
||||
// `stdioArgs` / `extraArgs` state. testMcp() is invoked through an
|
||||
// imperative handle (formRef.current.testMcp()) whose closure is only
|
||||
@@ -818,6 +856,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
// `uvx` with no package (exit 2 / "Connection closed", no detail).
|
||||
// The form values are kept in sync on every edit and on load, so they
|
||||
// are always current.
|
||||
const serverName =
|
||||
isEditMode && initServerName ? initServerName : form.getValues('name');
|
||||
const shouldTestPersistedServer =
|
||||
isEditMode && !!initServerName && !form.formState.isDirty;
|
||||
const formExtraArgs = form.getValues('extra_args') ?? [];
|
||||
const formStdioArgs = form.getValues('args') ?? [];
|
||||
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
||||
@@ -826,6 +868,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
|
||||
headers: Object.fromEntries(
|
||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
@@ -837,15 +880,24 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
env: Object.fromEntries(
|
||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
|
||||
};
|
||||
}
|
||||
|
||||
const { task_id } = await httpClient.testMCPServer('_', {
|
||||
name: form.getValues('name'),
|
||||
mode,
|
||||
enable: true,
|
||||
extra_args: extraArgsData,
|
||||
} as MCPServer);
|
||||
const testTarget = shouldTestPersistedServer ? serverName : '_';
|
||||
const testPayload = shouldTestPersistedServer
|
||||
? {}
|
||||
: ({
|
||||
name: serverName,
|
||||
mode,
|
||||
enable: true,
|
||||
extra_args: extraArgsData,
|
||||
} as MCPServer);
|
||||
|
||||
const { task_id } = await httpClient.testMCPServer(
|
||||
testTarget,
|
||||
testPayload,
|
||||
);
|
||||
|
||||
if (!task_id) {
|
||||
throw new Error(t('mcp.noTaskId'));
|
||||
@@ -871,14 +923,18 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
resource_count: 0,
|
||||
resources: [],
|
||||
});
|
||||
if (shouldTestPersistedServer) {
|
||||
await onPersistedTestComplete?.(serverName);
|
||||
}
|
||||
} else {
|
||||
if (isEditMode) {
|
||||
await loadServerForEdit(form.getValues('name'));
|
||||
if (shouldTestPersistedServer) {
|
||||
await loadServerForEdit(serverName);
|
||||
await onPersistedTestComplete?.(serverName);
|
||||
} else {
|
||||
// Create mode has no persisted server to reload tools from.
|
||||
// Transient tests have no persisted server to reload tools from.
|
||||
// The backend stashes the discovered runtime info (status +
|
||||
// tools) in the test task's metadata before tearing the
|
||||
// transient session down — surface it so a successful test
|
||||
// tools) in the task metadata before tearing the transient
|
||||
// session down — surface it so a successful test
|
||||
// shows the tool list instead of "no tools found".
|
||||
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||
@@ -999,13 +1055,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="remote">{t('mcp.remote')}</SelectItem>
|
||||
<SelectItem value="stdio" disabled={!boxAvailable}>
|
||||
<SelectItem
|
||||
value="stdio"
|
||||
disabled={!mcpStdioEnabled || !boxAvailable}
|
||||
>
|
||||
{t('mcp.local')}
|
||||
{!boxAvailable && (
|
||||
{!mcpStdioEnabled ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.disabledByPolicy')})
|
||||
</span>
|
||||
) : !boxAvailable ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
({t('mcp.boxRequired')})
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -1014,6 +1077,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
? t('mcp.localModeDescription')
|
||||
: t('mcp.remoteModeDescription')}
|
||||
</FormDescription>
|
||||
{stdioBlockedByPolicy && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-800 dark:text-amber-200"
|
||||
>
|
||||
{t('mcp.stdioDisabledByPolicy')}
|
||||
</div>
|
||||
)}
|
||||
{stdioBlockedByBox && (
|
||||
<BoxUnavailableNotice
|
||||
hint={boxHint}
|
||||
@@ -1026,6 +1097,30 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tool_call_timeout_sec"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.toolCallTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="300"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('mcp.toolCallTimeoutDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchMode === 'remote' && (
|
||||
<>
|
||||
<FormField
|
||||
@@ -1163,11 +1258,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
</Card>
|
||||
);
|
||||
|
||||
const persistedServerName =
|
||||
isEditMode && initServerName ? initServerName : form.getValues('name');
|
||||
|
||||
const runtimePanel = (
|
||||
<RuntimePanel
|
||||
mcpTesting={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
serverName={form.getValues('name')}
|
||||
serverName={persistedServerName}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
@@ -1200,6 +1298,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
<TabsTrigger value="resources" className="flex-none px-4">
|
||||
{resourcesTabLabel}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="flex-none px-4">
|
||||
{t('mcp.tabLogs')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||
<MCPReadme readme={readme} />
|
||||
@@ -1211,7 +1312,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
<RuntimePanel
|
||||
mcpTesting={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
serverName={form.getValues('name')}
|
||||
serverName={persistedServerName}
|
||||
content="tools"
|
||||
t={t}
|
||||
/>
|
||||
@@ -1223,11 +1324,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
<RuntimePanel
|
||||
mcpTesting={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
serverName={form.getValues('name')}
|
||||
serverName={persistedServerName}
|
||||
content="resources"
|
||||
t={t}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="logs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||
{persistedServerName && <MCPLogs serverName={persistedServerName} />}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
runtimePanel
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
|
||||
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
|
||||
|
||||
function levelClassName(level: string): string {
|
||||
switch (level) {
|
||||
case 'ERROR':
|
||||
case 'CRITICAL':
|
||||
return 'text-red-500';
|
||||
case 'WARNING':
|
||||
return 'text-amber-500';
|
||||
case 'DEBUG':
|
||||
return 'text-gray-400 dark:text-gray-500';
|
||||
default:
|
||||
return 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
}
|
||||
|
||||
export default function MCPLogs({ serverName }: { serverName: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [level, setLevel] = useState<string>('ALL');
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const atBottomRef = useRef(true);
|
||||
|
||||
const fetchLogs = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
httpClient
|
||||
.getMcpServerLogs(serverName, 500, level === 'ALL' ? undefined : level)
|
||||
.then((res) => {
|
||||
setLogs(res.logs ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
setLogs([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [serverName, level]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Auto-refresh poll loop.
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const timer = setInterval(fetchLogs, 3000);
|
||||
return () => clearInterval(timer);
|
||||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
// Keep view pinned to bottom when the user is already at the bottom.
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && atBottomRef.current) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [logs]);
|
||||
|
||||
function handleScroll() {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1 pb-3 sm:px-6">
|
||||
<Select value={level} onValueChange={setLevel}>
|
||||
<SelectTrigger className="h-8 w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LEVEL_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt} value={opt}>
|
||||
{opt === 'ALL' ? t('mcp.logsLevelAll') : opt}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={fetchLogs}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('mcp.logsRefresh')}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mcp-logs-auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="mcp-logs-auto-refresh"
|
||||
className="cursor-pointer text-sm font-normal text-muted-foreground"
|
||||
>
|
||||
{t('mcp.logsAutoRefresh')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-3 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40 sm:px-6"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('mcp.logsEmpty')}
|
||||
</div>
|
||||
) : (
|
||||
logs.map((entry, idx) => (
|
||||
<div
|
||||
key={`${entry.ts}-${idx}`}
|
||||
className={`whitespace-pre-wrap break-all ${levelClassName(
|
||||
entry.level,
|
||||
)}`}
|
||||
>
|
||||
{entry.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Cpu,
|
||||
Hash,
|
||||
User,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { MessageContentRenderer } from './MessageContentRenderer';
|
||||
import {
|
||||
ConversationTurn,
|
||||
hasRenderableMessageContent,
|
||||
} from '../utils/conversationTurns';
|
||||
import { MonitoringMessage } from '../types/monitoring';
|
||||
|
||||
interface ConversationTurnListProps {
|
||||
turns: ConversationTurn[];
|
||||
expandedTurnId: string | null;
|
||||
onToggleTurn: (turnId: string) => void;
|
||||
}
|
||||
|
||||
function shortId(id?: string) {
|
||||
if (!id) return '-';
|
||||
if (id.length <= 12) return id;
|
||||
return `${id.slice(0, 8)}...${id.slice(-4)}`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
if (!ms) return '0ms';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
}
|
||||
|
||||
function truncateDetail(value?: string) {
|
||||
if (!value) return '';
|
||||
return value.length > 1200 ? `${value.slice(0, 1200)}...` : value;
|
||||
}
|
||||
|
||||
function roleLabel(message: MonitoringMessage | undefined) {
|
||||
const role = message?.role?.toLowerCase();
|
||||
if (role === 'assistant') return 'assistant';
|
||||
if (role === 'user') return 'user';
|
||||
return 'message';
|
||||
}
|
||||
|
||||
function statusClass(level: ConversationTurn['level']) {
|
||||
if (level === 'error') {
|
||||
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300';
|
||||
}
|
||||
if (level === 'warning') {
|
||||
return 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900 dark:bg-yellow-950/40 dark:text-yellow-300';
|
||||
}
|
||||
return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300';
|
||||
}
|
||||
|
||||
function Metric({
|
||||
icon,
|
||||
label,
|
||||
tone = 'default',
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
tone?: 'default' | 'error';
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs font-medium',
|
||||
tone === 'error'
|
||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
|
||||
: 'border-border bg-background text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaItem({ label, value }: { label: string; value?: string }) {
|
||||
return (
|
||||
<div className="min-w-0 rounded-md bg-background px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{value || '-'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageLane({
|
||||
label,
|
||||
icon,
|
||||
content,
|
||||
empty,
|
||||
maxLines,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
content?: string;
|
||||
empty: string;
|
||||
maxLines: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-[5.25rem_minmax(0,1fr)] items-start gap-3 text-sm sm:grid-cols-[6rem_minmax(0,1fr)]">
|
||||
<div className="flex h-7 items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<div className="min-w-0 rounded-md bg-muted/45 px-3 py-2 text-foreground">
|
||||
{content && hasRenderableMessageContent(content) ? (
|
||||
<MessageContentRenderer content={content} maxLines={maxLines} />
|
||||
) : (
|
||||
<span className="italic text-muted-foreground">{empty}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandedMessage({
|
||||
message,
|
||||
label,
|
||||
}: {
|
||||
message: MonitoringMessage;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
<span>{message.timestamp.toLocaleString()}</span>
|
||||
<span className="font-mono">ID: {shortId(message.id)}</span>
|
||||
</div>
|
||||
<div className="text-sm leading-6 text-foreground">
|
||||
<MessageContentRenderer content={message.messageContent} maxLines={4} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationTurnList({
|
||||
turns,
|
||||
expandedTurnId,
|
||||
onToggleTurn,
|
||||
}: ConversationTurnListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedToolCallIds, setExpandedToolCallIds] = React.useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const toggleToolCallDetails = (toolCallKey: string) => {
|
||||
setExpandedToolCallIds((previous) => ({
|
||||
...previous,
|
||||
[toolCallKey]: !previous[toolCallKey],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{t('monitoring.messageList.turns', {
|
||||
defaultValue: '{{count}} 轮对话',
|
||||
count: turns.length,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{turns.map((turn) => {
|
||||
const expanded = expandedTurnId === turn.id;
|
||||
const firstAssistant = turn.assistantMessages[0];
|
||||
const assistantOverflow = Math.max(
|
||||
turn.assistantMessages.length - 1,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={turn.id}
|
||||
className={cn(
|
||||
'overflow-hidden rounded-xl border bg-card transition-colors',
|
||||
turn.level === 'error' && 'border-red-200 dark:border-red-900',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="cursor-pointer p-3 outline-none transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
|
||||
onClick={() => onToggleTurn(turn.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
onToggleTurn(turn.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex min-w-0 items-center gap-2">
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
||||
Turn: {shortId(turn.id)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{turn.botName}
|
||||
</span>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{turn.pipelineName}
|
||||
</span>
|
||||
{turn.runnerName && (
|
||||
<>
|
||||
<span className="text-muted-foreground">→</span>
|
||||
<span className="truncate text-sm text-muted-foreground">
|
||||
{turn.runnerName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<MessageLane
|
||||
label={t('monitoring.messageList.userMessage', {
|
||||
defaultValue: '用户',
|
||||
})}
|
||||
icon={<User className="h-3.5 w-3.5" />}
|
||||
content={turn.userMessage?.messageContent}
|
||||
empty={t('monitoring.messageList.noUserMessage', {
|
||||
defaultValue: '未记录用户输入',
|
||||
})}
|
||||
maxLines={2}
|
||||
/>
|
||||
<MessageLane
|
||||
label={
|
||||
assistantOverflow > 0
|
||||
? t('monitoring.messageList.assistantMessageCount', {
|
||||
defaultValue: '助手 +{{count}}',
|
||||
count: assistantOverflow,
|
||||
})
|
||||
: t('monitoring.messageList.assistantMessage', {
|
||||
defaultValue: '助手',
|
||||
})
|
||||
}
|
||||
icon={<Bot className="h-3.5 w-3.5" />}
|
||||
content={firstAssistant?.messageContent}
|
||||
empty={t('monitoring.messageList.noAssistantMessage', {
|
||||
defaultValue: '未记录助手回复',
|
||||
})}
|
||||
maxLines={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{turn.lastActivityAt.toLocaleString()}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex h-7 items-center rounded-md border px-2 text-xs font-medium',
|
||||
statusClass(turn.level),
|
||||
)}
|
||||
>
|
||||
{turn.level}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
||||
<Metric
|
||||
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||
label={`${turn.llmCalls.length} LLM`}
|
||||
/>
|
||||
{turn.toolCalls.length > 0 && (
|
||||
<Metric
|
||||
icon={<Wrench className="h-3.5 w-3.5" />}
|
||||
label={`${turn.toolCalls.length} tools`}
|
||||
/>
|
||||
)}
|
||||
<Metric
|
||||
icon={<Hash className="h-3.5 w-3.5" />}
|
||||
label={`${turn.totalTokens.toLocaleString()} tokens`}
|
||||
/>
|
||||
<Metric
|
||||
icon={<Clock className="h-3.5 w-3.5" />}
|
||||
label={formatDuration(turn.totalDuration)}
|
||||
/>
|
||||
{turn.errors.length > 0 && (
|
||||
<Metric
|
||||
icon={<AlertCircle className="h-3.5 w-3.5" />}
|
||||
label={`${turn.errors.length} errors`}
|
||||
tone="error"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t bg-muted/40 p-3 sm:p-5">
|
||||
<div className="space-y-5 border-l-2 border-border pl-4 sm:pl-6">
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-5">
|
||||
<MetaItem
|
||||
label={t('monitoring.messageList.platform', {
|
||||
defaultValue: '平台',
|
||||
})}
|
||||
value={turn.platform}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.messageList.user', {
|
||||
defaultValue: '用户',
|
||||
})}
|
||||
value={turn.userName || turn.userId}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.messageList.runner', {
|
||||
defaultValue: '执行器',
|
||||
})}
|
||||
value={turn.runnerName}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.sessions.sessionId', {
|
||||
defaultValue: '会话 ID',
|
||||
})}
|
||||
value={turn.sessionId}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.messageList.messageCount', {
|
||||
defaultValue: '消息数',
|
||||
})}
|
||||
value={String(turn.messages.length)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
{t('monitoring.messageList.conversationTrace', {
|
||||
defaultValue: '消息链路',
|
||||
})}
|
||||
</h4>
|
||||
<div className="rounded-lg bg-background px-3 py-3">
|
||||
{turn.messages.map((message) => (
|
||||
<ExpandedMessage
|
||||
key={message.id}
|
||||
message={message}
|
||||
label={t(
|
||||
`monitoring.messageList.roles.${roleLabel(message)}`,
|
||||
{
|
||||
defaultValue:
|
||||
roleLabel(message) === 'assistant'
|
||||
? '助手'
|
||||
: roleLabel(message) === 'user'
|
||||
? '用户'
|
||||
: '消息',
|
||||
},
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Cpu className="h-4 w-4" />
|
||||
{t('monitoring.llmCalls.title', {
|
||||
defaultValue: 'LLM 调用',
|
||||
})}{' '}
|
||||
({turn.llmCalls.length})
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<MetaItem
|
||||
label={t('monitoring.llmCalls.totalTokens', {
|
||||
defaultValue: '总 Token',
|
||||
})}
|
||||
value={turn.totalTokens.toLocaleString()}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.llmCalls.inputTokens', {
|
||||
defaultValue: '输入 Token',
|
||||
})}
|
||||
value={turn.inputTokens.toLocaleString()}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.llmCalls.duration', {
|
||||
defaultValue: '耗时',
|
||||
})}
|
||||
value={formatDuration(turn.totalDuration)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg bg-background px-3 py-3">
|
||||
{turn.llmCalls.length > 0 ? (
|
||||
turn.llmCalls.map((call, index) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
#{index + 1} {call.modelName}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-xs font-medium',
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
|
||||
)}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDuration(call.duration)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>In: {call.tokens.input}</span>
|
||||
<span>Out: {call.tokens.output}</span>
|
||||
<span>Total: {call.tokens.total}</span>
|
||||
<span className="font-mono">
|
||||
ID: {shortId(call.id)}
|
||||
</span>
|
||||
</div>
|
||||
{call.errorMessage && (
|
||||
<div className="mt-2 whitespace-pre-wrap break-words text-xs text-red-600 dark:text-red-400">
|
||||
{call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t('monitoring.messageList.noLlmCalls', {
|
||||
defaultValue: '未记录模型调用',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Wrench className="h-4 w-4" />
|
||||
{t('monitoring.toolCalls.title', {
|
||||
defaultValue: '工具调用',
|
||||
})}{' '}
|
||||
({turn.toolCalls.length})
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3">
|
||||
<MetaItem
|
||||
label={t('monitoring.toolCalls.totalCalls', {
|
||||
defaultValue: '调用次数',
|
||||
})}
|
||||
value={String(turn.toolCalls.length)}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.toolCalls.duration', {
|
||||
defaultValue: '工具耗时',
|
||||
})}
|
||||
value={formatDuration(turn.totalToolDuration)}
|
||||
/>
|
||||
<MetaItem
|
||||
label={t('monitoring.toolCalls.errorCalls', {
|
||||
defaultValue: '失败次数',
|
||||
})}
|
||||
value={String(
|
||||
turn.toolCalls.filter(
|
||||
(call) => call.status === 'error',
|
||||
).length,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 rounded-lg bg-background px-3 py-3">
|
||||
{turn.toolCalls.length > 0 ? (
|
||||
turn.toolCalls.map((call, index) => {
|
||||
const toolCallKey = `${turn.id}:${call.id}`;
|
||||
const hasToolDetails = Boolean(
|
||||
call.arguments || call.result || call.errorMessage,
|
||||
);
|
||||
const expandedToolCall = Boolean(
|
||||
expandedToolCallIds[toolCallKey],
|
||||
);
|
||||
const detailsId = `monitoring-tool-call-details-${call.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
|
||||
hasToolDetails &&
|
||||
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
aria-expanded={
|
||||
hasToolDetails ? expandedToolCall : undefined
|
||||
}
|
||||
aria-controls={
|
||||
hasToolDetails ? detailsId : undefined
|
||||
}
|
||||
aria-disabled={!hasToolDetails}
|
||||
onClick={() =>
|
||||
hasToolDetails &&
|
||||
toggleToolCallDetails(toolCallKey)
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
{hasToolDetails &&
|
||||
(expandedToolCall ? (
|
||||
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
))}
|
||||
<span className="min-w-0 truncate text-sm font-medium text-foreground">
|
||||
#{index + 1} {call.toolName}
|
||||
</span>
|
||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
||||
{call.toolSource}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 text-xs font-medium',
|
||||
call.status === 'success'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
|
||||
)}
|
||||
>
|
||||
{call.status}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
ID: {shortId(call.id)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatDuration(call.duration)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{hasToolDetails && expandedToolCall && (
|
||||
<div
|
||||
id={detailsId}
|
||||
className="mt-1 grid gap-2 px-2 pb-2 text-xs lg:grid-cols-2"
|
||||
>
|
||||
{call.arguments && (
|
||||
<div className="min-w-0 rounded-md bg-muted/50 p-2">
|
||||
<div className="mb-1 font-medium text-foreground">
|
||||
{t('monitoring.toolCalls.arguments', {
|
||||
defaultValue: '参数',
|
||||
})}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
|
||||
{truncateDetail(call.arguments)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{call.result && (
|
||||
<div className="min-w-0 rounded-md bg-muted/50 p-2">
|
||||
<div className="mb-1 font-medium text-foreground">
|
||||
{t('monitoring.toolCalls.result', {
|
||||
defaultValue: '结果',
|
||||
})}
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
|
||||
{truncateDetail(call.result)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{call.errorMessage && (
|
||||
<div className="min-w-0 whitespace-pre-wrap break-words rounded-md bg-red-50 p-2 text-red-600 dark:bg-red-950/40 dark:text-red-400 lg:col-span-2">
|
||||
{call.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
||||
{t('monitoring.toolCalls.noToolCalls', {
|
||||
defaultValue: '未记录工具调用',
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{turn.errors.length > 0 && (
|
||||
<section>
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-700 dark:text-red-300">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
{t('monitoring.errors.title', {
|
||||
defaultValue: '错误日志',
|
||||
})}{' '}
|
||||
({turn.errors.length})
|
||||
</h4>
|
||||
<div className="rounded-lg bg-background px-3 py-3">
|
||||
{turn.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border-t border-red-200/80 py-3 first:border-t-0 first:pt-0 last:pb-0 dark:border-red-900"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{error.errorType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{error.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words text-sm text-red-600 dark:text-red-400">
|
||||
{error.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export default function SystemStatusCard({
|
||||
try {
|
||||
const [plugin, box] = await Promise.all([
|
||||
httpClient.getPluginSystemStatus().catch(() => null),
|
||||
httpClient.getBoxStatus().catch(() => null),
|
||||
httpClient.getBoxRuntimeStatus().catch(() => null),
|
||||
]);
|
||||
const sessions = box?.hidden
|
||||
? []
|
||||
|
||||
@@ -106,6 +106,9 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
const llmCalls = Array.isArray(response?.llmCalls)
|
||||
? response.llmCalls
|
||||
: [];
|
||||
const toolCalls = Array.isArray(response?.toolCalls)
|
||||
? response.toolCalls
|
||||
: [];
|
||||
const embeddingCalls = Array.isArray(response?.embeddingCalls)
|
||||
? response.embeddingCalls
|
||||
: [];
|
||||
@@ -116,6 +119,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
const totalCount = response?.totalCount ?? {
|
||||
messages: messages.length,
|
||||
llmCalls: llmCalls.length,
|
||||
toolCalls: toolCalls.length,
|
||||
embeddingCalls: embeddingCalls.length,
|
||||
sessions: sessions.length,
|
||||
errors: errors.length,
|
||||
@@ -145,8 +149,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
level: string;
|
||||
platform?: string;
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
runner_name?: string;
|
||||
variables?: string;
|
||||
role?: string;
|
||||
}) => ({
|
||||
id: msg.id,
|
||||
timestamp: parseUTCTimestamp(msg.timestamp),
|
||||
@@ -160,8 +166,10 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
level: msg.level as 'info' | 'warning' | 'error' | 'debug',
|
||||
platform: msg.platform,
|
||||
userId: msg.user_id,
|
||||
userName: msg.user_name,
|
||||
runnerName: msg.runner_name,
|
||||
variables: msg.variables,
|
||||
role: msg.role,
|
||||
}),
|
||||
),
|
||||
llmCalls: llmCalls.map(
|
||||
@@ -179,6 +187,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
bot_name: string;
|
||||
pipeline_id: string;
|
||||
pipeline_name: string;
|
||||
session_id?: string;
|
||||
error_message?: string;
|
||||
message_id?: string;
|
||||
}) => ({
|
||||
@@ -197,10 +206,46 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
botName: call.bot_name,
|
||||
pipelineId: call.pipeline_id,
|
||||
pipelineName: call.pipeline_name,
|
||||
sessionId: call.session_id,
|
||||
errorMessage: call.error_message,
|
||||
messageId: call.message_id,
|
||||
}),
|
||||
),
|
||||
toolCalls: toolCalls.map(
|
||||
(call: {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
tool_name: string;
|
||||
tool_source: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
bot_id: string;
|
||||
bot_name: string;
|
||||
pipeline_id: string;
|
||||
pipeline_name: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
arguments?: string;
|
||||
result?: string;
|
||||
error_message?: string;
|
||||
}) => ({
|
||||
id: call.id,
|
||||
timestamp: parseUTCTimestamp(call.timestamp),
|
||||
toolName: call.tool_name,
|
||||
toolSource: call.tool_source,
|
||||
duration: call.duration,
|
||||
status: call.status as 'success' | 'error',
|
||||
botId: call.bot_id,
|
||||
botName: call.bot_name,
|
||||
pipelineId: call.pipeline_id,
|
||||
pipelineName: call.pipeline_name,
|
||||
sessionId: call.session_id,
|
||||
messageId: call.message_id,
|
||||
arguments: call.arguments,
|
||||
result: call.result,
|
||||
errorMessage: call.error_message,
|
||||
}),
|
||||
),
|
||||
embeddingCalls: embeddingCalls.map(
|
||||
(call: {
|
||||
id: string;
|
||||
@@ -294,6 +339,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
totalCount: {
|
||||
messages: totalCount.messages,
|
||||
llmCalls: totalCount.llmCalls,
|
||||
toolCalls: totalCount.toolCalls ?? toolCalls.length,
|
||||
embeddingCalls: totalCount.embeddingCalls || 0,
|
||||
sessions: totalCount.sessions,
|
||||
errors: totalCount.errors,
|
||||
@@ -317,6 +363,7 @@ export function useMonitoringData(filterState: FilterState) {
|
||||
botName: call.botName,
|
||||
pipelineId: call.pipelineId,
|
||||
pipelineName: call.pipelineName,
|
||||
sessionId: call.sessionId,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,62 +18,18 @@ import { ExportDropdown } from './components/ExportDropdown';
|
||||
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
|
||||
import { useMonitoringData } from './hooks/useMonitoringData';
|
||||
import { useFeedbackData } from './hooks/useFeedbackData';
|
||||
import { MessageDetailsCard } from './components/MessageDetailsCard';
|
||||
import { MessageContentRenderer } from './components/MessageContentRenderer';
|
||||
import { ConversationTurnList } from './components/ConversationTurnList';
|
||||
import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||
import { FeedbackList } from './components/FeedbackList';
|
||||
import { MessageDetails } from './types/monitoring';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { buildConversationTurns } from './utils/conversationTurns';
|
||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||
|
||||
interface RawMessageData {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
bot_id: string;
|
||||
bot_name: string;
|
||||
pipeline_id: string;
|
||||
pipeline_name: string;
|
||||
message_content: string;
|
||||
session_id: string;
|
||||
status: string;
|
||||
level: string;
|
||||
platform: string;
|
||||
user_id: string;
|
||||
runner_name: string;
|
||||
variables: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RawLLMCallData {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
model_name: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
error_message: string | null;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
interface RawLLMStatsData {
|
||||
total_calls: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_tokens: number;
|
||||
total_duration_ms: number;
|
||||
average_duration_ms: number;
|
||||
}
|
||||
|
||||
interface RawErrorData {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
stack_trace: string | null;
|
||||
}
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
function MonitoringPageContent() {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canExport =
|
||||
currentWorkspace?.permissions.includes('data.export') ?? false;
|
||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
@@ -146,115 +102,37 @@ function MonitoringPageContent() {
|
||||
setFeedbackRefreshKey((k) => k + 1);
|
||||
}, [refetch]);
|
||||
|
||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [messageDetails, setMessageDetails] = useState<
|
||||
Record<string, MessageDetails>
|
||||
>({});
|
||||
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
const conversationTurns = useMemo(
|
||||
() =>
|
||||
buildConversationTurns(
|
||||
data?.messages || [],
|
||||
data?.llmCalls || [],
|
||||
data?.errors || [],
|
||||
data?.toolCalls || [],
|
||||
),
|
||||
[data?.messages, data?.llmCalls, data?.errors, data?.toolCalls],
|
||||
);
|
||||
|
||||
// State for expanded errors
|
||||
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
||||
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
|
||||
|
||||
// State for controlled tabs
|
||||
const [activeTab, setActiveTab] = useState<string>('messages');
|
||||
|
||||
// Function to jump to a message record
|
||||
const jumpToMessage = async (messageId: string) => {
|
||||
const jumpToMessage = (messageId: string) => {
|
||||
setActiveTab('messages');
|
||||
// Small delay to ensure tab switch completes
|
||||
setTimeout(() => {
|
||||
toggleMessageExpand(messageId);
|
||||
const turn = conversationTurns.find((item) =>
|
||||
item.messages.some((message) => message.id === messageId),
|
||||
);
|
||||
setExpandedTurnId(turn?.id ?? messageId);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const toggleMessageExpand = async (messageId: string) => {
|
||||
if (expandedMessageId === messageId) {
|
||||
// Collapse
|
||||
setExpandedMessageId(null);
|
||||
} else {
|
||||
// Expand
|
||||
setExpandedMessageId(messageId);
|
||||
|
||||
// Fetch details if not already loaded
|
||||
if (!messageDetails[messageId]) {
|
||||
setLoadingDetails({ ...loadingDetails, [messageId]: true });
|
||||
try {
|
||||
// httpClient.get() returns the inner data directly (response.data.data)
|
||||
const result = await httpClient.get<{
|
||||
message_id: string;
|
||||
found: boolean;
|
||||
message: RawMessageData | null;
|
||||
llm_calls: RawLLMCallData[];
|
||||
llm_stats: RawLLMStatsData;
|
||||
errors: RawErrorData[];
|
||||
}>(`/api/v1/monitoring/messages/${messageId}/details`);
|
||||
|
||||
if (result) {
|
||||
setMessageDetails((prev) => ({
|
||||
...prev,
|
||||
[messageId]: {
|
||||
messageId: result.message_id,
|
||||
found: result.found,
|
||||
message: result.message
|
||||
? {
|
||||
id: result.message.id,
|
||||
timestamp: new Date(result.message.timestamp),
|
||||
botId: result.message.bot_id,
|
||||
botName: result.message.bot_name,
|
||||
pipelineId: result.message.pipeline_id,
|
||||
pipelineName: result.message.pipeline_name,
|
||||
messageContent: result.message.message_content,
|
||||
sessionId: result.message.session_id,
|
||||
status: result.message.status,
|
||||
level: result.message.level,
|
||||
platform: result.message.platform,
|
||||
userId: result.message.user_id,
|
||||
runnerName: result.message.runner_name,
|
||||
variables: result.message.variables,
|
||||
}
|
||||
: undefined,
|
||||
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
||||
id: call.id,
|
||||
timestamp: new Date(call.timestamp),
|
||||
modelName: call.model_name,
|
||||
status: call.status,
|
||||
duration: call.duration,
|
||||
errorMessage: call.error_message,
|
||||
tokens: {
|
||||
input: call.input_tokens || 0,
|
||||
output: call.output_tokens || 0,
|
||||
total: call.total_tokens || 0,
|
||||
},
|
||||
})),
|
||||
errors: result.errors.map((error: RawErrorData) => ({
|
||||
id: error.id,
|
||||
timestamp: new Date(error.timestamp),
|
||||
errorType: error.error_type,
|
||||
errorMessage: error.error_message,
|
||||
stackTrace: error.stack_trace,
|
||||
})),
|
||||
llmStats: {
|
||||
totalCalls: result.llm_stats.total_calls,
|
||||
totalInputTokens: result.llm_stats.total_input_tokens,
|
||||
totalOutputTokens: result.llm_stats.total_output_tokens,
|
||||
totalTokens: result.llm_stats.total_tokens,
|
||||
totalDurationMs: result.llm_stats.total_duration_ms,
|
||||
averageDurationMs: result.llm_stats.average_duration_ms,
|
||||
},
|
||||
} as MessageDetails,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch message details:', error);
|
||||
} finally {
|
||||
setLoadingDetails({ ...loadingDetails, [messageId]: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
const toggleTurnExpand = (turnId: string) => {
|
||||
setExpandedTurnId((current) => (current === turnId ? null : turnId));
|
||||
};
|
||||
|
||||
const toggleErrorExpand = (errorId: string) => {
|
||||
@@ -280,7 +158,7 @@ function MonitoringPageContent() {
|
||||
onTimeRangeChange={setTimeRange}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportDropdown filterState={filterState} />
|
||||
{canExport && <ExportDropdown filterState={filterState} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -342,127 +220,22 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
data &&
|
||||
data.messages &&
|
||||
data.messages.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{data.messages
|
||||
.filter((msg) => {
|
||||
// Filter out messages with empty content
|
||||
const content = msg.messageContent?.trim();
|
||||
return (
|
||||
content && content !== '[]' && content !== '""'
|
||||
);
|
||||
})
|
||||
.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="border rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Message Header - Always Visible */}
|
||||
<div
|
||||
className="p-3 cursor-pointer hover:bg-accent transition-colors sm:p-5"
|
||||
onClick={() => toggleMessageExpand(msg.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedMessageId === msg.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
turns={conversationTurns}
|
||||
expandedTurnId={expandedTurnId}
|
||||
onToggleTurn={toggleTurnExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Message Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
ID: {msg.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{msg.botName}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{msg.pipelineName}
|
||||
</span>
|
||||
{msg.runnerName && (
|
||||
<>
|
||||
<span className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{msg.runnerName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-base text-foreground">
|
||||
<MessageContentRenderer
|
||||
content={msg.messageContent}
|
||||
maxLines={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status and Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{msg.timestamp.toLocaleString()}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded ${
|
||||
msg.level === 'error'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
: msg.level === 'warning'
|
||||
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
}`}
|
||||
>
|
||||
{msg.level}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedMessageId === msg.id && (
|
||||
<div className="border-t p-4 bg-muted">
|
||||
{loadingDetails[msg.id] && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<LoadingSpinner size="sm" text="" />
|
||||
</div>
|
||||
)}
|
||||
{!loadingDetails[msg.id] &&
|
||||
messageDetails[msg.id] && (
|
||||
<MessageDetailsCard
|
||||
details={messageDetails[msg.id]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!loading && (!data || conversationTurns.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.messages || data.messages.length === 0) && (
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||
<div className="text-sm">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@@ -11,8 +11,10 @@ export interface MonitoringMessage {
|
||||
level: 'info' | 'warning' | 'error' | 'debug';
|
||||
platform?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
runnerName?: string;
|
||||
variables?: string;
|
||||
role?: 'user' | 'assistant' | string;
|
||||
}
|
||||
|
||||
export interface LLMCall {
|
||||
@@ -31,10 +33,29 @@ export interface LLMCall {
|
||||
botName: string;
|
||||
pipelineId: string;
|
||||
pipelineName: string;
|
||||
sessionId?: string;
|
||||
errorMessage?: string;
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
toolName: string;
|
||||
toolSource: 'native' | 'plugin' | 'mcp' | 'skill' | string;
|
||||
duration: number;
|
||||
status: 'success' | 'error';
|
||||
botId: string;
|
||||
botName: string;
|
||||
pipelineId: string;
|
||||
pipelineName: string;
|
||||
sessionId?: string;
|
||||
messageId?: string;
|
||||
arguments?: string;
|
||||
result?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface EmbeddingCall {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
@@ -199,6 +220,7 @@ export interface MonitoringData {
|
||||
overview: OverviewMetrics;
|
||||
messages: MonitoringMessage[];
|
||||
llmCalls: LLMCall[];
|
||||
toolCalls: ToolCall[];
|
||||
embeddingCalls: EmbeddingCall[];
|
||||
modelCalls: ModelCall[];
|
||||
sessions: SessionInfo[];
|
||||
@@ -208,6 +230,7 @@ export interface MonitoringData {
|
||||
totalCount: {
|
||||
messages: number;
|
||||
llmCalls: number;
|
||||
toolCalls?: number;
|
||||
embeddingCalls: number;
|
||||
sessions: number;
|
||||
errors: number;
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
ErrorLog,
|
||||
LLMCall,
|
||||
MonitoringMessage,
|
||||
ToolCall,
|
||||
} from '../types/monitoring';
|
||||
|
||||
type MessageRole = 'user' | 'assistant' | 'unknown';
|
||||
|
||||
export interface ConversationTurn {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
startedAt: Date;
|
||||
lastActivityAt: Date;
|
||||
botId: string;
|
||||
botName: string;
|
||||
pipelineId: string;
|
||||
pipelineName: string;
|
||||
runnerName?: string;
|
||||
platform?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
userMessage?: MonitoringMessage;
|
||||
assistantMessages: MonitoringMessage[];
|
||||
messages: MonitoringMessage[];
|
||||
llmCalls: LLMCall[];
|
||||
toolCalls: ToolCall[];
|
||||
errors: ErrorLog[];
|
||||
status: 'success' | 'error' | 'pending';
|
||||
level: 'info' | 'warning' | 'error' | 'debug';
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
totalDuration: number;
|
||||
totalToolDuration: number;
|
||||
}
|
||||
|
||||
function normalizeRole(
|
||||
message: MonitoringMessage,
|
||||
llmMessageIds: Set<string>,
|
||||
): MessageRole {
|
||||
const role = message.role?.toLowerCase();
|
||||
|
||||
if (role === 'user' || role === 'assistant') {
|
||||
return role;
|
||||
}
|
||||
|
||||
if (llmMessageIds.has(message.id)) {
|
||||
return 'user';
|
||||
}
|
||||
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export function hasRenderableMessageContent(content?: string): boolean {
|
||||
const trimmed = content?.trim();
|
||||
|
||||
if (!trimmed || trimmed === '[]' || trimmed === '""') {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
|
||||
if (typeof parsed === 'string') {
|
||||
return parsed.trim().length > 0;
|
||||
}
|
||||
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.some(
|
||||
(component) =>
|
||||
typeof component !== 'object' ||
|
||||
component === null ||
|
||||
component.type !== 'Source',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function createTurn(message: MonitoringMessage): ConversationTurn {
|
||||
return {
|
||||
id: message.id,
|
||||
sessionId: message.sessionId,
|
||||
startedAt: message.timestamp,
|
||||
lastActivityAt: message.timestamp,
|
||||
botId: message.botId,
|
||||
botName: message.botName,
|
||||
pipelineId: message.pipelineId,
|
||||
pipelineName: message.pipelineName,
|
||||
runnerName: message.runnerName,
|
||||
platform: message.platform,
|
||||
userId: message.userId,
|
||||
userName: message.userName,
|
||||
assistantMessages: [],
|
||||
messages: [],
|
||||
llmCalls: [],
|
||||
toolCalls: [],
|
||||
errors: [],
|
||||
status: message.status,
|
||||
level: message.level,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
totalTokens: 0,
|
||||
totalDuration: 0,
|
||||
totalToolDuration: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function updateTurnActivity(turn: ConversationTurn, timestamp: Date) {
|
||||
if (timestamp.getTime() > turn.lastActivityAt.getTime()) {
|
||||
turn.lastActivityAt = timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
function addMessageToTurn(
|
||||
turn: ConversationTurn,
|
||||
message: MonitoringMessage,
|
||||
role: MessageRole,
|
||||
) {
|
||||
turn.messages.push(message);
|
||||
updateTurnActivity(turn, message.timestamp);
|
||||
|
||||
if (message.level === 'error') {
|
||||
turn.level = 'error';
|
||||
} else if (message.level === 'warning' && turn.level !== 'error') {
|
||||
turn.level = 'warning';
|
||||
}
|
||||
|
||||
if (message.status === 'error') {
|
||||
turn.status = 'error';
|
||||
} else if (message.status === 'pending' && turn.status !== 'error') {
|
||||
turn.status = 'pending';
|
||||
}
|
||||
|
||||
if (role === 'assistant') {
|
||||
turn.assistantMessages.push(message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!turn.userMessage) {
|
||||
turn.userMessage = message;
|
||||
turn.userId = message.userId ?? turn.userId;
|
||||
turn.userName = message.userName ?? turn.userName;
|
||||
return;
|
||||
}
|
||||
|
||||
turn.assistantMessages.push(message);
|
||||
}
|
||||
|
||||
function findTurnBySessionTime(
|
||||
sessionTurns: Map<string, ConversationTurn[]>,
|
||||
sessionId: string | undefined,
|
||||
timestamp: Date,
|
||||
): ConversationTurn | undefined {
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const turns = sessionTurns.get(sessionId);
|
||||
if (!turns?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let nearest = turns[0];
|
||||
const targetTime = timestamp.getTime();
|
||||
|
||||
for (const turn of turns) {
|
||||
if (turn.startedAt.getTime() <= targetTime) {
|
||||
nearest = turn;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}
|
||||
|
||||
export function buildConversationTurns(
|
||||
messages: MonitoringMessage[],
|
||||
llmCalls: LLMCall[],
|
||||
errors: ErrorLog[],
|
||||
toolCalls: ToolCall[] = [],
|
||||
): ConversationTurn[] {
|
||||
const activityMessageIds = new Set([
|
||||
...llmCalls
|
||||
.map((call) => call.messageId)
|
||||
.filter((messageId): messageId is string => Boolean(messageId)),
|
||||
...toolCalls
|
||||
.map((call) => call.messageId)
|
||||
.filter((messageId): messageId is string => Boolean(messageId)),
|
||||
]);
|
||||
const visibleMessages = messages
|
||||
.filter((message) => hasRenderableMessageContent(message.messageContent))
|
||||
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
|
||||
const sessionTurns = new Map<string, ConversationTurn[]>();
|
||||
const lastTurnBySession = new Map<string, ConversationTurn>();
|
||||
const messageIdToTurn = new Map<string, ConversationTurn>();
|
||||
|
||||
for (const message of visibleMessages) {
|
||||
const role = normalizeRole(message, activityMessageIds);
|
||||
const previousTurn = lastTurnBySession.get(message.sessionId);
|
||||
const shouldStartTurn = role === 'user' || !previousTurn;
|
||||
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
|
||||
|
||||
if (shouldStartTurn) {
|
||||
const turns = sessionTurns.get(message.sessionId) ?? [];
|
||||
turns.push(turn);
|
||||
sessionTurns.set(message.sessionId, turns);
|
||||
lastTurnBySession.set(message.sessionId, turn);
|
||||
}
|
||||
|
||||
addMessageToTurn(turn, message, role);
|
||||
messageIdToTurn.set(message.id, turn);
|
||||
}
|
||||
|
||||
const allTurns = Array.from(sessionTurns.values()).flat();
|
||||
|
||||
for (const call of llmCalls) {
|
||||
const turn =
|
||||
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
turn.llmCalls.push(call);
|
||||
turn.inputTokens += call.tokens.input;
|
||||
turn.outputTokens += call.tokens.output;
|
||||
turn.totalTokens += call.tokens.total;
|
||||
turn.totalDuration += call.duration;
|
||||
updateTurnActivity(turn, call.timestamp);
|
||||
|
||||
if (call.status === 'error') {
|
||||
turn.status = 'error';
|
||||
turn.level = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
for (const call of toolCalls) {
|
||||
const turn =
|
||||
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
turn.toolCalls.push(call);
|
||||
turn.totalToolDuration += call.duration;
|
||||
updateTurnActivity(turn, call.timestamp);
|
||||
|
||||
if (call.status === 'error') {
|
||||
turn.status = 'error';
|
||||
turn.level = 'error';
|
||||
}
|
||||
}
|
||||
|
||||
for (const error of errors) {
|
||||
const turn =
|
||||
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
|
||||
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
|
||||
|
||||
if (!turn) {
|
||||
continue;
|
||||
}
|
||||
|
||||
turn.errors.push(error);
|
||||
turn.status = 'error';
|
||||
turn.level = 'error';
|
||||
updateTurnActivity(turn, error.timestamp);
|
||||
}
|
||||
|
||||
for (const turn of allTurns) {
|
||||
turn.messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
turn.assistantMessages.sort(
|
||||
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
|
||||
);
|
||||
turn.llmCalls.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
turn.toolCalls.sort(
|
||||
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
|
||||
);
|
||||
turn.errors.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
}
|
||||
|
||||
return allTurns.sort(
|
||||
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime(),
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,19 @@ import PipelineMonitoringTab from '@/app/home/pipelines/components/monitoring-ta
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, Bug, BarChart3 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperate =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
const canViewMonitoring =
|
||||
currentWorkspace?.permissions.includes('resource.view') ?? false;
|
||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
@@ -47,22 +55,26 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('pipelines.createPipeline')}
|
||||
</h1>
|
||||
<Button type="submit" form="pipeline-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button type="submit" form="pipeline-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<PipelineFormComponent
|
||||
pipelineId={undefined}
|
||||
isEditMode={false}
|
||||
disableForm={false}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={() => {}}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
pipelineId={undefined}
|
||||
isEditMode={false}
|
||||
disableForm={!canManage}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={() => {}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,14 +92,16 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
|
||||
<Button
|
||||
type="submit"
|
||||
form="pipeline-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="pipeline-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
@@ -102,21 +116,25 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
<Settings className="size-3.5" />
|
||||
{t('pipelines.configuration')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="debug" className="gap-1.5">
|
||||
<Bug className="size-3.5" />
|
||||
{t('pipelines.debugChat')}
|
||||
{activeTab === 'debug' && (
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="monitoring" className="gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{t('pipelines.monitoring.title')}
|
||||
</TabsTrigger>
|
||||
{canOperate && (
|
||||
<TabsTrigger value="debug" className="gap-1.5">
|
||||
<Bug className="size-3.5" />
|
||||
{t('pipelines.debugChat')}
|
||||
{activeTab === 'debug' && (
|
||||
<span
|
||||
className={`inline-block size-2 rounded-full ${
|
||||
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="monitoring" className="gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{t('pipelines.monitoring.title')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Configuration */}
|
||||
@@ -124,41 +142,47 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
value="config"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<PipelineFormComponent
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={false}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate('/home/pipelines')}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<PipelineFormComponent
|
||||
pipelineId={id}
|
||||
isEditMode={true}
|
||||
disableForm={!canManage}
|
||||
showButtons={false}
|
||||
onFinish={handleFinish}
|
||||
onNewPipelineCreated={handleNewPipelineCreated}
|
||||
onDeletePipeline={handleDeletePipeline}
|
||||
onCancel={() => navigate('/home/pipelines')}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
</fieldset>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Debug */}
|
||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
||||
<DebugDialog
|
||||
open={activeTab === 'debug'}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canOperate && (
|
||||
<TabsContent value="debug" className="flex-1 min-h-0 mt-4">
|
||||
<DebugDialog
|
||||
open={activeTab === 'debug'}
|
||||
pipelineId={id}
|
||||
isEmbedded={true}
|
||||
onConnectionStatusChange={setIsWebSocketConnected}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Tab: Monitoring */}
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
navigate('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
{canViewMonitoring && (
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<PipelineMonitoringTab
|
||||
pipelineId={id}
|
||||
onNavigateToMonitoring={() => {
|
||||
navigate('/home/monitoring');
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,63 +12,15 @@ import {
|
||||
Monitor,
|
||||
} from 'lucide-react';
|
||||
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
|
||||
import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';
|
||||
import { ConversationTurnList } from '@/app/home/monitoring/components/ConversationTurnList';
|
||||
import { buildConversationTurns } from '@/app/home/monitoring/utils/conversationTurns';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
|
||||
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
|
||||
|
||||
interface PipelineMonitoringTabProps {
|
||||
pipelineId: string;
|
||||
onNavigateToMonitoring?: () => void;
|
||||
}
|
||||
|
||||
interface RawMessageData {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
bot_id: string;
|
||||
bot_name: string;
|
||||
pipeline_id: string;
|
||||
pipeline_name: string;
|
||||
message_content: string;
|
||||
session_id: string;
|
||||
status: string;
|
||||
level: string;
|
||||
platform: string;
|
||||
user_id: string;
|
||||
runner_name: string;
|
||||
variables: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface RawLLMCallData {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
model_name: string;
|
||||
status: string;
|
||||
duration: number;
|
||||
error_message: string | null;
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
total_tokens: number;
|
||||
}
|
||||
|
||||
interface RawLLMStatsData {
|
||||
total_calls: number;
|
||||
total_input_tokens: number;
|
||||
total_output_tokens: number;
|
||||
total_tokens: number;
|
||||
total_duration_ms: number;
|
||||
average_duration_ms: number;
|
||||
}
|
||||
|
||||
interface RawErrorData {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
error_type: string;
|
||||
error_message: string;
|
||||
stack_trace: string | null;
|
||||
}
|
||||
|
||||
export default function PipelineMonitoringTab({
|
||||
pipelineId,
|
||||
onNavigateToMonitoring,
|
||||
@@ -88,98 +40,24 @@ export default function PipelineMonitoringTab({
|
||||
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
|
||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [messageDetails, setMessageDetails] = useState<
|
||||
Record<string, MessageDetails>
|
||||
>({});
|
||||
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
const conversationTurns = useMemo(
|
||||
() =>
|
||||
data
|
||||
? buildConversationTurns(
|
||||
data.messages,
|
||||
data.llmCalls,
|
||||
data.errors,
|
||||
data.toolCalls,
|
||||
)
|
||||
: [],
|
||||
[data],
|
||||
);
|
||||
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
|
||||
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<string>('messages');
|
||||
|
||||
const toggleMessageExpand = async (messageId: string) => {
|
||||
if (expandedMessageId === messageId) {
|
||||
setExpandedMessageId(null);
|
||||
} else {
|
||||
setExpandedMessageId(messageId);
|
||||
|
||||
if (!messageDetails[messageId]) {
|
||||
setLoadingDetails((prev) => ({ ...prev, [messageId]: true }));
|
||||
try {
|
||||
const result = await httpClient.get<{
|
||||
message_id: string;
|
||||
found: boolean;
|
||||
message: RawMessageData | null;
|
||||
llm_calls: RawLLMCallData[];
|
||||
llm_stats: RawLLMStatsData;
|
||||
errors: RawErrorData[];
|
||||
}>(`/api/v1/monitoring/messages/${messageId}/details`);
|
||||
|
||||
if (result) {
|
||||
setMessageDetails((prev) => ({
|
||||
...prev,
|
||||
[messageId]: {
|
||||
messageId: result.message_id,
|
||||
found: result.found,
|
||||
message: result.message
|
||||
? {
|
||||
id: result.message.id,
|
||||
timestamp: parseUTCTimestamp(result.message.timestamp),
|
||||
botId: result.message.bot_id,
|
||||
botName: result.message.bot_name,
|
||||
pipelineId: result.message.pipeline_id,
|
||||
pipelineName: result.message.pipeline_name,
|
||||
messageContent: result.message.message_content,
|
||||
sessionId: result.message.session_id,
|
||||
status: result.message.status,
|
||||
level: result.message.level,
|
||||
platform: result.message.platform,
|
||||
userId: result.message.user_id,
|
||||
runnerName: result.message.runner_name,
|
||||
variables: result.message.variables,
|
||||
}
|
||||
: undefined,
|
||||
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
||||
id: call.id,
|
||||
timestamp: parseUTCTimestamp(call.timestamp),
|
||||
modelName: call.model_name,
|
||||
status: call.status,
|
||||
duration: call.duration,
|
||||
errorMessage: call.error_message,
|
||||
tokens: {
|
||||
input: call.input_tokens || 0,
|
||||
output: call.output_tokens || 0,
|
||||
total: call.total_tokens || 0,
|
||||
},
|
||||
})),
|
||||
errors: result.errors.map((error: RawErrorData) => ({
|
||||
id: error.id,
|
||||
timestamp: parseUTCTimestamp(error.timestamp),
|
||||
errorType: error.error_type,
|
||||
errorMessage: error.error_message,
|
||||
stackTrace: error.stack_trace,
|
||||
})),
|
||||
llmStats: {
|
||||
totalCalls: result.llm_stats.total_calls,
|
||||
totalInputTokens: result.llm_stats.total_input_tokens,
|
||||
totalOutputTokens: result.llm_stats.total_output_tokens,
|
||||
totalTokens: result.llm_stats.total_tokens,
|
||||
totalDurationMs: result.llm_stats.total_duration_ms,
|
||||
averageDurationMs: result.llm_stats.average_duration_ms,
|
||||
},
|
||||
} as MessageDetails,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch message details:', error);
|
||||
} finally {
|
||||
setLoadingDetails((prev) => ({ ...prev, [messageId]: false }));
|
||||
}
|
||||
}
|
||||
}
|
||||
const toggleTurnExpand = (turnId: string) => {
|
||||
setExpandedTurnId((current) => (current === turnId ? null : turnId));
|
||||
};
|
||||
|
||||
const toggleErrorExpand = (errorId: string) => {
|
||||
@@ -190,12 +68,16 @@ export default function PipelineMonitoringTab({
|
||||
}
|
||||
};
|
||||
|
||||
const jumpToMessage = async (messageId: string) => {
|
||||
const jumpToMessage = (messageId: string) => {
|
||||
setActiveTab('messages');
|
||||
// Small delay to ensure tab transition completes before expanding
|
||||
setTimeout(() => {
|
||||
toggleMessageExpand(messageId);
|
||||
}, 100);
|
||||
|
||||
const turn = conversationTurns.find((item) =>
|
||||
item.messages.some((message) => message.id === messageId),
|
||||
);
|
||||
|
||||
if (turn) {
|
||||
setExpandedTurnId(turn.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -295,142 +177,22 @@ export default function PipelineMonitoringTab({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && data && data.messages && data.messages.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
{data.messages
|
||||
.filter((msg) => {
|
||||
const content = msg.messageContent?.trim();
|
||||
return content && content !== '[]' && content !== '""';
|
||||
})
|
||||
.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200"
|
||||
>
|
||||
<div
|
||||
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
onClick={() => toggleMessageExpand(msg.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
<div className="mr-2 mt-0.5">
|
||||
{expandedMessageId === msg.id ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
msg.status === 'success'
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||
: msg.status === 'error'
|
||||
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||
}`}
|
||||
>
|
||||
{msg.status}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{msg.botName}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
|
||||
<MessageContentRenderer
|
||||
content={msg.messageContent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
|
||||
{msg.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expandedMessageId === msg.id && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
|
||||
{loadingDetails[msg.id] && (
|
||||
<div className="flex justify-center py-8">
|
||||
<LoadingSpinner
|
||||
text={t('monitoring.messageList.loading')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingDetails[msg.id] &&
|
||||
messageDetails[msg.id] && (
|
||||
<div className="space-y-4">
|
||||
{messageDetails[msg.id].errors.length > 0 && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
|
||||
{t('monitoring.errors.errorMessage')}
|
||||
</h4>
|
||||
{messageDetails[msg.id].errors.map(
|
||||
(error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="text-sm space-y-2"
|
||||
>
|
||||
<div className="text-red-600 dark:text-red-400">
|
||||
{error.errorType}:{' '}
|
||||
{error.errorMessage}
|
||||
</div>
|
||||
{error.stackTrace && (
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-40 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messageDetails[msg.id].llmCalls.length > 0 && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-2">
|
||||
{t('monitoring.tabs.modelCalls')} (
|
||||
{messageDetails[msg.id].llmCalls.length})
|
||||
</h4>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div>
|
||||
{t('monitoring.llmCalls.totalTokens')}:{' '}
|
||||
{
|
||||
messageDetails[msg.id].llmStats
|
||||
.totalTokens
|
||||
}
|
||||
</div>
|
||||
<div>
|
||||
{t('monitoring.llmCalls.duration')}:{' '}
|
||||
{messageDetails[
|
||||
msg.id
|
||||
].llmStats.totalDurationMs.toFixed(0)}
|
||||
ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!loading && data && conversationTurns.length > 0 && (
|
||||
<ConversationTurnList
|
||||
turns={conversationTurns}
|
||||
expandedTurnId={expandedTurnId}
|
||||
onToggleTurn={toggleTurnExpand}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.messages || data.messages.length === 0) && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-base font-medium">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && (!data || conversationTurns.length === 0) && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
||||
<p className="text-base font-medium">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Errors Tab */}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
|
||||
|
||||
/**
|
||||
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
||||
@@ -80,11 +81,15 @@ function PluginPageIframe({
|
||||
pageId: string;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadedAssetUrl, setLoadedAssetUrl] = useState('');
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
|
||||
const { t, i18n } = useTranslation();
|
||||
const { url: assetUrl, error: assetError } = useAuthenticatedPluginAsset(
|
||||
author,
|
||||
pluginName,
|
||||
pagePath,
|
||||
);
|
||||
const loading = !assetUrl || loadedAssetUrl !== assetUrl;
|
||||
|
||||
// Send context (theme + language) to iframe
|
||||
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
||||
@@ -170,23 +175,29 @@ function PluginPageIframe({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{loading && (
|
||||
{assetError ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('plugins.loadFailed')}
|
||||
</div>
|
||||
) : loading || !assetUrl ? (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : null}
|
||||
{!assetError && assetUrl && (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={assetUrl}
|
||||
className="flex-1 w-full border-0 rounded-md"
|
||||
style={{ display: loading ? 'none' : 'block' }}
|
||||
onLoad={() => {
|
||||
setLoadedAssetUrl(assetUrl);
|
||||
sendContext();
|
||||
}}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
title={`${author}/${pluginName} - ${pagePath}`}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={assetUrl}
|
||||
className="flex-1 w-full border-0 rounded-md"
|
||||
style={{ display: loading ? 'none' : 'block' }}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
sendContext();
|
||||
}}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
title={`${author}/${pluginName} - ${pagePath}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
|
||||
type PluginLocalPreview = Awaited<
|
||||
ReturnType<typeof httpClient.previewPluginInstallFromLocal>
|
||||
@@ -16,6 +18,8 @@ interface PluginLocalPreviewPanelProps {
|
||||
file: File;
|
||||
onInstallStarted?: () => void;
|
||||
onCancel?: () => void;
|
||||
quota?: WorkspaceQuotaItem;
|
||||
quotaResource?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -30,6 +34,8 @@ export default function PluginLocalPreviewPanel({
|
||||
file,
|
||||
onInstallStarted,
|
||||
onCancel,
|
||||
quota,
|
||||
quotaResource = '',
|
||||
}: PluginLocalPreviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const { addTask, setSelectedTaskId } = usePluginInstallTasks();
|
||||
@@ -63,6 +69,7 @@ export default function PluginLocalPreviewPanel({
|
||||
}, [loadPreview]);
|
||||
|
||||
async function handleInstall() {
|
||||
if (quota?.disabled) return;
|
||||
setInstalling(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
@@ -190,13 +197,27 @@ export default function PluginLocalPreviewPanel({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={!preview || previewing || installing}
|
||||
>
|
||||
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
{quota ? (
|
||||
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={quota.disabled || !preview || previewing || installing}
|
||||
>
|
||||
{installing
|
||||
? t('plugins.installing')
|
||||
: t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={!preview || previewing || installing}
|
||||
>
|
||||
{installing ? t('plugins.installing') : t('plugins.confirmInstall')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Puzzle,
|
||||
} from 'lucide-react';
|
||||
import { getCloudServiceClientSync, systemInfo } from '@/app/infra/http';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -39,6 +39,11 @@ export default function ExtensionCardComponent({
|
||||
const { t } = useTranslation();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [iconFailed, setIconFailed] = useState(false);
|
||||
const authenticatedIcon = useAuthenticatedPluginIcon(
|
||||
cardVO.author,
|
||||
cardVO.name,
|
||||
cardVO.type === 'plugin',
|
||||
);
|
||||
|
||||
const FallbackIcon =
|
||||
cardVO.type === 'mcp'
|
||||
@@ -47,8 +52,8 @@ export default function ExtensionCardComponent({
|
||||
? Sparkles
|
||||
: Puzzle;
|
||||
const iconSrc =
|
||||
cardVO.iconURL || httpClient.getPluginIconURL(cardVO.author, cardVO.name);
|
||||
const showFallback = iconFailed || !iconSrc;
|
||||
cardVO.type === 'plugin' ? authenticatedIcon.url : cardVO.iconURL;
|
||||
const showFallback = iconFailed || authenticatedIcon.error || !iconSrc;
|
||||
|
||||
const getTypeLabel = (type: ExtensionType) => {
|
||||
switch (type) {
|
||||
|
||||
+77
-43
@@ -10,6 +10,74 @@ import rehypeSlug from 'rehype-slug';
|
||||
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
|
||||
import { getAPILanguageCode } from '@/i18n/I18nProvider';
|
||||
import '@/styles/github-markdown.css';
|
||||
import { useAuthenticatedPluginAsset } from '@/hooks/useAuthenticatedPluginResource';
|
||||
|
||||
function AuthenticatedReadmeImage({
|
||||
author,
|
||||
name,
|
||||
filepath,
|
||||
alt,
|
||||
...props
|
||||
}: {
|
||||
author: string;
|
||||
name: string;
|
||||
filepath: string;
|
||||
alt?: string;
|
||||
} & React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const { url, error } = useAuthenticatedPluginAsset(author, name, filepath);
|
||||
if (error)
|
||||
return (
|
||||
<span className="text-sm text-muted-foreground">{alt || filepath}</span>
|
||||
);
|
||||
if (!url)
|
||||
return (
|
||||
<span className="inline-block h-6 w-24 animate-pulse rounded bg-muted" />
|
||||
);
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginReadmeImage({
|
||||
author,
|
||||
name,
|
||||
src,
|
||||
alt,
|
||||
...props
|
||||
}: {
|
||||
author: string;
|
||||
name: string;
|
||||
src?: string;
|
||||
alt?: string;
|
||||
} & React.ImgHTMLAttributes<HTMLImageElement>) {
|
||||
const imageSrc = typeof src === 'string' ? src : '';
|
||||
if (!imageSrc || /^(https?:\/\/|data:)/i.test(imageSrc)) {
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let filepath = imageSrc.replace(/^(\.\/|\/)+/, '');
|
||||
filepath = filepath.replace(/^assets\//, '');
|
||||
return (
|
||||
<AuthenticatedReadmeImage
|
||||
author={author}
|
||||
name={name}
|
||||
filepath={filepath}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PluginReadme({
|
||||
pluginAuthor,
|
||||
@@ -71,49 +139,15 @@ export default function PluginReadme({
|
||||
<ol className="list-decimal">{children}</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="ml-4">{children}</li>,
|
||||
img: ({ src, alt, ...props }) => {
|
||||
let imageSrc = src || '';
|
||||
|
||||
if (typeof imageSrc !== 'string') {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || ''}
|
||||
className="max-w-full h-auto rounded-lg my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
imageSrc &&
|
||||
!imageSrc.startsWith('http://') &&
|
||||
!imageSrc.startsWith('https://') &&
|
||||
!imageSrc.startsWith('data:')
|
||||
) {
|
||||
imageSrc = imageSrc.replace(/^(\.\/|\/)+/, '');
|
||||
|
||||
if (!imageSrc.startsWith('assets/')) {
|
||||
imageSrc = `assets/${imageSrc}`;
|
||||
}
|
||||
|
||||
const assetPath = imageSrc.replace(/^assets\//, '');
|
||||
imageSrc = httpClient.getPluginAssetURL(
|
||||
pluginAuthor,
|
||||
pluginName,
|
||||
assetPath,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={alt || ''}
|
||||
className="max-w-lg h-auto my-4"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt, ...props }) => (
|
||||
<PluginReadmeImage
|
||||
author={pluginAuthor}
|
||||
name={pluginName}
|
||||
src={typeof src === 'string' ? src : undefined}
|
||||
alt={alt}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{readme}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
AppWindow,
|
||||
AudioWaveform,
|
||||
Book,
|
||||
FileText,
|
||||
Hash,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const pluginComponentIconMap: Record<string, LucideIcon> = {
|
||||
Tool: Wrench,
|
||||
EventListener: AudioWaveform,
|
||||
Command: Hash,
|
||||
KnowledgeEngine: Book,
|
||||
Parser: FileText,
|
||||
Page: AppWindow,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { pluginComponentIconMap } from './PluginComponentIcons';
|
||||
|
||||
export default function PluginComponentList({
|
||||
components,
|
||||
@@ -18,14 +18,6 @@ export default function PluginComponentList({
|
||||
t: TFunction;
|
||||
responsive?: boolean;
|
||||
}) {
|
||||
const kindIconMap: Record<string, React.ReactNode> = {
|
||||
Tool: <Wrench className="w-5 h-5" />,
|
||||
EventListener: <AudioWaveform className="w-5 h-5" />,
|
||||
Command: <Hash className="w-5 h-5" />,
|
||||
KnowledgeEngine: <Book className="w-5 h-5" />,
|
||||
Parser: <FileText className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const componentKindList = Object.keys(components || {});
|
||||
|
||||
return (
|
||||
@@ -34,11 +26,12 @@ export default function PluginComponentList({
|
||||
{componentKindList.length > 0 && (
|
||||
<>
|
||||
{componentKindList.map((kind) => {
|
||||
const ComponentIcon = pluginComponentIconMap[kind];
|
||||
return (
|
||||
<Fragment key={kind}>
|
||||
{useBadge && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
{kindIconMap[kind]}
|
||||
{ComponentIcon && <ComponentIcon className="w-5 h-5" />}
|
||||
{responsive ? (
|
||||
<span className="hidden md:inline">
|
||||
{t('market.componentName.' + kind)}
|
||||
@@ -52,7 +45,7 @@ export default function PluginComponentList({
|
||||
|
||||
{!useBadge && (
|
||||
<div className="flex flex-row items-center justify-start gap-[0.2rem]">
|
||||
{kindIconMap[kind]}
|
||||
{ComponentIcon && <ComponentIcon className="w-5 h-5" />}
|
||||
{responsive ? (
|
||||
<span className="hidden md:inline">
|
||||
{t('market.componentName.' + kind)}
|
||||
|
||||
@@ -80,9 +80,13 @@ function loadMarketFilters(): MarketFilters {
|
||||
function MarketPageContent({
|
||||
installPlugin,
|
||||
headerActions,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
headerActions?: React.ReactNode;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -127,7 +131,7 @@ function MarketPageContent({
|
||||
// Per-format extension counts shown next to the type filter options.
|
||||
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
|
||||
const [sortOption, setSortOption] = useState<string>(
|
||||
() => loadMarketFilters().sortOption ?? 'install_count_desc',
|
||||
() => loadMarketFilters().sortOption ?? 'hot_score_desc',
|
||||
);
|
||||
|
||||
// Persist filter conditions so they survive navigation / reload.
|
||||
@@ -154,6 +158,12 @@ function MarketPageContent({
|
||||
|
||||
// 排序选项
|
||||
const sortOptions: SortOption[] = [
|
||||
{
|
||||
value: 'hot_score_desc',
|
||||
label: t('market.sort.hottest'),
|
||||
sortBy: 'hot_score',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'created_at_desc',
|
||||
label: t('market.sort.recentlyAdded'),
|
||||
@@ -211,7 +221,7 @@ function MarketPageContent({
|
||||
const option = sortOptions.find((opt) => opt.value === sortOption);
|
||||
return option
|
||||
? { sortBy: option.sortBy, sortOrder: option.sortOrder }
|
||||
: { sortBy: 'install_count', sortOrder: 'DESC' };
|
||||
: { sortBy: 'hot_score', sortOrder: 'DESC' };
|
||||
}, [sortOption]);
|
||||
|
||||
// 将API响应转换为VO对象
|
||||
@@ -233,6 +243,7 @@ function MarketPageContent({
|
||||
description:
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count || 0,
|
||||
likeCount: plugin.like_count || 0,
|
||||
iconURL,
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
@@ -840,6 +851,8 @@ function MarketPageContent({
|
||||
lists={recommendationLists}
|
||||
tagNames={tagNames}
|
||||
onInstall={handleInstallPlugin}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -869,6 +882,8 @@ function MarketPageContent({
|
||||
cardVO={plugin}
|
||||
onInstall={handleInstallPlugin}
|
||||
tagNames={tagNames}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -908,9 +923,13 @@ function MarketPageContent({
|
||||
export default function MarketPage({
|
||||
installPlugin,
|
||||
headerActions,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
installPlugin: (plugin: PluginV4) => void;
|
||||
headerActions?: React.ReactNode;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<Suspense
|
||||
@@ -925,6 +944,8 @@ export default function MarketPage({
|
||||
<MarketPageContent
|
||||
installPlugin={installPlugin}
|
||||
headerActions={headerActions}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,7 @@ function pluginToVO(
|
||||
description:
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count,
|
||||
likeCount: plugin.like_count || 0,
|
||||
iconURL,
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
@@ -53,11 +54,15 @@ function RecommendationListRow({
|
||||
list,
|
||||
tagNames,
|
||||
onInstall,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
isLast,
|
||||
}: {
|
||||
list: RecommendationList;
|
||||
tagNames: Record<string, string>;
|
||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -262,6 +267,8 @@ function RecommendationListRow({
|
||||
cardVO={pluginToVO(plugin, t)}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -276,10 +283,14 @@ export function RecommendationLists({
|
||||
lists,
|
||||
tagNames,
|
||||
onInstall,
|
||||
installDisabled,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
lists: RecommendationList[];
|
||||
tagNames: Record<string, string>;
|
||||
onInstall: (cardVO: PluginMarketCardVO) => void;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
if (!lists || lists.length === 0) return null;
|
||||
|
||||
@@ -291,6 +302,8 @@ export function RecommendationLists({
|
||||
list={list}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
installDisabled={installDisabled}
|
||||
installDisabledTooltip={installDisabledTooltip}
|
||||
isLast={index === lists.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import FingerprintJS, { type LoadOptions } from '@fingerprintjs/fingerprintjs';
|
||||
import { getCloudServiceClient } from '@/app/infra/http';
|
||||
|
||||
const MARKETPLACE_LIKE_CHANGED_EVENT = 'langbot-marketplace-like-changed';
|
||||
|
||||
export interface MarketplaceLikeChange {
|
||||
key: string;
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
}
|
||||
|
||||
let fingerprintPromise: Promise<string> | undefined;
|
||||
let likedKeysPromise: Promise<Set<string>> | undefined;
|
||||
|
||||
export function marketplaceExtensionKey(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
): string {
|
||||
return `${type || 'plugin'}:${author}/${name}`;
|
||||
}
|
||||
|
||||
export function getMarketplaceFingerprint(): Promise<string> {
|
||||
if (!fingerprintPromise) {
|
||||
fingerprintPromise = FingerprintJS.load({
|
||||
monitoring: false,
|
||||
} as LoadOptions & { monitoring: boolean })
|
||||
.then((agent) => agent.get())
|
||||
.then((result) => result.visitorId)
|
||||
.catch((error) => {
|
||||
fingerprintPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return fingerprintPromise;
|
||||
}
|
||||
|
||||
async function loadLikedKeys(): Promise<Set<string>> {
|
||||
if (!likedKeysPromise) {
|
||||
likedKeysPromise = Promise.all([
|
||||
getMarketplaceFingerprint(),
|
||||
getCloudServiceClient(),
|
||||
])
|
||||
.then(([fingerprint, client]) =>
|
||||
client.getMarketplaceLikedExtensions(fingerprint),
|
||||
)
|
||||
.then((data) => {
|
||||
const keys = new Set<string>();
|
||||
for (const ref of data.extensions || []) {
|
||||
keys.add(`${ref.type}:${ref.extension_id}`);
|
||||
}
|
||||
return keys;
|
||||
})
|
||||
.catch((error) => {
|
||||
likedKeysPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return likedKeysPromise;
|
||||
}
|
||||
|
||||
export async function getMarketplaceExtensionLiked(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const likedKeys = await loadLikedKeys();
|
||||
return likedKeys.has(marketplaceExtensionKey(type, author, name));
|
||||
}
|
||||
|
||||
export async function toggleMarketplaceExtensionLike(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
liked: boolean,
|
||||
): Promise<MarketplaceLikeChange> {
|
||||
const extensionType = type || 'plugin';
|
||||
const [fingerprint, likedKeys, client] = await Promise.all([
|
||||
getMarketplaceFingerprint(),
|
||||
loadLikedKeys(),
|
||||
getCloudServiceClient(),
|
||||
]);
|
||||
const result = await client.setMarketplaceExtensionLike(
|
||||
extensionType,
|
||||
author,
|
||||
name,
|
||||
fingerprint,
|
||||
liked,
|
||||
);
|
||||
const key = marketplaceExtensionKey(extensionType, author, name);
|
||||
if (result.liked) {
|
||||
likedKeys.add(key);
|
||||
} else {
|
||||
likedKeys.delete(key);
|
||||
}
|
||||
const change: MarketplaceLikeChange = {
|
||||
key,
|
||||
liked: result.liked,
|
||||
likeCount: result.like_count,
|
||||
};
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<MarketplaceLikeChange>(MARKETPLACE_LIKE_CHANGED_EVENT, {
|
||||
detail: change,
|
||||
}),
|
||||
);
|
||||
return change;
|
||||
}
|
||||
|
||||
export function subscribeMarketplaceLikeChanges(
|
||||
listener: (change: MarketplaceLikeChange) => void,
|
||||
): () => void {
|
||||
const handler = (event: Event) => {
|
||||
listener((event as CustomEvent<MarketplaceLikeChange>).detail);
|
||||
};
|
||||
window.addEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
|
||||
}
|
||||
+115
-5
@@ -3,7 +3,7 @@ import { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PluginComponentList from '../PluginComponentList';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Info, Package, ExternalLink } from 'lucide-react';
|
||||
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -11,20 +11,34 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getMarketplaceExtensionLiked,
|
||||
marketplaceExtensionKey,
|
||||
subscribeMarketplaceLikeChanges,
|
||||
toggleMarketplaceExtensionLike,
|
||||
} from '../marketplace-likes';
|
||||
|
||||
export default function PluginMarketCardComponent({
|
||||
cardVO,
|
||||
onInstall,
|
||||
tagNames = {},
|
||||
installDisabled = false,
|
||||
installDisabledTooltip,
|
||||
}: {
|
||||
cardVO: PluginMarketCardVO;
|
||||
onInstall?: (cardVO: PluginMarketCardVO) => void;
|
||||
tagNames?: Record<string, string>;
|
||||
installDisabled?: boolean;
|
||||
installDisabledTooltip?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleTags, setVisibleTags] = useState(2);
|
||||
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(cardVO.likeCount);
|
||||
const [isLiking, setIsLiking] = useState(false);
|
||||
|
||||
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
|
||||
|
||||
@@ -52,6 +66,37 @@ export default function PluginMarketCardComponent({
|
||||
setIconFailed(!cardVO.iconURL);
|
||||
}, [cardVO.iconURL]);
|
||||
|
||||
useEffect(() => {
|
||||
setLikeCount(cardVO.likeCount);
|
||||
}, [cardVO.likeCount]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getMarketplaceExtensionLiked(cardVO.type, cardVO.author, cardVO.pluginName)
|
||||
.then((value) => {
|
||||
if (!cancelled) setLiked(value);
|
||||
})
|
||||
.catch(() => {
|
||||
// The count still renders if the viewer-state request is unavailable.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
|
||||
|
||||
useEffect(() => {
|
||||
const key = marketplaceExtensionKey(
|
||||
cardVO.type,
|
||||
cardVO.author,
|
||||
cardVO.pluginName,
|
||||
);
|
||||
return subscribeMarketplaceLikeChanges((change) => {
|
||||
if (change.key !== key) return;
|
||||
setLiked(change.liked);
|
||||
setLikeCount(change.likeCount);
|
||||
});
|
||||
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
|
||||
|
||||
useEffect(() => {
|
||||
const tags = cardVO.tags;
|
||||
if (!bottomRef.current || !tags || tags.length === 0) return;
|
||||
@@ -86,18 +131,50 @@ export default function PluginMarketCardComponent({
|
||||
|
||||
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
||||
const handleInstallClick = () => {
|
||||
if (installDisabled) return;
|
||||
onInstall?.(cardVO);
|
||||
};
|
||||
|
||||
return (
|
||||
const handleLikeClick = async (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isLiking) return;
|
||||
setIsLiking(true);
|
||||
try {
|
||||
const change = await toggleMarketplaceExtensionLike(
|
||||
cardVO.type,
|
||||
cardVO.author,
|
||||
cardVO.pluginName,
|
||||
!liked,
|
||||
);
|
||||
setLiked(change.liked);
|
||||
setLikeCount(change.likeCount);
|
||||
} catch {
|
||||
toast.error(t('market.likeFailed'));
|
||||
} finally {
|
||||
setIsLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cardContent = (
|
||||
<div
|
||||
role="button"
|
||||
role={installDisabled ? 'group' : 'button'}
|
||||
tabIndex={0}
|
||||
aria-disabled={installDisabled}
|
||||
aria-label={t('market.installCard', { name: cardVO.label })}
|
||||
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
|
||||
className={`w-[100%] h-[10rem] bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] transition-shadow duration-200 outline-none dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] relative ${
|
||||
installDisabled
|
||||
? 'cursor-not-allowed opacity-60'
|
||||
: 'cursor-pointer hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)]'
|
||||
}`}
|
||||
onClick={handleInstallClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
(event.key === 'Enter' || event.key === ' ')
|
||||
) {
|
||||
event.preventDefault();
|
||||
handleInstallClick();
|
||||
}
|
||||
@@ -177,6 +254,26 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start justify-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t(liked ? 'market.unlike' : 'market.like')}
|
||||
aria-label={t(liked ? 'market.unlike' : 'market.like')}
|
||||
aria-pressed={liked}
|
||||
disabled={isLiking}
|
||||
className="h-7 min-w-7 gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950/30"
|
||||
onClick={handleLikeClick}
|
||||
>
|
||||
{isLiking ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Heart
|
||||
className={`h-3.5 w-3.5 ${liked ? 'fill-red-500 text-red-500' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs tabular-nums">{likeCount}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -295,4 +392,17 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!installDisabled || !installDisabledTooltip) return cardContent;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{cardContent}</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-72 text-left">
|
||||
{installDisabledTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
+3
@@ -5,6 +5,7 @@ export interface IPluginMarketCardVO {
|
||||
label: string;
|
||||
description: string;
|
||||
installCount: number;
|
||||
likeCount?: number;
|
||||
iconURL: string;
|
||||
githubURL: string;
|
||||
version: string;
|
||||
@@ -22,6 +23,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
iconURL: string;
|
||||
githubURL: string;
|
||||
installCount: number;
|
||||
likeCount: number;
|
||||
version: string;
|
||||
components?: Record<string, number>;
|
||||
tags?: string[];
|
||||
@@ -35,6 +37,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
this.iconURL = prop.iconURL;
|
||||
this.githubURL = prop.githubURL;
|
||||
this.installCount = prop.installCount;
|
||||
this.likeCount = prop.likeCount ?? 0;
|
||||
this.pluginId = prop.pluginId;
|
||||
this.version = prop.version;
|
||||
this.components = prop.components;
|
||||
|
||||
@@ -57,6 +57,7 @@ function PluginListView() {
|
||||
const [debugInfo, setDebugInfo] = useState<{
|
||||
debug_url: string;
|
||||
plugin_debug_key: string;
|
||||
expires_at: string;
|
||||
} | null>(null);
|
||||
const [debugPopoverOpen, setDebugPopoverOpen] = useState(false);
|
||||
const [copiedDebugUrl, setCopiedDebugUrl] = useState(false);
|
||||
@@ -275,6 +276,13 @@ function PluginListView() {
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{debugInfo?.expires_at && (
|
||||
<p className="text-xs text-muted-foreground pl-[58px]">
|
||||
{t('plugins.debugKeyExpires', {
|
||||
time: new Date(debugInfo.expires_at).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{!debugInfo?.plugin_debug_key && (
|
||||
<p className="text-xs text-muted-foreground ml-[58px]">
|
||||
{t('plugins.debugKeyDisabled')}
|
||||
|
||||
@@ -24,11 +24,15 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { Sparkles, Trash2 } from 'lucide-react';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function SkillDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const skill = skills.find((item) => item.id === id);
|
||||
@@ -88,14 +92,16 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
className="shrink-0"
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
className="shrink-0"
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!boxAvailable && (
|
||||
@@ -105,13 +111,17 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={(skillName) =>
|
||||
handleImportedSkills([skillName])
|
||||
}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -133,16 +143,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
{t('skills.deleteConfirmation')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="mr-1.5 size-4" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Trash2 className="mr-1.5 size-4" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -185,14 +197,18 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key={id}
|
||||
initSkillName={id}
|
||||
layout="split"
|
||||
sideFooter={editActions}
|
||||
onNewSkillCreated={(skillName) => handleImportedSkills([skillName])}
|
||||
onSkillUpdated={handleSkillUpdated}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
key={id}
|
||||
initSkillName={id}
|
||||
layout="split"
|
||||
sideFooter={canManage ? editActions : undefined}
|
||||
onNewSkillCreated={(skillName) =>
|
||||
handleImportedSkills([skillName])
|
||||
}
|
||||
onSkillUpdated={handleSkillUpdated}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import type { Skill } from '@/app/infra/entities/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { WorkspaceQuotaTooltip } from '@/app/home/components/workspace-quota/WorkspaceQuotaTooltip';
|
||||
import type { WorkspaceQuotaItem } from '@/app/home/components/workspace-quota/useWorkspaceQuotaStatus';
|
||||
|
||||
interface PreviewSkill extends Skill {
|
||||
source_path?: string;
|
||||
@@ -16,6 +18,8 @@ interface SkillZipPreviewPanelProps {
|
||||
file: File;
|
||||
onImported: (skillNames: string[]) => void;
|
||||
onCancel?: () => void;
|
||||
quota?: WorkspaceQuotaItem;
|
||||
quotaResource?: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number): string {
|
||||
@@ -45,6 +49,8 @@ export default function SkillZipPreviewPanel({
|
||||
file,
|
||||
onImported,
|
||||
onCancel,
|
||||
quota,
|
||||
quotaResource = '',
|
||||
}: SkillZipPreviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [previewSkills, setPreviewSkills] = useState<PreviewSkill[]>([]);
|
||||
@@ -117,6 +123,7 @@ export default function SkillZipPreviewPanel({
|
||||
}
|
||||
|
||||
async function handleInstall() {
|
||||
if (quota?.disabled) return;
|
||||
if (selectedPaths.length === 0) return;
|
||||
|
||||
setInstalling(true);
|
||||
@@ -249,28 +256,56 @@ export default function SkillZipPreviewPanel({
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{quota ? (
|
||||
<WorkspaceQuotaTooltip quota={quota} resource={quotaResource}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
quota.disabled ||
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</WorkspaceQuotaTooltip>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleInstall}
|
||||
disabled={
|
||||
previewing ||
|
||||
installing ||
|
||||
previewSkills.length === 0 ||
|
||||
selectedPaths.length === 0
|
||||
}
|
||||
>
|
||||
{installing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t('skills.installing')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PackageOpen className="size-4" />
|
||||
{t('skills.confirmInstall')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -7,9 +7,13 @@ import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
@@ -61,7 +65,11 @@ export default function SkillsPage() {
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
disabled={!boxAvailable || !canManage}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -72,13 +80,15 @@ export default function SkillsPage() {
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={handleCreatedSkill}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
<fieldset className="contents" disabled={!canManage}>
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
initSkillName={undefined}
|
||||
layout="split"
|
||||
onNewSkillCreated={handleCreatedSkill}
|
||||
onSkillUpdated={() => {}}
|
||||
/>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -99,9 +99,33 @@ export interface LLMModel {
|
||||
provider?: ModelProvider;
|
||||
abilities?: string[];
|
||||
context_length?: number | null;
|
||||
reasoning_config?: ReasoningConfig;
|
||||
reasoning_capabilities?: ReasoningCapabilities;
|
||||
extra_args?: object;
|
||||
}
|
||||
|
||||
export type ReasoningLevel =
|
||||
| 'provider_default'
|
||||
| 'disabled'
|
||||
| 'enabled'
|
||||
| 'minimal'
|
||||
| 'low'
|
||||
| 'medium'
|
||||
| 'high'
|
||||
| 'xhigh'
|
||||
| 'max';
|
||||
|
||||
export interface ReasoningConfig {
|
||||
level: ReasoningLevel;
|
||||
}
|
||||
|
||||
export interface ReasoningCapabilities {
|
||||
supported: boolean;
|
||||
levels: ReasoningLevel[];
|
||||
legacy_levels?: ReasoningLevel[];
|
||||
source: 'litellm' | 'provider' | 'manual' | 'unknown';
|
||||
}
|
||||
|
||||
export interface ApiRespProviderEmbeddingModels {
|
||||
models: EmbeddingModel[];
|
||||
}
|
||||
@@ -350,6 +374,7 @@ export interface SystemLimitation {
|
||||
max_bots: number;
|
||||
max_pipelines: number;
|
||||
max_extensions: number;
|
||||
max_knowledge_bases?: number;
|
||||
/** When non-empty, every pipeline is forced to this Box sandbox-scope
|
||||
* template (e.g. ``{global}``) and the per-pipeline "Sandbox Scope"
|
||||
* selector is locked. Used by SaaS deployments. Empty = no restriction. */
|
||||
@@ -368,10 +393,16 @@ export interface ApiRespSystemInfo {
|
||||
debug: boolean;
|
||||
version: string;
|
||||
edition: string;
|
||||
/** Independent instance-level gate for local stdio MCP transports. */
|
||||
mcp_stdio_enabled: boolean;
|
||||
cloud_service_url: string;
|
||||
enable_marketplace: boolean;
|
||||
allow_modify_login_info: boolean;
|
||||
disable_models_service: boolean;
|
||||
invitation_delivery?: {
|
||||
enabled: boolean;
|
||||
provider: 'resend' | 'smtp' | null;
|
||||
};
|
||||
limitation: SystemLimitation;
|
||||
/** Public outbound IPs of the deployment (``system.outbound_ips`` in
|
||||
* config.yaml). Shown on adapter config forms whose platform requires
|
||||
@@ -541,18 +572,21 @@ export interface MCPServerExtraArgsSSE {
|
||||
headers: Record<string, string>;
|
||||
timeout: number;
|
||||
ssereadtimeout: number;
|
||||
tool_call_timeout_sec?: number;
|
||||
}
|
||||
|
||||
export interface MCPServerExtraArgsStdio {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
tool_call_timeout_sec?: number;
|
||||
}
|
||||
|
||||
export interface MCPServerExtraArgsHttp {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
timeout: number;
|
||||
tool_call_timeout_sec?: number;
|
||||
}
|
||||
|
||||
// "remote" mode: the user only supplies a URL; the backend auto-detects the
|
||||
@@ -562,6 +596,7 @@ export interface MCPServerExtraArgsRemote {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
tool_call_timeout_sec?: number;
|
||||
}
|
||||
|
||||
export enum MCPSessionStatus {
|
||||
|
||||
@@ -44,6 +44,10 @@ export interface IDynamicFormItemSchema {
|
||||
scopes?: string[];
|
||||
accept?: string; // For file type: accepted MIME types
|
||||
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
|
||||
url?: string; // For download-link type: relative or absolute download URL
|
||||
download_filename?: string; // Optional filename for download-link type
|
||||
help_links?: Record<string, string>; // Optional docs links for display-only fields
|
||||
help_label?: I18nObject; // Optional label for help_links
|
||||
}
|
||||
|
||||
export enum DynamicFormItemType {
|
||||
@@ -72,6 +76,7 @@ export enum DynamicFormItemType {
|
||||
WEBHOOK_URL = 'webhook-url',
|
||||
EMBED_CODE = 'embed-code',
|
||||
QR_CODE_LOGIN = 'qr-code-login',
|
||||
DOWNLOAD_LINK = 'download-link',
|
||||
}
|
||||
|
||||
export interface IFileConfig {
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface PluginV4 {
|
||||
repository: string;
|
||||
tags: string[];
|
||||
install_count: number;
|
||||
like_count?: number;
|
||||
hot_score?: number;
|
||||
latest_version: string;
|
||||
components: Record<string, number>;
|
||||
status: PluginV4Status;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export type WorkspaceRole =
|
||||
| 'owner'
|
||||
| 'admin'
|
||||
| 'developer'
|
||||
| 'operator'
|
||||
| 'viewer';
|
||||
|
||||
export interface Workspace {
|
||||
uuid: string;
|
||||
instance_uuid: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: 'personal' | 'team';
|
||||
status: 'provisioning' | 'active' | 'suspended' | 'archived' | 'deleted';
|
||||
source: 'local' | 'cloud_projection';
|
||||
}
|
||||
|
||||
export interface WorkspaceMembership {
|
||||
uuid: string;
|
||||
workspace_uuid: string;
|
||||
account_uuid: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: WorkspaceRole;
|
||||
status: 'active' | 'disabled' | 'removed';
|
||||
joined_at: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CurrentWorkspace {
|
||||
workspace: Workspace;
|
||||
membership: WorkspaceMembership;
|
||||
permissions: string[];
|
||||
placement_generation: number;
|
||||
/** Signed Cloud display metadata; never used for client-side authorization. */
|
||||
plan_name?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceSpaceBilling {
|
||||
credits: number | null;
|
||||
owner_space_bound: boolean;
|
||||
is_workspace_owner: boolean;
|
||||
}
|
||||
|
||||
/** Account-scoped Workspace entry returned before a Workspace is selected. */
|
||||
export type WorkspaceBootstrapEntry = CurrentWorkspace;
|
||||
|
||||
export interface WorkspaceBootstrapResponse {
|
||||
workspaces: WorkspaceBootstrapEntry[];
|
||||
}
|
||||
|
||||
export interface WorkspaceInvitation {
|
||||
uuid: string;
|
||||
workspace_uuid: string;
|
||||
normalized_email: string;
|
||||
role: Exclude<WorkspaceRole, 'owner'>;
|
||||
status: 'pending' | 'accepted' | 'revoked' | 'expired';
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type WorkspaceInvitationDeliveryStatus = 'sent' | 'link_only' | 'failed';
|
||||
|
||||
export interface WorkspaceInvitationDelivery {
|
||||
status: WorkspaceInvitationDeliveryStatus;
|
||||
provider: 'resend' | 'smtp' | null;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
/**
|
||||
* Load the instance-level stdio MCP gate independently of Box health.
|
||||
*
|
||||
* The hook fails closed while loading or when System Info is unavailable.
|
||||
* This is only a WebUI guard; the backend loader enforces the same gate at
|
||||
* the final transport boundary.
|
||||
*/
|
||||
export function useMCPStdioPolicy() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const info = await httpClient.getSystemInfo();
|
||||
setEnabled(info.mcp_stdio_enabled === true);
|
||||
} catch {
|
||||
setEnabled(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
return { enabled, loading, refresh };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BaseHttpClient } from './BaseHttpClient';
|
||||
import { BaseHttpClient, type RequestConfig } from './BaseHttpClient';
|
||||
import {
|
||||
ApiRespProviderRequesters,
|
||||
ApiRespProviderRequester,
|
||||
@@ -61,6 +61,16 @@ import type { PluginLogEntry } from '@/app/infra/entities/plugin';
|
||||
import type { I18nObject } from '@/app/infra/entities/common';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
import { GetBotLogsResponse } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
import type {
|
||||
CurrentWorkspace,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
WorkspaceInvitationDelivery,
|
||||
WorkspaceMembership,
|
||||
WorkspaceBootstrapResponse,
|
||||
WorkspaceRole,
|
||||
WorkspaceSpaceBilling,
|
||||
} from '@/app/infra/entities/workspace';
|
||||
|
||||
/**
|
||||
* 后端服务客户端
|
||||
@@ -671,6 +681,21 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
public getMcpServerLogs(
|
||||
serverName: string,
|
||||
limit: number = 200,
|
||||
level?: string,
|
||||
): Promise<{ logs: PluginLogEntry[] }> {
|
||||
const params = new URLSearchParams();
|
||||
params.set('limit', String(limit));
|
||||
if (level) {
|
||||
params.set('level', level);
|
||||
}
|
||||
return this.get(
|
||||
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/logs?${params.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getPluginAssetURL(
|
||||
author: string,
|
||||
name: string,
|
||||
@@ -685,6 +710,32 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
private async getAuthenticatedObjectURL(path: string): Promise<string> {
|
||||
const response = await this.instance.get<Blob>(path, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
return URL.createObjectURL(response.data);
|
||||
}
|
||||
|
||||
public getAuthenticatedPluginAssetURL(
|
||||
author: string,
|
||||
name: string,
|
||||
filepath: string,
|
||||
): Promise<string> {
|
||||
return this.getAuthenticatedObjectURL(
|
||||
`/api/v1/plugins/${author}/${name}/authenticated-assets/${filepath}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getAuthenticatedPluginIconURL(
|
||||
author: string,
|
||||
name: string,
|
||||
): Promise<string> {
|
||||
return this.getAuthenticatedObjectURL(
|
||||
`/api/v1/plugins/${author}/${name}/authenticated-icon`,
|
||||
);
|
||||
}
|
||||
|
||||
public async pluginPageApi(
|
||||
author: string,
|
||||
name: string,
|
||||
@@ -718,13 +769,15 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
public installPluginFromGithub(
|
||||
assetUrl: string,
|
||||
assetId: number,
|
||||
releaseId: number,
|
||||
owner: string,
|
||||
repo: string,
|
||||
releaseTag: string,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/plugins/install/github', {
|
||||
asset_url: assetUrl,
|
||||
asset_id: assetId,
|
||||
release_id: releaseId,
|
||||
owner,
|
||||
repo,
|
||||
release_tag: releaseTag,
|
||||
@@ -1038,10 +1091,15 @@ export class BackendClient extends BaseHttpClient {
|
||||
public getPluginDebugInfo(): Promise<{
|
||||
debug_url: string;
|
||||
plugin_debug_key: string;
|
||||
expires_at: string;
|
||||
}> {
|
||||
return this.get('/api/v1/plugins/debug-info');
|
||||
}
|
||||
|
||||
public getBoxRuntimeStatus(): Promise<ApiRespBoxStatus> {
|
||||
return this.get('/api/v1/box/runtime-status');
|
||||
}
|
||||
|
||||
public getBoxStatus(): Promise<ApiRespBoxStatus> {
|
||||
return this.get('/api/v1/box/status');
|
||||
}
|
||||
@@ -1052,19 +1110,29 @@ export class BackendClient extends BaseHttpClient {
|
||||
|
||||
// ============ User API ============
|
||||
public checkIfInited(): Promise<{ initialized: boolean }> {
|
||||
return this.get('/api/v1/user/init');
|
||||
return this.get('/api/v1/user/init', undefined, { skipWorkspace: true });
|
||||
}
|
||||
|
||||
public initUser(user: string, password: string): Promise<object> {
|
||||
return this.post('/api/v1/user/init', { user, password });
|
||||
return this.post(
|
||||
'/api/v1/user/init',
|
||||
{ user, password },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public authUser(user: string, password: string): Promise<ApiRespUserToken> {
|
||||
return this.post('/api/v1/user/auth', { user, password });
|
||||
return this.post(
|
||||
'/api/v1/user/auth',
|
||||
{ user, password },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public checkUserToken(): Promise<ApiRespUserToken> {
|
||||
return this.get('/api/v1/user/check-token');
|
||||
return this.get('/api/v1/user/check-token', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
public resetPassword(
|
||||
@@ -1072,51 +1140,169 @@ export class BackendClient extends BaseHttpClient {
|
||||
recoveryKey: string,
|
||||
newPassword: string,
|
||||
): Promise<{ user: string }> {
|
||||
return this.post('/api/v1/user/reset-password', {
|
||||
user,
|
||||
recovery_key: recoveryKey,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return this.post(
|
||||
'/api/v1/user/reset-password',
|
||||
{
|
||||
user,
|
||||
recovery_key: recoveryKey,
|
||||
new_password: newPassword,
|
||||
},
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public changePassword(
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<{ user: string }> {
|
||||
return this.post('/api/v1/user/change-password', {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
return this.post(
|
||||
'/api/v1/user/change-password',
|
||||
{
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
},
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public getUserInfo(): Promise<{
|
||||
account_uuid: string;
|
||||
user: string;
|
||||
account_type: 'local' | 'space';
|
||||
has_password: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/user/info');
|
||||
return this.get('/api/v1/user/info', undefined, { skipWorkspace: true });
|
||||
}
|
||||
|
||||
public getSpaceCredits(): Promise<{ credits: number | null }> {
|
||||
public getWorkspaceSpaceBilling(): Promise<WorkspaceSpaceBilling> {
|
||||
return this.get('/api/v1/user/space-credits');
|
||||
}
|
||||
|
||||
public getAccountInfo(): Promise<{
|
||||
initialized: boolean;
|
||||
account_type?: 'local' | 'space';
|
||||
has_password?: boolean;
|
||||
authenticated_invitation_acceptance_enabled?: boolean;
|
||||
password_login_enabled?: boolean;
|
||||
space_login_enabled?: boolean;
|
||||
}> {
|
||||
return this.get('/api/v1/user/account-info');
|
||||
return this.get('/api/v1/user/account-info', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Workspace API ============
|
||||
public getWorkspaceBootstrap(): Promise<WorkspaceBootstrapResponse> {
|
||||
return this.get('/api/v1/workspaces/bootstrap', undefined, {
|
||||
skipWorkspace: true,
|
||||
});
|
||||
}
|
||||
|
||||
public getWorkspaces(): Promise<{ workspaces: Workspace[] }> {
|
||||
return this.get('/api/v1/workspaces', undefined, { skipWorkspace: true });
|
||||
}
|
||||
|
||||
public getCurrentWorkspace(): Promise<CurrentWorkspace> {
|
||||
return this.get('/api/v1/workspaces/current');
|
||||
}
|
||||
|
||||
public getWorkspace(
|
||||
workspaceUuid: string,
|
||||
): Promise<{ workspace: Workspace }> {
|
||||
return this.get(`/api/v1/workspaces/${workspaceUuid}`);
|
||||
}
|
||||
|
||||
public getWorkspaceMembers(
|
||||
workspaceUuid: string,
|
||||
): Promise<{ members: WorkspaceMembership[] }> {
|
||||
return this.get(`/api/v1/workspaces/${workspaceUuid}/members`);
|
||||
}
|
||||
|
||||
public createWorkspaceInvitation(
|
||||
workspaceUuid: string,
|
||||
email: string,
|
||||
role: Exclude<WorkspaceRole, 'owner'>,
|
||||
): Promise<{
|
||||
invitation: WorkspaceInvitation;
|
||||
token: string;
|
||||
link: string;
|
||||
delivery: WorkspaceInvitationDelivery;
|
||||
}> {
|
||||
return this.post(`/api/v1/workspaces/${workspaceUuid}/invitations`, {
|
||||
email,
|
||||
role,
|
||||
});
|
||||
}
|
||||
|
||||
public getWorkspaceInvitations(
|
||||
workspaceUuid: string,
|
||||
): Promise<{ invitations: WorkspaceInvitation[] }> {
|
||||
return this.get(`/api/v1/workspaces/${workspaceUuid}/invitations`);
|
||||
}
|
||||
|
||||
public revokeWorkspaceInvitation(
|
||||
workspaceUuid: string,
|
||||
invitationUuid: string,
|
||||
): Promise<object> {
|
||||
return this.delete(
|
||||
`/api/v1/workspaces/${workspaceUuid}/invitations/${invitationUuid}`,
|
||||
);
|
||||
}
|
||||
|
||||
public inspectWorkspaceInvitation(
|
||||
token: string,
|
||||
): Promise<{ invitation: WorkspaceInvitation; workspace: Workspace }> {
|
||||
return this.post(
|
||||
'/api/v1/invitations/inspect',
|
||||
{ token },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public acceptWorkspaceInvitation(
|
||||
token: string,
|
||||
registration?: { email: string; password: string },
|
||||
): Promise<{ token: string; workspace_uuid: string }> {
|
||||
return this.post(
|
||||
'/api/v1/invitations/accept',
|
||||
{
|
||||
token,
|
||||
registration,
|
||||
},
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public updateWorkspaceMemberRole(
|
||||
workspaceUuid: string,
|
||||
accountUuid: string,
|
||||
role: WorkspaceRole,
|
||||
): Promise<{ member: WorkspaceMembership }> {
|
||||
return this.patch(
|
||||
`/api/v1/workspaces/${workspaceUuid}/members/${accountUuid}`,
|
||||
{ role },
|
||||
);
|
||||
}
|
||||
|
||||
public removeWorkspaceMember(
|
||||
workspaceUuid: string,
|
||||
accountUuid: string,
|
||||
): Promise<object> {
|
||||
return this.delete(
|
||||
`/api/v1/workspaces/${workspaceUuid}/members/${accountUuid}`,
|
||||
);
|
||||
}
|
||||
|
||||
public setPassword(
|
||||
newPassword: string,
|
||||
currentPassword?: string,
|
||||
): Promise<{ user: string }> {
|
||||
return this.post('/api/v1/user/set-password', {
|
||||
new_password: newPassword,
|
||||
current_password: currentPassword,
|
||||
});
|
||||
return this.post(
|
||||
'/api/v1/user/set-password',
|
||||
{
|
||||
new_password: newPassword,
|
||||
current_password: currentPassword,
|
||||
},
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public async bindSpaceAccount(
|
||||
@@ -1127,10 +1313,11 @@ export class BackendClient extends BaseHttpClient {
|
||||
user: string;
|
||||
account_type: 'local' | 'space';
|
||||
}> {
|
||||
const response = await this.instance.post('/api/v1/user/bind-space', {
|
||||
code,
|
||||
state,
|
||||
});
|
||||
const response = await this.instance.post(
|
||||
'/api/v1/user/bind-space',
|
||||
{ code, state },
|
||||
{ skipWorkspace: true } as RequestConfig,
|
||||
);
|
||||
if (response.data.code !== 0) {
|
||||
throw {
|
||||
code: response.data.code,
|
||||
@@ -1141,26 +1328,48 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
// ============ Space OAuth API (Redirect Flow) ============
|
||||
public getSpaceAuthorizeUrl(
|
||||
redirectUri: string,
|
||||
state?: string,
|
||||
): Promise<{
|
||||
public getSpaceAuthorizeUrl(redirectUri: string): Promise<{
|
||||
authorize_url: string;
|
||||
}> {
|
||||
const params: Record<string, string> = { redirect_uri: redirectUri };
|
||||
if (state) {
|
||||
params.state = state;
|
||||
}
|
||||
return this.get('/api/v1/user/space/authorize-url', params);
|
||||
return this.get(
|
||||
'/api/v1/user/space/authorize-url',
|
||||
{ redirect_uri: redirectUri },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public async exchangeSpaceOAuthCode(code: string): Promise<{
|
||||
token: string;
|
||||
user: string;
|
||||
public getSpaceBindAuthorizeUrl(redirectUri: string): Promise<{
|
||||
authorize_url: string;
|
||||
}> {
|
||||
const response = await this.instance.post('/api/v1/user/space/callback', {
|
||||
code,
|
||||
});
|
||||
return this.get(
|
||||
'/api/v1/user/space/bind-authorize-url',
|
||||
{ redirect_uri: redirectUri },
|
||||
{ skipWorkspace: true },
|
||||
);
|
||||
}
|
||||
|
||||
public async exchangeSpaceOAuthCode(
|
||||
code: string,
|
||||
state: string,
|
||||
workspaceUuid?: string,
|
||||
launchAssertion?: string,
|
||||
): Promise<{
|
||||
token: string;
|
||||
user?: string;
|
||||
workspace_uuid?: string;
|
||||
principal_type?: 'account' | 'support_admin';
|
||||
actor_account_uuid?: string;
|
||||
}> {
|
||||
const response = await this.instance.post(
|
||||
'/api/v1/user/space/callback',
|
||||
{
|
||||
code,
|
||||
state,
|
||||
workspace_uuid: workspaceUuid,
|
||||
launch_assertion: launchAssertion,
|
||||
},
|
||||
{ skipWorkspace: true } as RequestConfig,
|
||||
);
|
||||
if (response.data.code !== 0) {
|
||||
throw {
|
||||
code: response.data.code,
|
||||
@@ -1199,8 +1408,10 @@ export class BackendClient extends BaseHttpClient {
|
||||
level: string;
|
||||
platform?: string;
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
runner_name?: string;
|
||||
variables?: string;
|
||||
role?: string;
|
||||
}>;
|
||||
llmCalls: Array<{
|
||||
id: string;
|
||||
@@ -1216,9 +1427,27 @@ export class BackendClient extends BaseHttpClient {
|
||||
bot_name: string;
|
||||
pipeline_id: string;
|
||||
pipeline_name: string;
|
||||
session_id?: string;
|
||||
error_message?: string;
|
||||
message_id?: string;
|
||||
}>;
|
||||
toolCalls: Array<{
|
||||
id: string;
|
||||
timestamp: string;
|
||||
tool_name: string;
|
||||
tool_source: string;
|
||||
duration: number;
|
||||
status: string;
|
||||
bot_id: string;
|
||||
bot_name: string;
|
||||
pipeline_id: string;
|
||||
pipeline_name: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
arguments?: string;
|
||||
result?: string;
|
||||
error_message?: string;
|
||||
}>;
|
||||
embeddingCalls: Array<{
|
||||
id: string;
|
||||
timestamp: string;
|
||||
@@ -1263,6 +1492,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
totalCount: {
|
||||
messages: number;
|
||||
llmCalls: number;
|
||||
toolCalls?: number;
|
||||
embeddingCalls: number;
|
||||
sessions: number;
|
||||
errors: number;
|
||||
|
||||
@@ -4,6 +4,12 @@ import axios, {
|
||||
AxiosResponse,
|
||||
AxiosError,
|
||||
} from 'axios';
|
||||
import {
|
||||
clearActiveWorkspaceUuid,
|
||||
getActiveWorkspaceUuid,
|
||||
} from './workspaceContext';
|
||||
import { setCurrentWorkspaceSnapshot } from './currentWorkspaceStore';
|
||||
import { clearWorkspaceBootstrapSnapshot } from './workspaceBootstrapStore';
|
||||
|
||||
type JSONValue = string | number | boolean | JSONObject | JSONArray | null;
|
||||
interface JSONObject {
|
||||
@@ -21,6 +27,8 @@ export interface ResponseData<T = unknown> {
|
||||
export interface RequestConfig extends AxiosRequestConfig {
|
||||
isSSR?: boolean; // 服务端渲染标识
|
||||
retry?: number; // 重试次数
|
||||
/** Account-scoped endpoints must not receive a stale Workspace selector. */
|
||||
skipWorkspace?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,6 +85,17 @@ export abstract class BaseHttpClient {
|
||||
if (session) {
|
||||
config.headers.Authorization = `Bearer ${session}`;
|
||||
}
|
||||
|
||||
const requestConfig = config as RequestConfig;
|
||||
const workspaceUuid = getActiveWorkspaceUuid();
|
||||
if (requestConfig.skipWorkspace) {
|
||||
delete config.headers['X-Workspace-Id'];
|
||||
delete config.headers['x-workspace-id'];
|
||||
} else if (workspaceUuid) {
|
||||
config.headers['X-Workspace-Id'] = workspaceUuid;
|
||||
}
|
||||
|
||||
delete requestConfig.skipWorkspace;
|
||||
}
|
||||
|
||||
return config;
|
||||
@@ -99,6 +118,10 @@ export abstract class BaseHttpClient {
|
||||
case 401:
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
clearActiveWorkspaceUuid();
|
||||
setCurrentWorkspaceSnapshot(null);
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
if (!error.request.responseURL.includes('/check-token')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
@@ -187,6 +210,14 @@ export abstract class BaseHttpClient {
|
||||
return this.request<T>({ method: 'put', url, data, ...config });
|
||||
}
|
||||
|
||||
public patch<T = unknown>(
|
||||
url: string,
|
||||
data?: object,
|
||||
config?: RequestConfig,
|
||||
): Promise<T> {
|
||||
return this.request<T>({ method: 'patch', url, data, ...config });
|
||||
}
|
||||
|
||||
public delete<T = unknown>(url: string, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>({ method: 'delete', url, ...config });
|
||||
}
|
||||
|
||||
@@ -117,6 +117,32 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
|
||||
}
|
||||
|
||||
public getMarketplaceLikedExtensions(
|
||||
fingerprint: string,
|
||||
): Promise<{ extensions: Array<{ type: string; extension_id: string }> }> {
|
||||
return this.post('/api/v1/marketplace/extensions/likes/status', {
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
public setMarketplaceExtensionLike(
|
||||
type: string,
|
||||
author: string,
|
||||
name: string,
|
||||
fingerprint: string,
|
||||
liked: boolean,
|
||||
): Promise<{
|
||||
type: string;
|
||||
extension_id: string;
|
||||
liked: boolean;
|
||||
like_count: number;
|
||||
}> {
|
||||
return this.put(
|
||||
`/api/v1/marketplace/extensions/${encodeURIComponent(type)}/${encodeURIComponent(author)}/${encodeURIComponent(name)}/like`,
|
||||
{ fingerprint, liked },
|
||||
);
|
||||
}
|
||||
|
||||
private async searchMarketplaceExtensionsLegacy(data: {
|
||||
query?: string;
|
||||
page: number;
|
||||
@@ -128,6 +154,8 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
tags_filter?: string[];
|
||||
}): Promise<ApiRespMarketplacePlugins> {
|
||||
const query = data.query || '';
|
||||
const legacySortBy =
|
||||
data.sort_by === 'hot_score' ? 'install_count' : data.sort_by;
|
||||
|
||||
if (
|
||||
data.type_filter === 'plugin' ||
|
||||
@@ -139,7 +167,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
data.sort_by,
|
||||
legacySortBy,
|
||||
data.sort_order,
|
||||
data.component_filter,
|
||||
data.tags_filter,
|
||||
@@ -157,7 +185,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
data.sort_by,
|
||||
legacySortBy,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
@@ -167,7 +195,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
data.sort_by,
|
||||
legacySortBy,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
@@ -177,7 +205,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
data.sort_by,
|
||||
legacySortBy,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { CurrentWorkspace } from '@/app/infra/entities/workspace';
|
||||
|
||||
let snapshot: CurrentWorkspace | null = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getCurrentWorkspaceSnapshot(): CurrentWorkspace | null {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function setCurrentWorkspaceSnapshot(
|
||||
workspace: CurrentWorkspace | null,
|
||||
): void {
|
||||
snapshot = workspace;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** React-safe access to the currently selected Workspace and its permissions. */
|
||||
export function useCurrentWorkspace(): CurrentWorkspace | null {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
getCurrentWorkspaceSnapshot,
|
||||
getCurrentWorkspaceSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,39 @@
|
||||
import { BackendClient } from './BackendClient';
|
||||
import { CloudServiceClient } from './CloudServiceClient';
|
||||
import { ApiRespSystemInfo } from '@/app/infra/entities/api';
|
||||
import type {
|
||||
CurrentWorkspace,
|
||||
WorkspaceBootstrapEntry,
|
||||
} from '@/app/infra/entities/workspace';
|
||||
import {
|
||||
clearActiveWorkspaceUuid,
|
||||
getActiveWorkspaceUuid,
|
||||
setActiveWorkspaceUuid,
|
||||
} from './workspaceContext';
|
||||
import {
|
||||
getCurrentWorkspaceSnapshot,
|
||||
setCurrentWorkspaceSnapshot,
|
||||
} from './currentWorkspaceStore';
|
||||
import {
|
||||
clearWorkspaceBootstrapSnapshot,
|
||||
getWorkspaceBootstrapSnapshot,
|
||||
setWorkspaceBootstrapSnapshot,
|
||||
} from './workspaceBootstrapStore';
|
||||
|
||||
// 系统信息
|
||||
export const systemInfo: ApiRespSystemInfo = {
|
||||
debug: false,
|
||||
version: '',
|
||||
edition: 'community',
|
||||
mcp_stdio_enabled: false,
|
||||
enable_marketplace: true,
|
||||
cloud_service_url: '',
|
||||
allow_modify_login_info: true,
|
||||
disable_models_service: false,
|
||||
invitation_delivery: {
|
||||
enabled: false,
|
||||
provider: null,
|
||||
},
|
||||
limitation: {
|
||||
max_bots: -1,
|
||||
max_pipelines: -1,
|
||||
@@ -23,6 +46,7 @@ export const systemInfo: ApiRespSystemInfo = {
|
||||
|
||||
// 用户信息
|
||||
export let userInfo: {
|
||||
account_uuid: string;
|
||||
user: string;
|
||||
account_type: 'local' | 'space';
|
||||
has_password: boolean;
|
||||
@@ -109,25 +133,281 @@ export const initializeSystemInfo = async (options?: {
|
||||
* 初始化用户信息
|
||||
* 应该在用户登录后调用此方法
|
||||
*/
|
||||
export const initializeUserInfo = async (): Promise<void> => {
|
||||
export const initializeUserInfo = async (options?: {
|
||||
throwOnError?: boolean;
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
userInfo = await backendClient.getUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('userEmail', userInfo.user);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize user info:', error);
|
||||
userInfo = null;
|
||||
if (options?.throwOnError) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const initializeWorkspaceInfo = async (): Promise<void> => {
|
||||
const storedWorkspaceUuid = getActiveWorkspaceUuid();
|
||||
try {
|
||||
const workspace = await backendClient.getCurrentWorkspace();
|
||||
setCurrentWorkspaceSnapshot(workspace);
|
||||
setActiveWorkspaceUuid(workspace.workspace.uuid);
|
||||
} catch (error) {
|
||||
setCurrentWorkspaceSnapshot(null);
|
||||
clearActiveWorkspaceUuid();
|
||||
// A restored Community browser session can outlive a database reset or an
|
||||
// instance replacement. Its stale selector is not an authorization
|
||||
// credential, and the OSS policy still has exactly one legal Workspace,
|
||||
// so retry once without it. Cloud must remain explicit and fail closed.
|
||||
if (storedWorkspaceUuid && systemInfo.edition === 'community') {
|
||||
const workspace = await backendClient.getCurrentWorkspace();
|
||||
setCurrentWorkspaceSnapshot(workspace);
|
||||
setActiveWorkspaceUuid(workspace.workspace.uuid);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export type WorkspaceBootstrapResult =
|
||||
| {
|
||||
status: 'ready';
|
||||
workspace: CurrentWorkspace;
|
||||
workspaces: WorkspaceBootstrapEntry[];
|
||||
}
|
||||
| {
|
||||
status: 'selection-required';
|
||||
workspaces: WorkspaceBootstrapEntry[];
|
||||
}
|
||||
| {
|
||||
status: 'unavailable';
|
||||
workspaces: [];
|
||||
};
|
||||
|
||||
export interface WorkspaceBootstrapOptions {
|
||||
/** Discard any selector left by a previous Account session. */
|
||||
resetSelection?: boolean;
|
||||
/** Explicit intent, such as accepting an invitation. */
|
||||
preferredWorkspaceUuid?: string;
|
||||
/** Always show the chooser when the Account has multiple Workspaces. */
|
||||
requireExplicitSelection?: boolean;
|
||||
}
|
||||
|
||||
function clearWorkspaceSelection(): void {
|
||||
setCurrentWorkspaceSnapshot(null);
|
||||
clearActiveWorkspaceUuid();
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a new Account token without carrying Workspace state across Accounts.
|
||||
* Call bootstrapWorkspaceSession immediately afterwards.
|
||||
*/
|
||||
export function beginAuthenticatedSession(
|
||||
token: string,
|
||||
userEmail?: string,
|
||||
): void {
|
||||
userInfo = null;
|
||||
clearWorkspaceSelection();
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
localStorage.removeItem('authPrincipalType');
|
||||
localStorage.setItem('token', token);
|
||||
if (userEmail) localStorage.setItem('userEmail', userEmail);
|
||||
}
|
||||
|
||||
export function beginSupportAdminSession(
|
||||
token: string,
|
||||
workspaceUuid: string,
|
||||
): void {
|
||||
userInfo = null;
|
||||
clearWorkspaceSelection();
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('authPrincipalType', 'support_admin');
|
||||
setActiveWorkspaceUuid(workspaceUuid);
|
||||
}
|
||||
|
||||
export function isSupportAdminSession(): boolean {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
localStorage.getItem('authPrincipalType') === 'support_admin'
|
||||
);
|
||||
}
|
||||
|
||||
async function initializeSelectedWorkspace(
|
||||
workspaceUuid: string,
|
||||
workspaces: WorkspaceBootstrapEntry[],
|
||||
): Promise<WorkspaceBootstrapResult> {
|
||||
setActiveWorkspaceUuid(workspaceUuid);
|
||||
try {
|
||||
await Promise.all([
|
||||
initializeUserInfo({ throwOnError: true }),
|
||||
initializeWorkspaceInfo(),
|
||||
]);
|
||||
} catch (error) {
|
||||
clearWorkspaceSelection();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const workspace = getCurrentWorkspaceSnapshot();
|
||||
if (!workspace) {
|
||||
clearWorkspaceSelection();
|
||||
throw new Error('Selected Workspace could not be initialized');
|
||||
}
|
||||
return { status: 'ready', workspace, workspaces };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Account membership before any Workspace-scoped request is made.
|
||||
* A singleton is selected automatically; multiple Workspaces require explicit
|
||||
* user intent unless a still-valid selector already exists.
|
||||
*/
|
||||
export async function bootstrapWorkspaceSession(
|
||||
options: WorkspaceBootstrapOptions = {},
|
||||
): Promise<WorkspaceBootstrapResult> {
|
||||
if (isSupportAdminSession()) {
|
||||
const selectedWorkspaceUuid = getActiveWorkspaceUuid();
|
||||
if (!selectedWorkspaceUuid) {
|
||||
throw new Error('Support admin session is missing its Workspace scope');
|
||||
}
|
||||
if (
|
||||
options.preferredWorkspaceUuid &&
|
||||
options.preferredWorkspaceUuid !== selectedWorkspaceUuid
|
||||
) {
|
||||
throw new Error('Support admin session cannot change Workspace scope');
|
||||
}
|
||||
await initializeWorkspaceInfo();
|
||||
const workspace = getCurrentWorkspaceSnapshot();
|
||||
if (!workspace || workspace.workspace.uuid !== selectedWorkspaceUuid) {
|
||||
clearWorkspaceSelection();
|
||||
throw new Error('Support admin Workspace scope could not be initialized');
|
||||
}
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
return { status: 'ready', workspace, workspaces: [] };
|
||||
}
|
||||
|
||||
if (options.resetSelection) {
|
||||
clearWorkspaceSelection();
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
}
|
||||
|
||||
const response = await backendClient.getWorkspaceBootstrap();
|
||||
const workspaces = response.workspaces;
|
||||
setWorkspaceBootstrapSnapshot(workspaces);
|
||||
|
||||
if (workspaces.length === 0) {
|
||||
clearWorkspaceSelection();
|
||||
return { status: 'unavailable', workspaces: [] };
|
||||
}
|
||||
|
||||
const preferredWorkspace = options.preferredWorkspaceUuid
|
||||
? workspaces.find(
|
||||
(entry) => entry.workspace.uuid === options.preferredWorkspaceUuid,
|
||||
)
|
||||
: undefined;
|
||||
if (options.preferredWorkspaceUuid && !preferredWorkspace) {
|
||||
clearWorkspaceSelection();
|
||||
return { status: 'selection-required', workspaces };
|
||||
}
|
||||
|
||||
const activeWorkspace = !options.requireExplicitSelection
|
||||
? workspaces.find(
|
||||
(entry) => entry.workspace.uuid === getActiveWorkspaceUuid(),
|
||||
)
|
||||
: undefined;
|
||||
const selectedWorkspace =
|
||||
preferredWorkspace ??
|
||||
(workspaces.length === 1 ? workspaces[0] : activeWorkspace);
|
||||
|
||||
if (!selectedWorkspace) {
|
||||
clearWorkspaceSelection();
|
||||
return { status: 'selection-required', workspaces };
|
||||
}
|
||||
|
||||
return initializeSelectedWorkspace(
|
||||
selectedWorkspace.workspace.uuid,
|
||||
workspaces,
|
||||
);
|
||||
}
|
||||
|
||||
/** Revalidate and activate an explicit choice made on the chooser page. */
|
||||
export async function selectWorkspace(
|
||||
workspaceUuid: string,
|
||||
): Promise<WorkspaceBootstrapResult> {
|
||||
const response = await backendClient.getWorkspaceBootstrap();
|
||||
setWorkspaceBootstrapSnapshot(response.workspaces);
|
||||
const selected = response.workspaces.find(
|
||||
(entry) => entry.workspace.uuid === workspaceUuid,
|
||||
);
|
||||
if (!selected) {
|
||||
clearWorkspaceSelection();
|
||||
if (response.workspaces.length === 0) {
|
||||
return { status: 'unavailable', workspaces: [] };
|
||||
}
|
||||
return { status: 'selection-required', workspaces: response.workspaces };
|
||||
}
|
||||
return initializeSelectedWorkspace(
|
||||
selected.workspace.uuid,
|
||||
response.workspaces,
|
||||
);
|
||||
}
|
||||
|
||||
/** Clear in-memory Workspace snapshots before reloading into a new scope. */
|
||||
export function switchWorkspaceAndReload(workspaceUuid: string): void {
|
||||
const isAvailable = getWorkspaceBootstrapSnapshot().some(
|
||||
(entry) => entry.workspace.uuid === workspaceUuid,
|
||||
);
|
||||
if (!isAvailable || workspaceUuid === getActiveWorkspaceUuid()) return;
|
||||
|
||||
clearWorkspaceSelection();
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
setActiveWorkspaceUuid(workspaceUuid);
|
||||
window.location.replace('/home/monitoring');
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除用户信息
|
||||
* 应该在用户登出时调用此方法
|
||||
*/
|
||||
export const clearUserInfo = (): void => {
|
||||
userInfo = null;
|
||||
clearWorkspaceSelection();
|
||||
clearWorkspaceBootstrapSnapshot();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('authPrincipalType');
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
clearActiveWorkspaceUuid,
|
||||
clearPendingInvitationToken,
|
||||
getActiveWorkspaceUuid,
|
||||
getPendingInvitationToken,
|
||||
setActiveWorkspaceUuid,
|
||||
setPendingInvitationToken,
|
||||
} from './workspaceContext';
|
||||
|
||||
// 导出类型,以便其他地方使用
|
||||
export type { ResponseData, RequestConfig } from './BaseHttpClient';
|
||||
export { BaseHttpClient } from './BaseHttpClient';
|
||||
export { BackendClient } from './BackendClient';
|
||||
export { CloudServiceClient } from './CloudServiceClient';
|
||||
export {
|
||||
getCurrentWorkspaceSnapshot,
|
||||
useCurrentWorkspace,
|
||||
} from './currentWorkspaceStore';
|
||||
export {
|
||||
getWorkspaceBootstrapSnapshot,
|
||||
useWorkspaceBootstrap,
|
||||
} from './workspaceBootstrapStore';
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import type { WorkspaceBootstrapEntry } from '@/app/infra/entities/workspace';
|
||||
|
||||
let snapshot: WorkspaceBootstrapEntry[] = [];
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getWorkspaceBootstrapSnapshot(): WorkspaceBootstrapEntry[] {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function setWorkspaceBootstrapSnapshot(
|
||||
workspaces: WorkspaceBootstrapEntry[],
|
||||
): void {
|
||||
snapshot = workspaces;
|
||||
listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
export function clearWorkspaceBootstrapSnapshot(): void {
|
||||
setWorkspaceBootstrapSnapshot([]);
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** React-safe access to the Account's selectable Workspaces. */
|
||||
export function useWorkspaceBootstrap(): WorkspaceBootstrapEntry[] {
|
||||
return useSyncExternalStore(
|
||||
subscribe,
|
||||
getWorkspaceBootstrapSnapshot,
|
||||
getWorkspaceBootstrapSnapshot,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export const ACTIVE_WORKSPACE_STORAGE_KEY = 'langbot_active_workspace_uuid';
|
||||
export const PENDING_INVITATION_TOKEN_KEY = 'langbot_pending_invitation_token';
|
||||
|
||||
let activeWorkspaceUuid: string | null = null;
|
||||
|
||||
function readStoredWorkspaceUuid(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const value = localStorage.getItem(ACTIVE_WORKSPACE_STORAGE_KEY)?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
export function getActiveWorkspaceUuid(): string | null {
|
||||
if (activeWorkspaceUuid) return activeWorkspaceUuid;
|
||||
activeWorkspaceUuid = readStoredWorkspaceUuid();
|
||||
return activeWorkspaceUuid;
|
||||
}
|
||||
|
||||
export function setActiveWorkspaceUuid(workspaceUuid: string): void {
|
||||
const normalized = workspaceUuid.trim();
|
||||
if (!normalized) {
|
||||
throw new Error('Workspace UUID cannot be empty');
|
||||
}
|
||||
activeWorkspaceUuid = normalized;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(ACTIVE_WORKSPACE_STORAGE_KEY, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearActiveWorkspaceUuid(): void {
|
||||
activeWorkspaceUuid = null;
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem(ACTIVE_WORKSPACE_STORAGE_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export function getPendingInvitationToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const token = sessionStorage.getItem(PENDING_INVITATION_TOKEN_KEY)?.trim();
|
||||
return token || null;
|
||||
}
|
||||
|
||||
export function setPendingInvitationToken(token: string): void {
|
||||
const normalized = token.trim();
|
||||
if (!normalized) throw new Error('Invitation token cannot be empty');
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.setItem(PENDING_INVITATION_TOKEN_KEY, normalized);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearPendingInvitationToken(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
sessionStorage.removeItem(PENDING_INVITATION_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
* WebSocket客户端类
|
||||
* 用于管理WebSocket连接和消息处理
|
||||
*/
|
||||
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
|
||||
|
||||
export interface WebSocketMessage {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
@@ -91,7 +93,22 @@ export class WebSocketClient {
|
||||
this.ws.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnecting = false;
|
||||
this.startHeartbeat();
|
||||
const token = this.token || localStorage.getItem('token');
|
||||
const workspaceUuid = getActiveWorkspaceUuid();
|
||||
if (!token || !workspaceUuid) {
|
||||
const error = new Error('WebSocket认证信息缺失');
|
||||
this.onErrorCallback?.(error);
|
||||
this.ws?.close();
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
this.ws?.send(
|
||||
JSON.stringify({
|
||||
type: 'authenticate',
|
||||
token,
|
||||
workspace_uuid: workspaceUuid,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
// 接收消息
|
||||
@@ -103,6 +120,7 @@ export class WebSocketClient {
|
||||
// 第一次连接成功
|
||||
if (data.type === 'connected' && data.connection_id) {
|
||||
this.connectionId = data.connection_id;
|
||||
this.startHeartbeat();
|
||||
resolve(data.connection_id);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Loader2, Lock, Mail } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
import type {
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
} from '@/app/infra/entities/workspace';
|
||||
import {
|
||||
backendClient,
|
||||
bootstrapWorkspaceSession,
|
||||
clearPendingInvitationToken,
|
||||
clearUserInfo,
|
||||
getPendingInvitationToken,
|
||||
setPendingInvitationToken,
|
||||
} from '@/app/infra/http';
|
||||
|
||||
type InvitationView = {
|
||||
invitation: WorkspaceInvitation;
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const TERMINAL_INVITATION_ERROR_CODES = new Set([
|
||||
'invitation_invalid',
|
||||
'invitation_expired',
|
||||
'invitation_revoked',
|
||||
'invitation_used',
|
||||
'invitation_email_mismatch',
|
||||
]);
|
||||
|
||||
function invitationErrorKey(
|
||||
code: string | null | undefined,
|
||||
fallback: 'workspace.invitationInvalid' | 'workspace.invitationAcceptFailed',
|
||||
) {
|
||||
switch (code) {
|
||||
case 'invitation_invalid':
|
||||
return 'workspace.invitationInvalid';
|
||||
case 'invitation_expired':
|
||||
return 'workspace.invitationExpired';
|
||||
case 'invitation_revoked':
|
||||
return 'workspace.invitationAlreadyRevoked';
|
||||
case 'invitation_used':
|
||||
return 'workspace.invitationAlreadyUsed';
|
||||
case 'invitation_email_mismatch':
|
||||
return 'workspace.invitationEmailMismatch';
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function captureInvitationTokenFromFragment(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
||||
const token = fragment.get('token')?.trim();
|
||||
if (!token) return getPendingInvitationToken();
|
||||
|
||||
setPendingInvitationToken(token);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
document.title,
|
||||
`${window.location.pathname}${window.location.search}`,
|
||||
);
|
||||
return token;
|
||||
}
|
||||
|
||||
export default function AcceptInvitationPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [invitationHash, setInvitationHash] = useState(() =>
|
||||
typeof window === 'undefined' ? '' : window.location.hash,
|
||||
);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [view, setView] = useState<InvitationView | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
'loading' | 'ready' | 'submitting' | 'success' | 'error'
|
||||
>('loading');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||
useState(false);
|
||||
const [
|
||||
authenticatedInvitationAcceptanceEnabled,
|
||||
setAuthenticatedInvitationAcceptanceEnabled,
|
||||
] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => setInvitationHash(window.location.hash);
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus('loading');
|
||||
setView(null);
|
||||
setErrorMessage('');
|
||||
const invitationToken = captureInvitationTokenFromFragment();
|
||||
const deferredErrorCode = new URLSearchParams(window.location.search).get(
|
||||
'error',
|
||||
);
|
||||
setToken(invitationToken);
|
||||
backendClient
|
||||
.getAccountInfo()
|
||||
.then((info) => {
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false);
|
||||
setAuthenticatedInvitationAcceptanceEnabled(
|
||||
info.authenticated_invitation_acceptance_enabled === true,
|
||||
);
|
||||
})
|
||||
.catch(() => setPasswordRegistrationEnabled(false));
|
||||
if (!invitationToken) {
|
||||
setErrorMessage(t('workspace.invitationMissing'));
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
backendClient
|
||||
.inspectWorkspaceInvitation(invitationToken)
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setView(response);
|
||||
if (deferredErrorCode) {
|
||||
setErrorMessage(
|
||||
t(
|
||||
invitationErrorKey(
|
||||
deferredErrorCode,
|
||||
'workspace.invitationAcceptFailed',
|
||||
),
|
||||
),
|
||||
);
|
||||
setStatus('error');
|
||||
} else {
|
||||
setStatus('ready');
|
||||
}
|
||||
})
|
||||
.catch((error: { code?: string; msg?: string }) => {
|
||||
if (cancelled) return;
|
||||
clearPendingInvitationToken();
|
||||
setErrorMessage(
|
||||
t(invitationErrorKey(error.code, 'workspace.invitationInvalid')),
|
||||
);
|
||||
setStatus('error');
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [invitationHash, t]);
|
||||
|
||||
async function finishAcceptance(registration?: {
|
||||
email: string;
|
||||
password: string;
|
||||
}) {
|
||||
if (!token) return;
|
||||
setStatus('submitting');
|
||||
setErrorMessage('');
|
||||
try {
|
||||
const response = await backendClient.acceptWorkspaceInvitation(
|
||||
token,
|
||||
registration,
|
||||
);
|
||||
if (registration) {
|
||||
clearPendingInvitationToken();
|
||||
toast.success(t('workspace.invitationAccepted'));
|
||||
navigate('/login?invitation=1', { replace: true });
|
||||
return;
|
||||
}
|
||||
clearPendingInvitationToken();
|
||||
const workspaceResult = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid: response.workspace_uuid,
|
||||
});
|
||||
if (workspaceResult.status !== 'ready') {
|
||||
throw new Error('Accepted Workspace could not be initialized');
|
||||
}
|
||||
setStatus('success');
|
||||
toast.success(t('workspace.invitationAccepted'));
|
||||
window.setTimeout(() => navigate('/home', { replace: true }), 600);
|
||||
} catch (error) {
|
||||
const apiError = error as { code?: string; msg?: string };
|
||||
if (apiError.code === 'account_exists_login_required') {
|
||||
setStatus('ready');
|
||||
setErrorMessage(t('workspace.existingAccountLoginRequired'));
|
||||
return;
|
||||
}
|
||||
setErrorMessage(
|
||||
t(
|
||||
invitationErrorKey(apiError.code, 'workspace.invitationAcceptFailed'),
|
||||
),
|
||||
);
|
||||
setStatus(
|
||||
apiError.code && TERMINAL_INVITATION_ERROR_CODES.has(apiError.code)
|
||||
? 'error'
|
||||
: 'ready',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function registerAndAccept() {
|
||||
if (!view) return;
|
||||
if (password.length < 8) {
|
||||
setErrorMessage(t('workspace.passwordMinimum'));
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setErrorMessage(t('workspace.passwordMismatch'));
|
||||
return;
|
||||
}
|
||||
void finishAcceptance({
|
||||
email: view.invitation.normalized_email,
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
function logoutAndReturn() {
|
||||
if (token) setPendingInvitationToken(token);
|
||||
clearUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
}
|
||||
navigate('/login?invitation=1', { replace: true });
|
||||
}
|
||||
|
||||
function returnToLogin() {
|
||||
clearUserInfo();
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
}
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
const hasLoginToken =
|
||||
typeof window !== 'undefined' && Boolean(localStorage.getItem('token'));
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
|
||||
<Card className="w-full max-w-md shadow-lg dark:shadow-white/10">
|
||||
<CardHeader>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<ThemeToggle />
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<img
|
||||
src={langbotIcon}
|
||||
alt="LangBot"
|
||||
className="mx-auto mb-3 size-14"
|
||||
/>
|
||||
<CardTitle className="text-center">
|
||||
{t('workspace.acceptInvitation')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{view
|
||||
? t('workspace.invitedToWorkspace', {
|
||||
workspace: view.workspace.name,
|
||||
})
|
||||
: t('workspace.checkingInvitation')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{status === 'loading' && (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
<CheckCircle2 className="size-9 text-green-600" />
|
||||
<p>{t('workspace.invitationAccepted')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={returnToLogin}
|
||||
>
|
||||
{t('workspace.backToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === 'ready' || status === 'submitting') && view && (
|
||||
<div className="space-y-4">
|
||||
{errorMessage && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasLoginToken && authenticatedInvitationAcceptanceEnabled ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={() => void finishAcceptance()}
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
) : null}
|
||||
{t('workspace.acceptInvitation')}
|
||||
</Button>
|
||||
) : hasLoginToken ? (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-900 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
{t('workspace.authenticatedInvitationNotice')}
|
||||
</div>
|
||||
<Button className="w-full" onClick={logoutAndReturn}>
|
||||
{t('workspace.logoutAndReturn')}
|
||||
</Button>
|
||||
</div>
|
||||
) : passwordRegistrationEnabled ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="invite-email"
|
||||
>
|
||||
{t('common.email')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="invite-email"
|
||||
value={view.invitation.normalized_email}
|
||||
readOnly
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="invite-password"
|
||||
>
|
||||
{t('common.password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="invite-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className="pl-10"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
className="text-sm font-medium"
|
||||
htmlFor="invite-password-confirm"
|
||||
>
|
||||
{t('workspace.confirmPassword')}
|
||||
</label>
|
||||
<Input
|
||||
id="invite-password-confirm"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) =>
|
||||
setConfirmPassword(event.target.value)
|
||||
}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={registerAndAccept}
|
||||
>
|
||||
{status === 'submitting' && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{t('workspace.registerAndAccept')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
disabled={status === 'submitting'}
|
||||
onClick={() => navigate('/login?invitation=1')}
|
||||
>
|
||||
{t('workspace.alreadyHaveAccount')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => navigate('/login?invitation=1&auto=space')}
|
||||
>
|
||||
{t('common.loginWithSpace')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+92
-24
@@ -19,8 +19,14 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient, initializeUserInfo } from '@/app/infra/http';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
beginAuthenticatedSession,
|
||||
bootstrapWorkspaceSession,
|
||||
clearPendingInvitationToken,
|
||||
getPendingInvitationToken,
|
||||
httpClient,
|
||||
} from '@/app/infra/http';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Mail,
|
||||
@@ -43,17 +49,24 @@ const formSchema = (t: (key: string) => string) =>
|
||||
password: z.string().min(1, t('common.emptyPassword')),
|
||||
});
|
||||
|
||||
type AccountType = 'local' | 'space';
|
||||
const TERMINAL_INVITATION_ERROR_CODES = new Set([
|
||||
'invitation_invalid',
|
||||
'invitation_expired',
|
||||
'invitation_revoked',
|
||||
'invitation_used',
|
||||
'invitation_email_mismatch',
|
||||
]);
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
const [accountType, setAccountType] = useState<AccountType | null>(null);
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [showLocalLogin, setShowLocalLogin] = useState(false);
|
||||
const [showSpaceLogin, setShowSpaceLogin] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [retrying, setRetrying] = useState(false);
|
||||
const autoSpaceLoginStarted = useRef(false);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
@@ -75,8 +88,8 @@ export default function Login() {
|
||||
navigate('/register');
|
||||
return;
|
||||
}
|
||||
setAccountType(res.account_type || 'local');
|
||||
setHasPassword(res.has_password || false);
|
||||
setShowLocalLogin(res.password_login_enabled !== false);
|
||||
setShowSpaceLogin(res.space_login_enabled !== false);
|
||||
setLoading(false);
|
||||
|
||||
// Also check if already logged in
|
||||
@@ -109,15 +122,64 @@ export default function Login() {
|
||||
function checkIfAlreadyLoggedIn() {
|
||||
httpClient
|
||||
.checkUserToken()
|
||||
.then((res) => {
|
||||
.then(async (res) => {
|
||||
if (res.token) {
|
||||
localStorage.setItem('token', res.token);
|
||||
navigate('/home');
|
||||
await finishLogin(res.token);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function finishLogin(
|
||||
token: string,
|
||||
username?: string,
|
||||
): Promise<boolean> {
|
||||
beginAuthenticatedSession(token, username);
|
||||
|
||||
const invitationToken = getPendingInvitationToken();
|
||||
let preferredWorkspaceUuid: string | undefined;
|
||||
if (invitationToken) {
|
||||
try {
|
||||
const response =
|
||||
await httpClient.acceptWorkspaceInvitation(invitationToken);
|
||||
beginAuthenticatedSession(response.token, username);
|
||||
preferredWorkspaceUuid = response.workspace_uuid;
|
||||
clearPendingInvitationToken();
|
||||
} catch (error) {
|
||||
const apiError = error as { code?: string };
|
||||
const errorCode =
|
||||
typeof apiError.code === 'string'
|
||||
? apiError.code
|
||||
: 'invitation_accept_failed';
|
||||
const invitationPath = TERMINAL_INVITATION_ERROR_CODES.has(errorCode)
|
||||
? `/invitations/accept?error=${encodeURIComponent(errorCode)}`
|
||||
: '/invitations/accept';
|
||||
navigate(invitationPath, { replace: true });
|
||||
toast.error(
|
||||
t(
|
||||
errorCode === 'invitation_email_mismatch'
|
||||
? 'workspace.invitationEmailMismatch'
|
||||
: 'workspace.invitationAcceptFailed',
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const result = await bootstrapWorkspaceSession({
|
||||
preferredWorkspaceUuid,
|
||||
});
|
||||
if (result.status === 'selection-required') {
|
||||
navigate('/workspaces/select?returnTo=%2Fhome', { replace: true });
|
||||
return true;
|
||||
}
|
||||
if (result.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
navigate('/home');
|
||||
return true;
|
||||
}
|
||||
|
||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||
handleLogin(values.email, values.password);
|
||||
}
|
||||
@@ -126,18 +188,16 @@ export default function Login() {
|
||||
httpClient
|
||||
.authUser(username, password)
|
||||
.then(async (res) => {
|
||||
localStorage.setItem('token', res.token);
|
||||
localStorage.setItem('userEmail', username);
|
||||
await initializeUserInfo();
|
||||
navigate('/home');
|
||||
toast.success(t('common.loginSuccess'));
|
||||
if (await finishLogin(res.token, username)) {
|
||||
toast.success(t('common.loginSuccess'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('common.loginFailed'));
|
||||
});
|
||||
}
|
||||
|
||||
const handleSpaceLoginClick = async () => {
|
||||
const handleSpaceLoginClick = useCallback(async () => {
|
||||
setSpaceLoading(true);
|
||||
try {
|
||||
const currentOrigin = window.location.origin;
|
||||
@@ -148,7 +208,20 @@ export default function Login() {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
setSpaceLoading(false);
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
loading ||
|
||||
!showSpaceLogin ||
|
||||
autoSpaceLoginStarted.current ||
|
||||
new URLSearchParams(window.location.search).get('auto') !== 'space'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
autoSpaceLoginStarted.current = true;
|
||||
void handleSpaceLoginClick();
|
||||
}, [handleSpaceLoginClick, loading, showSpaceLogin]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -211,11 +284,6 @@ export default function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
// Determine what to show based on account type
|
||||
const showLocalLogin =
|
||||
accountType === 'local' || (accountType === 'space' && hasPassword);
|
||||
const showSpaceLogin = accountType === 'space';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:dark:bg-neutral-900">
|
||||
<Card className="w-[375px] shadow-lg dark:shadow-white/10">
|
||||
@@ -237,7 +305,7 @@ export default function Login() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Space Login - only show for space accounts */}
|
||||
{/* Space and password login are per-account capabilities. */}
|
||||
{showSpaceLogin && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
@@ -270,7 +338,7 @@ export default function Login() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local Account Login - show for local accounts or space accounts with password */}
|
||||
{/* Password login remains available to every account with a password. */}
|
||||
{showLocalLogin && (
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
||||
@@ -44,6 +44,8 @@ export default function Register() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const [spaceLoading, setSpaceLoading] = useState(false);
|
||||
const [passwordRegistrationEnabled, setPasswordRegistrationEnabled] =
|
||||
useState(true);
|
||||
|
||||
const form = useForm<z.infer<ReturnType<typeof formSchema>>>({
|
||||
resolver: zodResolver(formSchema(t)),
|
||||
@@ -55,6 +57,12 @@ export default function Register() {
|
||||
|
||||
useEffect(() => {
|
||||
getIsInitialized();
|
||||
httpClient
|
||||
.getAccountInfo()
|
||||
.then((info) =>
|
||||
setPasswordRegistrationEnabled(info.password_login_enabled !== false),
|
||||
)
|
||||
.catch(() => setPasswordRegistrationEnabled(true));
|
||||
}, []);
|
||||
|
||||
function getIsInitialized() {
|
||||
@@ -159,72 +167,79 @@ export default function Register() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{passwordRegistrationEnabled && (
|
||||
<>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-white dark:bg-card px-2 text-muted-foreground">
|
||||
{t('common.or')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Local Account Registration */}
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('common.enterEmail')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{/* Local Account Registration */}
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.email')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
placeholder={t('common.enterEmail')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('common.enterPassword')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('common.password')}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t('common.enterPassword')}
|
||||
className="pl-10"
|
||||
{...field}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{t('register.registerWithPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
{t('register.registerWithPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
{t('common.agreementNotice')}{' '}
|
||||
|
||||
@@ -18,7 +18,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
userInfo,
|
||||
systemInfo,
|
||||
initializeUserInfo,
|
||||
bootstrapWorkspaceSession,
|
||||
initializeSystemInfo,
|
||||
} from '@/app/infra/http';
|
||||
import {
|
||||
@@ -129,8 +129,16 @@ export default function WizardPage() {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
// Initialize user/system info (wizard is outside /home layout)
|
||||
await Promise.all([initializeUserInfo(), initializeSystemInfo()]);
|
||||
// Resolve the Account's Workspace before loading scoped wizard data.
|
||||
const workspaceResult = await bootstrapWorkspaceSession();
|
||||
if (workspaceResult.status === 'selection-required') {
|
||||
navigate('/workspaces/select?returnTo=%2Fwizard', { replace: true });
|
||||
return;
|
||||
}
|
||||
if (workspaceResult.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
await initializeSystemInfo({ throwOnError: true });
|
||||
|
||||
const [adaptersResp, metadataResp] = await Promise.all([
|
||||
httpClient.getAdapters(),
|
||||
@@ -191,7 +199,7 @@ export default function WizardPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [t]);
|
||||
}, [navigate, t]);
|
||||
|
||||
// ---- Derived data ----
|
||||
|
||||
@@ -229,6 +237,10 @@ export default function WizardPage() {
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
url: item.url,
|
||||
download_filename: item.download_filename,
|
||||
help_links: item.help_links,
|
||||
help_label: item.help_label,
|
||||
}),
|
||||
);
|
||||
}, [adapters, selectedAdapter]);
|
||||
@@ -249,6 +261,10 @@ export default function WizardPage() {
|
||||
options: item.options,
|
||||
show_if: item.show_if,
|
||||
login_platform: item.login_platform,
|
||||
url: item.url,
|
||||
download_filename: item.download_filename,
|
||||
help_links: item.help_links,
|
||||
help_label: item.help_label,
|
||||
}),
|
||||
);
|
||||
}, [selectedRunnerConfigStage]);
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { AlertCircle, ArrowRight, Building2, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import {
|
||||
bootstrapWorkspaceSession,
|
||||
clearUserInfo,
|
||||
selectWorkspace,
|
||||
useWorkspaceBootstrap,
|
||||
} from '@/app/infra/http';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { LanguageSelector } from '@/components/ui/language-selector';
|
||||
import { ThemeToggle } from '@/components/ui/theme-toggle';
|
||||
|
||||
function safeReturnPath(value: string | null): string {
|
||||
if (
|
||||
value &&
|
||||
value.startsWith('/') &&
|
||||
!value.startsWith('//') &&
|
||||
(value === '/wizard' || value.startsWith('/home'))
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
return '/home';
|
||||
}
|
||||
|
||||
export default function WorkspaceSelectPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const workspaces = useWorkspaceBootstrap();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [selectingUuid, setSelectingUuid] = useState<string | null>(null);
|
||||
const returnTo = safeReturnPath(searchParams.get('returnTo'));
|
||||
|
||||
const loadWorkspaces = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const result = await bootstrapWorkspaceSession({
|
||||
requireExplicitSelection: true,
|
||||
});
|
||||
if (result.status === 'ready') {
|
||||
navigate(returnTo, { replace: true });
|
||||
} else if (result.status === 'unavailable') {
|
||||
setError(true);
|
||||
}
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [navigate, returnTo]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadWorkspaces();
|
||||
}, [loadWorkspaces]);
|
||||
|
||||
async function handleSelect(workspaceUuid: string) {
|
||||
setSelectingUuid(workspaceUuid);
|
||||
setError(false);
|
||||
try {
|
||||
const result = await selectWorkspace(workspaceUuid);
|
||||
if (result.status === 'ready') {
|
||||
navigate(returnTo, { replace: true });
|
||||
return;
|
||||
}
|
||||
setError(true);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setSelectingUuid(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
clearUserInfo();
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('userEmail');
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4 dark:bg-neutral-900">
|
||||
<Card className="w-full max-w-xl shadow-lg dark:shadow-white/10">
|
||||
<CardHeader>
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<ThemeToggle />
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
<img
|
||||
src={langbotIcon}
|
||||
alt="LangBot"
|
||||
className="mx-auto mb-4 size-16"
|
||||
/>
|
||||
<CardTitle
|
||||
className="text-center text-2xl"
|
||||
role="heading"
|
||||
aria-level={1}
|
||||
>
|
||||
{t('workspace.selectTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
{t('workspace.selectDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
<div className="flex items-center gap-3 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
<span>{t('workspace.selectionLoadFailed')}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => void loadWorkspaces()}
|
||||
>
|
||||
{t('common.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workspaces.map((entry) => {
|
||||
const selecting = selectingUuid === entry.workspace.uuid;
|
||||
return (
|
||||
<button
|
||||
key={entry.workspace.uuid}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-xl border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent disabled:cursor-wait disabled:opacity-70"
|
||||
onClick={() => void handleSelect(entry.workspace.uuid)}
|
||||
disabled={selectingUuid !== null}
|
||||
>
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<Building2 className="size-5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{entry.workspace.name}
|
||||
</span>
|
||||
<span className="mt-1 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="secondary">
|
||||
{t(`workspace.roles.${entry.membership.role}`)}
|
||||
</Badge>
|
||||
<span>
|
||||
{entry.workspace.type === 'personal'
|
||||
? t('workspace.types.personal')
|
||||
: t('workspace.types.team')}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
{selecting ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<ArrowRight className="size-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useAuthenticatedPluginIcon } from '@/hooks/useAuthenticatedPluginResource';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function AuthenticatedPluginIcon({
|
||||
author,
|
||||
name,
|
||||
alt = '',
|
||||
className,
|
||||
}: {
|
||||
author: string;
|
||||
name: string;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const icon = useAuthenticatedPluginIcon(
|
||||
author,
|
||||
name,
|
||||
Boolean(author && name),
|
||||
);
|
||||
|
||||
if (!icon.url || icon.error) {
|
||||
return (
|
||||
<span
|
||||
aria-hidden={alt ? undefined : true}
|
||||
aria-label={alt || undefined}
|
||||
className={cn('inline-block bg-muted', className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={icon.url} alt={alt} className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from 'react';
|
||||
import * as SliderPrimitive from '@radix-ui/react-slider';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ComponentRef<typeof SliderPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SliderPrimitive.Root
|
||||
ref={ref}
|
||||
data-slot="slider"
|
||||
className={cn(
|
||||
'relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SliderPrimitive.Track
|
||||
data-slot="slider-track"
|
||||
className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20"
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
className="absolute h-full bg-primary"
|
||||
/>
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
className="block size-4 shrink-0 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
</SliderPrimitive.Root>
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider };
|
||||
@@ -119,7 +119,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
type AuthenticatedResourceState = {
|
||||
key: string;
|
||||
url: string;
|
||||
error: boolean;
|
||||
};
|
||||
|
||||
const EMPTY_RESOURCE: AuthenticatedResourceState = {
|
||||
key: '',
|
||||
url: '',
|
||||
error: false,
|
||||
};
|
||||
|
||||
export function useAuthenticatedPluginIcon(
|
||||
author: string,
|
||||
name: string,
|
||||
enabled = true,
|
||||
): { url: string; error: boolean } {
|
||||
const [resource, setResource] =
|
||||
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setResource({ key: resourceKey, url: '', error: false });
|
||||
return;
|
||||
}
|
||||
let active = true;
|
||||
let objectURL = '';
|
||||
setResource({ key: resourceKey, url: '', error: false });
|
||||
httpClient
|
||||
.getAuthenticatedPluginIconURL(author, name)
|
||||
.then((nextURL) => {
|
||||
objectURL = nextURL;
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||
} else {
|
||||
URL.revokeObjectURL(nextURL);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: '', error: true });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [author, enabled, name, resourceKey]);
|
||||
|
||||
return {
|
||||
url: resource.key === resourceKey ? resource.url : '',
|
||||
error: resource.key === resourceKey && resource.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuthenticatedPluginAsset(
|
||||
author: string,
|
||||
name: string,
|
||||
filepath: string,
|
||||
): { url: string; error: boolean } {
|
||||
const [resource, setResource] =
|
||||
useState<AuthenticatedResourceState>(EMPTY_RESOURCE);
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const workspaceUuid = currentWorkspace?.workspace.uuid;
|
||||
const resourceKey = `${workspaceUuid ?? ''}:${author}/${name}/${filepath}`;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectURL = '';
|
||||
setResource({ key: resourceKey, url: '', error: false });
|
||||
httpClient
|
||||
.getAuthenticatedPluginAssetURL(author, name, filepath)
|
||||
.then((nextURL) => {
|
||||
objectURL = nextURL;
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: nextURL, error: false });
|
||||
} else {
|
||||
URL.revokeObjectURL(nextURL);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) {
|
||||
setResource({ key: resourceKey, url: '', error: true });
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectURL) URL.revokeObjectURL(objectURL);
|
||||
};
|
||||
}, [author, name, filepath, resourceKey]);
|
||||
|
||||
return {
|
||||
url: resource.key === resourceKey ? resource.url : '',
|
||||
error: resource.key === resourceKey && resource.error,
|
||||
};
|
||||
}
|
||||
@@ -80,7 +80,7 @@ const enUS = {
|
||||
loading: 'Loading...',
|
||||
fieldRequired: 'This field is required',
|
||||
or: 'or',
|
||||
loginWithSpace: 'Login with Space',
|
||||
loginWithSpace: 'Login with LangBot Account',
|
||||
spaceLoginRecommended:
|
||||
'Recommended: Use official stable model APIs and cloud services',
|
||||
loginLocal: 'Login with local account',
|
||||
@@ -167,6 +167,7 @@ const enUS = {
|
||||
actions: 'Actions',
|
||||
apiKeyCreatedMessage:
|
||||
'Please copy this API key, if the button is invalid, please copy manually.',
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
none: 'None',
|
||||
more: 'More ({{count}})',
|
||||
less: 'Less',
|
||||
@@ -213,6 +214,19 @@ const enUS = {
|
||||
selectModelAbilities: 'Select model abilities',
|
||||
visionAbility: 'Vision Ability',
|
||||
functionCallAbility: 'Function Call',
|
||||
reasoningAbility: 'Reasoning',
|
||||
reasoningLevel: 'Reasoning level',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'Provider default',
|
||||
disabled: 'Off',
|
||||
enabled: 'On',
|
||||
minimal: 'Minimal',
|
||||
low: 'Low',
|
||||
medium: 'Medium',
|
||||
high: 'High',
|
||||
xhigh: 'Extra high',
|
||||
max: 'Maximum',
|
||||
},
|
||||
contextLength: 'Context Window',
|
||||
contextLengthPlaceholder: 'Unknown',
|
||||
contextLengthInvalid: 'Context window must be a positive integer',
|
||||
@@ -276,8 +290,12 @@ const enUS = {
|
||||
searchProviders: 'Search providers...',
|
||||
langbotModelsDescription: 'Cloud models powered by LangBot Space',
|
||||
credits: 'Credits',
|
||||
loginWithSpace: 'Login with Space',
|
||||
loginWithSpace: 'Login with LangBot Account',
|
||||
loginToUseModels: 'Login with Space to use cloud models',
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
noModels: 'No models configured',
|
||||
langbotModels: 'LangBot Models',
|
||||
spaceTrialTooltip:
|
||||
@@ -520,9 +538,9 @@ const enUS = {
|
||||
debugInfoTitle: 'Plugin Debug Information',
|
||||
debugUrl: 'Debug URL',
|
||||
debugKey: 'Debug Key',
|
||||
debugKeyExpires: 'Rotates at {{time}}; each Workspace has a different key',
|
||||
noDebugKey: '(Not Set)',
|
||||
debugKeyDisabled:
|
||||
'Debug key is not set, plugin debugging does not require authentication',
|
||||
debugKeyDisabled: 'Debug credential is temporarily unavailable',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'Status',
|
||||
boxConnected: 'Connected',
|
||||
@@ -704,6 +722,7 @@ const enUS = {
|
||||
notFound: 'Plugin information not found',
|
||||
sortBy: 'Sort by',
|
||||
sort: {
|
||||
hottest: 'Most Popular',
|
||||
recentlyAdded: 'Recently Added',
|
||||
recentlyUpdated: 'Recently Updated',
|
||||
mostDownloads: 'Most Downloads',
|
||||
@@ -711,6 +730,9 @@ const enUS = {
|
||||
},
|
||||
downloads: 'downloads',
|
||||
download: 'Download',
|
||||
like: 'Like',
|
||||
unlike: 'Unlike',
|
||||
likeFailed: 'Failed to update like. Please try again.',
|
||||
repository: 'Repository',
|
||||
downloadFailed: 'Download failed',
|
||||
noReadme: 'This plugin does not provide README documentation',
|
||||
@@ -801,6 +823,9 @@ const enUS = {
|
||||
url: 'URL',
|
||||
headers: 'Headers',
|
||||
timeout: 'Timeout',
|
||||
toolCallTimeout: 'Tool call timeout (seconds)',
|
||||
toolCallTimeoutDescription:
|
||||
'Maximum wait for one tool call. Set to 0 for no timeout. Defaults to 300 seconds.',
|
||||
addArgument: 'Add Argument',
|
||||
addEnvVar: 'Add Environment Variable',
|
||||
addHeader: 'Add Header',
|
||||
@@ -823,6 +848,9 @@ const enUS = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Enable Box (box.enabled = true) and ensure the runtime is healthy, or switch this server to http/sse mode.',
|
||||
boxRequired: 'requires Box',
|
||||
disabledByPolicy: 'disabled by policy',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP is disabled for this deployment. Use a remote MCP server instead.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Stdio MCP cannot be saved while the Box sandbox is disabled or unreachable. Enable Box or pick http/sse.',
|
||||
toolsFound: 'tools',
|
||||
@@ -832,6 +860,12 @@ const enUS = {
|
||||
tabTools: 'Tools',
|
||||
tabResources: 'Resources',
|
||||
tabDocs: 'Docs',
|
||||
tabLogs: 'Logs',
|
||||
logsLevelAll: 'All levels',
|
||||
logsRefresh: 'Refresh',
|
||||
logsAutoRefresh: 'Auto refresh',
|
||||
logsEmpty:
|
||||
'No logs yet. Runtime logs from the MCP server will appear here.',
|
||||
noReadme: 'No documentation available',
|
||||
parseResultFailed: 'Failed to parse test result',
|
||||
noResultReturned: 'Test returned no result',
|
||||
@@ -1270,7 +1304,96 @@ const enUS = {
|
||||
'Invalid bind request. Please try again from account settings.',
|
||||
setPasswordHint: 'Set a password to login with email and password',
|
||||
spaceEmailMismatch:
|
||||
'Space login email does not match the local account email',
|
||||
'The Space login email does not match the local account email.',
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: 'Create invitation',
|
||||
invitationCreated: 'Invitation created',
|
||||
delivery: {
|
||||
sent: 'Invitation sent',
|
||||
link_only: 'Invitation link created',
|
||||
failed: 'Invitation link created, but email could not be sent',
|
||||
},
|
||||
invitationCreateFailed: 'Failed to create invitation',
|
||||
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Dashboard',
|
||||
@@ -1333,6 +1456,20 @@ const enUS = {
|
||||
level: 'Level',
|
||||
runner: 'Runner',
|
||||
viewConversation: 'View Conversation',
|
||||
turns: '{{count}} conversation turns',
|
||||
userMessage: 'User',
|
||||
noUserMessage: 'No user input recorded',
|
||||
assistantMessage: 'Assistant',
|
||||
assistantMessageCount: 'Assistant +{{count}}',
|
||||
noAssistantMessage: 'No assistant reply recorded',
|
||||
messageCount: 'Messages',
|
||||
conversationTrace: 'Conversation Trace',
|
||||
noLlmCalls: 'No model calls recorded',
|
||||
roles: {
|
||||
user: 'User',
|
||||
assistant: 'Assistant',
|
||||
message: 'Message',
|
||||
},
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'LLM Calls',
|
||||
@@ -1347,6 +1484,15 @@ const enUS = {
|
||||
avgDuration: 'Avg Duration',
|
||||
calls: 'Calls',
|
||||
},
|
||||
toolCalls: {
|
||||
title: 'Tool Calls',
|
||||
totalCalls: 'Calls',
|
||||
duration: 'Tool Duration',
|
||||
errorCalls: 'Failed Calls',
|
||||
arguments: 'Arguments',
|
||||
result: 'Result',
|
||||
noToolCalls: 'No tool calls recorded',
|
||||
},
|
||||
tokens: {
|
||||
totalTokens: 'Total Tokens',
|
||||
inputTokens: 'Input Tokens',
|
||||
@@ -1500,6 +1646,7 @@ const enUS = {
|
||||
settingsDialog: {
|
||||
title: 'Settings',
|
||||
nav: {
|
||||
workspace: 'Workspace',
|
||||
models: 'Models',
|
||||
api: 'API',
|
||||
storage: 'Storage',
|
||||
@@ -1548,6 +1695,12 @@ const enUS = {
|
||||
'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.',
|
||||
maxExtensionsReached:
|
||||
'Maximum number of extensions ({{max}}) reached. Please remove an existing extension before adding a new one.',
|
||||
quotaLoadingTooltip:
|
||||
'Workspace usage is still loading. Please wait before creating a resource.',
|
||||
quotaCheckFailed:
|
||||
'Unable to verify the current workspace quota. Please try again.',
|
||||
createDisabledTooltip:
|
||||
'The {{resource}} limit ({{max}}) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
},
|
||||
skills: {
|
||||
title: 'Skills',
|
||||
@@ -1772,6 +1925,9 @@ const enUS = {
|
||||
'Scan the QR code below with WeChat to authorize and automatically fill in the token',
|
||||
loginSuccess: 'Login successful! Token has been filled in',
|
||||
loginFailed: 'Login failed',
|
||||
connecting: 'Connecting to WeChat service...',
|
||||
waitingForScan: 'Waiting for scan',
|
||||
retry: 'Retry',
|
||||
},
|
||||
itchat: {
|
||||
scanLogin: 'Scan QR Login',
|
||||
@@ -1807,6 +1963,19 @@ const enUS = {
|
||||
robotNameNote:
|
||||
'Robot Name cannot be obtained automatically. Please fill it in manually.',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: 'One-Click QR Binding for QQ Official Bot',
|
||||
scanQRCode:
|
||||
'Scan the QR code below with mobile QQ and authorize the binding in QQ Bot Assistant',
|
||||
waitingForScan: 'Waiting for scan',
|
||||
bindSuccess: 'Bound successfully! AppID and Secret have been filled in',
|
||||
bindFailed: 'Binding failed',
|
||||
connecting: 'Connecting to QQ service...',
|
||||
retry: 'Retry',
|
||||
tokenNote:
|
||||
'The Token field is not used by the current adapter — you can leave it blank.',
|
||||
boundBy: 'Bound by QQ user {{openid}}',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||
invalidPage: 'Invalid plugin page',
|
||||
|
||||
@@ -83,7 +83,7 @@ const esES = {
|
||||
loading: 'Cargando...',
|
||||
fieldRequired: 'Este campo es obligatorio',
|
||||
or: 'o',
|
||||
loginWithSpace: 'Iniciar sesión con Space',
|
||||
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Recomendado: Usa API de modelos oficiales estables y servicios en la nube',
|
||||
loginLocal: 'Iniciar sesión con cuenta local',
|
||||
@@ -175,6 +175,8 @@ const esES = {
|
||||
more: 'Más ({{count}})',
|
||||
less: 'Menos',
|
||||
noItems: 'Sin elementos',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Página no encontrada',
|
||||
@@ -218,6 +220,19 @@ const esES = {
|
||||
selectModelAbilities: 'Seleccionar capacidades del modelo',
|
||||
visionAbility: 'Capacidad de visión',
|
||||
functionCallAbility: 'Llamada a funciones',
|
||||
reasoningAbility: 'Razonamiento',
|
||||
reasoningLevel: 'Nivel de razonamiento',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'Predeterminado del proveedor',
|
||||
disabled: 'Desactivado',
|
||||
enabled: 'Activado',
|
||||
minimal: 'Mínimo',
|
||||
low: 'Bajo',
|
||||
medium: 'Medio',
|
||||
high: 'Alto',
|
||||
xhigh: 'Extra alto',
|
||||
max: 'Máximo',
|
||||
},
|
||||
contextLength: 'Ventana de contexto',
|
||||
contextLengthPlaceholder: 'Desconocido',
|
||||
contextLengthInvalid: 'La ventana de contexto debe ser un entero positivo',
|
||||
@@ -286,7 +301,7 @@ const esES = {
|
||||
searchProviders: 'Buscar proveedores...',
|
||||
langbotModelsDescription: 'Modelos en la nube impulsados por LangBot Space',
|
||||
credits: 'Créditos',
|
||||
loginWithSpace: 'Iniciar sesión con Space',
|
||||
loginWithSpace: 'Iniciar sesión con una cuenta de LangBot',
|
||||
loginToUseModels: 'Inicia sesión con Space para usar modelos en la nube',
|
||||
noModels: 'No hay modelos configurados',
|
||||
langbotModels: 'Modelos LangBot',
|
||||
@@ -324,6 +339,11 @@ const esES = {
|
||||
fallbackList: 'Modelos de respaldo',
|
||||
addFallback: 'Añadir modelo de respaldo',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: 'Bots',
|
||||
@@ -528,9 +548,11 @@ const esES = {
|
||||
debugInfoTitle: 'Información de depuración del plugin',
|
||||
debugUrl: 'URL de depuración',
|
||||
debugKey: 'Clave de depuración',
|
||||
debugKeyExpires:
|
||||
'Rota a las {{time}}; cada Workspace tiene una clave distinta',
|
||||
noDebugKey: '(No establecida)',
|
||||
debugKeyDisabled:
|
||||
'La clave de depuración no está configurada, la depuración del plugin no requiere autenticación',
|
||||
'La credencial de depuración no está disponible temporalmente',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'Estado',
|
||||
boxConnected: 'Conectado',
|
||||
@@ -713,6 +735,7 @@ const esES = {
|
||||
notFound: 'No se encontró la información del plugin',
|
||||
sortBy: 'Ordenar por',
|
||||
sort: {
|
||||
hottest: 'Más populares',
|
||||
recentlyAdded: 'Añadidos recientemente',
|
||||
recentlyUpdated: 'Actualizados recientemente',
|
||||
mostDownloads: 'Más descargas',
|
||||
@@ -720,6 +743,9 @@ const esES = {
|
||||
},
|
||||
downloads: 'descargas',
|
||||
download: 'Descargar',
|
||||
like: 'Me gusta',
|
||||
unlike: 'Ya no me gusta',
|
||||
likeFailed: 'No se pudo actualizar el Me gusta. Inténtalo de nuevo.',
|
||||
repository: 'Repositorio',
|
||||
downloadFailed: 'Error en la descarga',
|
||||
noReadme: 'Este plugin no proporciona documentación README',
|
||||
@@ -811,6 +837,9 @@ const esES = {
|
||||
url: 'URL',
|
||||
headers: 'Encabezados',
|
||||
timeout: 'Tiempo de espera',
|
||||
toolCallTimeout: 'Tiempo de espera de herramienta (segundos)',
|
||||
toolCallTimeoutDescription:
|
||||
'Espera máxima para una llamada de herramienta. Use 0 para no limitar. El valor predeterminado es 300 segundos.',
|
||||
addArgument: 'Añadir argumento',
|
||||
addEnvVar: 'Añadir variable de entorno',
|
||||
addHeader: 'Añadir encabezado',
|
||||
@@ -833,6 +862,9 @@ const esES = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Active Box (box.enabled = true) y asegúrese de que el runtime está conectado, o cambie este servidor a modo http/sse.',
|
||||
boxRequired: 'requiere Box',
|
||||
disabledByPolicy: 'desactivado por la política',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP está deshabilitado en este despliegue. Use un servidor MCP remoto.',
|
||||
stdioBlockedByBoxToast:
|
||||
'No se puede guardar el MCP en modo stdio mientras el sandbox de Box está desactivado o no disponible. Active Box o seleccione modo http/sse.',
|
||||
toolsFound: 'herramientas',
|
||||
@@ -842,6 +874,12 @@ const esES = {
|
||||
tabTools: 'Herramientas',
|
||||
tabResources: 'Recursos',
|
||||
tabDocs: 'Documentación',
|
||||
tabLogs: 'Registros',
|
||||
logsLevelAll: 'Todos los niveles',
|
||||
logsRefresh: 'Actualizar',
|
||||
logsAutoRefresh: 'Actualización automática',
|
||||
logsEmpty:
|
||||
'Aún no hay registros. Los registros de ejecución del servidor MCP aparecerán aquí.',
|
||||
noReadme: 'No hay documentación disponible',
|
||||
parseResultFailed: 'Error al analizar el resultado de la prueba',
|
||||
noResultReturned: 'La prueba no devolvió resultados',
|
||||
@@ -1299,6 +1337,13 @@ const esES = {
|
||||
'Establece una contraseña para iniciar sesión con correo y contraseña',
|
||||
spaceEmailMismatch:
|
||||
'El correo de inicio de sesión de Space no coincide con el correo de la cuenta local',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Panel de control',
|
||||
@@ -1363,6 +1408,20 @@ const esES = {
|
||||
level: 'Nivel',
|
||||
runner: 'Ejecutor',
|
||||
viewConversation: 'Ver conversación',
|
||||
turns: '{{count}} turnos de conversación',
|
||||
userMessage: 'Usuario',
|
||||
noUserMessage: 'No se registró entrada del usuario',
|
||||
assistantMessage: 'Asistente',
|
||||
assistantMessageCount: 'Asistente +{{count}}',
|
||||
noAssistantMessage: 'No se registró respuesta del asistente',
|
||||
messageCount: 'Mensajes',
|
||||
conversationTrace: 'Flujo de conversación',
|
||||
noLlmCalls: 'No se registraron llamadas al modelo',
|
||||
roles: {
|
||||
user: 'Usuario',
|
||||
assistant: 'Asistente',
|
||||
message: 'Mensaje',
|
||||
},
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'Llamadas LLM',
|
||||
@@ -1377,6 +1436,15 @@ const esES = {
|
||||
avgDuration: 'Duración promedio',
|
||||
calls: 'Llamadas',
|
||||
},
|
||||
toolCalls: {
|
||||
title: 'Llamadas de herramientas',
|
||||
totalCalls: 'Llamadas',
|
||||
duration: 'Duración de herramientas',
|
||||
errorCalls: 'Llamadas fallidas',
|
||||
arguments: 'Argumentos',
|
||||
result: 'Resultado',
|
||||
noToolCalls: 'No se registraron llamadas de herramientas',
|
||||
},
|
||||
tokens: {
|
||||
totalTokens: 'Tokens totales',
|
||||
inputTokens: 'Tokens de entrada',
|
||||
@@ -1534,6 +1602,8 @@ const esES = {
|
||||
api: 'API',
|
||||
storage: 'Almacenamiento',
|
||||
account: 'Cuenta',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1579,6 +1649,12 @@ const esES = {
|
||||
'Se ha alcanzado el número máximo de Pipelines ({{max}}). Por favor, elimina un Pipeline existente antes de crear uno nuevo.',
|
||||
maxExtensionsReached:
|
||||
'Se ha alcanzado el número máximo de extensiones ({{max}}). Por favor, elimina un servidor MCP o plugin existente antes de añadir uno nuevo.',
|
||||
quotaLoadingTooltip:
|
||||
'El uso del espacio de trabajo aún se está cargando. Espera antes de crear un recurso.',
|
||||
quotaCheckFailed:
|
||||
'No se pudo verificar la cuota actual del espacio de trabajo. Inténtalo de nuevo.',
|
||||
createDisabledTooltip:
|
||||
'Se alcanzó el límite de {{resource}} ({{max}}) de este espacio de trabajo. Elimina uno existente antes de crear otro.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Crea un Bot con pasos guiados',
|
||||
@@ -1684,6 +1760,9 @@ const esES = {
|
||||
loginSuccess:
|
||||
'¡Inicio de sesión correcto! El token se ha rellenado automáticamente',
|
||||
loginFailed: 'Error al iniciar sesión',
|
||||
connecting: 'Conectando con el servicio de WeChat...',
|
||||
waitingForScan: 'Esperando escaneo',
|
||||
retry: 'Reintentar',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Crear aplicación de DingTalk con un clic',
|
||||
@@ -1711,6 +1790,20 @@ const esES = {
|
||||
robotNameNote:
|
||||
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: 'Vinculación QR con un clic para el bot oficial de QQ',
|
||||
scanQRCode:
|
||||
'Escanea el código QR siguiente con QQ móvil y autoriza la vinculación en «QQ Bot Assistant»',
|
||||
waitingForScan: 'Esperando escaneo',
|
||||
bindSuccess:
|
||||
'¡Vinculación correcta! AppID y Secret se han rellenado automáticamente',
|
||||
bindFailed: 'Error en la vinculación',
|
||||
connecting: 'Conectando con el servicio de QQ...',
|
||||
retry: 'Reintentar',
|
||||
tokenNote:
|
||||
'El campo Token no es utilizado por el adaptador actual; puedes dejarlo vacío.',
|
||||
boundBy: 'Vinculado por el usuario QQ {{openid}}',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||
invalidPage: 'Página de plugin no válida',
|
||||
@@ -1841,6 +1934,90 @@ const esES = {
|
||||
unsupportedFileType:
|
||||
'Tipo de archivo no admitido. Solo se admiten archivos .zip y .lbpkg',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: 'Create invitation',
|
||||
invitationCreated: 'Invitation created',
|
||||
delivery: {
|
||||
sent: 'Invitation sent',
|
||||
link_only: 'Invitation link created',
|
||||
failed: 'Invitation link created, but email could not be sent',
|
||||
},
|
||||
invitationCreateFailed: 'Failed to create invitation',
|
||||
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default esES;
|
||||
|
||||
@@ -81,7 +81,7 @@ const jaJP = {
|
||||
loading: '読み込み中...',
|
||||
fieldRequired: 'この項目は必須です',
|
||||
or: 'または',
|
||||
loginWithSpace: 'Space でログイン',
|
||||
loginWithSpace: 'LangBot アカウントでログイン',
|
||||
spaceLoginRecommended:
|
||||
'おすすめ:公式の安定したモデル API とクラウドサービスを利用',
|
||||
loginLocal: 'ローカルアカウントでログイン',
|
||||
@@ -169,6 +169,7 @@ const jaJP = {
|
||||
actions: 'アクション',
|
||||
apiKeyCreatedMessage:
|
||||
'この API キーをコピーしてください。もしボタンが無効な場合は手動でコピーしてください。',
|
||||
apiKeyStoredSecurely: 'シークレットは作成時のみ表示されます',
|
||||
none: 'なし',
|
||||
more: 'もっと見る ({{count}})',
|
||||
less: '折りたたむ',
|
||||
@@ -216,6 +217,19 @@ const jaJP = {
|
||||
selectModelAbilities: 'モデル機能を選択',
|
||||
visionAbility: '視覚機能',
|
||||
functionCallAbility: '関数呼び出し',
|
||||
reasoningAbility: '推論',
|
||||
reasoningLevel: '推論レベル',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'Provider デフォルト',
|
||||
disabled: 'オフ',
|
||||
enabled: 'オン',
|
||||
minimal: '最小',
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
xhigh: '最高',
|
||||
max: '最大',
|
||||
},
|
||||
contextLength: 'コンテキストウィンドウ',
|
||||
contextLengthPlaceholder: '不明',
|
||||
contextLengthInvalid:
|
||||
@@ -281,8 +295,12 @@ const jaJP = {
|
||||
searchProviders: 'プロバイダーを検索...',
|
||||
langbotModelsDescription: 'LangBot Space が提供するクラウドモデル',
|
||||
credits: 'クレジット',
|
||||
loginWithSpace: 'Space でログイン',
|
||||
loginWithSpace: 'LangBot アカウントでログイン',
|
||||
loginToUseModels: 'Space でログインしてクラウドモデルを使用',
|
||||
ownerMustBindSpace:
|
||||
'LangBot モデルを使うにはワークスペース所有者が Space を連携する必要があります。',
|
||||
usesOwnerSpaceBilling:
|
||||
'ワークスペース所有者の Space 課金とクレジットを使用します。',
|
||||
noModels: 'モデルがありません',
|
||||
langbotModels: 'LangBot モデル',
|
||||
spaceTrialTooltip:
|
||||
@@ -526,9 +544,10 @@ const jaJP = {
|
||||
debugInfoTitle: 'プラグインデバッグ情報',
|
||||
debugUrl: 'デバッグURL',
|
||||
debugKey: 'デバッグキー',
|
||||
debugKeyExpires:
|
||||
'{{time}} にローテーションします。Workspace ごとにキーが異なります',
|
||||
noDebugKey: '(未設定)',
|
||||
debugKeyDisabled:
|
||||
'デバッグキーが設定されていません。プラグインデバッグには認証が不要です',
|
||||
debugKeyDisabled: 'デバッグ認証情報を一時的に利用できません',
|
||||
boxStatusTitle: 'Box ランタイム',
|
||||
boxStatus: 'ステータス',
|
||||
boxConnected: '接続済み',
|
||||
@@ -710,6 +729,7 @@ const jaJP = {
|
||||
notFound: 'プラグイン情報が見つかりません',
|
||||
sortBy: '並び順',
|
||||
sort: {
|
||||
hottest: '人気順',
|
||||
recentlyAdded: '最近追加',
|
||||
recentlyUpdated: '最近更新',
|
||||
mostDownloads: 'ダウンロード数多',
|
||||
@@ -717,6 +737,9 @@ const jaJP = {
|
||||
},
|
||||
downloads: '回ダウンロード',
|
||||
download: 'ダウンロード',
|
||||
like: 'いいね',
|
||||
unlike: 'いいねを解除',
|
||||
likeFailed: 'いいねを更新できませんでした。もう一度お試しください。',
|
||||
repository: 'リポジトリ',
|
||||
downloadFailed: 'ダウンロード失敗',
|
||||
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
|
||||
@@ -807,6 +830,9 @@ const jaJP = {
|
||||
url: 'URL',
|
||||
headers: 'ヘッダー',
|
||||
timeout: 'タイムアウト',
|
||||
toolCallTimeout: 'ツール呼び出しタイムアウト(秒)',
|
||||
toolCallTimeoutDescription:
|
||||
'1 回のツール呼び出しの最大待機時間です。0 で無制限、既定値は 300 秒です。',
|
||||
addArgument: '引数を追加',
|
||||
addEnvVar: '環境変数を追加',
|
||||
addHeader: 'ヘッダーを追加',
|
||||
@@ -829,6 +855,9 @@ const jaJP = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Box を有効化(box.enabled = true)してランタイムの接続を確認するか、このサーバーを http/sse モードに切り替えてください。',
|
||||
boxRequired: 'Box が必要',
|
||||
disabledByPolicy: 'ポリシーにより無効',
|
||||
stdioDisabledByPolicy:
|
||||
'このデプロイでは Stdio MCP が無効です。リモート MCP サーバーを使用してください。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box サンドボックスが無効または利用できないため、stdio モードの MCP は保存できません。Box を有効化するか、http/sse モードに切り替えてください。',
|
||||
toolsFound: '個のツール',
|
||||
@@ -838,6 +867,11 @@ const jaJP = {
|
||||
tabTools: 'ツール',
|
||||
tabResources: 'リソース',
|
||||
tabDocs: 'ドキュメント',
|
||||
tabLogs: 'ログ',
|
||||
logsLevelAll: 'すべてのレベル',
|
||||
logsRefresh: '更新',
|
||||
logsAutoRefresh: '自動更新',
|
||||
logsEmpty: 'ログはありません。MCPサーバーの実行ログがここに表示されます。',
|
||||
noReadme: 'ドキュメントがありません',
|
||||
parseResultFailed: 'テスト結果の解析に失敗しました',
|
||||
noResultReturned: 'テスト結果が返されませんでした',
|
||||
@@ -1277,6 +1311,96 @@ const jaJP = {
|
||||
'パスワードを設定するとメールとパスワードでログインできます',
|
||||
spaceEmailMismatch:
|
||||
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
|
||||
space_account_not_registeredTitle: 'アカウントが登録されていません',
|
||||
space_account_not_registered:
|
||||
'この Space メールアドレスのローカルアカウントはありません。ワークスペース所有者に招待を依頼してください。',
|
||||
space_account_binding_requiredTitle: 'Space の連携が必要です',
|
||||
space_account_binding_required:
|
||||
'Space ログインを使用する前に、アカウント設定でこのローカルアカウントを Space に連携してください。',
|
||||
},
|
||||
workspace: {
|
||||
title: 'ワークスペース',
|
||||
description: 'メンバー、ロール、招待リンクを管理します',
|
||||
selectTitle: 'ワークスペースを選択',
|
||||
selectDescription: 'LangBot で使用するワークスペースを選択してください。',
|
||||
selectionLoadFailed:
|
||||
'ワークスペースを読み込めませんでした。もう一度お試しください。',
|
||||
switchWorkspace: 'ワークスペースを切り替え',
|
||||
ossSingletonDescription:
|
||||
'このセルフホストインスタンスには1つのワークスペースがあり、複数のユーザーを追加できます。',
|
||||
cloudManagedDescription:
|
||||
'このワークスペースは LangBot Cloud でホストされています。メンバーはここで管理し、請求は Cloud で開きます。',
|
||||
loadFailed: 'ワークスペース情報の読み込みに失敗しました',
|
||||
members: 'メンバー',
|
||||
you: 'あなた',
|
||||
inviteMember: 'メンバーを招待',
|
||||
inviteDescription:
|
||||
'現在のワークスペースにユーザーを追加する一度限りのリンクを作成します。',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: '招待を作成',
|
||||
invitationCreated: '招待を作成しました',
|
||||
delivery: {
|
||||
sent: '招待メールを送信しました',
|
||||
link_only: '招待リンクを作成しました',
|
||||
failed: '招待リンクを作成しましたが、メールを送信できませんでした',
|
||||
},
|
||||
invitationCreateFailed: '招待の作成に失敗しました',
|
||||
oneTimeLinkWarning:
|
||||
'このリンクを今すぐコピーしてください。一度だけ表示されます。',
|
||||
copyInvitation: '招待リンクをコピー',
|
||||
invitationCopied: '招待リンクをコピーしました',
|
||||
pendingInvitations: '保留中の招待',
|
||||
expiresAt: '{{date}} に期限切れ',
|
||||
revokeInvitation: '招待を取り消す',
|
||||
invitationRevoked: '招待を取り消しました',
|
||||
invitationRevokeFailed: '招待の取り消しに失敗しました',
|
||||
acceptInvitation: '招待を承認',
|
||||
invitedToWorkspace: '{{workspace}} に招待されました',
|
||||
checkingInvitation: '招待を確認しています...',
|
||||
invitationMissing: 'この招待リンクには必要な情報がありません。',
|
||||
invitationExpired: 'この招待は期限切れです。',
|
||||
invitationAlreadyRevoked: 'この招待は取り消されました。',
|
||||
invitationAlreadyUsed: 'この招待はすでに使用されています。',
|
||||
invitationInvalid: 'この招待は無効か、利用できなくなっています。',
|
||||
invitationAccepted: '招待を承認しました',
|
||||
invitationAcceptFailed: '招待の承認に失敗しました',
|
||||
invitationEmailMismatch: 'この招待は別のメールアドレスに送られたものです。',
|
||||
existingAccountLoginRequired:
|
||||
'このメールアドレスのアカウントは既に存在します。ログインしてください。',
|
||||
acceptAsCurrentAccount: '現在のアカウントで承認',
|
||||
authenticatedInvitationNotice:
|
||||
'一度ログアウトし、招待されたアカウントでログインしてください。招待は保持されます。',
|
||||
logoutAndReturn: 'ログアウトしてこの招待に戻る',
|
||||
switchAccount: 'アカウントを切り替える',
|
||||
registerAndAccept: 'アカウントを作成して承認',
|
||||
alreadyHaveAccount: 'アカウントを持っています',
|
||||
confirmPassword: 'パスワードを確認',
|
||||
passwordMinimum: 'パスワードは8文字以上にしてください。',
|
||||
passwordMismatch: 'パスワードが一致しません。',
|
||||
backToLogin: 'ログインに戻る',
|
||||
memberUpdated: 'メンバーのロールを更新しました',
|
||||
memberUpdateFailed: 'メンバーのロール更新に失敗しました',
|
||||
removeMember: 'メンバーを削除',
|
||||
removeMemberConfirm: 'このメンバーをワークスペースから削除しますか?',
|
||||
memberRemoved: 'メンバーを削除しました',
|
||||
memberRemoveFailed: 'メンバーの削除に失敗しました',
|
||||
transferOwnership: '所有権を移譲',
|
||||
types: {
|
||||
personal: '個人',
|
||||
team: 'チーム',
|
||||
},
|
||||
roles: {
|
||||
owner: '所有者',
|
||||
admin: '管理者',
|
||||
developer: '開発者',
|
||||
operator: 'オペレーター',
|
||||
viewer: '閲覧者',
|
||||
},
|
||||
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'ダッシュボード',
|
||||
@@ -1340,6 +1464,20 @@ const jaJP = {
|
||||
level: 'レベル',
|
||||
runner: 'ランナー',
|
||||
viewConversation: '会話詳細を表示',
|
||||
turns: '{{count}} 会話ターン',
|
||||
userMessage: 'ユーザー',
|
||||
noUserMessage: 'ユーザー入力は記録されていません',
|
||||
assistantMessage: 'アシスタント',
|
||||
assistantMessageCount: 'アシスタント +{{count}}',
|
||||
noAssistantMessage: 'アシスタントの返信は記録されていません',
|
||||
messageCount: 'メッセージ数',
|
||||
conversationTrace: '会話トレース',
|
||||
noLlmCalls: 'モデル呼び出しは記録されていません',
|
||||
roles: {
|
||||
user: 'ユーザー',
|
||||
assistant: 'アシスタント',
|
||||
message: 'メッセージ',
|
||||
},
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'LLM呼び出し',
|
||||
@@ -1354,6 +1492,15 @@ const jaJP = {
|
||||
avgDuration: '平均期間',
|
||||
calls: '呼び出し',
|
||||
},
|
||||
toolCalls: {
|
||||
title: 'ツール呼び出し',
|
||||
totalCalls: '呼び出し',
|
||||
duration: 'ツール時間',
|
||||
errorCalls: '失敗した呼び出し',
|
||||
arguments: '引数',
|
||||
result: '結果',
|
||||
noToolCalls: 'ツール呼び出しは記録されていません',
|
||||
},
|
||||
tokens: {
|
||||
totalTokens: '総トークン数',
|
||||
inputTokens: '入力トークン',
|
||||
@@ -1507,6 +1654,7 @@ const jaJP = {
|
||||
settingsDialog: {
|
||||
title: '設定',
|
||||
nav: {
|
||||
workspace: 'ワークスペース',
|
||||
models: 'モデル',
|
||||
api: 'API',
|
||||
storage: 'ストレージ',
|
||||
@@ -1555,6 +1703,12 @@ const jaJP = {
|
||||
'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。',
|
||||
maxExtensionsReached:
|
||||
'拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。',
|
||||
quotaLoadingTooltip:
|
||||
'ワークスペースの使用状況を読み込んでいます。リソースを作成する前にお待ちください。',
|
||||
quotaCheckFailed:
|
||||
'現在のワークスペース上限を確認できません。もう一度お試しください。',
|
||||
createDisabledTooltip:
|
||||
'このワークスペースの{{resource}}数が上限({{max}}個)に達しました。新しく作成する前に既存の{{resource}}を削除してください。',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'ガイド付きステップでボットを作成',
|
||||
@@ -1687,6 +1841,9 @@ const jaJP = {
|
||||
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
|
||||
loginSuccess: 'ログイン成功!トークンが自動入力されました',
|
||||
loginFailed: 'ログイン失敗',
|
||||
connecting: 'WeChatサービスに接続中...',
|
||||
waitingForScan: 'スキャン待ち',
|
||||
retry: '再試行',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'ワンクリックでDingTalkアプリ作成',
|
||||
@@ -1709,6 +1866,19 @@ const jaJP = {
|
||||
retry: '再試行',
|
||||
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: 'ワンクリックで QQ 公式ボットを QR バインド',
|
||||
scanQRCode:
|
||||
'以下の QR コードをモバイル QQ でスキャンし、「QQ ボットアシスタント」でバインドを承認してください',
|
||||
waitingForScan: 'スキャン待ち',
|
||||
bindSuccess: 'バインド成功!AppID と Secret が自動入力されました',
|
||||
bindFailed: 'バインド失敗',
|
||||
connecting: 'QQ サービスに接続中...',
|
||||
retry: '再試行',
|
||||
tokenNote:
|
||||
'Token フィールドは現行アダプターでは使用しません。空欄のままで構いません。',
|
||||
boundBy: 'QQ ユーザー {{openid}} によりバインドされました',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||
invalidPage: '無効なプラグインページ',
|
||||
|
||||
@@ -80,7 +80,7 @@ const ruRU = {
|
||||
loading: 'Загрузка...',
|
||||
fieldRequired: 'Это поле обязательно для заполнения',
|
||||
or: 'или',
|
||||
loginWithSpace: 'Войти через Space',
|
||||
loginWithSpace: 'Войти с аккаунтом LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Рекомендуется: Используйте официальные стабильные API моделей и облачные сервисы',
|
||||
loginLocal: 'Войти с локальной учётной записью',
|
||||
@@ -172,6 +172,8 @@ const ruRU = {
|
||||
less: 'Свернуть',
|
||||
noItems: 'Нет элементов',
|
||||
termsOfService: 'Условия обслуживания',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Страница не найдена',
|
||||
@@ -215,6 +217,19 @@ const ruRU = {
|
||||
selectModelAbilities: 'Выберите возможности модели',
|
||||
visionAbility: 'Распознавание изображений',
|
||||
functionCallAbility: 'Вызов функций',
|
||||
reasoningAbility: 'Рассуждение',
|
||||
reasoningLevel: 'Уровень рассуждений',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'По умолчанию провайдера',
|
||||
disabled: 'Выключено',
|
||||
enabled: 'Включено',
|
||||
minimal: 'Минимальный',
|
||||
low: 'Низкий',
|
||||
medium: 'Средний',
|
||||
high: 'Высокий',
|
||||
xhigh: 'Очень высокий',
|
||||
max: 'Максимальный',
|
||||
},
|
||||
contextLength: 'Контекстное окно',
|
||||
contextLengthPlaceholder: 'Неизвестно',
|
||||
contextLengthInvalid:
|
||||
@@ -284,7 +299,7 @@ const ruRU = {
|
||||
searchProviders: 'Поиск провайдеров...',
|
||||
langbotModelsDescription: 'Облачные модели на базе LangBot Space',
|
||||
credits: 'Кредиты',
|
||||
loginWithSpace: 'Войти через Space',
|
||||
loginWithSpace: 'Войти с аккаунтом LangBot',
|
||||
loginToUseModels: 'Войдите через Space, чтобы использовать облачные модели',
|
||||
noModels: 'Модели не настроены',
|
||||
langbotModels: 'Модели LangBot',
|
||||
@@ -323,6 +338,11 @@ const ruRU = {
|
||||
fallbackList: 'Резервные модели',
|
||||
addFallback: 'Добавить резервную модель',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: 'Боты',
|
||||
@@ -527,9 +547,9 @@ const ruRU = {
|
||||
debugInfoTitle: 'Отладочная информация плагина',
|
||||
debugUrl: 'URL для отладки',
|
||||
debugKey: 'Ключ отладки',
|
||||
debugKeyExpires: 'Смена в {{time}}; у каждого Workspace свой ключ',
|
||||
noDebugKey: '(Не задан)',
|
||||
debugKeyDisabled:
|
||||
'Ключ отладки не задан, аутентификация при отладке плагина не требуется',
|
||||
debugKeyDisabled: 'Учетные данные отладки временно недоступны',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'Статус',
|
||||
boxConnected: 'Подключено',
|
||||
@@ -711,6 +731,7 @@ const ruRU = {
|
||||
notFound: 'Информация о плагине не найдена',
|
||||
sortBy: 'Сортировать по',
|
||||
sort: {
|
||||
hottest: 'По популярности',
|
||||
recentlyAdded: 'Недавно добавленные',
|
||||
recentlyUpdated: 'Недавно обновлённые',
|
||||
mostDownloads: 'Больше всего загрузок',
|
||||
@@ -718,6 +739,9 @@ const ruRU = {
|
||||
},
|
||||
downloads: 'загрузок',
|
||||
download: 'Скачать',
|
||||
like: 'Нравится',
|
||||
unlike: 'Убрать отметку',
|
||||
likeFailed: 'Не удалось обновить отметку. Повторите попытку.',
|
||||
repository: 'Репозиторий',
|
||||
downloadFailed: 'Ошибка загрузки',
|
||||
noReadme: 'Этот плагин не предоставляет документацию README',
|
||||
@@ -808,6 +832,9 @@ const ruRU = {
|
||||
url: 'URL',
|
||||
headers: 'Заголовки',
|
||||
timeout: 'Таймаут',
|
||||
toolCallTimeout: 'Таймаут вызова инструмента (секунды)',
|
||||
toolCallTimeoutDescription:
|
||||
'Максимальное ожидание одного вызова. 0 отключает ограничение. По умолчанию 300 секунд.',
|
||||
addArgument: 'Добавить аргумент',
|
||||
addEnvVar: 'Добавить переменную окружения',
|
||||
addHeader: 'Добавить заголовок',
|
||||
@@ -830,6 +857,9 @@ const ruRU = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Включите Box (box.enabled = true) и убедитесь, что среда работает, либо переключите этот сервер в режим http/sse.',
|
||||
boxRequired: 'требуется Box',
|
||||
disabledByPolicy: 'отключено политикой',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP отключён в этом развёртывании. Используйте удалённый MCP-сервер.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Сохранить MCP в режиме stdio нельзя: песочница Box отключена или недоступна. Включите Box либо выберите режим http/sse.',
|
||||
toolsFound: 'инструментов',
|
||||
@@ -839,6 +869,12 @@ const ruRU = {
|
||||
tabTools: 'Инструменты',
|
||||
tabResources: 'Ресурсы',
|
||||
tabDocs: 'Документация',
|
||||
tabLogs: 'Журнал',
|
||||
logsLevelAll: 'Все уровни',
|
||||
logsRefresh: 'Обновить',
|
||||
logsAutoRefresh: 'Автообновление',
|
||||
logsEmpty:
|
||||
'Журналов пока нет. Здесь будут отображаться журналы выполнения MCP-сервера.',
|
||||
noReadme: 'Документация отсутствует',
|
||||
parseResultFailed: 'Не удалось разобрать результат теста',
|
||||
noResultReturned: 'Тест не вернул результат',
|
||||
@@ -1276,6 +1312,13 @@ const ruRU = {
|
||||
setPasswordHint: 'Установите пароль для входа с email и паролем',
|
||||
spaceEmailMismatch:
|
||||
'Email входа через Space не совпадает с email локальной учётной записи',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Мониторинг',
|
||||
@@ -1339,6 +1382,20 @@ const ruRU = {
|
||||
level: 'Уровень',
|
||||
runner: 'Обработчик',
|
||||
viewConversation: 'Просмотр диалога',
|
||||
turns: '{{count}} диалоговых ходов',
|
||||
userMessage: 'Пользователь',
|
||||
noUserMessage: 'Ввод пользователя не записан',
|
||||
assistantMessage: 'Ассистент',
|
||||
assistantMessageCount: 'Ассистент +{{count}}',
|
||||
noAssistantMessage: 'Ответ ассистента не записан',
|
||||
messageCount: 'Сообщения',
|
||||
conversationTrace: 'Ход диалога',
|
||||
noLlmCalls: 'Вызовы модели не записаны',
|
||||
roles: {
|
||||
user: 'Пользователь',
|
||||
assistant: 'Ассистент',
|
||||
message: 'Сообщение',
|
||||
},
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'Вызовы LLM',
|
||||
@@ -1353,6 +1410,15 @@ const ruRU = {
|
||||
avgDuration: 'Средняя длительность',
|
||||
calls: 'Вызовы',
|
||||
},
|
||||
toolCalls: {
|
||||
title: 'Вызовы инструментов',
|
||||
totalCalls: 'Вызовы',
|
||||
duration: 'Длительность инструментов',
|
||||
errorCalls: 'Неудачные вызовы',
|
||||
arguments: 'Аргументы',
|
||||
result: 'Результат',
|
||||
noToolCalls: 'Вызовы инструментов не записаны',
|
||||
},
|
||||
tokens: {
|
||||
totalTokens: 'Всего токенов',
|
||||
inputTokens: 'Входные токены',
|
||||
@@ -1510,6 +1576,8 @@ const ruRU = {
|
||||
api: 'API',
|
||||
storage: 'Хранилище',
|
||||
account: 'Аккаунт',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1554,6 +1622,12 @@ const ruRU = {
|
||||
'Достигнуто максимальное количество конвейеров ({{max}}). Удалите существующий конвейер перед созданием нового.',
|
||||
maxExtensionsReached:
|
||||
'Достигнуто максимальное количество расширений ({{max}}). Удалите существующий MCP-сервер или плагин перед добавлением нового.',
|
||||
quotaLoadingTooltip:
|
||||
'Данные об использовании рабочего пространства загружаются. Подождите перед созданием ресурса.',
|
||||
quotaCheckFailed:
|
||||
'Не удалось проверить текущую квоту рабочего пространства. Повторите попытку.',
|
||||
createDisabledTooltip:
|
||||
'Достигнут лимит {{resource}} ({{max}}) для этого рабочего пространства. Удалите существующий ресурс перед созданием нового.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Создать бота с пошаговым руководством',
|
||||
@@ -1656,6 +1730,9 @@ const ruRU = {
|
||||
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
|
||||
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
|
||||
loginFailed: 'Не удалось выполнить вход',
|
||||
connecting: 'Подключение к сервису WeChat...',
|
||||
waitingForScan: 'Ожидание сканирования',
|
||||
retry: 'Повторить',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Создать приложение DingTalk в один клик',
|
||||
@@ -1682,6 +1759,19 @@ const ruRU = {
|
||||
robotNameNote:
|
||||
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: 'Привязка официального бота QQ по QR-коду',
|
||||
scanQRCode:
|
||||
'Отсканируйте QR-код ниже мобильным QQ и подтвердите привязку в «QQ Bot Assistant»',
|
||||
waitingForScan: 'Ожидание сканирования',
|
||||
bindSuccess: 'Привязка успешна! AppID и Secret заполнены автоматически',
|
||||
bindFailed: 'Не удалось выполнить привязку',
|
||||
connecting: 'Подключение к сервису QQ...',
|
||||
retry: 'Повторить',
|
||||
tokenNote:
|
||||
'Поле Token не используется текущим адаптером — его можно оставить пустым.',
|
||||
boundBy: 'Привязано пользователем QQ {{openid}}',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||
invalidPage: 'Недопустимая страница плагина',
|
||||
@@ -1810,6 +1900,90 @@ const ruRU = {
|
||||
unsupportedFileType:
|
||||
'Неподдерживаемый тип файла. Поддерживаются только файлы .zip и .lbpkg',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: 'Create invitation',
|
||||
invitationCreated: 'Invitation created',
|
||||
delivery: {
|
||||
sent: 'Invitation sent',
|
||||
link_only: 'Invitation link created',
|
||||
failed: 'Invitation link created, but email could not be sent',
|
||||
},
|
||||
invitationCreateFailed: 'Failed to create invitation',
|
||||
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default ruRU;
|
||||
|
||||
@@ -80,7 +80,7 @@ const thTH = {
|
||||
loading: 'กำลังโหลด...',
|
||||
fieldRequired: 'ช่องนี้จำเป็นต้องกรอก',
|
||||
or: 'หรือ',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วย Space',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
spaceLoginRecommended:
|
||||
'แนะนำ: ใช้ API โมเดลที่เสถียรอย่างเป็นทางการและบริการคลาวด์',
|
||||
loginLocal: 'เข้าสู่ระบบด้วยบัญชีท้องถิ่น',
|
||||
@@ -169,6 +169,8 @@ const thTH = {
|
||||
more: 'เพิ่มเติม ({{count}})',
|
||||
less: 'น้อยลง',
|
||||
noItems: 'ไม่มีรายการ',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: 'ไม่พบหน้า',
|
||||
@@ -211,6 +213,19 @@ const thTH = {
|
||||
selectModelAbilities: 'เลือกความสามารถของโมเดล',
|
||||
visionAbility: 'ความสามารถด้านภาพ',
|
||||
functionCallAbility: 'การเรียกฟังก์ชัน',
|
||||
reasoningAbility: 'ความสามารถในการให้เหตุผล',
|
||||
reasoningLevel: 'ระดับการให้เหตุผล',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'ค่าเริ่มต้นของผู้ให้บริการ',
|
||||
disabled: 'ปิด',
|
||||
enabled: 'เปิด',
|
||||
minimal: 'ต่ำสุด',
|
||||
low: 'ต่ำ',
|
||||
medium: 'ปานกลาง',
|
||||
high: 'สูง',
|
||||
xhigh: 'สูงมาก',
|
||||
max: 'สูงสุด',
|
||||
},
|
||||
contextLength: 'หน้าต่างบริบท',
|
||||
contextLengthPlaceholder: 'ไม่ทราบ',
|
||||
contextLengthInvalid: 'หน้าต่างบริบทต้องเป็นจำนวนเต็มบวก',
|
||||
@@ -273,7 +288,7 @@ const thTH = {
|
||||
searchProviders: 'ค้นหาผู้ให้บริการ...',
|
||||
langbotModelsDescription: 'โมเดลคลาวด์ขับเคลื่อนโดย LangBot Space',
|
||||
credits: 'เครดิต',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วย Space',
|
||||
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
|
||||
loginToUseModels: 'เข้าสู่ระบบด้วย Space เพื่อใช้โมเดลคลาวด์',
|
||||
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
|
||||
langbotModels: 'โมเดล LangBot',
|
||||
@@ -310,6 +325,11 @@ const thTH = {
|
||||
fallbackList: 'โมเดลสำรอง',
|
||||
addFallback: 'เพิ่มโมเดลสำรอง',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: 'บอท',
|
||||
@@ -511,9 +531,9 @@ const thTH = {
|
||||
debugInfoTitle: 'ข้อมูลดีบักปลั๊กอิน',
|
||||
debugUrl: 'URL ดีบัก',
|
||||
debugKey: 'คีย์ดีบัก',
|
||||
debugKeyExpires: 'หมุนเวียนเวลา {{time}}; แต่ละ Workspace ใช้คีย์ต่างกัน',
|
||||
noDebugKey: '(ไม่ได้ตั้งค่า)',
|
||||
debugKeyDisabled:
|
||||
'ไม่ได้ตั้งค่าคีย์ดีบัก การดีบักปลั๊กอินไม่ต้องยืนยันตัวตน',
|
||||
debugKeyDisabled: 'ข้อมูลรับรองการดีบักไม่พร้อมใช้งานชั่วคราว',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'สถานะ',
|
||||
boxConnected: 'เชื่อมต่อแล้ว',
|
||||
@@ -690,6 +710,7 @@ const thTH = {
|
||||
notFound: 'ไม่พบข้อมูลปลั๊กอิน',
|
||||
sortBy: 'เรียงตาม',
|
||||
sort: {
|
||||
hottest: 'ยอดนิยมที่สุด',
|
||||
recentlyAdded: 'เพิ่มล่าสุด',
|
||||
recentlyUpdated: 'อัปเดตล่าสุด',
|
||||
mostDownloads: 'ดาวน์โหลดมากที่สุด',
|
||||
@@ -697,6 +718,9 @@ const thTH = {
|
||||
},
|
||||
downloads: 'ดาวน์โหลด',
|
||||
download: 'ดาวน์โหลด',
|
||||
like: 'ถูกใจ',
|
||||
unlike: 'เลิกถูกใจ',
|
||||
likeFailed: 'อัปเดตการถูกใจไม่สำเร็จ โปรดลองอีกครั้ง',
|
||||
repository: 'Repository',
|
||||
downloadFailed: 'ดาวน์โหลดล้มเหลว',
|
||||
noReadme: 'ปลั๊กอินนี้ไม่มีเอกสาร README',
|
||||
@@ -786,6 +810,9 @@ const thTH = {
|
||||
url: 'URL',
|
||||
headers: 'ส่วนหัว',
|
||||
timeout: 'หมดเวลา',
|
||||
toolCallTimeout: 'หมดเวลาการเรียกเครื่องมือ (วินาที)',
|
||||
toolCallTimeoutDescription:
|
||||
'เวลารอสูงสุดต่อการเรียกเครื่องมือหนึ่งครั้ง ตั้งเป็น 0 เพื่อไม่จำกัด ค่าเริ่มต้นคือ 300 วินาที',
|
||||
addArgument: 'เพิ่มอาร์กิวเมนต์',
|
||||
addEnvVar: 'เพิ่มตัวแปรสภาพแวดล้อม',
|
||||
addHeader: 'เพิ่มส่วนหัว',
|
||||
@@ -808,6 +835,9 @@ const thTH = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'กรุณาเปิดใช้งาน Box (box.enabled = true) และตรวจสอบว่ารันไทม์ทำงานปกติ หรือเปลี่ยน MCP server เป็นโหมด http/sse',
|
||||
boxRequired: 'ต้องใช้ Box',
|
||||
disabledByPolicy: 'ถูกปิดใช้งานโดยนโยบาย',
|
||||
stdioDisabledByPolicy:
|
||||
'การติดตั้งใช้งานนี้ปิด Stdio MCP อยู่ โปรดใช้เซิร์ฟเวอร์ MCP แบบระยะไกล',
|
||||
stdioBlockedByBoxToast:
|
||||
'ไม่สามารถบันทึก MCP โหมด stdio เนื่องจาก Sandbox Box ถูกปิดใช้งานหรือไม่พร้อมใช้งาน กรุณาเปิดใช้งาน Box หรือเลือกโหมด http/sse',
|
||||
toolsFound: 'เครื่องมือ',
|
||||
@@ -817,6 +847,11 @@ const thTH = {
|
||||
tabTools: 'เครื่องมือ',
|
||||
tabResources: 'ทรัพยากร',
|
||||
tabDocs: 'เอกสาร',
|
||||
tabLogs: 'บันทึก',
|
||||
logsLevelAll: 'ทุกระดับ',
|
||||
logsRefresh: 'รีเฟรช',
|
||||
logsAutoRefresh: 'รีเฟรชอัตโนมัติ',
|
||||
logsEmpty: 'ยังไม่มีบันทึก บันทึกการทำงานของ MCP Server จะแสดงที่นี่',
|
||||
noReadme: 'ไม่มีเอกสาร',
|
||||
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
|
||||
noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา',
|
||||
@@ -1245,6 +1280,13 @@ const thTH = {
|
||||
bindSpaceInvalidState: 'คำขอผูกไม่ถูกต้อง กรุณาลองใหม่จากการตั้งค่าบัญชี',
|
||||
setPasswordHint: 'ตั้งรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน',
|
||||
spaceEmailMismatch: 'อีเมลเข้าสู่ระบบ Space ไม่ตรงกับอีเมลบัญชีท้องถิ่น',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'แดชบอร์ด',
|
||||
@@ -1307,6 +1349,20 @@ const thTH = {
|
||||
level: 'ระดับ',
|
||||
runner: 'ตัวประมวลผล',
|
||||
viewConversation: 'ดูการสนทนา',
|
||||
turns: '{{count}} รอบการสนทนา',
|
||||
userMessage: 'ผู้ใช้',
|
||||
noUserMessage: 'ยังไม่มีการบันทึกข้อความจากผู้ใช้',
|
||||
assistantMessage: 'ผู้ช่วย',
|
||||
assistantMessageCount: 'ผู้ช่วย +{{count}}',
|
||||
noAssistantMessage: 'ยังไม่มีการบันทึกคำตอบจากผู้ช่วย',
|
||||
messageCount: 'จำนวนข้อความ',
|
||||
conversationTrace: 'ลำดับการสนทนา',
|
||||
noLlmCalls: 'ยังไม่มีการบันทึกการเรียกโมเดล',
|
||||
roles: {
|
||||
user: 'ผู้ใช้',
|
||||
assistant: 'ผู้ช่วย',
|
||||
message: 'ข้อความ',
|
||||
},
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'การเรียก LLM',
|
||||
@@ -1321,6 +1377,15 @@ const thTH = {
|
||||
avgDuration: 'ระยะเวลาเฉลี่ย',
|
||||
calls: 'การเรียก',
|
||||
},
|
||||
toolCalls: {
|
||||
title: 'การเรียกใช้เครื่องมือ',
|
||||
totalCalls: 'การเรียก',
|
||||
duration: 'ระยะเวลาเครื่องมือ',
|
||||
errorCalls: 'การเรียกที่ล้มเหลว',
|
||||
arguments: 'อาร์กิวเมนต์',
|
||||
result: 'ผลลัพธ์',
|
||||
noToolCalls: 'ยังไม่มีการบันทึกการเรียกใช้เครื่องมือ',
|
||||
},
|
||||
tokens: {
|
||||
totalTokens: 'Token ทั้งหมด',
|
||||
inputTokens: 'Token อินพุต',
|
||||
@@ -1478,6 +1543,8 @@ const thTH = {
|
||||
api: 'API',
|
||||
storage: 'พื้นที่จัดเก็บ',
|
||||
account: 'บัญชี',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1522,6 +1589,12 @@ const thTH = {
|
||||
'จำนวน Pipeline สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Pipeline ที่มีอยู่ก่อนสร้างใหม่',
|
||||
maxExtensionsReached:
|
||||
'จำนวนส่วนขยายสูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบเซิร์ฟเวอร์ MCP หรือปลั๊กอินที่มีอยู่ก่อนเพิ่มใหม่',
|
||||
quotaLoadingTooltip:
|
||||
'กำลังโหลดการใช้งานพื้นที่ทำงาน โปรดรอก่อนสร้างทรัพยากร',
|
||||
quotaCheckFailed:
|
||||
'ไม่สามารถตรวจสอบโควตาปัจจุบันของพื้นที่ทำงานได้ โปรดลองอีกครั้ง',
|
||||
createDisabledTooltip:
|
||||
'ถึงขีดจำกัด {{resource}} ({{max}}) ของเวิร์กสเปซนี้แล้ว โปรดลบรายการเดิมก่อนสร้างรายการใหม่',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'สร้าง Bot ด้วยขั้นตอนที่แนะนำ',
|
||||
@@ -1620,6 +1693,9 @@ const thTH = {
|
||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
|
||||
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
|
||||
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
|
||||
connecting: 'กำลังเชื่อมต่อบริการ WeChat...',
|
||||
waitingForScan: 'กำลังรอการสแกน',
|
||||
retry: 'ลองอีกครั้ง',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
|
||||
@@ -1644,6 +1720,18 @@ const thTH = {
|
||||
retry: 'ลองใหม่',
|
||||
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: 'ผูกบอต QQ Official ด้วย QR คลิกเดียว',
|
||||
scanQRCode:
|
||||
'สแกนคิวอาร์โค้ดด้านล่างด้วย QQ มือถือ แล้วอนุญาตการผูกใน «QQ Bot Assistant»',
|
||||
waitingForScan: 'กำลังรอสแกน',
|
||||
bindSuccess: 'ผูกสำเร็จ! AppID และ Secret ถูกกรอกอัตโนมัติแล้ว',
|
||||
bindFailed: 'การผูกล้มเหลว',
|
||||
connecting: 'กำลังเชื่อมต่อบริการ QQ...',
|
||||
retry: 'ลองใหม่',
|
||||
tokenNote: 'อะแดปเตอร์ปัจจุบันไม่ได้ใช้ฟิลด์ Token จึงเว้นว่างไว้ได้',
|
||||
boundBy: 'ผูกโดยผู้ใช้ QQ {{openid}}',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||
@@ -1769,6 +1857,90 @@ const thTH = {
|
||||
createSkillHint: 'นำเข้าจากไดเรกทอรีในเครื่องหรือสร้างด้วยตนเอง',
|
||||
unsupportedFileType: 'ประเภทไฟล์ไม่รองรับ รองรับเฉพาะไฟล์ .zip และ .lbpkg',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: 'Create invitation',
|
||||
invitationCreated: 'Invitation created',
|
||||
delivery: {
|
||||
sent: 'Invitation sent',
|
||||
link_only: 'Invitation link created',
|
||||
failed: 'Invitation link created, but email could not be sent',
|
||||
},
|
||||
invitationCreateFailed: 'Failed to create invitation',
|
||||
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default thTH;
|
||||
|
||||
@@ -81,7 +81,7 @@ const viVN = {
|
||||
loading: 'Đang tải...',
|
||||
fieldRequired: 'Trường này là bắt buộc',
|
||||
or: 'hoặc',
|
||||
loginWithSpace: 'Đăng nhập với Space',
|
||||
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
|
||||
spaceLoginRecommended:
|
||||
'Khuyến nghị: Sử dụng API mô hình ổn định chính thức và dịch vụ đám mây',
|
||||
loginLocal: 'Đăng nhập với tài khoản cục bộ',
|
||||
@@ -172,6 +172,8 @@ const viVN = {
|
||||
more: 'Thêm ({{count}})',
|
||||
less: 'Thu gọn',
|
||||
noItems: 'Không có mục nào',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: 'Không tìm thấy trang',
|
||||
@@ -215,6 +217,19 @@ const viVN = {
|
||||
selectModelAbilities: 'Chọn khả năng mô hình',
|
||||
visionAbility: 'Khả năng thị giác',
|
||||
functionCallAbility: 'Gọi hàm',
|
||||
reasoningAbility: 'Khả năng suy luận',
|
||||
reasoningLevel: 'Mức độ suy luận',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'Mặc định của nhà cung cấp',
|
||||
disabled: 'Tắt',
|
||||
enabled: 'Bật',
|
||||
minimal: 'Tối thiểu',
|
||||
low: 'Thấp',
|
||||
medium: 'Trung bình',
|
||||
high: 'Cao',
|
||||
xhigh: 'Rất cao',
|
||||
max: 'Tối đa',
|
||||
},
|
||||
contextLength: 'Cửa sổ ngữ cảnh',
|
||||
contextLengthPlaceholder: 'Không rõ',
|
||||
contextLengthInvalid: 'Cửa sổ ngữ cảnh phải là số nguyên dương',
|
||||
@@ -281,7 +296,7 @@ const viVN = {
|
||||
searchProviders: 'Tìm kiếm nhà cung cấp...',
|
||||
langbotModelsDescription: 'Mô hình đám mây được cung cấp bởi LangBot Space',
|
||||
credits: 'Tín dụng',
|
||||
loginWithSpace: 'Đăng nhập với Space',
|
||||
loginWithSpace: 'Đăng nhập bằng tài khoản LangBot',
|
||||
loginToUseModels: 'Đăng nhập với Space để sử dụng mô hình đám mây',
|
||||
noModels: 'Chưa cấu hình mô hình nào',
|
||||
langbotModels: 'Mô hình LangBot',
|
||||
@@ -319,6 +334,11 @@ const viVN = {
|
||||
fallbackList: 'Mô hình dự phòng',
|
||||
addFallback: 'Thêm mô hình dự phòng',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: 'Bot',
|
||||
@@ -522,9 +542,9 @@ const viVN = {
|
||||
debugInfoTitle: 'Thông tin gỡ lỗi Plugin',
|
||||
debugUrl: 'URL gỡ lỗi',
|
||||
debugKey: 'Khóa gỡ lỗi',
|
||||
debugKeyExpires: 'Xoay vòng lúc {{time}}; mỗi Workspace có khóa riêng',
|
||||
noDebugKey: '(Chưa đặt)',
|
||||
debugKeyDisabled:
|
||||
'Khóa gỡ lỗi chưa được đặt, gỡ lỗi plugin không yêu cầu xác thực',
|
||||
debugKeyDisabled: 'Thông tin xác thực gỡ lỗi tạm thời không khả dụng',
|
||||
boxStatusTitle: 'Box Runtime',
|
||||
boxStatus: 'Trạng thái',
|
||||
boxConnected: 'Đã kết nối',
|
||||
@@ -705,6 +725,7 @@ const viVN = {
|
||||
notFound: 'Không tìm thấy thông tin plugin',
|
||||
sortBy: 'Sắp xếp theo',
|
||||
sort: {
|
||||
hottest: 'Phổ biến nhất',
|
||||
recentlyAdded: 'Mới thêm gần đây',
|
||||
recentlyUpdated: 'Mới cập nhật gần đây',
|
||||
mostDownloads: 'Tải nhiều nhất',
|
||||
@@ -712,6 +733,9 @@ const viVN = {
|
||||
},
|
||||
downloads: 'lượt tải',
|
||||
download: 'Tải xuống',
|
||||
like: 'Thích',
|
||||
unlike: 'Bỏ thích',
|
||||
likeFailed: 'Không thể cập nhật lượt thích. Vui lòng thử lại.',
|
||||
repository: 'Kho lưu trữ',
|
||||
downloadFailed: 'Tải xuống thất bại',
|
||||
noReadme: 'Plugin này không cung cấp tài liệu README',
|
||||
@@ -801,6 +825,9 @@ const viVN = {
|
||||
url: 'URL',
|
||||
headers: 'Tiêu đề',
|
||||
timeout: 'Thời gian chờ',
|
||||
toolCallTimeout: 'Thời gian chờ gọi công cụ (giây)',
|
||||
toolCallTimeoutDescription:
|
||||
'Thời gian chờ tối đa cho một lần gọi công cụ. Đặt 0 để không giới hạn. Mặc định là 300 giây.',
|
||||
addArgument: 'Thêm tham số',
|
||||
addEnvVar: 'Thêm biến môi trường',
|
||||
addHeader: 'Thêm tiêu đề',
|
||||
@@ -823,6 +850,9 @@ const viVN = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'Hãy bật Box (box.enabled = true) và đảm bảo runtime hoạt động, hoặc chuyển server này sang chế độ http/sse.',
|
||||
boxRequired: 'cần Box',
|
||||
disabledByPolicy: 'bị tắt theo chính sách',
|
||||
stdioDisabledByPolicy:
|
||||
'Stdio MCP đã bị tắt trong bản triển khai này. Hãy dùng máy chủ MCP từ xa.',
|
||||
stdioBlockedByBoxToast:
|
||||
'Không thể lưu MCP ở chế độ stdio khi Sandbox Box bị tắt hoặc không khả dụng. Hãy bật Box hoặc chọn chế độ http/sse.',
|
||||
toolsFound: 'công cụ',
|
||||
@@ -832,6 +862,12 @@ const viVN = {
|
||||
tabTools: 'Công cụ',
|
||||
tabResources: 'Tài nguyên',
|
||||
tabDocs: 'Tài liệu',
|
||||
tabLogs: 'Nhật ký',
|
||||
logsLevelAll: 'Tất cả cấp độ',
|
||||
logsRefresh: 'Làm mới',
|
||||
logsAutoRefresh: 'Tự động làm mới',
|
||||
logsEmpty:
|
||||
'Chưa có nhật ký. Nhật ký chạy của MCP Server sẽ hiển thị ở đây.',
|
||||
noReadme: 'Không có tài liệu',
|
||||
parseResultFailed: 'Phân tích kết quả kiểm tra thất bại',
|
||||
noResultReturned: 'Kiểm tra không trả về kết quả',
|
||||
@@ -1270,6 +1306,13 @@ const viVN = {
|
||||
setPasswordHint: 'Đặt mật khẩu để đăng nhập bằng email và mật khẩu',
|
||||
spaceEmailMismatch:
|
||||
'Email đăng nhập Space không khớp với email tài khoản cục bộ',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: 'Bảng điều khiển',
|
||||
@@ -1332,6 +1375,20 @@ const viVN = {
|
||||
level: 'Mức',
|
||||
runner: 'Trình chạy',
|
||||
viewConversation: 'Xem cuộc trò chuyện',
|
||||
turns: '{{count}} lượt hội thoại',
|
||||
userMessage: 'Người dùng',
|
||||
noUserMessage: 'Chưa ghi nhận đầu vào người dùng',
|
||||
assistantMessage: 'Trợ lý',
|
||||
assistantMessageCount: 'Trợ lý +{{count}}',
|
||||
noAssistantMessage: 'Chưa ghi nhận phản hồi của trợ lý',
|
||||
messageCount: 'Số tin nhắn',
|
||||
conversationTrace: 'Luồng hội thoại',
|
||||
noLlmCalls: 'Chưa ghi nhận lệnh gọi mô hình',
|
||||
roles: {
|
||||
user: 'Người dùng',
|
||||
assistant: 'Trợ lý',
|
||||
message: 'Tin nhắn',
|
||||
},
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'Cuộc gọi LLM',
|
||||
@@ -1346,6 +1403,15 @@ const viVN = {
|
||||
avgDuration: 'Thời lượng trung bình',
|
||||
calls: 'Cuộc gọi',
|
||||
},
|
||||
toolCalls: {
|
||||
title: 'Lượt gọi công cụ',
|
||||
totalCalls: 'Lượt gọi',
|
||||
duration: 'Thời lượng công cụ',
|
||||
errorCalls: 'Lượt gọi thất bại',
|
||||
arguments: 'Tham số',
|
||||
result: 'Kết quả',
|
||||
noToolCalls: 'Chưa ghi nhận lượt gọi công cụ',
|
||||
},
|
||||
tokens: {
|
||||
totalTokens: 'Tổng số Token',
|
||||
inputTokens: 'Token đầu vào',
|
||||
@@ -1503,6 +1569,8 @@ const viVN = {
|
||||
api: 'API',
|
||||
storage: 'Lưu trữ',
|
||||
account: 'Tài khoản',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1547,6 +1615,12 @@ const viVN = {
|
||||
'Đã đạt số lượng Pipeline tối đa ({{max}}). Vui lòng xóa một Pipeline hiện có trước khi tạo mới.',
|
||||
maxExtensionsReached:
|
||||
'Đã đạt số lượng tiện ích mở rộng tối đa ({{max}}). Vui lòng xóa một máy chủ MCP hoặc plugin hiện có trước khi thêm mới.',
|
||||
quotaLoadingTooltip:
|
||||
'Dữ liệu sử dụng không gian làm việc đang tải. Vui lòng chờ trước khi tạo tài nguyên.',
|
||||
quotaCheckFailed:
|
||||
'Không thể kiểm tra hạn mức hiện tại của không gian làm việc. Vui lòng thử lại.',
|
||||
createDisabledTooltip:
|
||||
'Đã đạt giới hạn {{resource}} ({{max}}) của workspace này. Hãy xóa một mục hiện có trước khi tạo mới.',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: 'Tạo Bot với các bước hướng dẫn',
|
||||
@@ -1647,6 +1721,9 @@ const viVN = {
|
||||
'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token',
|
||||
loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động',
|
||||
loginFailed: 'Đăng nhập thất bại',
|
||||
connecting: 'Đang kết nối tới dịch vụ WeChat...',
|
||||
waitingForScan: 'Đang chờ quét mã',
|
||||
retry: 'Thử lại',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp',
|
||||
@@ -1672,6 +1749,19 @@ const viVN = {
|
||||
retry: 'Thử lại',
|
||||
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: 'Liên kết bot QQ Official bằng QR một chạm',
|
||||
scanQRCode:
|
||||
'Quét mã QR bên dưới bằng QQ trên di động và xác nhận liên kết trong «QQ Bot Assistant»',
|
||||
waitingForScan: 'Đang chờ quét',
|
||||
bindSuccess: 'Liên kết thành công! AppID và Secret đã được điền tự động',
|
||||
bindFailed: 'Liên kết thất bại',
|
||||
connecting: 'Đang kết nối tới dịch vụ QQ...',
|
||||
retry: 'Thử lại',
|
||||
tokenNote:
|
||||
'Bộ chuyển đổi hiện tại không dùng trường Token; có thể để trống.',
|
||||
boundBy: 'Được liên kết bởi người dùng QQ {{openid}}',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||
invalidPage: 'Trang plugin không hợp lệ',
|
||||
@@ -1801,6 +1891,90 @@ const viVN = {
|
||||
unsupportedFileType:
|
||||
'Loại tệp không được hỗ trợ. Chỉ hỗ trợ tệp .zip và .lbpkg',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: 'Create invitation',
|
||||
invitationCreated: 'Invitation created',
|
||||
delivery: {
|
||||
sent: 'Invitation sent',
|
||||
link_only: 'Invitation link created',
|
||||
failed: 'Invitation link created, but email could not be sent',
|
||||
},
|
||||
invitationCreateFailed: 'Failed to create invitation',
|
||||
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default viVN;
|
||||
|
||||
@@ -79,7 +79,7 @@ const zhHans = {
|
||||
loading: '加载中...',
|
||||
fieldRequired: '此字段为必填项',
|
||||
or: '或',
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
loginWithSpace: '使用 LangBot 账号登录',
|
||||
spaceLoginRecommended: '推荐:使用官方提供的稳定模型 API 和云服务',
|
||||
loginLocal: '使用本地账号登录',
|
||||
loginWithPassword: '通过密码登录',
|
||||
@@ -156,6 +156,7 @@ const zhHans = {
|
||||
webhookHint: 'Webhook 允许 LangBot 将个人消息和群消息事件推送到外部系统',
|
||||
actions: '操作',
|
||||
apiKeyCreatedMessage: '请复制此 API 密钥,若按钮无效,请手动复制。',
|
||||
apiKeyStoredSecurely: '密钥仅在创建时显示',
|
||||
none: '无',
|
||||
more: '更多 ({{count}})',
|
||||
less: '收起',
|
||||
@@ -203,6 +204,19 @@ const zhHans = {
|
||||
selectModelAbilities: '选择模型能力',
|
||||
visionAbility: '视觉能力',
|
||||
functionCallAbility: '函数调用',
|
||||
reasoningAbility: '思考能力',
|
||||
reasoningLevel: '思考档位',
|
||||
reasoningLevels: {
|
||||
providerDefault: 'Provider 默认',
|
||||
disabled: '关闭',
|
||||
enabled: '开启',
|
||||
minimal: '最低',
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
xhigh: '极高',
|
||||
max: '最大',
|
||||
},
|
||||
contextLength: '上下文窗口',
|
||||
contextLengthPlaceholder: '未知',
|
||||
contextLengthInvalid: '上下文窗口必须是正整数',
|
||||
@@ -264,8 +278,10 @@ const zhHans = {
|
||||
searchProviders: '搜索供应商...',
|
||||
langbotModelsDescription: 'LangBot Space 提供的云端模型',
|
||||
credits: '积分',
|
||||
loginWithSpace: '通过 Space 登录',
|
||||
loginWithSpace: '使用 LangBot 账号登录',
|
||||
loginToUseModels: '通过 Space 登录以使用云端模型',
|
||||
ownerMustBindSpace: '工作区所有者需要绑定 Space 才能使用 LangBot 模型。',
|
||||
usesOwnerSpaceBilling: '使用工作区所有者的 Space 计费与积分。',
|
||||
noModels: '暂无模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
spaceTrialTooltip:
|
||||
@@ -497,8 +513,9 @@ const zhHans = {
|
||||
debugInfoTitle: '插件调试信息',
|
||||
debugUrl: '调试地址',
|
||||
debugKey: '调试密钥',
|
||||
debugKeyExpires: '将于 {{time}} 轮换;每个工作区的密钥不同',
|
||||
noDebugKey: '(未设置)',
|
||||
debugKeyDisabled: '未设置调试密钥,插件调试无需认证',
|
||||
debugKeyDisabled: '调试凭据暂不可用',
|
||||
boxStatusTitle: 'Box 运行时',
|
||||
boxStatus: '状态',
|
||||
boxConnected: '已连接',
|
||||
@@ -672,6 +689,7 @@ const zhHans = {
|
||||
notFound: '插件信息未找到',
|
||||
sortBy: '排序方式',
|
||||
sort: {
|
||||
hottest: '热度最高',
|
||||
recentlyAdded: '最近新增',
|
||||
recentlyUpdated: '最近更新',
|
||||
mostDownloads: '最多下载',
|
||||
@@ -679,6 +697,9 @@ const zhHans = {
|
||||
},
|
||||
downloads: '次下载',
|
||||
download: '下载',
|
||||
like: '点赞',
|
||||
unlike: '取消点赞',
|
||||
likeFailed: '点赞失败,请稍后重试',
|
||||
repository: '代码仓库',
|
||||
downloadFailed: '下载失败',
|
||||
noReadme: '该插件没有提供 README 文档',
|
||||
@@ -767,6 +788,9 @@ const zhHans = {
|
||||
url: 'URL地址',
|
||||
headers: '请求头',
|
||||
timeout: '超时时间',
|
||||
toolCallTimeout: '工具调用超时(秒)',
|
||||
toolCallTimeoutDescription:
|
||||
'单次工具调用的最长等待时间。设为 0 表示不限制,默认 300 秒。',
|
||||
addArgument: '添加参数',
|
||||
addEnvVar: '添加环境变量',
|
||||
addHeader: '添加请求头',
|
||||
@@ -789,6 +813,8 @@ const zhHans = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'请启用 Box(box.enabled = true)并确认运行时连接正常,或将此服务器切换到 http/sse 模式。',
|
||||
boxRequired: '需要 Box',
|
||||
disabledByPolicy: '已被策略禁用',
|
||||
stdioDisabledByPolicy: '此部署已禁用 Stdio MCP,请改用远程 MCP 服务器。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box 沙箱已禁用或不可用,无法保存 stdio 模式的 MCP。请启用 Box 或改为 http/sse 模式。',
|
||||
toolsFound: '个工具',
|
||||
@@ -798,6 +824,11 @@ const zhHans = {
|
||||
tabTools: '工具',
|
||||
tabResources: '资源',
|
||||
tabDocs: '文档',
|
||||
tabLogs: '日志',
|
||||
logsLevelAll: '全部级别',
|
||||
logsRefresh: '刷新',
|
||||
logsAutoRefresh: '自动刷新',
|
||||
logsEmpty: '暂无日志。MCP 服务器的运行日志会显示在这里。',
|
||||
noReadme: '暂无文档',
|
||||
parseResultFailed: '解析测试结果失败',
|
||||
noResultReturned: '测试未返回结果',
|
||||
@@ -1208,6 +1239,91 @@ const zhHans = {
|
||||
bindSpaceInvalidState: '无效的绑定请求,请从账户设置重新发起',
|
||||
setPasswordHint: '设置密码后可使用邮箱密码登录',
|
||||
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
|
||||
space_account_not_registeredTitle: '账户尚未注册',
|
||||
space_account_not_registered:
|
||||
'此 Space 邮箱尚无本地账户,请联系工作区所有者获取邀请。',
|
||||
space_account_binding_requiredTitle: '需要绑定 Space',
|
||||
space_account_binding_required:
|
||||
'此本地账户必须先在账户设置中绑定 Space,才能使用 Space 登录。',
|
||||
},
|
||||
workspace: {
|
||||
title: '工作区',
|
||||
description: '管理成员、角色与邀请链接',
|
||||
selectTitle: '选择工作区',
|
||||
selectDescription: '选择你要进入的 LangBot 工作区。',
|
||||
selectionLoadFailed: '无法加载你的工作区,请重试。',
|
||||
switchWorkspace: '切换工作区',
|
||||
settings: '工作区设置',
|
||||
currentPlan: '当前计划',
|
||||
planUnavailable: '暂不可用',
|
||||
upgradePlan: '切换或升级计划',
|
||||
ossSingletonDescription:
|
||||
'当前自托管实例只有一个工作区,但可以包含多个用户。',
|
||||
cloudManagedDescription:
|
||||
'此工作区托管于 LangBot Cloud。成员在此管理,计费在 Cloud 中打开。',
|
||||
loadFailed: '加载工作区信息失败',
|
||||
members: '成员',
|
||||
you: '你',
|
||||
inviteMember: '邀请成员',
|
||||
inviteDescription: '创建一次性链接,将其他用户加入当前工作区。',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: '创建邀请',
|
||||
invitationCreated: '邀请已创建',
|
||||
delivery: {
|
||||
sent: '邀请邮件已发送',
|
||||
link_only: '邀请链接已创建',
|
||||
failed: '邀请链接已创建,但邮件发送失败',
|
||||
},
|
||||
invitationCreateFailed: '创建邀请失败',
|
||||
oneTimeLinkWarning: '请立即复制此链接。它只显示一次。',
|
||||
copyInvitation: '复制邀请链接',
|
||||
invitationCopied: '邀请链接已复制',
|
||||
pendingInvitations: '待接受邀请',
|
||||
expiresAt: '{{date}} 过期',
|
||||
revokeInvitation: '撤销邀请',
|
||||
invitationRevoked: '邀请已撤销',
|
||||
invitationRevokeFailed: '撤销邀请失败',
|
||||
acceptInvitation: '接受邀请',
|
||||
invitedToWorkspace: '你已受邀加入 {{workspace}}',
|
||||
checkingInvitation: '正在验证邀请…',
|
||||
invitationMissing: '此邀请链接缺少必要信息。',
|
||||
invitationExpired: '此邀请已过期。',
|
||||
invitationAlreadyRevoked: '此邀请已被撤销。',
|
||||
invitationAlreadyUsed: '此邀请已被使用。',
|
||||
invitationInvalid: '此邀请无效或已不可用。',
|
||||
invitationAccepted: '已接受邀请',
|
||||
invitationAcceptFailed: '接受邀请失败',
|
||||
invitationEmailMismatch: '此邀请属于另一个邮箱地址。',
|
||||
existingAccountLoginRequired: '此邮箱已有账户,请登录后继续。',
|
||||
acceptAsCurrentAccount: '使用当前账户接受',
|
||||
authenticatedInvitationNotice:
|
||||
'请先退出,再使用受邀账户登录。邀请令牌会被保留。',
|
||||
logoutAndReturn: '退出并返回此邀请',
|
||||
switchAccount: '切换账号',
|
||||
registerAndAccept: '创建账户并接受',
|
||||
alreadyHaveAccount: '我已有账户',
|
||||
confirmPassword: '确认密码',
|
||||
passwordMinimum: '密码至少需要 8 个字符。',
|
||||
passwordMismatch: '两次输入的密码不一致。',
|
||||
backToLogin: '返回登录',
|
||||
memberUpdated: '成员角色已更新',
|
||||
memberUpdateFailed: '更新成员角色失败',
|
||||
removeMember: '移除成员',
|
||||
removeMemberConfirm: '确定将此成员移出工作区吗?',
|
||||
memberRemoved: '成员已移除',
|
||||
memberRemoveFailed: '移除成员失败',
|
||||
transferOwnership: '转让所有权',
|
||||
types: {
|
||||
personal: '个人',
|
||||
team: '团队',
|
||||
},
|
||||
roles: {
|
||||
owner: '所有者',
|
||||
admin: '管理员',
|
||||
developer: '开发者',
|
||||
operator: '运维人员',
|
||||
viewer: '查看者',
|
||||
},
|
||||
},
|
||||
monitoring: {
|
||||
title: '仪表盘',
|
||||
@@ -1270,6 +1386,20 @@ const zhHans = {
|
||||
level: '级别',
|
||||
runner: '执行器',
|
||||
viewConversation: '显示对话详情',
|
||||
turns: '{{count}} 轮对话',
|
||||
userMessage: '用户',
|
||||
noUserMessage: '未记录用户输入',
|
||||
assistantMessage: '助手',
|
||||
assistantMessageCount: '助手 +{{count}}',
|
||||
noAssistantMessage: '未记录助手回复',
|
||||
messageCount: '消息数',
|
||||
conversationTrace: '消息链路',
|
||||
noLlmCalls: '未记录模型调用',
|
||||
roles: {
|
||||
user: '用户',
|
||||
assistant: '助手',
|
||||
message: '消息',
|
||||
},
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'LLM调用',
|
||||
@@ -1284,6 +1414,15 @@ const zhHans = {
|
||||
avgDuration: '平均耗时',
|
||||
calls: '调用次数',
|
||||
},
|
||||
toolCalls: {
|
||||
title: '工具调用',
|
||||
totalCalls: '调用次数',
|
||||
duration: '工具耗时',
|
||||
errorCalls: '失败次数',
|
||||
arguments: '参数',
|
||||
result: '结果',
|
||||
noToolCalls: '未记录工具调用',
|
||||
},
|
||||
tokens: {
|
||||
totalTokens: '总 Token 数',
|
||||
inputTokens: '输入 Token',
|
||||
@@ -1436,6 +1575,7 @@ const zhHans = {
|
||||
settingsDialog: {
|
||||
title: '设置',
|
||||
nav: {
|
||||
workspace: '工作区',
|
||||
models: '模型',
|
||||
api: 'API',
|
||||
storage: '存储',
|
||||
@@ -1484,6 +1624,10 @@ const zhHans = {
|
||||
'已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。',
|
||||
maxExtensionsReached:
|
||||
'已达到扩展数量上限({{max}}个)。请先删除已有扩展后再添加新的。',
|
||||
quotaLoadingTooltip: '正在加载工作空间用量,请稍后再创建资源。',
|
||||
quotaCheckFailed: '无法确认当前工作空间额度,请重试。',
|
||||
createDisabledTooltip:
|
||||
'当前工作区的{{resource}}数量已达到上限({{max}}个)。请先删除一个已有{{resource}}后再创建。',
|
||||
},
|
||||
skills: {
|
||||
title: '技能',
|
||||
@@ -1694,6 +1838,9 @@ const zhHans = {
|
||||
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
|
||||
loginSuccess: '登录成功!令牌已自动填入',
|
||||
loginFailed: '登录失败',
|
||||
connecting: '正在连接微信服务...',
|
||||
waitingForScan: '等待扫码中',
|
||||
retry: '重试',
|
||||
},
|
||||
itchat: {
|
||||
scanLogin: '扫码登录微信',
|
||||
@@ -1726,6 +1873,17 @@ const zhHans = {
|
||||
retry: '重试',
|
||||
robotNameNote: '机器人名称无法自动获取,请手动填写。',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: '一键扫码绑定 QQ 机器人',
|
||||
scanQRCode: '请使用手机 QQ 扫描以下二维码,在「QQ 机器人助手」中授权绑定',
|
||||
waitingForScan: '等待扫码中',
|
||||
bindSuccess: '绑定成功!AppID 与密钥已自动填入',
|
||||
bindFailed: '绑定失败',
|
||||
connecting: '正在连接 QQ 服务...',
|
||||
retry: '重试',
|
||||
tokenNote: 'Token 字段当前适配器未使用,留空即可。',
|
||||
boundBy: '由 QQ 用户 {{openid}} 扫码绑定',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||
invalidPage: '无效的插件页面',
|
||||
|
||||
@@ -79,7 +79,7 @@ const zhHant = {
|
||||
loading: '載入中...',
|
||||
fieldRequired: '此欄位為必填',
|
||||
or: '或',
|
||||
loginWithSpace: '透過 Space 登入',
|
||||
loginWithSpace: '使用 LangBot 帳號登入',
|
||||
spaceLoginRecommended: '推薦:使用官方提供的穩定模型 API 和雲服務',
|
||||
loginLocal: '使用本地帳號登入',
|
||||
loginWithPassword: '透過密碼登入',
|
||||
@@ -160,6 +160,8 @@ const zhHant = {
|
||||
more: '更多 ({{count}})',
|
||||
less: '收起',
|
||||
noItems: '暫無內容',
|
||||
|
||||
apiKeyStoredSecurely: 'Secret shown only when created',
|
||||
},
|
||||
notFound: {
|
||||
title: '頁面不存在',
|
||||
@@ -203,6 +205,19 @@ const zhHant = {
|
||||
selectModelAbilities: '選擇模型能力',
|
||||
visionAbility: '視覺能力',
|
||||
functionCallAbility: '函數呼叫',
|
||||
reasoningAbility: '思考能力',
|
||||
reasoningLevel: '思考等級',
|
||||
reasoningLevels: {
|
||||
providerDefault: '供應商預設',
|
||||
disabled: '關閉',
|
||||
enabled: '開啟',
|
||||
minimal: '最低',
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
xhigh: '極高',
|
||||
max: '最大',
|
||||
},
|
||||
contextLength: '上下文視窗',
|
||||
contextLengthPlaceholder: '未知',
|
||||
contextLengthInvalid: '上下文視窗必須是正整數',
|
||||
@@ -263,7 +278,7 @@ const zhHant = {
|
||||
searchProviders: '搜尋供應商...',
|
||||
langbotModelsDescription: '由 LangBot Space 提供的雲端模型',
|
||||
credits: '積分',
|
||||
loginWithSpace: '使用 Space 登入',
|
||||
loginWithSpace: '使用 LangBot 帳號登入',
|
||||
loginToUseModels: '使用 Space 登入以使用雲端模型',
|
||||
noModels: '暫無模型',
|
||||
langbotModels: 'LangBot 模型',
|
||||
@@ -300,6 +315,11 @@ const zhHant = {
|
||||
fallbackList: '備用模型',
|
||||
addFallback: '新增備用模型',
|
||||
},
|
||||
|
||||
ownerMustBindSpace:
|
||||
'The Workspace owner must connect Space for LangBot Models.',
|
||||
usesOwnerSpaceBilling:
|
||||
"Uses the Workspace owner's Space billing and credits.",
|
||||
},
|
||||
bots: {
|
||||
title: '機器人',
|
||||
@@ -494,8 +514,9 @@ const zhHant = {
|
||||
debugInfoTitle: '外掛偵錯資訊',
|
||||
debugUrl: '偵錯位址',
|
||||
debugKey: '偵錯金鑰',
|
||||
debugKeyExpires: '將於 {{time}} 輪換;每個工作區的密鑰不同',
|
||||
noDebugKey: '(未設定)',
|
||||
debugKeyDisabled: '未設定偵錯金鑰,外掛偵錯無需認證',
|
||||
debugKeyDisabled: '偵錯憑據暫時無法使用',
|
||||
boxStatusTitle: 'Box 執行時',
|
||||
boxStatus: '狀態',
|
||||
boxConnected: '已連線',
|
||||
@@ -668,6 +689,7 @@ const zhHant = {
|
||||
notFound: '插件資訊未找到',
|
||||
sortBy: '排序方式',
|
||||
sort: {
|
||||
hottest: '熱度最高',
|
||||
recentlyAdded: '最近新增',
|
||||
recentlyUpdated: '最近更新',
|
||||
mostDownloads: '最多下載',
|
||||
@@ -675,6 +697,9 @@ const zhHant = {
|
||||
},
|
||||
downloads: '次下載',
|
||||
download: '下載',
|
||||
like: '按讚',
|
||||
unlike: '取消按讚',
|
||||
likeFailed: '按讚失敗,請稍後重試',
|
||||
repository: '代碼倉庫',
|
||||
downloadFailed: '下載失敗',
|
||||
noReadme: '該插件沒有提供 README 文件',
|
||||
@@ -762,6 +787,9 @@ const zhHant = {
|
||||
url: 'URL位址',
|
||||
headers: '請求標頭',
|
||||
timeout: '逾時時間',
|
||||
toolCallTimeout: '工具呼叫逾時(秒)',
|
||||
toolCallTimeoutDescription:
|
||||
'單次工具呼叫的最長等待時間。設為 0 表示不限制,預設 300 秒。',
|
||||
addArgument: '新增參數',
|
||||
addEnvVar: '新增環境變數',
|
||||
addHeader: '新增請求標頭',
|
||||
@@ -784,6 +812,8 @@ const zhHant = {
|
||||
boxStdioRefusedSuggestion:
|
||||
'請啟用 Box(box.enabled = true)並確認執行時連線正常,或將此伺服器切換到 http/sse 模式。',
|
||||
boxRequired: '需要 Box',
|
||||
disabledByPolicy: '已被策略停用',
|
||||
stdioDisabledByPolicy: '此部署已停用 Stdio MCP,請改用遠端 MCP 伺服器。',
|
||||
stdioBlockedByBoxToast:
|
||||
'Box 沙箱已停用或無法使用,無法儲存 stdio 模式的 MCP。請啟用 Box 或改為 http/sse 模式。',
|
||||
toolsFound: '個工具',
|
||||
@@ -793,6 +823,11 @@ const zhHant = {
|
||||
tabTools: '工具',
|
||||
tabResources: '資源',
|
||||
tabDocs: '文件',
|
||||
tabLogs: '日誌',
|
||||
logsLevelAll: '全部級別',
|
||||
logsRefresh: '重新整理',
|
||||
logsAutoRefresh: '自動重新整理',
|
||||
logsEmpty: '暫無日誌。MCP 服務器的運行日誌會顯示在這裡。',
|
||||
noReadme: '暫無文件',
|
||||
parseResultFailed: '解析測試結果失敗',
|
||||
noResultReturned: '測試未返回結果',
|
||||
@@ -1202,6 +1237,13 @@ const zhHant = {
|
||||
bindSpaceInvalidState: '無效的綁定請求,請從帳戶設定重新發起',
|
||||
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
|
||||
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配',
|
||||
|
||||
space_account_not_registeredTitle: 'Account not registered',
|
||||
space_account_not_registered:
|
||||
'No local account is registered for this Space email. Ask the Workspace owner for an invitation.',
|
||||
space_account_binding_requiredTitle: 'Space connection required',
|
||||
space_account_binding_required:
|
||||
'This local account must connect Space from Account settings before using Space login.',
|
||||
},
|
||||
monitoring: {
|
||||
title: '儀表盤',
|
||||
@@ -1264,6 +1306,20 @@ const zhHant = {
|
||||
level: '級別',
|
||||
runner: '執行器',
|
||||
viewConversation: '顯示對話詳情',
|
||||
turns: '{{count}} 輪對話',
|
||||
userMessage: '使用者',
|
||||
noUserMessage: '未記錄使用者輸入',
|
||||
assistantMessage: '助手',
|
||||
assistantMessageCount: '助手 +{{count}}',
|
||||
noAssistantMessage: '未記錄助手回覆',
|
||||
messageCount: '訊息數',
|
||||
conversationTrace: '訊息鏈路',
|
||||
noLlmCalls: '未記錄模型呼叫',
|
||||
roles: {
|
||||
user: '使用者',
|
||||
assistant: '助手',
|
||||
message: '訊息',
|
||||
},
|
||||
},
|
||||
llmCalls: {
|
||||
title: 'LLM呼叫',
|
||||
@@ -1278,6 +1334,15 @@ const zhHant = {
|
||||
avgDuration: '平均持續時間',
|
||||
calls: '呼叫次數',
|
||||
},
|
||||
toolCalls: {
|
||||
title: '工具呼叫',
|
||||
totalCalls: '呼叫次數',
|
||||
duration: '工具耗時',
|
||||
errorCalls: '失敗次數',
|
||||
arguments: '參數',
|
||||
result: '結果',
|
||||
noToolCalls: '未記錄工具呼叫',
|
||||
},
|
||||
tokens: {
|
||||
totalTokens: '總 Token 數',
|
||||
inputTokens: '輸入 Token',
|
||||
@@ -1434,6 +1499,8 @@ const zhHant = {
|
||||
api: 'API',
|
||||
storage: '儲存',
|
||||
account: '帳戶',
|
||||
|
||||
workspace: 'Workspace',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
@@ -1478,6 +1545,10 @@ const zhHant = {
|
||||
'已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。',
|
||||
maxExtensionsReached:
|
||||
'已達到擴充功能數量上限({{max}}個)。請先刪除已有擴充功能後再新增。',
|
||||
quotaLoadingTooltip: '正在載入工作空間用量,請稍後再建立資源。',
|
||||
quotaCheckFailed: '無法確認目前工作空間額度,請重試。',
|
||||
createDisabledTooltip:
|
||||
'目前工作區的{{resource}}數量已達上限({{max}}個)。請先刪除一個現有{{resource}}後再建立。',
|
||||
},
|
||||
wizard: {
|
||||
sidebarDescription: '透過引導步驟建立機器人',
|
||||
@@ -1599,6 +1670,9 @@ const zhHant = {
|
||||
scanQRCode: '請使用微信掃描以下 QR Code,授權後將自動登入並填寫令牌',
|
||||
loginSuccess: '登入成功!令牌已自動填入',
|
||||
loginFailed: '登入失敗',
|
||||
connecting: '正在連接微信服務...',
|
||||
waitingForScan: '等待掃碼中',
|
||||
retry: '重試',
|
||||
},
|
||||
dingtalk: {
|
||||
createApp: '一鍵建立釘釘應用',
|
||||
@@ -1622,6 +1696,17 @@ const zhHant = {
|
||||
retry: '重試',
|
||||
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
|
||||
},
|
||||
qqofficial: {
|
||||
createBinding: '一鍵掃碼綁定 QQ 機器人',
|
||||
scanQRCode: '請使用手機 QQ 掃描以下 QR Code,在「QQ 機器人助手」中授權綁定',
|
||||
waitingForScan: '等待掃碼中',
|
||||
bindSuccess: '綁定成功!AppID 與密鑰已自動填入',
|
||||
bindFailed: '綁定失敗',
|
||||
connecting: '正在連線 QQ 服務...',
|
||||
retry: '重試',
|
||||
tokenNote: 'Token 欄位目前介面卡未使用,留空即可。',
|
||||
boundBy: '由 QQ 用戶 {{openid}} 掃碼綁定',
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||
invalidPage: '無效的插件頁面',
|
||||
@@ -1714,6 +1799,90 @@ const zhHant = {
|
||||
saveFileSuccess: '檔案儲存成功',
|
||||
saveFileError: '檔案儲存失敗:',
|
||||
},
|
||||
|
||||
workspace: {
|
||||
title: 'Workspace',
|
||||
description: 'Manage members, roles, and invitation links',
|
||||
selectTitle: 'Choose a Workspace',
|
||||
selectDescription: 'Select where you want to continue in LangBot.',
|
||||
selectionLoadFailed:
|
||||
'Your Workspaces could not be loaded. Please try again.',
|
||||
switchWorkspace: 'Switch Workspace',
|
||||
settings: 'Workspace Settings',
|
||||
currentPlan: 'Current plan',
|
||||
planUnavailable: 'Unavailable',
|
||||
upgradePlan: 'Change or upgrade plan',
|
||||
ossSingletonDescription:
|
||||
'This self-hosted instance has one Workspace and can include multiple users.',
|
||||
cloudManagedDescription:
|
||||
'This Workspace is hosted by LangBot Cloud. Manage members here; billing opens in Cloud.',
|
||||
loadFailed: 'Failed to load Workspace information',
|
||||
members: 'Members',
|
||||
you: 'You',
|
||||
inviteMember: 'Invite a member',
|
||||
inviteDescription:
|
||||
'Create a one-time link to add another user to this Workspace.',
|
||||
emailPlaceholder: 'member@example.com',
|
||||
createInvitation: 'Create invitation',
|
||||
invitationCreated: 'Invitation created',
|
||||
delivery: {
|
||||
sent: 'Invitation sent',
|
||||
link_only: 'Invitation link created',
|
||||
failed: 'Invitation link created, but email could not be sent',
|
||||
},
|
||||
invitationCreateFailed: 'Failed to create invitation',
|
||||
oneTimeLinkWarning: 'Copy this link now. It is shown only once.',
|
||||
copyInvitation: 'Copy invitation link',
|
||||
invitationCopied: 'Invitation link copied',
|
||||
pendingInvitations: 'Pending invitations',
|
||||
expiresAt: 'Expires {{date}}',
|
||||
revokeInvitation: 'Revoke invitation',
|
||||
invitationRevoked: 'Invitation revoked',
|
||||
invitationRevokeFailed: 'Failed to revoke invitation',
|
||||
acceptInvitation: 'Accept invitation',
|
||||
invitedToWorkspace: 'You were invited to {{workspace}}',
|
||||
checkingInvitation: 'Checking this invitation...',
|
||||
invitationMissing: 'This invitation link is missing required information.',
|
||||
invitationExpired: 'This invitation has expired.',
|
||||
invitationAlreadyRevoked: 'This invitation was revoked.',
|
||||
invitationAlreadyUsed: 'This invitation was already used.',
|
||||
invitationInvalid: 'This invitation is invalid or no longer available.',
|
||||
invitationAccepted: 'Invitation accepted',
|
||||
invitationAcceptFailed: 'Failed to accept invitation',
|
||||
invitationEmailMismatch:
|
||||
'This invitation belongs to a different email address.',
|
||||
existingAccountLoginRequired:
|
||||
'An account already exists for this email. Sign in to continue.',
|
||||
acceptAsCurrentAccount: 'Accept with current account',
|
||||
authenticatedInvitationNotice:
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
logoutAndReturn: 'Sign out and return to this invitation',
|
||||
switchAccount: 'Switch account',
|
||||
registerAndAccept: 'Create account and accept',
|
||||
alreadyHaveAccount: 'I already have an account',
|
||||
confirmPassword: 'Confirm password',
|
||||
passwordMinimum: 'Password must contain at least 8 characters.',
|
||||
passwordMismatch: 'The passwords do not match.',
|
||||
backToLogin: 'Back to sign in',
|
||||
memberUpdated: 'Member role updated',
|
||||
memberUpdateFailed: 'Failed to update member role',
|
||||
removeMember: 'Remove member',
|
||||
removeMemberConfirm: 'Remove this member from the Workspace?',
|
||||
memberRemoved: 'Member removed',
|
||||
memberRemoveFailed: 'Failed to remove member',
|
||||
transferOwnership: 'Transfer ownership',
|
||||
types: {
|
||||
personal: 'Personal',
|
||||
team: 'Team',
|
||||
},
|
||||
roles: {
|
||||
owner: 'Owner',
|
||||
admin: 'Admin',
|
||||
developer: 'Developer',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHant;
|
||||
|
||||
@@ -26,6 +26,8 @@ import ErrorPage from '@/components/ErrorPage';
|
||||
import BackendUnavailablePage from '@/components/BackendUnavailablePage';
|
||||
import PluginPagesPage from '@/app/home/plugin-pages/page';
|
||||
import RootLayout from '@/app/RootLayout';
|
||||
import AcceptInvitationPage from '@/app/invitations/accept/page';
|
||||
import WorkspaceSelectPage from '@/app/workspaces/select/page';
|
||||
|
||||
const Loading = () => <div>Loading...</div>;
|
||||
|
||||
@@ -62,6 +64,14 @@ export const router = createBrowserRouter([
|
||||
</ResetPasswordLayout>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/invitations/accept',
|
||||
element: <AcceptInvitationPage />,
|
||||
},
|
||||
{
|
||||
path: '/workspaces/select',
|
||||
element: <WorkspaceSelectPage />,
|
||||
},
|
||||
{
|
||||
path: '/wizard',
|
||||
element: <WizardPage />,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const botId = 'bot-tool-timeline';
|
||||
const sessionId = 'person-tool-timeline-user';
|
||||
const botName = 'Tool Timeline Bot';
|
||||
const pipelineId = 'pipeline-tool-timeline';
|
||||
const pipelineName = 'Tool Timeline Pipeline';
|
||||
|
||||
function at(minute: number, second = 0) {
|
||||
return `2026-07-02T10:${String(minute).padStart(2, '0')}:${String(
|
||||
second,
|
||||
).padStart(2, '0')}Z`;
|
||||
}
|
||||
|
||||
function sessionMessage(
|
||||
id: string,
|
||||
role: 'user' | 'assistant',
|
||||
minute: number,
|
||||
content: string,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute),
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_content: content,
|
||||
session_id: sessionId,
|
||||
status: 'success',
|
||||
level: 'info',
|
||||
platform: role === 'user' ? 'person' : 'bot',
|
||||
user_id: 'timeline-user',
|
||||
user_name: 'Timeline User',
|
||||
runner_name: role === 'assistant' ? 'local-agent' : null,
|
||||
variables: '{}',
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(
|
||||
id: string,
|
||||
minute: number,
|
||||
toolName: string,
|
||||
duration: number,
|
||||
status: 'success' | 'error' = 'success',
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute, 30),
|
||||
tool_name: toolName,
|
||||
tool_source: 'native',
|
||||
duration,
|
||||
status,
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
session_id: sessionId,
|
||||
message_id: 'user-message',
|
||||
arguments: JSON.stringify({ target: toolName }),
|
||||
result: status === 'success' ? JSON.stringify({ ok: true }) : null,
|
||||
error_message: status === 'error' ? 'Tool execution failed' : null,
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('bot session monitor tool timeline', () => {
|
||||
test('renders tool calls as left-side agent events interleaved with messages', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringSessions: [
|
||||
{
|
||||
session_id: sessionId,
|
||||
bot_id: botId,
|
||||
bot_name: botName,
|
||||
pipeline_id: pipelineId,
|
||||
pipeline_name: pipelineName,
|
||||
message_count: 3,
|
||||
start_time: at(0),
|
||||
last_activity: at(4),
|
||||
is_active: true,
|
||||
platform: 'person',
|
||||
user_id: 'timeline-user',
|
||||
user_name: 'Timeline User',
|
||||
},
|
||||
],
|
||||
sessionMessages: {
|
||||
[sessionId]: [
|
||||
sessionMessage('user-message', 'user', 0, 'Need a timeline check'),
|
||||
sessionMessage(
|
||||
'assistant-step-1',
|
||||
'assistant',
|
||||
2,
|
||||
'Agent step 1: inspected repository files',
|
||||
),
|
||||
sessionMessage(
|
||||
'assistant-step-2',
|
||||
'assistant',
|
||||
4,
|
||||
'Agent step 2: test suite finished',
|
||||
),
|
||||
],
|
||||
},
|
||||
sessionAnalyses: {
|
||||
[sessionId]: {
|
||||
session_id: sessionId,
|
||||
found: true,
|
||||
tool_calls: [
|
||||
toolCall('tool-repo-read', 1, 'repo_file_read', 80),
|
||||
toolCall('tool-test-run', 3, 'run_test_suite', 140),
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/home/bots?id=${botId}`);
|
||||
await page.getByRole('tab', { name: /Sessions/ }).click();
|
||||
await page.getByRole('button', { name: /Timeline User/ }).click();
|
||||
|
||||
await expect(page.getByText('Need a timeline check')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('repo_file_read', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Agent step 1: inspected repository files'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('run_test_suite', { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Agent step 2: test suite finished'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('{"target":"repo_file_read"}')).toHaveCount(0);
|
||||
await expect(page.getByText('{"ok":true}')).toHaveCount(0);
|
||||
|
||||
await expect(
|
||||
page.locator('div.flex.justify-start').filter({
|
||||
hasText: 'repo_file_read',
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('div.flex.justify-start').filter({
|
||||
hasText: 'run_test_suite',
|
||||
}),
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
page.locator('div.flex.justify-end').filter({
|
||||
hasText: 'repo_file_read',
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator('div.flex.justify-end').filter({
|
||||
hasText: 'run_test_suite',
|
||||
}),
|
||||
).toHaveCount(0);
|
||||
|
||||
const text = await page.locator('body').innerText();
|
||||
expect(text.indexOf('Need a timeline check')).toBeLessThan(
|
||||
text.indexOf('repo_file_read'),
|
||||
);
|
||||
expect(text.indexOf('repo_file_read')).toBeLessThan(
|
||||
text.indexOf('Agent step 1: inspected repository files'),
|
||||
);
|
||||
expect(
|
||||
text.indexOf('Agent step 1: inspected repository files'),
|
||||
).toBeLessThan(text.indexOf('run_test_suite'));
|
||||
expect(text.indexOf('run_test_suite')).toBeLessThan(
|
||||
text.indexOf('Agent step 2: test suite finished'),
|
||||
);
|
||||
|
||||
await page.getByText('repo_file_read', { exact: true }).click();
|
||||
await expect(page.getByText('{"target":"repo_file_read"}')).toBeVisible();
|
||||
await expect(page.getByText('{"ok":true}').first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
function wrapped(data: unknown) {
|
||||
return JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
test('Cloud never exposes or requests storage analysis', async ({ page }) => {
|
||||
const workspace = makeWorkspaceEntry(
|
||||
'workspace-cloud',
|
||||
'Cloud Workspace',
|
||||
'cloud_projection',
|
||||
);
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [workspace],
|
||||
});
|
||||
await page.route(
|
||||
/\/api\/v1\/workspaces\/workspace-cloud\/(members|invitations)$/,
|
||||
async (route) => {
|
||||
const collection = route.request().url().endsWith('/members')
|
||||
? 'members'
|
||||
: 'invitations';
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped({ [collection]: [] }),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
let storageAnalysisRequests = 0;
|
||||
await page.route('**/api/v1/system/storage-analysis', async (route) => {
|
||||
storageAnalysisRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped({}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/home/bots');
|
||||
await page.getByRole('button', { name: /admin@example\.com/i }).click();
|
||||
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
await page.goto('/home/bots?action=showStorageAnalysis');
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
await expect(page.getByRole('heading', { name: 'Workspace' })).toBeVisible();
|
||||
await expect(page.getByText('Storage Analysis', { exact: true })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
expect(storageAnalysisRequests).toBe(0);
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
async function save(page: Page) {
|
||||
const button = page.getByRole('button', { name: /^Save$/ });
|
||||
@@ -20,6 +23,61 @@ async function confirmDelete(page: Page) {
|
||||
}
|
||||
|
||||
test.describe('frontend CRUD smoke flows', () => {
|
||||
test('viewer keeps ordinary bot and pipeline monitoring access', async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = makeWorkspaceEntry(
|
||||
'workspace-viewer',
|
||||
'Viewer Workspace',
|
||||
'local',
|
||||
);
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [workspace],
|
||||
});
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Viewer Test Bot');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Proves monitoring is ordinary resource visibility.');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Viewer Pipeline');
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Viewer monitoring permission regression.');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
|
||||
workspace.membership.role = 'viewer';
|
||||
workspace.permissions = ['member.view', 'resource.view', 'workspace.view'];
|
||||
|
||||
await page.goto('/home/bots?id=bot-1');
|
||||
await expect(page.getByRole('tab', { name: 'Logs' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Sessions' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
await page.getByRole('tab', { name: 'Logs' }).click();
|
||||
await expect(page.getByText('No logs yet')).toBeVisible();
|
||||
|
||||
await page.goto('/home/pipelines?id=pipeline-1');
|
||||
await expect(page.getByRole('tab', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Refresh Data' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export Data' })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
@@ -88,6 +146,31 @@ test.describe('frontend CRUD smoke flows', () => {
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('opens pipeline AI capabilities with malformed model options', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.goto('/home/pipelines?id=pipeline-ai');
|
||||
|
||||
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
|
||||
await page.getByRole('button', { name: /^AI$/ }).click();
|
||||
|
||||
await expect(page.getByText('Runtime')).toBeVisible();
|
||||
await expect(
|
||||
page.locator('[data-slot="card-title"]').filter({
|
||||
hasText: 'Built-in Agent',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.locator('label').filter({
|
||||
hasText: 'Model',
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('A <Select.Item')).toHaveCount(0);
|
||||
await expect(page.getByText('500')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a knowledge base', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
|
||||
@@ -67,13 +67,44 @@ interface BotMock {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceEntryMock {
|
||||
workspace: {
|
||||
uuid: string;
|
||||
instance_uuid: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
type: 'personal' | 'team';
|
||||
status: 'active';
|
||||
source: 'local' | 'cloud_projection';
|
||||
};
|
||||
membership: {
|
||||
uuid: string;
|
||||
workspace_uuid: string;
|
||||
account_uuid: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
role: 'owner' | 'admin' | 'developer' | 'operator' | 'viewer';
|
||||
status: 'active';
|
||||
joined_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
permissions: string[];
|
||||
placement_generation: number;
|
||||
}
|
||||
|
||||
interface LangBotApiMockState {
|
||||
authenticated: boolean;
|
||||
bots: BotMock[];
|
||||
counters: Record<string, number>;
|
||||
knowledgeBases: KnowledgeBaseMock[];
|
||||
mcpServers: MCPServerMock[];
|
||||
monitoringData: unknown;
|
||||
monitoringSessions: unknown[];
|
||||
pipelines: PipelineMock[];
|
||||
sessionAnalyses: Record<string, unknown>;
|
||||
sessionMessages: Record<string, unknown[]>;
|
||||
skills: SkillMock[];
|
||||
workspaces: WorkspaceEntryMock[];
|
||||
}
|
||||
|
||||
function ok(data: unknown) {
|
||||
@@ -105,6 +136,59 @@ function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export function makeWorkspaceEntry(
|
||||
uuid: string,
|
||||
name: string,
|
||||
source: 'local' | 'cloud_projection' = 'cloud_projection',
|
||||
): WorkspaceEntryMock {
|
||||
const createdAt = now();
|
||||
return {
|
||||
workspace: {
|
||||
uuid,
|
||||
instance_uuid: 'instance-playwright',
|
||||
name,
|
||||
slug: uuid,
|
||||
type: 'team',
|
||||
status: 'active',
|
||||
source,
|
||||
},
|
||||
membership: {
|
||||
uuid: `membership-${uuid}`,
|
||||
workspace_uuid: uuid,
|
||||
account_uuid: 'account-playwright',
|
||||
display_name: 'Playwright Admin',
|
||||
email: 'admin@example.com',
|
||||
role: 'owner',
|
||||
status: 'active',
|
||||
joined_at: createdAt,
|
||||
created_at: createdAt,
|
||||
},
|
||||
permissions: [
|
||||
'api_key.manage',
|
||||
'audit.view',
|
||||
'data.export',
|
||||
'member.invite',
|
||||
'member.remove',
|
||||
'member.update_role',
|
||||
'member.view',
|
||||
'provider_secret.manage',
|
||||
'resource.manage',
|
||||
'resource.view',
|
||||
'runtime.operate',
|
||||
'workspace.view',
|
||||
],
|
||||
placement_generation: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function defaultWorkspaceEntry(): WorkspaceEntryMock {
|
||||
return makeWorkspaceEntry(
|
||||
'workspace-playwright',
|
||||
'Playwright Workspace',
|
||||
'local',
|
||||
);
|
||||
}
|
||||
|
||||
function nextId(state: LangBotApiMockState, prefix: string) {
|
||||
state.counters[prefix] = (state.counters[prefix] || 0) + 1;
|
||||
return `${prefix}-${state.counters[prefix]}`;
|
||||
@@ -122,12 +206,14 @@ function emptyMonitoringData() {
|
||||
},
|
||||
messages: [],
|
||||
llmCalls: [],
|
||||
toolCalls: [],
|
||||
embeddingCalls: [],
|
||||
sessions: [],
|
||||
errors: [],
|
||||
totalCount: {
|
||||
messages: 0,
|
||||
llmCalls: 0,
|
||||
toolCalls: 0,
|
||||
embeddingCalls: 0,
|
||||
sessions: 0,
|
||||
errors: 0,
|
||||
@@ -188,6 +274,102 @@ function makePipeline(
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineMetadata() {
|
||||
return {
|
||||
configs: [
|
||||
{
|
||||
name: 'ai',
|
||||
label: {
|
||||
en_US: 'AI Capabilities',
|
||||
zh_Hans: 'AI 能力',
|
||||
},
|
||||
stages: [
|
||||
{
|
||||
name: 'runner',
|
||||
label: {
|
||||
en_US: 'Runtime',
|
||||
zh_Hans: '运行方式',
|
||||
},
|
||||
config: [
|
||||
{
|
||||
id: 'runner',
|
||||
name: 'runner',
|
||||
label: {
|
||||
en_US: 'Runner',
|
||||
zh_Hans: '运行器',
|
||||
},
|
||||
type: 'select',
|
||||
required: true,
|
||||
default: 'local-agent',
|
||||
options: [
|
||||
{
|
||||
name: 'local-agent',
|
||||
label: {
|
||||
en_US: 'Built-in Agent',
|
||||
zh_Hans: '内置 Agent',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'local-agent',
|
||||
label: {
|
||||
en_US: 'Built-in Agent',
|
||||
zh_Hans: '内置 Agent',
|
||||
},
|
||||
config: [
|
||||
{
|
||||
id: 'model',
|
||||
name: 'model',
|
||||
label: {
|
||||
en_US: 'Model',
|
||||
zh_Hans: '模型',
|
||||
},
|
||||
type: 'model-fallback-selector',
|
||||
required: true,
|
||||
default: {
|
||||
primary: 'llm-valid',
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function providerModelList() {
|
||||
return {
|
||||
models: [
|
||||
{
|
||||
uuid: '',
|
||||
name: 'Broken Empty UUID Model',
|
||||
provider_uuid: 'provider-empty',
|
||||
provider: {
|
||||
uuid: 'provider-empty',
|
||||
name: 'Broken Provider',
|
||||
requester: 'mock-provider',
|
||||
},
|
||||
},
|
||||
{
|
||||
uuid: 'llm-valid',
|
||||
name: 'Valid Mock Model',
|
||||
provider_uuid: 'provider-valid',
|
||||
provider: {
|
||||
uuid: 'provider-valid',
|
||||
name: 'Mock Provider',
|
||||
requester: 'mock-provider',
|
||||
},
|
||||
abilities: ['func_call'],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function knowledgeEngine() {
|
||||
return {
|
||||
plugin_id: 'builtin/minimal-knowledge',
|
||||
@@ -321,21 +503,33 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
if (path === '/api/v1/user/account-info') {
|
||||
return fulfillJson(route, {
|
||||
initialized: true,
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
password_login_enabled: true,
|
||||
space_login_enabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/check-token') {
|
||||
return fulfillJson(route, { token: '' });
|
||||
return fulfillJson(route, {
|
||||
token: state.authenticated ? 'playwright-token' : '',
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/auth') {
|
||||
state.authenticated = true;
|
||||
return fulfillJson(route, { token: 'playwright-token' });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/space/callback') {
|
||||
state.authenticated = true;
|
||||
return fulfillJson(route, {
|
||||
token: 'playwright-space-token',
|
||||
user: 'admin@example.com',
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/user/info') {
|
||||
return fulfillJson(route, {
|
||||
account_uuid: 'account-playwright',
|
||||
user: 'admin@example.com',
|
||||
account_type: 'local',
|
||||
has_password: true,
|
||||
@@ -346,6 +540,24 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
return fulfillJson(route, { credits: null });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/workspaces/bootstrap') {
|
||||
return fulfillJson(route, { workspaces: state.workspaces });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/workspaces/current') {
|
||||
const selectedWorkspaceUuid = request.headers()['x-workspace-id'];
|
||||
const entry = state.workspaces.find(
|
||||
(item) => item.workspace.uuid === selectedWorkspaceUuid,
|
||||
);
|
||||
return fulfillJson(route, entry || state.workspaces[0]);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/workspaces') {
|
||||
return fulfillJson(route, {
|
||||
workspaces: state.workspaces.map((entry) => entry.workspace),
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/platform/adapters') {
|
||||
return fulfillJson(route, { adapters: mockAdapters() });
|
||||
}
|
||||
@@ -365,7 +577,7 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
|
||||
const botLogsMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)\/logs$/);
|
||||
if (botLogsMatch) {
|
||||
return fulfillJson(route, { logs: [], total: 0 });
|
||||
return fulfillJson(route, { logs: [], total_count: 0 });
|
||||
}
|
||||
|
||||
const botMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)$/);
|
||||
@@ -389,8 +601,20 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/provider/models/llm') {
|
||||
return fulfillJson(route, providerModelList());
|
||||
}
|
||||
|
||||
if (path === '/api/v1/provider/models/embedding') {
|
||||
return fulfillJson(route, { models: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/provider/models/rerank') {
|
||||
return fulfillJson(route, { models: [] });
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines/_/metadata') {
|
||||
return fulfillJson(route, { configs: [] });
|
||||
return fulfillJson(route, pipelineMetadata());
|
||||
}
|
||||
|
||||
if (path === '/api/v1/pipelines') {
|
||||
@@ -689,11 +913,43 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/data') {
|
||||
return fulfillJson(route, emptyMonitoringData());
|
||||
return fulfillJson(route, state.monitoringData);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/sessions') {
|
||||
return fulfillJson(route, {
|
||||
sessions: state.monitoringSessions,
|
||||
total: state.monitoringSessions.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/messages') {
|
||||
const sessionId = url.searchParams.get('sessionId') || '';
|
||||
const messages = state.sessionMessages[sessionId] || [];
|
||||
return fulfillJson(route, {
|
||||
messages,
|
||||
total: messages.length,
|
||||
});
|
||||
}
|
||||
|
||||
const sessionAnalysisMatch = path.match(
|
||||
/^\/api\/v1\/monitoring\/sessions\/([^/]+)\/analysis$/,
|
||||
);
|
||||
if (sessionAnalysisMatch) {
|
||||
const sessionId = decodeURIComponent(sessionAnalysisMatch[1]);
|
||||
return fulfillJson(
|
||||
route,
|
||||
state.sessionAnalyses[sessionId] || {
|
||||
session_id: sessionId,
|
||||
found: true,
|
||||
tool_calls: [],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/overview') {
|
||||
return fulfillJson(route, emptyMonitoringData().overview);
|
||||
const data = state.monitoringData as { overview?: unknown };
|
||||
return fulfillJson(route, data.overview || emptyMonitoringData().overview);
|
||||
}
|
||||
|
||||
if (path === '/api/v1/monitoring/token-statistics') {
|
||||
@@ -798,16 +1054,38 @@ async function handleCloudApi(route: Route) {
|
||||
|
||||
export async function installLangBotApiMocks(
|
||||
page: Page,
|
||||
options: { authenticated?: boolean; storage?: JsonRecord } = {},
|
||||
options: {
|
||||
authenticated?: boolean;
|
||||
monitoringData?: unknown;
|
||||
monitoringSessions?: unknown[];
|
||||
sessionAnalyses?: Record<string, unknown>;
|
||||
sessionMessages?: Record<string, unknown[]>;
|
||||
storage?: JsonRecord;
|
||||
workspaces?: WorkspaceEntryMock[];
|
||||
} = {},
|
||||
) {
|
||||
const { authenticated = false, storage = {} } = options;
|
||||
const {
|
||||
authenticated = false,
|
||||
monitoringData,
|
||||
monitoringSessions,
|
||||
sessionAnalyses,
|
||||
sessionMessages,
|
||||
storage = {},
|
||||
workspaces = [defaultWorkspaceEntry()],
|
||||
} = options;
|
||||
const state: LangBotApiMockState = {
|
||||
authenticated,
|
||||
bots: [],
|
||||
counters: {},
|
||||
knowledgeBases: [],
|
||||
mcpServers: [],
|
||||
monitoringData: monitoringData || emptyMonitoringData(),
|
||||
monitoringSessions: monitoringSessions || [],
|
||||
pipelines: [],
|
||||
sessionAnalyses: sessionAnalyses || {},
|
||||
sessionMessages: sessionMessages || {},
|
||||
skills: [],
|
||||
workspaces,
|
||||
};
|
||||
|
||||
await page.addInitScript(
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
test('terminal invitation errors refresh on a new fragment and allow account switching', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
storage: {
|
||||
token: 'playwright-token',
|
||||
userEmail: 'another-account@example.com',
|
||||
},
|
||||
});
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
const body = JSON.parse(route.request().postData() || '{}') as {
|
||||
token?: string;
|
||||
};
|
||||
const code =
|
||||
body.token === 'revoked-invitation'
|
||||
? 'invitation_revoked'
|
||||
: 'invitation_used';
|
||||
await route.fulfill({
|
||||
status: 410,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code, msg: code }),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=used-invitation');
|
||||
await expect(
|
||||
page.getByText('This invitation was already used.'),
|
||||
).toBeVisible();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() =>
|
||||
sessionStorage.getItem('langbot_pending_invitation_token'),
|
||||
),
|
||||
)
|
||||
.toBeNull();
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.location.hash = 'token=revoked-invitation';
|
||||
});
|
||||
await expect(page.getByText('This invitation was revoked.')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Back to sign in' }).click();
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(page.getByText('Welcome')).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
token: localStorage.getItem('token'),
|
||||
userEmail: localStorage.getItem('userEmail'),
|
||||
})),
|
||||
).toEqual({ token: null, userEmail: null });
|
||||
});
|
||||
|
||||
test('login preserves an explicit invitation email mismatch error', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: false });
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
invitation: {
|
||||
uuid: 'mismatch-invitation',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
normalized_email: 'invited@example.com',
|
||||
role: 'viewer',
|
||||
status: 'pending',
|
||||
},
|
||||
workspace: {
|
||||
uuid: 'workspace-playwright',
|
||||
name: 'Playwright Workspace',
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 'invitation_email_mismatch',
|
||||
msg: 'Invitation email does not match the Account',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=mismatch-invitation');
|
||||
await page.getByRole('button', { name: 'I already have an account' }).click();
|
||||
await page.getByPlaceholder('Enter email address').fill('other@example.com');
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
/\/invitations\/accept\?error=invitation_email_mismatch$/,
|
||||
);
|
||||
await expect(
|
||||
page.getByText('This invitation belongs to a different email address.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Login successful')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an authenticated OSS invitation requires logout before registration', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
storage: {
|
||||
token: 'playwright-token',
|
||||
userEmail: 'invited@example.com',
|
||||
},
|
||||
});
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
invitation: {
|
||||
uuid: 'logout-invitation',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
normalized_email: 'invited@example.com',
|
||||
role: 'viewer',
|
||||
status: 'pending',
|
||||
},
|
||||
workspace: {
|
||||
uuid: 'workspace-playwright',
|
||||
name: 'Playwright Workspace',
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=logout-invitation');
|
||||
await expect(
|
||||
page.getByText(
|
||||
'Sign out first, then sign in with the invited account. Your invitation will be preserved.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', {
|
||||
name: 'Sign out and return to this invitation',
|
||||
})
|
||||
.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login\?invitation=1$/);
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
token: localStorage.getItem('token'),
|
||||
userEmail: localStorage.getItem('userEmail'),
|
||||
invitation: sessionStorage.getItem('langbot_pending_invitation_token'),
|
||||
})),
|
||||
).toEqual({
|
||||
token: null,
|
||||
userEmail: null,
|
||||
invitation: 'logout-invitation',
|
||||
});
|
||||
});
|
||||
|
||||
test('an authenticated Cloud Account can accept its invitation directly', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
storage: {
|
||||
token: 'invited-account-token',
|
||||
userEmail: 'invited@example.com',
|
||||
},
|
||||
});
|
||||
await page.route('**/api/v1/user/account-info', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
initialized: true,
|
||||
authenticated_invitation_acceptance_enabled: true,
|
||||
password_login_enabled: false,
|
||||
space_login_enabled: true,
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
invitation: {
|
||||
uuid: 'cloud-invitation',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
normalized_email: 'invited@example.com',
|
||||
role: 'viewer',
|
||||
status: 'pending',
|
||||
},
|
||||
workspace: {
|
||||
uuid: 'workspace-playwright',
|
||||
name: 'Playwright Workspace',
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let acceptanceAuthorization = '';
|
||||
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||
acceptanceAuthorization = route.request().headers().authorization ?? '';
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
token: 'accepted-cloud-account-token',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=cloud-invitation');
|
||||
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Accept Invitation' }),
|
||||
).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Accept Invitation' }).click();
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
expect(acceptanceAuthorization).toBe('Bearer invited-account-token');
|
||||
});
|
||||
|
||||
test('Space OAuth accepts a pending invitation with the freshly authenticated account', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: false,
|
||||
storage: {
|
||||
token: 'stale-other-account-token',
|
||||
userEmail: 'other@example.com',
|
||||
},
|
||||
});
|
||||
await page.addInitScript(() => {
|
||||
sessionStorage.setItem(
|
||||
'langbot_pending_invitation_token',
|
||||
'matching-invitation',
|
||||
);
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/user/space/callback', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
token: 'fresh-invited-account-token',
|
||||
user: 'invited@example.com',
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/user/info', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
account_uuid: 'invited-account',
|
||||
user: 'invited@example.com',
|
||||
account_type: 'space',
|
||||
has_password: false,
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let acceptanceAuthorization = '';
|
||||
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||
acceptanceAuthorization = route.request().headers().authorization ?? '';
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
token: 'accepted-invited-account-token',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
expect(acceptanceAuthorization).toBe('Bearer fresh-invited-account-token');
|
||||
expect(
|
||||
await page.evaluate(() => ({
|
||||
token: localStorage.getItem('token'),
|
||||
userEmail: localStorage.getItem('userEmail'),
|
||||
invitation: sessionStorage.getItem('langbot_pending_invitation_token'),
|
||||
})),
|
||||
).toEqual({
|
||||
token: 'accepted-invited-account-token',
|
||||
userEmail: 'invited@example.com',
|
||||
invitation: null,
|
||||
});
|
||||
});
|
||||
+132
-2
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
test('local account login reaches the authenticated home shell', async ({
|
||||
page,
|
||||
@@ -14,9 +17,136 @@ test('local account login reaches the authenticated home shell', async ({
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/home$/);
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
await expect(page.getByText('Home').first()).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByText('Total Messages').first()).toBeVisible();
|
||||
await expect(page.getByText('Unable to connect to server')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('an existing Account token bootstraps the singleton without a selector loop', async ({
|
||||
page,
|
||||
}) => {
|
||||
const bootstrapWorkspaceHeaders: Array<string | undefined> = [];
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/api/v1/workspaces/bootstrap') {
|
||||
bootstrapWorkspaceHeaders.push(request.headers()['x-workspace-id']);
|
||||
}
|
||||
});
|
||||
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
await page.goto('/login');
|
||||
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
await expect(page.getByRole('button', { name: 'Dashboard' })).toBeVisible();
|
||||
expect(bootstrapWorkspaceHeaders.length).toBeGreaterThan(0);
|
||||
expect(bootstrapWorkspaceHeaders.every((header) => !header)).toBe(true);
|
||||
});
|
||||
|
||||
test('multi-Workspace login waits for an explicit selection', async ({
|
||||
page,
|
||||
}) => {
|
||||
const accountScopedRequests: { path: string; workspace?: string }[] = [];
|
||||
const selectedWorkspaceHeaders: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
const workspace = request.headers()['x-workspace-id'];
|
||||
if (
|
||||
path === '/api/v1/user/auth' ||
|
||||
path === '/api/v1/user/check-token' ||
|
||||
path === '/api/v1/workspaces/bootstrap'
|
||||
) {
|
||||
accountScopedRequests.push({ path, workspace });
|
||||
}
|
||||
if (path === '/api/v1/workspaces/current' && workspace) {
|
||||
selectedWorkspaceHeaders.push(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
await installLangBotApiMocks(page, {
|
||||
storage: {
|
||||
langbot_active_workspace_uuid: 'workspace-from-another-account',
|
||||
},
|
||||
workspaces: [
|
||||
makeWorkspaceEntry('workspace-alpha', 'Alpha Workspace'),
|
||||
makeWorkspaceEntry('workspace-beta', 'Beta Workspace'),
|
||||
],
|
||||
});
|
||||
|
||||
await page.goto('/login');
|
||||
await page.getByPlaceholder('Enter email address').fill('admin@example.com');
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/workspaces\/select/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Choose a Workspace' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Alpha Workspace')).toBeVisible();
|
||||
await expect(page.getByText('Beta Workspace')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: /Beta Workspace/ }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
const workspaceSwitcher = page
|
||||
.getByRole('button', {
|
||||
name: /Switch Workspace/,
|
||||
})
|
||||
.first();
|
||||
await expect(workspaceSwitcher).toBeVisible();
|
||||
await workspaceSwitcher.click();
|
||||
await expect(
|
||||
page.getByRole('menuitem', { name: 'Workspace Settings' }),
|
||||
).toBeVisible();
|
||||
expect(selectedWorkspaceHeaders).toContain('workspace-beta');
|
||||
expect(accountScopedRequests.length).toBeGreaterThan(0);
|
||||
expect(accountScopedRequests.every((request) => !request.workspace)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('Space OAuth bootstraps a singleton before entering home', async ({
|
||||
page,
|
||||
}) => {
|
||||
const selectedWorkspaceHeaders: string[] = [];
|
||||
page.on('request', (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
const workspace = request.headers()['x-workspace-id'];
|
||||
if (path === '/api/v1/workspaces/current' && workspace) {
|
||||
selectedWorkspaceHeaders.push(workspace);
|
||||
}
|
||||
});
|
||||
|
||||
await installLangBotApiMocks(page);
|
||||
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/, {
|
||||
timeout: 5_000,
|
||||
});
|
||||
expect(selectedWorkspaceHeaders).toContain('workspace-playwright');
|
||||
});
|
||||
|
||||
test('Space OAuth sends a multi-Workspace Account to the chooser', async ({
|
||||
page,
|
||||
}) => {
|
||||
const bootstrapWorkspaceHeaders: Array<string | undefined> = [];
|
||||
page.on('request', (request) => {
|
||||
if (new URL(request.url()).pathname === '/api/v1/workspaces/bootstrap') {
|
||||
bootstrapWorkspaceHeaders.push(request.headers()['x-workspace-id']);
|
||||
}
|
||||
});
|
||||
|
||||
await installLangBotApiMocks(page, {
|
||||
workspaces: [
|
||||
makeWorkspaceEntry('workspace-alpha', 'Alpha Workspace'),
|
||||
makeWorkspaceEntry('workspace-beta', 'Beta Workspace'),
|
||||
],
|
||||
});
|
||||
await page.goto('/auth/space/callback?code=oauth-code&state=oauth-state');
|
||||
|
||||
await expect(page).toHaveURL(/\/workspaces\/select/, { timeout: 5_000 });
|
||||
await expect(page.getByText('Alpha Workspace')).toBeVisible();
|
||||
await expect(page.getByText('Beta Workspace')).toBeVisible();
|
||||
expect(bootstrapWorkspaceHeaders.length).toBeGreaterThan(0);
|
||||
expect(bootstrapWorkspaceHeaders.every((header) => !header)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
|
||||
import {
|
||||
ErrorLog,
|
||||
LLMCall,
|
||||
MonitoringMessage,
|
||||
ToolCall,
|
||||
} from '../../src/app/home/monitoring/types/monitoring';
|
||||
|
||||
const bot = {
|
||||
id: 'bot-monitoring',
|
||||
name: 'Monitoring Bot',
|
||||
};
|
||||
|
||||
const pipeline = {
|
||||
id: 'pipeline-monitoring',
|
||||
name: 'Monitoring Pipeline',
|
||||
};
|
||||
|
||||
function time(minute: number) {
|
||||
return new Date(`2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`);
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: 'user' | 'assistant',
|
||||
minute: number,
|
||||
content: string,
|
||||
sessionId = 'session-agent',
|
||||
): MonitoringMessage {
|
||||
return {
|
||||
id,
|
||||
timestamp: time(minute),
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
pipelineId: pipeline.id,
|
||||
pipelineName: pipeline.name,
|
||||
messageContent: content,
|
||||
sessionId,
|
||||
status: 'success',
|
||||
level: 'info',
|
||||
platform: role === 'user' ? 'person' : 'bot',
|
||||
userId: 'user-1',
|
||||
userName: 'Playwright User',
|
||||
runnerName: 'local-agent',
|
||||
variables: '{}',
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
function llmCall(
|
||||
id: string,
|
||||
minute: number,
|
||||
messageId: string | undefined,
|
||||
input: number,
|
||||
output: number,
|
||||
duration: number,
|
||||
sessionId = 'session-agent',
|
||||
): LLMCall {
|
||||
return {
|
||||
id,
|
||||
timestamp: time(minute),
|
||||
modelName: 'gpt-5.5',
|
||||
tokens: {
|
||||
input,
|
||||
output,
|
||||
total: input + output,
|
||||
},
|
||||
duration,
|
||||
status: 'success',
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
pipelineId: pipeline.id,
|
||||
pipelineName: pipeline.name,
|
||||
sessionId,
|
||||
messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function errorLog(id: string, minute: number, messageId: string): ErrorLog {
|
||||
return {
|
||||
id,
|
||||
timestamp: time(minute),
|
||||
errorType: 'ToolExecutionError',
|
||||
errorMessage: 'Tool retry failed',
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
pipelineId: pipeline.id,
|
||||
pipelineName: pipeline.name,
|
||||
sessionId: 'session-agent',
|
||||
messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(
|
||||
id: string,
|
||||
minute: number,
|
||||
messageId: string | undefined,
|
||||
toolName: string,
|
||||
duration: number,
|
||||
sessionId = 'session-agent',
|
||||
status: 'success' | 'error' = 'success',
|
||||
): ToolCall {
|
||||
return {
|
||||
id,
|
||||
timestamp: time(minute),
|
||||
toolName,
|
||||
toolSource: 'native',
|
||||
duration,
|
||||
status,
|
||||
botId: bot.id,
|
||||
botName: bot.name,
|
||||
pipelineId: pipeline.id,
|
||||
pipelineName: pipeline.name,
|
||||
sessionId,
|
||||
messageId,
|
||||
arguments: JSON.stringify({ query: toolName }),
|
||||
result: status === 'success' ? JSON.stringify({ ok: true }) : undefined,
|
||||
errorMessage: status === 'error' ? 'Tool failed' : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function rawMessage(message: MonitoringMessage) {
|
||||
return {
|
||||
id: message.id,
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
bot_id: message.botId,
|
||||
bot_name: message.botName,
|
||||
pipeline_id: message.pipelineId,
|
||||
pipeline_name: message.pipelineName,
|
||||
message_content: message.messageContent,
|
||||
session_id: message.sessionId,
|
||||
status: message.status,
|
||||
level: message.level,
|
||||
platform: message.platform,
|
||||
user_id: message.userId,
|
||||
user_name: message.userName,
|
||||
runner_name: message.runnerName,
|
||||
variables: message.variables,
|
||||
role: message.role,
|
||||
};
|
||||
}
|
||||
|
||||
function rawLlmCall(call: LLMCall) {
|
||||
return {
|
||||
id: call.id,
|
||||
timestamp: call.timestamp.toISOString(),
|
||||
model_name: call.modelName,
|
||||
input_tokens: call.tokens.input,
|
||||
output_tokens: call.tokens.output,
|
||||
total_tokens: call.tokens.total,
|
||||
duration: call.duration,
|
||||
cost: call.cost,
|
||||
status: call.status,
|
||||
bot_id: call.botId,
|
||||
bot_name: call.botName,
|
||||
pipeline_id: call.pipelineId,
|
||||
pipeline_name: call.pipelineName,
|
||||
session_id: call.sessionId,
|
||||
error_message: call.errorMessage,
|
||||
message_id: call.messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function rawError(error: ErrorLog) {
|
||||
return {
|
||||
id: error.id,
|
||||
timestamp: error.timestamp.toISOString(),
|
||||
error_type: error.errorType,
|
||||
error_message: error.errorMessage,
|
||||
bot_id: error.botId,
|
||||
bot_name: error.botName,
|
||||
pipeline_id: error.pipelineId,
|
||||
pipeline_name: error.pipelineName,
|
||||
session_id: error.sessionId,
|
||||
stack_trace: error.stackTrace,
|
||||
message_id: error.messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function rawToolCall(call: ToolCall) {
|
||||
return {
|
||||
id: call.id,
|
||||
timestamp: call.timestamp.toISOString(),
|
||||
tool_name: call.toolName,
|
||||
tool_source: call.toolSource,
|
||||
duration: call.duration,
|
||||
status: call.status,
|
||||
bot_id: call.botId,
|
||||
bot_name: call.botName,
|
||||
pipeline_id: call.pipelineId,
|
||||
pipeline_name: call.pipelineName,
|
||||
session_id: call.sessionId,
|
||||
message_id: call.messageId,
|
||||
arguments: call.arguments,
|
||||
result: call.result,
|
||||
error_message: call.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
function monitoringScenario() {
|
||||
const messages = [
|
||||
message(
|
||||
'single-user',
|
||||
'user',
|
||||
1,
|
||||
'Standalone question with no reply',
|
||||
'session-single',
|
||||
),
|
||||
message('agent-user-1', 'user', 10, 'Need deployment plan'),
|
||||
message('agent-assistant-1', 'assistant', 11, 'Agent step 1: inspect repo'),
|
||||
message('agent-assistant-2', 'assistant', 12, 'Agent step 2: run tests'),
|
||||
message(
|
||||
'agent-assistant-3',
|
||||
'assistant',
|
||||
13,
|
||||
'Final answer: deployment plan ready',
|
||||
),
|
||||
message('agent-user-2', 'user', 20, 'Continue with rollback plan'),
|
||||
message('agent-assistant-4', 'assistant', 21, 'Rollback plan ready'),
|
||||
];
|
||||
const llmCalls = [
|
||||
llmCall('agent-call-1', 10, 'agent-user-1', 100, 40, 120),
|
||||
llmCall('agent-call-2', 11, 'agent-user-1', 200, 60, 220),
|
||||
llmCall('agent-call-3', 12, 'agent-user-1', 300, 90, 260),
|
||||
llmCall('agent-call-4', 20, 'agent-user-2', 50, 25, 80),
|
||||
];
|
||||
const errors = [errorLog('agent-error-1', 12, 'agent-user-1')];
|
||||
const toolCalls = [
|
||||
toolCall('agent-tool-1', 11, 'agent-user-1', 'repo_search', 90),
|
||||
toolCall('agent-tool-2', 12, 'agent-user-1', 'run_tests', 150),
|
||||
toolCall('agent-tool-3', 20, 'agent-user-2', 'rollback_lookup', 70),
|
||||
];
|
||||
|
||||
return {
|
||||
messages,
|
||||
llmCalls,
|
||||
toolCalls,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
function rawMonitoringData() {
|
||||
const scenario = monitoringScenario();
|
||||
|
||||
return {
|
||||
overview: {
|
||||
total_messages: scenario.messages.length,
|
||||
llm_calls: scenario.llmCalls.length,
|
||||
embedding_calls: 0,
|
||||
model_calls: scenario.llmCalls.length,
|
||||
success_rate: 100,
|
||||
active_sessions: 2,
|
||||
},
|
||||
messages: scenario.messages.map(rawMessage),
|
||||
llmCalls: scenario.llmCalls.map(rawLlmCall),
|
||||
toolCalls: scenario.toolCalls.map(rawToolCall),
|
||||
embeddingCalls: [],
|
||||
sessions: [],
|
||||
errors: scenario.errors.map(rawError),
|
||||
totalCount: {
|
||||
messages: scenario.messages.length,
|
||||
llmCalls: scenario.llmCalls.length,
|
||||
toolCalls: scenario.toolCalls.length,
|
||||
embeddingCalls: 0,
|
||||
sessions: 0,
|
||||
errors: scenario.errors.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('monitoring conversation turn grouping', () => {
|
||||
test('keeps a single user message as one observable turn', () => {
|
||||
const userOnly = message(
|
||||
'single-user-only',
|
||||
'user',
|
||||
1,
|
||||
'No answer yet',
|
||||
'session-user-only',
|
||||
);
|
||||
|
||||
const turns = buildConversationTurns([userOnly], [], []);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0].id).toBe(userOnly.id);
|
||||
expect(turns[0].userMessage?.messageContent).toBe('No answer yet');
|
||||
expect(turns[0].assistantMessages).toHaveLength(0);
|
||||
expect(turns[0].llmCalls).toHaveLength(0);
|
||||
expect(turns[0].totalTokens).toBe(0);
|
||||
});
|
||||
|
||||
test('groups multi-step agent execution and multiple replies into one user turn', () => {
|
||||
const scenario = monitoringScenario();
|
||||
const turns = buildConversationTurns(
|
||||
scenario.messages,
|
||||
scenario.llmCalls,
|
||||
scenario.errors,
|
||||
scenario.toolCalls,
|
||||
);
|
||||
|
||||
const agentTurn = turns.find((turn) => turn.id === 'agent-user-1');
|
||||
|
||||
expect(agentTurn).toBeTruthy();
|
||||
expect(agentTurn?.userMessage?.messageContent).toBe('Need deployment plan');
|
||||
expect(
|
||||
agentTurn?.assistantMessages.map((item) => item.messageContent),
|
||||
).toEqual([
|
||||
'Agent step 1: inspect repo',
|
||||
'Agent step 2: run tests',
|
||||
'Final answer: deployment plan ready',
|
||||
]);
|
||||
expect(agentTurn?.llmCalls).toHaveLength(3);
|
||||
expect(agentTurn?.toolCalls).toHaveLength(2);
|
||||
expect(agentTurn?.errors).toHaveLength(1);
|
||||
expect(agentTurn?.totalTokens).toBe(790);
|
||||
expect(agentTurn?.totalDuration).toBe(600);
|
||||
expect(agentTurn?.totalToolDuration).toBe(240);
|
||||
});
|
||||
|
||||
test('starts a new turn for each later user message in the same session', () => {
|
||||
const firstUser = message('same-session-user-1', 'user', 1, 'First');
|
||||
const firstReply = message(
|
||||
'same-session-reply-1',
|
||||
'assistant',
|
||||
2,
|
||||
'First reply',
|
||||
);
|
||||
const secondUser = message('same-session-user-2', 'user', 3, 'Second');
|
||||
const secondReply = message(
|
||||
'same-session-reply-2',
|
||||
'assistant',
|
||||
4,
|
||||
'Second reply',
|
||||
);
|
||||
|
||||
const turns = buildConversationTurns(
|
||||
[firstUser, firstReply, secondUser, secondReply],
|
||||
[
|
||||
llmCall('same-session-call-1', 1, firstUser.id, 10, 5, 40),
|
||||
llmCall('same-session-call-2', 3, secondUser.id, 20, 10, 50),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
expect(turns.map((turn) => turn.id)).toEqual([
|
||||
'same-session-user-2',
|
||||
'same-session-user-1',
|
||||
]);
|
||||
expect(
|
||||
turns[0].assistantMessages.map((item) => item.messageContent),
|
||||
).toEqual(['Second reply']);
|
||||
expect(
|
||||
turns[1].assistantMessages.map((item) => item.messageContent),
|
||||
).toEqual(['First reply']);
|
||||
});
|
||||
|
||||
test('attaches calls without message ids by session time', () => {
|
||||
const user = message('fallback-user', 'user', 1, 'Use session fallback');
|
||||
const assistant = message(
|
||||
'fallback-assistant',
|
||||
'assistant',
|
||||
2,
|
||||
'Fallback reply',
|
||||
);
|
||||
const call = llmCall('fallback-call', 2, undefined, 25, 5, 70);
|
||||
|
||||
const turns = buildConversationTurns([user, assistant], [call], []);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0].llmCalls).toHaveLength(1);
|
||||
expect(turns[0].llmCalls[0].id).toBe(call.id);
|
||||
expect(turns[0].totalTokens).toBe(30);
|
||||
});
|
||||
|
||||
test('attaches tool calls without message ids by session time', () => {
|
||||
const user = message('tool-fallback-user', 'user', 1, 'Use tool fallback');
|
||||
const assistant = message(
|
||||
'tool-fallback-assistant',
|
||||
'assistant',
|
||||
2,
|
||||
'Tool fallback reply',
|
||||
);
|
||||
const call = toolCall(
|
||||
'tool-fallback-call',
|
||||
2,
|
||||
undefined,
|
||||
'memory_lookup',
|
||||
45,
|
||||
);
|
||||
|
||||
const turns = buildConversationTurns([user, assistant], [], [], [call]);
|
||||
|
||||
expect(turns).toHaveLength(1);
|
||||
expect(turns[0].toolCalls).toHaveLength(1);
|
||||
expect(turns[0].toolCalls[0].id).toBe(call.id);
|
||||
expect(turns[0].totalToolDuration).toBe(45);
|
||||
});
|
||||
|
||||
test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: rawMonitoringData(),
|
||||
});
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
|
||||
await expect(page.getByText('3 conversation turns')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Standalone question with no reply'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('No assistant reply recorded')).toBeVisible();
|
||||
await expect(page.getByText('Need deployment plan')).toBeVisible();
|
||||
await expect(page.getByText('Agent step 1: inspect repo')).toBeVisible();
|
||||
await expect(page.getByText('Assistant +2')).toBeVisible();
|
||||
await expect(page.getByText('3 LLM')).toBeVisible();
|
||||
await expect(page.getByText('2 tools')).toBeVisible();
|
||||
await expect(page.getByText('790 tokens')).toBeVisible();
|
||||
await expect(page.getByText('1 errors')).toBeVisible();
|
||||
await expect(page.getByText('Continue with rollback plan')).toBeVisible();
|
||||
await expect(page.getByText('Rollback plan ready')).toBeVisible();
|
||||
|
||||
const agentTurn = page
|
||||
.locator('div[role="button"]')
|
||||
.filter({ hasText: 'Need deployment plan' });
|
||||
await expect(agentTurn).toHaveCount(1);
|
||||
await agentTurn.click();
|
||||
|
||||
await expect(page.getByText('Conversation Trace')).toBeVisible();
|
||||
await expect(page.getByText('Agent step 2: run tests')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Final answer: deployment plan ready'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('LLM Calls (3)')).toBeVisible();
|
||||
await expect(page.getByText('#3 gpt-5.5')).toBeVisible();
|
||||
await expect(page.getByText('In: 300')).toBeVisible();
|
||||
await expect(page.getByText('Out: 90')).toBeVisible();
|
||||
await expect(page.getByText('Total: 390')).toBeVisible();
|
||||
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
|
||||
await expect(page.getByText('#1 repo_search')).toBeVisible();
|
||||
await expect(page.getByText('#2 run_tests')).toBeVisible();
|
||||
await expect(page.getByText('Arguments')).toHaveCount(0);
|
||||
await expect(page.getByText('Result')).toHaveCount(0);
|
||||
|
||||
await page.getByText('#1 repo_search').click();
|
||||
await expect(page.getByText('Arguments').first()).toBeVisible();
|
||||
await expect(page.getByText('Result').first()).toBeVisible();
|
||||
await expect(page.getByText('Tool retry failed')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
const bot = {
|
||||
id: 'bot-pipeline-monitoring',
|
||||
name: 'Pipeline Bot',
|
||||
};
|
||||
|
||||
const pipeline = {
|
||||
id: 'pipeline-monitoring',
|
||||
name: 'Pipeline Under Test',
|
||||
};
|
||||
|
||||
function at(minute: number) {
|
||||
return `2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`;
|
||||
}
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
role: 'user' | 'assistant',
|
||||
minute: number,
|
||||
content: string,
|
||||
sessionId = 'session-pipeline-agent',
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute),
|
||||
bot_id: bot.id,
|
||||
bot_name: bot.name,
|
||||
pipeline_id: pipeline.id,
|
||||
pipeline_name: pipeline.name,
|
||||
message_content: content,
|
||||
session_id: sessionId,
|
||||
status: 'success',
|
||||
level: 'info',
|
||||
platform: role === 'user' ? 'person' : 'bot',
|
||||
user_id: 'pipeline-user',
|
||||
user_name: 'Pipeline User',
|
||||
runner_name: 'local-agent',
|
||||
variables: '{}',
|
||||
role,
|
||||
};
|
||||
}
|
||||
|
||||
function llmCall(
|
||||
id: string,
|
||||
minute: number,
|
||||
messageId: string,
|
||||
input: number,
|
||||
output: number,
|
||||
duration: number,
|
||||
) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute),
|
||||
model_name: 'gpt-5.5',
|
||||
input_tokens: input,
|
||||
output_tokens: output,
|
||||
total_tokens: input + output,
|
||||
duration,
|
||||
cost: 0,
|
||||
status: 'success',
|
||||
bot_id: bot.id,
|
||||
bot_name: bot.name,
|
||||
pipeline_id: pipeline.id,
|
||||
pipeline_name: pipeline.name,
|
||||
session_id: 'session-pipeline-agent',
|
||||
message_id: messageId,
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, minute: number, messageId: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
timestamp: at(minute),
|
||||
tool_name: name,
|
||||
tool_source: 'native',
|
||||
duration: 120,
|
||||
status: 'success',
|
||||
bot_id: bot.id,
|
||||
bot_name: bot.name,
|
||||
pipeline_id: pipeline.id,
|
||||
pipeline_name: pipeline.name,
|
||||
session_id: 'session-pipeline-agent',
|
||||
message_id: messageId,
|
||||
arguments: JSON.stringify({ query: name }),
|
||||
result: JSON.stringify({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function monitoringData() {
|
||||
const messages = [
|
||||
message(
|
||||
'single-user',
|
||||
'user',
|
||||
1,
|
||||
'Pipeline single user message without reply',
|
||||
'session-pipeline-single',
|
||||
),
|
||||
message('agent-user', 'user', 10, 'Pipeline needs a deployment plan'),
|
||||
message(
|
||||
'agent-assistant-1',
|
||||
'assistant',
|
||||
11,
|
||||
'Pipeline agent step 1: inspect repository',
|
||||
),
|
||||
message(
|
||||
'agent-assistant-2',
|
||||
'assistant',
|
||||
12,
|
||||
'Pipeline agent step 2: run tests',
|
||||
),
|
||||
message(
|
||||
'agent-assistant-3',
|
||||
'assistant',
|
||||
13,
|
||||
'Pipeline final answer: deployment ready',
|
||||
),
|
||||
];
|
||||
const llmCalls = [
|
||||
llmCall('pipeline-call-1', 10, 'agent-user', 100, 40, 180),
|
||||
llmCall('pipeline-call-2', 11, 'agent-user', 140, 50, 220),
|
||||
];
|
||||
const toolCalls = [
|
||||
toolCall('pipeline-tool-1', 11, 'agent-user', 'repo_search'),
|
||||
toolCall('pipeline-tool-2', 12, 'agent-user', 'run_tests'),
|
||||
];
|
||||
|
||||
return {
|
||||
overview: {
|
||||
total_messages: messages.length,
|
||||
llm_calls: llmCalls.length,
|
||||
embedding_calls: 0,
|
||||
model_calls: llmCalls.length,
|
||||
success_rate: 100,
|
||||
active_sessions: 2,
|
||||
},
|
||||
messages,
|
||||
llmCalls,
|
||||
toolCalls,
|
||||
embeddingCalls: [],
|
||||
sessions: [],
|
||||
errors: [],
|
||||
totalCount: {
|
||||
messages: messages.length,
|
||||
llmCalls: llmCalls.length,
|
||||
toolCalls: toolCalls.length,
|
||||
embeddingCalls: 0,
|
||||
sessions: 0,
|
||||
errors: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test.describe('pipeline monitoring conversation turns', () => {
|
||||
test('uses conversation turns and folded tool calls in the pipeline dashboard', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
monitoringData: monitoringData(),
|
||||
});
|
||||
|
||||
await page.goto(`/home/pipelines?id=${pipeline.id}`);
|
||||
await page.getByRole('tab', { name: 'Dashboard' }).click();
|
||||
|
||||
await expect(page.getByText('2 conversation turns')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Pipeline single user message without reply'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Pipeline needs a deployment plan'),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText('Pipeline agent step 1: inspect repository'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Assistant +2')).toBeVisible();
|
||||
await expect(page.getByText('2 tools')).toBeVisible();
|
||||
|
||||
const agentTurn = page
|
||||
.locator('div[role="button"]')
|
||||
.filter({ hasText: 'Pipeline needs a deployment plan' });
|
||||
await expect(agentTurn).toHaveCount(1);
|
||||
await agentTurn.click();
|
||||
|
||||
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
|
||||
await expect(page.getByText('#1 repo_search')).toBeVisible();
|
||||
await expect(page.getByText('#2 run_tests')).toBeVisible();
|
||||
await expect(page.getByText('Arguments')).toHaveCount(0);
|
||||
await page.getByText('#1 repo_search').click();
|
||||
await expect(page.getByText('Arguments')).toBeVisible();
|
||||
await expect(page.getByText('Result')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
function wrapped(data: unknown) {
|
||||
return JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
test('loads a Cloud plugin page through the authenticated asset route', async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = makeWorkspaceEntry(
|
||||
'workspace-cloud',
|
||||
'Cloud Workspace',
|
||||
'cloud_projection',
|
||||
);
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [workspace],
|
||||
});
|
||||
|
||||
await page.route('**/api/v1/plugins', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped({
|
||||
plugins: [
|
||||
{
|
||||
install_source: 'marketplace',
|
||||
install_info: {},
|
||||
debug: false,
|
||||
manifest: {
|
||||
manifest: {
|
||||
metadata: {
|
||||
author: 'langbot-team',
|
||||
name: 'LangRAG',
|
||||
version: '0.1.9',
|
||||
label: { en_US: 'LangRAG', zh_Hans: 'LangRAG' },
|
||||
},
|
||||
spec: {
|
||||
pages: [
|
||||
{
|
||||
id: 'observability',
|
||||
path: 'components/pages/observability.html',
|
||||
label: { en_US: 'Observability', zh_Hans: '观测面板' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
let authenticatedAssetRequests = 0;
|
||||
await page.route(
|
||||
'**/api/v1/plugins/langbot-team/LangRAG/authenticated-assets/**',
|
||||
async (route) => {
|
||||
authenticatedAssetRequests += 1;
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'text/html',
|
||||
body: '<!doctype html><html><body><h1>LangRAG Observability</h1></body></html>',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto(
|
||||
'/home/plugin-pages?id=langbot-team%2FLangRAG%2Fobservability',
|
||||
);
|
||||
|
||||
await expect(
|
||||
page
|
||||
.frameLocator('iframe')
|
||||
.getByRole('heading', { name: 'LangRAG Observability' }),
|
||||
).toBeVisible();
|
||||
expect(authenticatedAssetRequests).toBeGreaterThan(0);
|
||||
await expect(page.getByText('Loading...')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
|
||||
function wrapped(data: unknown) {
|
||||
return JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function fulfill(
|
||||
route: Parameters<Parameters<import('@playwright/test').Page['route']>[1]>[0],
|
||||
data: unknown,
|
||||
) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: wrapped(data),
|
||||
});
|
||||
}
|
||||
|
||||
test('quota-reached create actions are disabled and explain the current limit', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
await page.route('**/api/v1/system/info', (route) =>
|
||||
fulfill(route, {
|
||||
debug: false,
|
||||
version: 'quota-e2e',
|
||||
edition: 'community',
|
||||
cloud_service_url: 'https://space.langbot.app',
|
||||
enable_marketplace: true,
|
||||
allow_modify_login_info: true,
|
||||
disable_models_service: false,
|
||||
limitation: {
|
||||
max_bots: 2,
|
||||
max_pipelines: 3,
|
||||
max_extensions: 3,
|
||||
max_knowledge_bases: 2,
|
||||
},
|
||||
outbound_ips: [],
|
||||
wizard_status: 'completed',
|
||||
wizard_progress: null,
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/platform/bots**', (route) =>
|
||||
fulfill(route, {
|
||||
bots: Array.from({ length: 2 }, (_, index) => ({
|
||||
uuid: `bot-${index}`,
|
||||
name: `Bot ${index + 1}`,
|
||||
description: '',
|
||||
adapter: 'aiocqhttp',
|
||||
enable: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/pipelines**', (route) =>
|
||||
fulfill(route, {
|
||||
pipelines: Array.from({ length: 3 }, (_, index) => ({
|
||||
uuid: `pipeline-${index}`,
|
||||
name: `Pipeline ${index + 1}`,
|
||||
description: '',
|
||||
emoji: '⚙️',
|
||||
updated_at: new Date().toISOString(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/knowledge/bases**', (route) =>
|
||||
fulfill(route, {
|
||||
bases: Array.from({ length: 2 }, (_, index) => ({
|
||||
uuid: `kb-${index}`,
|
||||
name: `Knowledge ${index + 1}`,
|
||||
description: '',
|
||||
emoji: '📚',
|
||||
updated_at: new Date().toISOString(),
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/plugins**', (route) =>
|
||||
fulfill(route, { plugins: [] }),
|
||||
);
|
||||
await page.route('**/api/v1/mcp/servers**', (route) =>
|
||||
fulfill(route, {
|
||||
servers: Array.from({ length: 3 }, (_, index) => ({
|
||||
name: `mcp-${index}`,
|
||||
mode: 'http',
|
||||
enable: true,
|
||||
runtime_info: { status: 'connected' },
|
||||
})),
|
||||
}),
|
||||
);
|
||||
await page.route('**/api/v1/skills**', (route) =>
|
||||
fulfill(route, { skills: [] }),
|
||||
);
|
||||
|
||||
await page.goto('/home/bots');
|
||||
|
||||
const botCreate = page.getByRole('button', {
|
||||
name: 'Create Bots',
|
||||
exact: true,
|
||||
});
|
||||
const pipelineCreate = page.getByRole('button', {
|
||||
name: 'Create Pipelines',
|
||||
exact: true,
|
||||
});
|
||||
const knowledgeCreate = page.getByRole('button', {
|
||||
name: 'Create Knowledge',
|
||||
exact: true,
|
||||
});
|
||||
const addExtension = page.getByRole('button', {
|
||||
name: 'Add Extension',
|
||||
exact: true,
|
||||
});
|
||||
|
||||
await expect(botCreate).toBeDisabled();
|
||||
await expect(pipelineCreate).toBeDisabled();
|
||||
await expect(knowledgeCreate).toBeDisabled();
|
||||
await expect(addExtension).toBeEnabled();
|
||||
|
||||
const botQuotaTrigger = botCreate.locator('..');
|
||||
await botQuotaTrigger.hover();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
),
|
||||
).toBeVisible();
|
||||
await botQuotaTrigger.focus();
|
||||
await expect(botQuotaTrigger).toBeFocused();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The Bots limit (2) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await addExtension.click();
|
||||
await expect(page).toHaveURL(/\/home\/add-extension$/);
|
||||
const manualAdd = page.getByRole('button', { name: 'Manual Add' });
|
||||
await expect(manualAdd).toBeDisabled();
|
||||
await manualAdd.locator('..').hover();
|
||||
await expect(
|
||||
page.getByText(
|
||||
'The Extensions limit (3) for this workspace has been reached. Delete one existing item before creating another.',
|
||||
),
|
||||
).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
const members = [
|
||||
{
|
||||
uuid: 'membership-owner',
|
||||
workspace_uuid: 'workspace-email-test',
|
||||
account_uuid: 'account-playwright',
|
||||
display_name: 'RockChinQ',
|
||||
email: 'rock@example.com',
|
||||
role: 'owner',
|
||||
status: 'active',
|
||||
joined_at: '2026-08-01T00:00:00Z',
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
uuid: 'membership-admin',
|
||||
workspace_uuid: 'workspace-email-test',
|
||||
account_uuid: 'account-admin',
|
||||
display_name: 'Junyan Qin',
|
||||
email: 'a.very.long.workspace.member.email.address@example-company.test',
|
||||
role: 'admin',
|
||||
status: 'active',
|
||||
joined_at: '2026-08-02T00:00:00Z',
|
||||
created_at: '2026-08-02T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test('workspace member list displays each member email without overlapping controls', async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const workspace = makeWorkspaceEntry(
|
||||
'workspace-email-test',
|
||||
'Email Test Workspace',
|
||||
'cloud_projection',
|
||||
);
|
||||
workspace.membership.display_name = 'RockChinQ';
|
||||
workspace.membership.email = 'rock@example.com';
|
||||
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [workspace],
|
||||
});
|
||||
await page.route(
|
||||
'**/api/v1/workspaces/workspace-email-test/members',
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ code: 0, message: 'ok', data: { members } }),
|
||||
}),
|
||||
);
|
||||
await page.route(
|
||||
'**/api/v1/workspaces/workspace-email-test/invitations',
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: { invitations: [] },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await page.goto('/home?action=showWorkspaceSettings');
|
||||
|
||||
await expect(page.getByText('RockChinQ')).toBeVisible();
|
||||
await expect(page.getByText('rock@example.com')).toBeVisible();
|
||||
await expect(page.getByText('Junyan Qin')).toBeVisible();
|
||||
const longEmail = page.getByText(
|
||||
'a.very.long.workspace.member.email.address@example-company.test',
|
||||
);
|
||||
await expect(longEmail).toBeVisible();
|
||||
await longEmail.scrollIntoViewIfNeeded();
|
||||
|
||||
const roleSelect = page.getByRole('combobox').last();
|
||||
const [emailBox, selectBox] = await Promise.all([
|
||||
longEmail.boundingBox(),
|
||||
roleSelect.boundingBox(),
|
||||
]);
|
||||
expect(emailBox).not.toBeNull();
|
||||
expect(selectBox).not.toBeNull();
|
||||
const overlaps =
|
||||
emailBox!.x < selectBox!.x + selectBox!.width &&
|
||||
emailBox!.x + emailBox!.width > selectBox!.x &&
|
||||
emailBox!.y < selectBox!.y + selectBox!.height &&
|
||||
emailBox!.y + emailBox!.height > selectBox!.y;
|
||||
expect(overlaps).toBe(false);
|
||||
expect(selectBox!.y).toBeGreaterThanOrEqual(emailBox!.y + emailBox!.height);
|
||||
await roleSelect.scrollIntoViewIfNeeded();
|
||||
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath('workspace-member-emails-mobile.png'),
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts',
|
||||
);
|
||||
|
||||
function loadNormalizer() {
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('DynamicFormSaveValues must not have runtime imports');
|
||||
},
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports.normalizeDynamicFormValuesForSave;
|
||||
}
|
||||
|
||||
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
||||
const normalizeDynamicFormValuesForSave = loadNormalizer();
|
||||
const specs = [
|
||||
{ name: 'single-line', type: 'string', default: '' },
|
||||
{ name: 'multiline', type: 'text', default: '' },
|
||||
{ name: 'string-list', type: 'array[string]', default: [] },
|
||||
{ name: 'count', type: 'integer', default: 0 },
|
||||
{
|
||||
name: 'model',
|
||||
type: 'model-fallback-selector',
|
||||
default: { primary: '', fallbacks: [], reasoning: {} },
|
||||
},
|
||||
];
|
||||
const values = {
|
||||
'single-line': '\t hello world \n',
|
||||
multiline: ' keep multiline whitespace \n',
|
||||
'string-list': [' first ', ' second '],
|
||||
count: 3,
|
||||
model: {
|
||||
primary: 'primary-model',
|
||||
fallbacks: ['fallback-model'],
|
||||
reasoning: {
|
||||
'primary-model': 'high',
|
||||
'fallback-model': 'provider_default',
|
||||
'removed-model': 'medium',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(normalizeDynamicFormValuesForSave(specs, values), {
|
||||
'single-line': 'hello world',
|
||||
multiline: ' keep multiline whitespace \n',
|
||||
'string-list': [' first ', ' second '],
|
||||
count: 3,
|
||||
model: {
|
||||
primary: 'primary-model',
|
||||
fallbacks: ['fallback-model'],
|
||||
reasoning: {
|
||||
'primary-model': 'high',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user