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:
fdc310
2026-08-12 16:44:33 +08:00
587 changed files with 108423 additions and 11182 deletions
+3
View File
@@ -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",
+216
View File
@@ -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:
+170 -30
View File
@@ -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' &&
+106 -43
View File
@@ -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);
+126 -99
View File
@@ -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>
);
}
+73 -57
View File
@@ -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
View File
@@ -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>
+78 -54
View File
@@ -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,
}),
);
+38 -265
View File
@@ -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 */}
+28 -17
View File
@@ -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) {
@@ -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);
}
@@ -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>
);
}
@@ -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;
+8
View File
@@ -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')}
+49 -33
View File
@@ -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>
);
+18 -8
View File
@@ -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>
);
+35
View File
@@ -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;
+67
View File
@@ -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 };
}
+274 -44
View File
@@ -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;
+31
View File
@@ -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 });
}
+32 -4
View File
@@ -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,
);
}
+281 -1
View File
@@ -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);
}
}
+19 -1
View File
@@ -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) {
+422
View File
@@ -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
View File
@@ -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
+78 -63
View File
@@ -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')}{' '}
+20 -4
View File
@@ -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]);
+191
View File
@@ -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} />;
}
+36
View File
@@ -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 };
+1 -1
View File
@@ -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,
};
}
+174 -5
View File
@@ -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',
+180 -3
View File
@@ -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;
+174 -4
View File
@@ -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: '無効なプラグインページ',
+178 -4
View File
@@ -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;
+176 -4
View File
@@ -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;
+178 -4
View File
@@ -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;
+161 -3
View File
@@ -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:
'请启用 Boxbox.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: '无效的插件页面',
+172 -3
View File
@@ -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:
'請啟用 Boxbox.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;
+10
View File
@@ -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);
});
+84 -1
View File
@@ -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 });
+287 -9
View File
@@ -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(
+328
View File
@@ -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
View File
@@ -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);
});
+453
View File
@@ -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();
});
});
+88
View File
@@ -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);
});
+149
View File
@@ -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