diff --git a/packages/global/common/system/types/index.ts b/packages/global/common/system/types/index.ts index 916511d4d9ac..e133c8e4ec14 100644 --- a/packages/global/common/system/types/index.ts +++ b/packages/global/common/system/types/index.ts @@ -147,6 +147,12 @@ export type FastGPTFeConfigsType = { agentSandboxFree?: boolean; // Beta features show_skill?: boolean; + /** host[:port] under which sandbox subdomains live, e.g. `localhost:3006`. */ + sandbox_proxy_base?: string; + /** http | https — scheme to build the iframe URL with. */ + sandbox_proxy_scheme?: 'http' | 'https'; + /** Sandbox proxy token TTL in seconds; used to avoid reusing expired iframe tokens. */ + sandbox_proxy_token_ttl?: number; }; export type SystemEnvType = { diff --git a/packages/global/core/ai/sandbox/proxyToken.ts b/packages/global/core/ai/sandbox/proxyToken.ts new file mode 100644 index 000000000000..bb1822660b1e --- /dev/null +++ b/packages/global/core/ai/sandbox/proxyToken.ts @@ -0,0 +1,12 @@ +// Shared between the FastGPT app (sign side) and sandbox-proxy (verify side). +// Keep this file dep-free so both standalone services can import without dragging extras. +export const SANDBOX_PROXY_AUTH_REFRESH_PATH = '/__fastgpt_proxy_auth'; + +export type ProxyTokenPayload = { + /** Sandbox provider id; bound to the iframe's subdomain on every request. */ + sid: string; + /** Inner port metadata. Currently informational; reserved for multi-port support. */ + p: number; + /** Direct upstream base URL (host:port form), bypassing opensandbox path-proxy. */ + t: string; +}; diff --git a/packages/global/openapi/core/agentSkills/api.ts b/packages/global/openapi/core/agentSkills/api.ts index fbc724219db5..f14c52514c2f 100644 --- a/packages/global/openapi/core/agentSkills/api.ts +++ b/packages/global/openapi/core/agentSkills/api.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { TeamMemberStatusEnum } from '../../../support/user/team/constant'; import { AgentSkillCategorySchema, AgentSkillListItemSchema, @@ -44,7 +45,7 @@ export const ListSkillsResponseItemSchema = AgentSkillListItemSchema.omit({ .object({ name: z.string(), avatar: z.string().nullable().optional(), - status: z.string() + status: z.nativeEnum(TeamMemberStatusEnum) }) .optional() }); @@ -266,7 +267,10 @@ export const AppsBySkillIdItemSchema = z.object({ }); export type AppsBySkillIdItem = z.infer; -export const ListAppsBySkillIdResponseSchema = z.array(AppsBySkillIdItemSchema); +export const ListAppsBySkillIdResponseSchema = z.object({ + list: z.array(AppsBySkillIdItemSchema), + hiddenCount: z.number().int().nonnegative().describe('当前用户无权限查看的引用应用数量') +}); export type ListAppsBySkillIdResponse = z.infer; export const CreateSkillFolderBodySchema = z.object({ diff --git a/packages/global/openapi/core/agentSkills/index.ts b/packages/global/openapi/core/agentSkills/index.ts index b87ffc706335..de7093eac84e 100644 --- a/packages/global/openapi/core/agentSkills/index.ts +++ b/packages/global/openapi/core/agentSkills/index.ts @@ -1,7 +1,7 @@ import type { OpenAPIPath } from '../../type'; import { TagsMap } from '../../tag'; import { - AppsBySkillIdItemSchema, + ListAppsBySkillIdResponseSchema, CreateEditDebugSandboxBodySchema, CreateEditDebugSandboxResponseSchema, CreateSkillBodySchema, @@ -197,7 +197,7 @@ export const AgentSkillsPath: OpenAPIPath = { description: '成功返回引用应用列表', content: { 'application/json': { - schema: AppsBySkillIdItemSchema.array() + schema: ListAppsBySkillIdResponseSchema } } } diff --git a/packages/global/package.json b/packages/global/package.json index 4fa9bdeac963..bf9b30bc8afb 100644 --- a/packages/global/package.json +++ b/packages/global/package.json @@ -1,6 +1,7 @@ { "name": "@fastgpt/global", "version": "1.0.0", + "type": "module", "scripts": { "test": "vitest run -c vitest.config.ts", "test:watch": "vitest -c vitest.config.ts" diff --git a/packages/service/core/sandbox/proxyToken.ts b/packages/service/core/sandbox/proxyToken.ts new file mode 100644 index 000000000000..8c47827b544b --- /dev/null +++ b/packages/service/core/sandbox/proxyToken.ts @@ -0,0 +1,12 @@ +import jwt from 'jsonwebtoken'; +import { serviceEnv } from '../../env'; +import type { ProxyTokenPayload } from '@fastgpt/global/core/ai/sandbox/proxyToken'; + +export const signSandboxProxyToken = ( + payload: ProxyTokenPayload +): { token: string; exp: number; ttl: number } => { + const ttl = serviceEnv.SANDBOX_PROXY_TOKEN_TTL; + const exp = Math.floor(Date.now() / 1000) + ttl; + const token = jwt.sign(payload, serviceEnv.SANDBOX_PROXY_SECRET!, { expiresIn: ttl }); + return { token, exp, ttl }; +}; diff --git a/packages/service/env.ts b/packages/service/env.ts index 671a05533aaf..d24019e845f4 100644 --- a/packages/service/env.ts +++ b/packages/service/env.ts @@ -249,6 +249,30 @@ export const serviceEnv = createEnv({ description: '评估任务 worker 并发数' }), + // Sandbox proxy (separate process). Each sandbox is reached at .. + // BASE is the host[:port] portion (no scheme, no leading dot). Subdomains are derived + // from this on the frontend; the secret is shared HMAC for JWT signing. + SANDBOX_PROXY_BASE: z.string().optional(), + SANDBOX_PROXY_SECRET: z.string().min(16).optional(), + /** + * JWT (and cookie Max-Age) lifetime in seconds. Default 3600 (1h) — still short enough to + * minimise revocation latency on team-membership changes. Active code-server + * WebSocket connections established within the window stay alive past TTL + * (TCP-pipe is opaque to JWT), so users editing continuously aren't interrupted + * at the boundary; only sporadic HTTP fetches and WS reconnects need a fresh + * token, at which point the iframe will reload the Skill page. + */ + SANDBOX_PROXY_TOKEN_TTL: NumSchema.int().positive().default(3600), + /** Whether the proxy serves over https (controls iframe scheme on the frontend). */ + SANDBOX_PROXY_HTTPS: BoolSchema.default(false), + /** + * 这里改写发生在 app 进程,但改写结果(host:port)会塞进 JWT.t 由 sandbox-proxy 进程消费—— + * 所以判定标准是 "sandbox-proxy 跑在哪儿": + * - sandbox-proxy 在容器/k8s 跑:false(默认;容器内 host.docker.internal 是宿主别名,保留即可) + * - sandbox-proxy 在宿主进程跑(如本地 dev 直接 tsx 起 proxy):手动设 true,宿主进程无 host.docker.internal 解析。 + */ + SANDBOX_PROXY_REPLACE_DOCKER_INTERNAL_WITH_LOCALHOST: BoolSchema.default(false), + // ==================== 资源限制 ==================== SERVICE_REQUEST_MAX_CONTENT_LENGTH: IntSchema.default(10).meta({ description: '服务器接收请求的最大大小(MB)' @@ -291,3 +315,9 @@ if (serviceEnv.WORKFLOW_PARALLEL_MAX_CONCURRENCY > serviceEnv.WORKFLOW_MAX_LOOP_ `Invalid environment configuration: WORKFLOW_PARALLEL_MAX_CONCURRENCY (${serviceEnv.WORKFLOW_PARALLEL_MAX_CONCURRENCY}) must not exceed WORKFLOW_MAX_LOOP_TIMES (${serviceEnv.WORKFLOW_MAX_LOOP_TIMES})` ); } + +if (serviceEnv.SHOW_SKILL && (!serviceEnv.SANDBOX_PROXY_BASE || !serviceEnv.SANDBOX_PROXY_SECRET)) { + throw new Error( + 'Invalid environment configuration: SHOW_SKILL=true requires both SANDBOX_PROXY_BASE and SANDBOX_PROXY_SECRET' + ); +} diff --git a/packages/web/components/common/Icon/constants.ts b/packages/web/components/common/Icon/constants.ts index 11727f7d9773..94601095bac7 100644 --- a/packages/web/components/common/Icon/constants.ts +++ b/packages/web/components/common/Icon/constants.ts @@ -106,13 +106,14 @@ export const iconPaths = { 'common/trash': () => import('./icons/common/trash.svg'), 'common/uploadFileFill': () => import('./icons/common/uploadFileFill.svg'), 'common/upperRight': () => import('./icons/common/upperRight.svg'), - 'common/user': () => import('./icons/common/user.svg'), + 'common/lineUser': () => import('./icons/common/lineUser.svg'), 'common/userInfo': () => import('./icons/common/userInfo.svg'), 'common/variable': () => import('./icons/common/variable.svg'), 'common/viewLight': () => import('./icons/common/viewLight.svg'), 'common/voiceLight': () => import('./icons/common/voiceLight.svg'), 'common/wallet': () => import('./icons/common/wallet.svg'), 'common/warn': () => import('./icons/common/warn.svg'), + 'common/exclamationMark': () => import('./icons/common/exclamationMark.svg'), 'common/wechat': () => import('./icons/common/wechat.svg'), 'common/wechatFill': () => import('./icons/common/wechatFill.svg'), 'common/wecom': () => import('./icons/common/wecom.svg'), diff --git a/packages/web/components/common/Icon/icons/common/exclamationMark.svg b/packages/web/components/common/Icon/icons/common/exclamationMark.svg new file mode 100644 index 000000000000..0af249dc936c --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/exclamationMark.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/common/lineUser.svg b/packages/web/components/common/Icon/icons/common/lineUser.svg new file mode 100644 index 000000000000..8fed2169baae --- /dev/null +++ b/packages/web/components/common/Icon/icons/common/lineUser.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/web/components/common/Icon/icons/common/skill.svg b/packages/web/components/common/Icon/icons/common/skill.svg index fad96a2e9379..c6ce85b2f08e 100644 --- a/packages/web/components/common/Icon/icons/common/skill.svg +++ b/packages/web/components/common/Icon/icons/common/skill.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/packages/web/components/common/Icon/icons/common/user.svg b/packages/web/components/common/Icon/icons/common/user.svg deleted file mode 100644 index e4cb074d7d0a..000000000000 --- a/packages/web/components/common/Icon/icons/common/user.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/packages/web/components/common/Icon/icons/core/skill/default.svg b/packages/web/components/common/Icon/icons/core/skill/default.svg index 491a1ff6c256..6f5c0a19e2f8 100644 --- a/packages/web/components/common/Icon/icons/core/skill/default.svg +++ b/packages/web/components/common/Icon/icons/core/skill/default.svg @@ -1 +1,22 @@ - \ No newline at end of file + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index abdd2cf493ab..7ddce683fe00 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -872,6 +872,7 @@ "navbar.Chat": "Portal", "navbar.Config": "Admin", "navbar.Datasets": "Dataset", + "navbar.Skill": "Skill", "navbar.Studio": "Studio", "navbar.Tools": "Tools", "navbar.plugin": "Plugins", diff --git a/packages/web/i18n/en/skill.json b/packages/web/i18n/en/skill.json index b5ba0a0c90f0..2f6105884e4a 100644 --- a/packages/web/i18n/en/skill.json +++ b/packages/web/i18n/en/skill.json @@ -4,12 +4,15 @@ "no_skills": "No skills yet", "copy_skill": "Create a copy", "related_apps_count": "{{count}} linked apps", - "delete_disabled_tip": "Cannot delete: linked apps exist", - "confirm_delete_tip": "Are you sure you want to delete this Skill?", + "confirm_delete_title": "Delete this skill?", + "confirm_delete_with_refs": "This skill is currently used by {{count}} app(s). After deletion, those apps will no longer be able to call it. Consider unlinking or backing up the configuration first.", + "confirm_delete_action": "Delete anyway", + "confirm_delete_cancel": "Keep it", "custom_skill": "Custom Skill", "custom_skill_desc": "Intelligently generate skill outlines and frameworks by describing skill requirements", "import_skill_zip": "Import zip", "related_count": "Related Apps", + "related_apps_hidden": "{{count}} more app(s) hidden due to permissions", "creator_tooltip": "Creator: {{creator}}", "update_time_tooltip": "Updated: {{updateTime}}", "permission_settings": "Permission Settings", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 5da10d595d77..33c076a2d79b 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -872,6 +872,7 @@ "navbar.Chat": "门户", "navbar.Config": "管理员", "navbar.Datasets": "知识库", + "navbar.Skill": "技能", "navbar.Studio": "工作台", "navbar.Tools": "我的工具", "navbar.plugin": "插件库", diff --git a/packages/web/i18n/zh-CN/skill.json b/packages/web/i18n/zh-CN/skill.json index 7a2bfb5ab306..ee3316004994 100644 --- a/packages/web/i18n/zh-CN/skill.json +++ b/packages/web/i18n/zh-CN/skill.json @@ -4,12 +4,15 @@ "no_skills": "暂无 Skill", "copy_skill": "创建副本", "related_apps_count": "关联应用 {{count}}", - "delete_disabled_tip": "存在关联应用,无法删除", - "confirm_delete_tip": "确认删除该 Skill?", + "confirm_delete_title": "确定删除该技能吗?", + "confirm_delete_with_refs": "该技能当前正被{{count}}个应用引用。删除后,相关应用将无法调用此技能。建议先解除关联或备份配置。", + "confirm_delete_action": "坚持删除", + "confirm_delete_cancel": "放弃删除", "custom_skill": "自定义 Skill", "custom_skill_desc": "通过描述 Skill 需求,智能生成 Skill 大纲及框架", "import_skill_zip": "导入压缩包", "related_count": "关联应用", + "related_apps_hidden": "其余 {{count}} 个应用无权限查看", "creator_tooltip": "创建人:{{creator}}", "update_time_tooltip": "更新时间:{{updateTime}}", "permission_settings": "权限设置", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 0b02196e16c1..290fad965980 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -863,6 +863,7 @@ "navbar.Account": "帳戶", "navbar.Chat": "門戶", "navbar.Datasets": "知識庫", + "navbar.Skill": "技能", "navbar.Studio": "工作區", "navbar.Tools": "我的工具", "navbar.plugin": "插件庫", diff --git a/packages/web/i18n/zh-Hant/skill.json b/packages/web/i18n/zh-Hant/skill.json index 4466d4252b93..57877e14603a 100644 --- a/packages/web/i18n/zh-Hant/skill.json +++ b/packages/web/i18n/zh-Hant/skill.json @@ -4,12 +4,15 @@ "no_skills": "暫無 Skill", "copy_skill": "建立副本", "related_apps_count": "關聯應用 {{count}}", - "delete_disabled_tip": "存在關聯應用,無法刪除", - "confirm_delete_tip": "確認刪除該 Skill?", + "confirm_delete_title": "確定刪除該技能嗎?", + "confirm_delete_with_refs": "該技能當前正被{{count}}個應用引用。刪除後,相關應用將無法調用此技能。建議先解除關聯或備份配置。", + "confirm_delete_action": "堅持刪除", + "confirm_delete_cancel": "放棄刪除", "custom_skill": "自定義 Skill", "custom_skill_desc": "通過描述 Skill 需求,智能生成 Skill 大綱及框架", "import_skill_zip": "導入壓縮包", "related_count": "關聯應用", + "related_apps_hidden": "其餘 {{count}} 個應用無權限查看", "creator_tooltip": "創建人:{{creator}}", "update_time_tooltip": "更新時間:{{updateTime}}", "permission_settings": "權限設置", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7843b9b025d5..5f051f55cd54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -907,7 +907,7 @@ importers: specifier: ^1.2.1 version: 1.2.1 crawlee: - specifier: ^3.16.0 + specifier: ^3.13.1 version: 3.16.0(@types/node@20.17.24)(bufferutil@4.1.0)(canvas@3.2.3)(puppeteer@23.11.1(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) date-fns: specifier: 'catalog:' @@ -1087,9 +1087,9 @@ importers: pro/sso: dependencies: - '@fastgpt-sdk/otel': + '@fastgpt-sdk/logger': specifier: workspace:* - version: link:../../sdk/otel + version: link:../../sdk/logger '@node-saml/node-saml': specifier: ^5.1.0 version: 5.1.0 @@ -1220,9 +1220,6 @@ importers: framer-motion: specifier: 9.1.7 version: 9.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - http-proxy: - specifier: ^1.18.1 - version: 1.18.1 hyperdown: specifier: ^2.4.29 version: 2.4.29 @@ -1350,9 +1347,6 @@ importers: '@types/archiver': specifier: ^6.0.2 version: 6.0.4 - '@types/http-proxy': - specifier: ^1.17.15 - version: 1.17.17 '@types/js-yaml': specifier: 'catalog:' version: 4.0.9 @@ -1603,6 +1597,58 @@ importers: specifier: ^5.0.1 version: 5.0.1 + projects/sandbox-proxy: + dependencies: + '@fastgpt-sdk/otel': + specifier: workspace:* + version: link:../../sdk/otel + '@fastgpt/global': + specifier: workspace:* + version: link:../../packages/global + cookie: + specifier: ^0.7.2 + version: 0.7.2 + dotenv: + specifier: ^17.3.1 + version: 17.4.2 + http-proxy: + specifier: ^1.18.1 + version: 1.18.1 + jsonwebtoken: + specifier: 'catalog:' + version: 9.0.3 + lru-cache: + specifier: ^11.3.6 + version: 11.3.6 + zod: + specifier: 'catalog:' + version: 4.1.12 + devDependencies: + '@types/cookie': + specifier: ^0.6.0 + version: 0.6.0 + '@types/http-proxy': + specifier: ^1.17.15 + version: 1.17.17 + '@types/jsonwebtoken': + specifier: 'catalog:' + version: 9.0.9 + '@types/node': + specifier: 'catalog:' + version: 20.17.24 + tsdown: + specifier: 'catalog:' + version: 0.21.10(typescript@5.9.3) + tsx: + specifier: 'catalog:' + version: 4.20.6 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@20.17.24)(@vitest/coverage-v8@4.1.5)(jsdom@26.1.0(bufferutil@4.1.0)(canvas@3.2.3)(utf-8-validate@5.0.10))(vite@6.2.2(@types/node@20.17.24)(jiti@2.7.0)(lightningcss@1.32.0)(sass@1.85.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)) + scripts/icon: dependencies: express: @@ -6201,6 +6247,9 @@ packages: '@types/cookie@0.5.4': resolution: {integrity: sha512-7z/eR6O859gyWIAjuvBWFzNURmf2oPBmJlfVWkwehU5nzIyjwBsTh7WMmEEV4JFnHuQ3ex4oyTvfKzcyJVDBNA==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/cors@2.8.19': resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} @@ -10485,8 +10534,8 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - lru-cache@11.2.7: - resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==} + lru-cache@11.3.6: + resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} lru-cache@5.1.1: @@ -20217,6 +20266,8 @@ snapshots: '@types/cookie@0.5.4': {} + '@types/cookie@0.6.0': {} + '@types/cors@2.8.19': dependencies: '@types/node': 20.17.24 @@ -23683,7 +23734,7 @@ snapshots: estree-util-value-to-estree: 3.5.0 fumadocs-core: 15.6.3(@types/react@18.3.1)(next@15.5.16(@opentelemetry/api@1.9.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(sass@1.85.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) js-yaml: 4.1.1 - lru-cache: 11.2.7 + lru-cache: 11.3.6 picocolors: 1.1.1 tinyexec: 1.0.4 tinyglobby: 0.2.15 @@ -25268,7 +25319,7 @@ snapshots: lru-cache@10.4.3: {} - lru-cache@11.2.7: {} + lru-cache@11.3.6: {} lru-cache@5.1.1: dependencies: @@ -26964,7 +27015,7 @@ snapshots: path-scurry@2.0.2: dependencies: - lru-cache: 11.2.7 + lru-cache: 11.3.6 minipass: 7.1.2 path-to-regexp@0.1.12: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1bea94d3126c..9c85b6e4632b 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - projects/code-sandbox - projects/marketplace - projects/mcp_server + - projects/sandbox-proxy - pro/admin - pro/sso diff --git a/pro b/pro index ee1b1d779dfa..cd0d9a7ca0e2 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit ee1b1d779dfa90b36503d0693c97437fd9ba7b6a +Subproject commit cd0d9a7ca0e200996bec3558287a4b652037dff4 diff --git a/projects/agent-sandbox/settings.json b/projects/agent-sandbox/settings.json index 5d1d675036cd..3c0785fbcd5e 100644 --- a/projects/agent-sandbox/settings.json +++ b/projects/agent-sandbox/settings.json @@ -27,5 +27,6 @@ "workbench.editor.showTabs": "none", "window.commandCenter": false, "workbench.editor.editorActionsLocation": "hidden", - "workbench.layoutControl.enabled": false + "workbench.layoutControl.enabled": false, + "workbench.secondarySideBar.defaultVisibility": "hidden" } diff --git a/projects/app/.env.template b/projects/app/.env.template index 1bd1ff3a0c63..dc358aa1a2cb 100644 --- a/projects/app/.env.template +++ b/projects/app/.env.template @@ -176,6 +176,20 @@ AGENT_ENGINE=default HELPER_BOT_MODEL=qwen-max SKIP_FILE_TYPE_CHECK=false +# Sandbox proxy(独立进程 projects/sandbox-proxy,子域路由) +# host[:port],sandbox iframe 加载于 . +SANDBOX_PROXY_BASE=localhost:3006 +# 与 sandbox-proxy 共享的 HMAC 密钥(≥16 位随机串),两侧必须一致 +SANDBOX_PROXY_SECRET= +# JWT TTL(秒),默认 3600 (1h) —— 即撤销延迟上限。 +# 活跃 WS 不受 TTL 影响,只有页面刷新或新 HTTP 请求才会触发重新签发。 +SANDBOX_PROXY_TOKEN_TTL=3600 +# proxy 是否走 https;dev 用 false,生产建议 true +SANDBOX_PROXY_HTTPS=false +# sandbox-proxy 跑在容器/k8s(默认 false,保留 host.docker.internal); +# 本地 dev 直接 tsx 起 proxy 时设 true,把 host.docker.internal 改写为 localhost。 +SANDBOX_PROXY_REPLACE_DOCKER_INTERNAL_WITH_LOCALHOST=false + # ==================== 对话日志推送(可选) ==================== # 日志服务地址 # CHAT_LOG_URL=http://localhost:8080 diff --git a/projects/app/Dockerfile b/projects/app/Dockerfile index a2a4936500a3..4ca75db8cabb 100644 --- a/projects/app/Dockerfile +++ b/projects/app/Dockerfile @@ -48,17 +48,6 @@ ENV NEXT_PUBLIC_BASE_URL=$base_url RUN pnpm build:sdks RUN pnpm --filter=app build -# Bundle server.ts into a single CJS file; only 'next' is kept external (already in standalone output) -# Banner: mirrors what standalone server.js does — chdir to __dirname and load pre-serialized -# Next.js config so that next() skips webpack (config-utils) during startup. -RUN cd projects/app && ./node_modules/.bin/esbuild server.ts \ - --bundle \ - --platform=node \ - --format=cjs \ - --outfile=server-proxy.js \ - --external:next \ - '--banner:js=process.chdir(__dirname);try{const d=require("./.next/required-server-files.json");process.env.__NEXT_PRIVATE_STANDALONE_CONFIG=JSON.stringify(d.config);}catch(e){}' - # Remove build-time-only packages from standalone output before copying to runner. # These are traced into standalone by mistake (rspack bindings, gnu platform binaries, etc.) RUN rm -rf projects/app/.next/standalone/node_modules/.pnpm/@next+rspack-binding-*/ \ @@ -74,8 +63,6 @@ WORKDIR /app ARG proxy ARG base_url -ARG SHOW_SKILL=false -ENV SHOW_SKILL=$SHOW_SKILL # create user and use it RUN addgroup --system --gid 1001 nodejs @@ -120,4 +107,4 @@ EXPOSE 3000 USER nextjs -ENTRYPOINT ["sh","-c","if [ \"$SHOW_SKILL\" = \"true\" ]; then node --max-old-space-size=4096 ./projects/app/server-proxy.js; else node --max-old-space-size=4096 ./projects/app/server.js; fi"] +ENTRYPOINT ["node","--max-old-space-size=4096","./projects/app/server.js"] diff --git a/projects/app/package.json b/projects/app/package.json index 10e66463ee23..8bf8210cf058 100644 --- a/projects/app/package.json +++ b/projects/app/package.json @@ -50,7 +50,6 @@ "echarts-gl": "2.0.9", "esbuild": "^0.25.11", "framer-motion": "9.1.7", - "http-proxy": "^1.18.1", "hyperdown": "^2.4.29", "i18next": "catalog:", "immer": "^9.0.19", @@ -95,7 +94,6 @@ "@next/bundle-analyzer": "16.1.6", "@svgr/webpack": "catalog:", "@types/archiver": "^6.0.2", - "@types/http-proxy": "^1.17.15", "@types/js-yaml": "catalog:", "@types/jsonwebtoken": "catalog:", "@types/lodash": "catalog:", diff --git a/projects/app/server.ts b/projects/app/server.ts deleted file mode 100644 index a8d6e2360ba9..000000000000 --- a/projects/app/server.ts +++ /dev/null @@ -1,600 +0,0 @@ -import type { IncomingMessage } from 'http'; -import { createServer, ServerResponse } from 'http'; -import next from 'next'; -import httpProxy from 'http-proxy'; -import { Readable } from 'stream'; -import net from 'net'; -import crypto from 'crypto'; - -const dev = process.env.NODE_ENV !== 'production'; -const port = parseInt(process.env.PORT || '3000', 10); - -// sandboxId: alphanumeric + hyphens, 8–64 chars, must start/end with alnum. -// Explicitly excludes '.', '/', '%', '..', and other path-traversal characters. -const SANDBOX_ID_RE = /[a-zA-Z0-9][a-zA-Z0-9-]{6,62}[a-zA-Z0-9]/; - -// Match /proxy/{sandboxId}/{port} or /absproxy/{sandboxId}/{port} -const PATH_PROXY_RE = new RegExp(`^\\/(proxy|absproxy)\\/(${SANDBOX_ID_RE.source})\\/(\\d+)`); - -// Match /tcptunnel/{sandboxId}/{port} — WebSocket upgrade only -const TCPTUNNEL_RE = new RegExp(`^\\/tcptunnel\\/(${SANDBOX_ID_RE.source})\\/(\\d+)`); - -// Strip subdomain prefix from a host string (may include :port). -// "port--uuid.localhost:3000" → "localhost:3000" -function deriveBaseHost(subdomainHost: string): string { - const dotIdx = subdomainHost.indexOf('.'); - return dotIdx >= 0 ? subdomainHost.substring(dotIdx + 1) : subdomainHost; -} - -async function main() { - const app = next({ dev }); - const handle = app.getRequestHandler(); - await app.prepare(); - - // Import pure utilities from sandboxProxyUtils — no service-layer deps, safe in tsx CJS mode. - // getSandboxProxyTarget is NOT imported here; auth is delegated to the proxyAuth API route. - const { - parseSubdomainProxy, - rewriteHtml, - redeemRelayToken, - ensureCodeServerSession, - deleteCsSession - } = (await import( - './src/service/core/sandbox/proxyUtils' - )) as typeof import('./src/service/core/sandbox/proxyUtils'); - - // Fetch the code-server password from the container config.yaml via the internal API. - async function fetchCodeServerPassword(sandboxId: string): Promise { - try { - const resp = await fetch(`http://127.0.0.1:${port}/api/core/sandbox/proxyCSPassword`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ sandboxId }) - }); - if (!resp.ok) return null; - const { password } = await resp.json(); - return password || null; - } catch { - return null; - } - } - - // Inject code-server session cookie into an outgoing request header object. - function injectCsKey(reqHeaders: IncomingMessage['headers'], key: string): void { - const existing = (reqHeaders.cookie as string | undefined) ?? ''; - const stripped = existing - .split(';') - .map((s) => s.trim()) - .filter((s) => !s.toLowerCase().startsWith('code-server-session=')) - .join('; '); - reqHeaders.cookie = stripped - ? `${stripped}; code-server-session=${key}` - : `code-server-session=${key}`; - } - - // Build the correct code-server login base URL. - // After prefix stripping, req.url starts with /proxy/8080/... when going through execd. - // In that case the login endpoint is at target/proxy/8080, not target/login directly. - function deriveCsLoginTarget(target: string, url: string): string { - const m = url.match(/^\/proxy\/(\d+)/); - return m ? `${target}/proxy/${m[1]}` : target; - } - - // Ensure code-server is authenticated and inject the session cookie into reqHeaders. - async function injectCodeServerAuth( - reqHeaders: IncomingMessage['headers'], - sandboxId: string, - target: string - ): Promise { - const key = await ensureCodeServerSession(sandboxId, target, () => - fetchCodeServerPassword(sandboxId) - ); - if (key) injectCsKey(reqHeaders, key); - } - - const proxy = httpProxy.createProxyServer({ xfwd: true, changeOrigin: true }); - proxy.on( - 'error', - (err: Error, _req: IncomingMessage, res: ServerResponse | import('stream').Duplex) => { - if (res instanceof ServerResponse && !res.headersSent) { - res.writeHead(502, { 'Content-Type': 'text/plain' }); - res.end(`Proxy error: ${err.message}`); - } - } - ); - - // Detect code-server session expiry: if the upstream returns a 302 to /login, - // evict the cached CS session so the next request triggers a fresh login. - proxy.on('proxyRes', (proxyRes, req) => { - if ( - proxyRes.statusCode === 302 && - typeof proxyRes.headers.location === 'string' && - proxyRes.headers.location.includes('/login') - ) { - const sid = (req as IncomingMessage).headers['x-fastgpt-sandbox-id'] as string | undefined; - if (sid) { - dev && console.log(`[proxy:cs] session expired, evicting csSession sandboxId=${sid}`); - deleteCsSession(sid); - } - } - }); - - // absproxy: fetch upstream then rewrite HTML paths with base prefix - async function handleAbsProxy( - req: IncomingMessage, - res: ServerResponse, - target: string, - sandboxId: string, - targetPort: string - ) { - const upstreamUrl = `${target}${req.url || '/'}`; - const response = await fetch(upstreamUrl, { - method: req.method, - headers: buildProxyHeaders(req.headers), - // @ts-ignore — Node 18+ supports duplex on fetch body streams - duplex: 'half', - body: req.method !== 'GET' && req.method !== 'HEAD' ? (req as any) : undefined - }); - - const skipHeaders = new Set([ - 'content-encoding', - 'transfer-encoding', - 'x-frame-options', - 'content-security-policy' - ]); - response.headers.forEach((value, key) => { - if (!skipHeaders.has(key.toLowerCase())) res.setHeader(key, value); - }); - res.statusCode = response.status; - - const contentType = response.headers.get('content-type') || ''; - const contentLength = Number(response.headers.get('content-length') || 0); - - // Only rewrite HTML; stream large or binary responses directly - if (contentType.includes('text/html') && response.body && contentLength < 10 * 1024 * 1024) { - const html = await response.text(); - const basePath = `/absproxy/${sandboxId}/${targetPort}`; - const rewritten = rewriteHtml(html, basePath); - res.setHeader('content-length', Buffer.byteLength(rewritten)); - res.end(rewritten); - } else if (response.body) { - Readable.fromWeb(response.body as any).pipe(res); - } else { - res.end(); - } - } - - async function handleProxy( - req: IncomingMessage, - res: ServerResponse, - sandboxId: string, - portNum: number, - proxyType: string - ) { - try { - const target = await authProxyTarget(req.headers, sandboxId, portNum); - const csTarget = deriveCsLoginTarget(target, req.url || ''); - if (proxyType === 'absproxy') { - await injectCodeServerAuth(req.headers, sandboxId, csTarget); - await handleAbsProxy(req, res, target, sandboxId, String(portNum)); - } else { - // Rewrite Origin so code-server's CSRF check passes (changeOrigin only rewrites Host). - const targetUrl = new URL(target); - await injectCodeServerAuth(req.headers, sandboxId, csTarget); - // Mark the request so the proxyRes handler can identify the sandbox on session expiry. - req.headers['x-fastgpt-sandbox-id'] = sandboxId; - proxy.web(req, res, { - target, - headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } - }); - } - } catch (err: any) { - const status = err.statusCode || 502; - if (!res.headersSent) { - res.writeHead(status, { 'Content-Type': 'text/plain' }); - res.end(err.message || 'Proxy error'); - } - } - } - - // Subdomain proxy handler: on auth failure (401/403) redirect to proxyAuth for cross-domain cookie hand-off. - async function handleSubdomainProxy( - req: IncomingMessage, - res: ServerResponse, - sandboxId: string, - portNum: number - ) { - // Check for relay token in query string (?__pt=). - // proxyAuth GET redirects here after storing fastgptToken server-side. - // We set the cookie from this subdomain so Chrome scopes it correctly. - const urlObj = new URL(`http://placeholder${req.url || '/'}`); - const relayToken = urlObj.searchParams.get('__pt'); - if (relayToken) { - const fastgptToken = redeemRelayToken(relayToken); - if (fastgptToken) { - urlObj.searchParams.delete('__pt'); - const cleanUrl = urlObj.pathname + (urlObj.search !== '?' ? urlObj.search : ''); - dev && - console.log( - `[proxy:subdomain] relay token redeemed, setting cookie and redirecting to ${cleanUrl}` - ); - res.setHeader( - 'Set-Cookie', - `fastgpt_token=${fastgptToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=604800` - ); - res.writeHead(302, { Location: cleanUrl || '/' }); - res.end(); - return; - } - console.warn(`[proxy:subdomain] relay token invalid or expired: ${relayToken}`); - } - - try { - const target = await authProxyTarget(req.headers, sandboxId, portNum); - const targetUrl = new URL(target); - const csTarget = deriveCsLoginTarget(target, req.url || ''); - await injectCodeServerAuth(req.headers, sandboxId, csTarget); - req.headers['x-fastgpt-sandbox-id'] = sandboxId; - proxy.web(req, res, { - target, - headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } - }); - } catch (err: any) { - const status = err.statusCode || 502; - // Auth failure — redirect to proxyAuth on the base origin for cookie hand-off - if (status === 401 || status === 403) { - const host = req.headers.host!; - const proto = (req.headers['x-forwarded-proto'] as string) || 'http'; - const originalUrl = `${proto}://${host}${req.url || '/'}`; - const authBase = `${proto}://${deriveBaseHost(host)}`; - const authUrl = new URL(`${authBase}/api/core/sandbox/proxyAuth`); - authUrl.searchParams.set('sandboxId', sandboxId); - authUrl.searchParams.set('port', String(portNum)); - authUrl.searchParams.set('next', originalUrl); - console.warn( - `[proxy:subdomain] auth failed (${status}), redirecting to proxyAuth. next=${originalUrl}` - ); - res.writeHead(302, { Location: authUrl.toString() }); - res.end(); - return; - } - if (!res.headersSent) { - res.writeHead(status, { 'Content-Type': 'text/plain' }); - res.end(err.message || 'Proxy error'); - } - } - } - - const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { - const parsedUrl = new URL(req.url || '/', 'http://localhost'); - - // ① Check subdomain proxy first: {port}--{sandboxId}.{baseDomain} - const subdomain = parseSubdomainProxy(req.headers.host); - if (subdomain) { - await handleSubdomainProxy(req, res, subdomain.sandboxId, subdomain.port); - return; - } - - // ② Path-based proxy: /proxy/{sandboxId}/{port} or /absproxy/{sandboxId}/{port} - const match = parsedUrl.pathname?.match(PATH_PROXY_RE); - if (match) { - const [, proxyType, sandboxId, portStr] = match; - // Strip proxy prefix so upstream sees the real path - req.url = req.url!.replace(`/${proxyType}/${sandboxId}/${portStr}`, '') || '/'; - await handleProxy(req, res, sandboxId, Number(portStr), proxyType); - return; - } - - // ③ Fall through to Next.js handler - handle(req, res); - }); - - // WebSocket upgrade handler — supports all three proxy modes - server.on('upgrade', async (req: IncomingMessage, socket, head) => { - // ① tcptunnel: raw TCP-over-WebSocket, handled before all other upgrade logic - const tunnelMatch = req.url?.match(TCPTUNNEL_RE); - if (tunnelMatch) { - const tunnelSandboxId = tunnelMatch[1]; - const tunnelPort = Number(tunnelMatch[2]); - dev && - console.log( - `[proxy:tcptunnel] upgrade sandboxId=${tunnelSandboxId} port=${tunnelPort} hasCookie=${!!req.headers.cookie}` - ); - - let target: string; - try { - target = await authProxyTarget(req.headers, tunnelSandboxId, tunnelPort); - dev && console.log(`[proxy:tcptunnel] auth ok target=${target}`); - } catch (err: any) { - const status = err.statusCode || 502; - console.error(`[proxy:tcptunnel] auth failed status=${status} message=${err.message}`); - socket.write(`HTTP/1.1 ${status} ${err.message || 'Auth error'}\r\n\r\n`); - socket.destroy(); - return; - } - - // Parse host from auth target URL - const targetUrl = new URL(target); - const containerHost = targetUrl.hostname; - const containerPort = tunnelPort; - - // Complete WebSocket handshake (RFC 6455 §4.2.2) - const wsKey = req.headers['sec-websocket-key']; - if (!wsKey) { - socket.write('HTTP/1.1 400 Missing Sec-WebSocket-Key\r\n\r\n'); - socket.destroy(); - return; - } - const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; - const acceptKey = crypto - .createHash('sha1') - .update(wsKey + WS_GUID) - .digest('base64'); - - // Connect to TCP target first, then send 101 after connect - const tcpSocket = net.createConnection({ host: containerHost, port: containerPort }); - let closed = false; - - function cleanup() { - if (closed) return; - closed = true; - tcpSocket.destroy(); - socket.destroy(); - } - - tcpSocket.once('connect', () => { - dev && - console.log( - `[proxy:tcptunnel] TCP connected host=${containerHost} port=${containerPort}` - ); - - // Send 101 after TCP is ready - socket.write( - 'HTTP/1.1 101 Switching Protocols\r\n' + - 'Upgrade: websocket\r\n' + - 'Connection: Upgrade\r\n' + - `Sec-WebSocket-Accept: ${acceptKey}\r\n` + - '\r\n' - ); - - // Flush any buffered data that came in with the upgrade request - const decoder = new WsFrameDecoder(); - if (head && head.length > 0) { - for (const payload of decoder.push(head)) { - if (payload.length === 0) { - cleanup(); - return; - } - tcpSocket.write(payload); - } - } - - // Browser → TCP: decode WS frames, write raw bytes to tcpSocket - socket.on('data', (chunk: Buffer) => { - if (closed) return; - for (const payload of decoder.push(chunk)) { - if (payload.length === 0) { - cleanup(); - return; - } - tcpSocket.write(payload); - } - }); - - // TCP → Browser: wrap raw bytes in WS binary frames - tcpSocket.on('data', (chunk: Buffer) => { - if (closed) return; - socket.write(encodeWsFrame(chunk)); - }); - }); - - tcpSocket.once('error', (err) => { - console.error(`[proxy:tcptunnel] TCP error: ${err.message}`); - cleanup(); - }); - tcpSocket.once('close', cleanup); - socket.once('error', cleanup); - socket.once('close', cleanup); - return; - } - - let sandboxId: string; - let portNum: number; - let proxyType = 'proxy'; - - const subdomain = parseSubdomainProxy(req.headers.host); - if (subdomain) { - sandboxId = subdomain.sandboxId; - portNum = subdomain.port; - } else { - const match = req.url?.match(PATH_PROXY_RE); - if (!match) { - dev && console.log(`[proxy:ws] no match, destroying socket. url=${req.url}`); - socket.destroy(); - return; - } - proxyType = match[1]; - sandboxId = match[2]; - portNum = Number(match[3]); - req.url = req.url!.replace(`/${proxyType}/${sandboxId}/${portNum}`, '') || '/'; - } - - dev && - console.log( - `[proxy:ws] upgrade sandboxId=${sandboxId} port=${portNum} url=${req.url} hasCookie=${!!req.headers.cookie}` - ); - - try { - const target = await authProxyTarget(req.headers, sandboxId, portNum); - dev && console.log(`[proxy:ws] auth ok, forwarding to target=${target}`); - // Rewrite Origin to match the target host so code-server's CSRF check passes. - // changeOrigin:true only rewrites Host, not Origin. - const targetUrl = new URL(target); - const csTarget = deriveCsLoginTarget(target, req.url || ''); - await injectCodeServerAuth(req.headers, sandboxId, csTarget); - req.headers['x-fastgpt-sandbox-id'] = sandboxId; - proxy.ws(req, socket, head, { - target, - headers: { origin: `${targetUrl.protocol}//${targetUrl.host}` } - }); - } catch (err: any) { - const status = err.statusCode || 502; - console.error(`[proxy:ws] auth failed status=${status} message=${err.message}`); - socket.write(`HTTP/1.1 ${status} ${err.message || 'Proxy error'}\r\n\r\n`); - socket.destroy(); - } - }); - - server.listen(port, () => { - console.log(`> Ready on http://localhost:${port} [${dev ? 'dev' : 'production'}]`); - }); -} - -// Authenticate a sandbox proxy request via the internal Next.js API route. -// This avoids importing @fastgpt/service (ESM-only deps) directly in server.ts. -async function authProxyTarget( - reqHeaders: IncomingMessage['headers'], - sandboxId: string, - targetPort: number -): Promise { - dev && - console.log( - `[proxy:auth] POST proxyAuth sandboxId=${sandboxId} port=${targetPort} hasCookie=${!!reqHeaders.cookie}` - ); - const authResp = await fetch(`http://127.0.0.1:${port}/api/core/sandbox/proxyAuth`, { - method: 'POST', - headers: { - 'content-type': 'application/json', - ...(reqHeaders.cookie ? { cookie: reqHeaders.cookie as string } : {}), - ...(reqHeaders.authorization ? { authorization: reqHeaders.authorization as string } : {}) - }, - body: JSON.stringify({ sandboxId, targetPort }) - }); - - if (!authResp.ok) { - // NextAPI always returns HTTP 500 for errors; read the real code from JSON body - const body = await authResp.json().catch(() => ({ code: authResp.status })); - const code = body?.code || authResp.status; - const msg = body?.message || body?.error || 'Auth failed'; - console.error( - `[proxy:auth] proxyAuth failed httpStatus=${authResp.status} code=${code} message=${msg}` - ); - throw Object.assign(new Error(msg), { statusCode: code }); - } - - const { target } = await authResp.json(); - dev && console.log(`[proxy:auth] proxyAuth ok target=${target}`); - return target as string; -} - -// Build upstream request headers, dropping hop-by-hop headers -function buildProxyHeaders(headers: IncomingMessage['headers']): Record { - const hopByHop = new Set([ - 'host', - 'connection', - 'keep-alive', - 'proxy-authenticate', - 'proxy-authorization', - 'te', - 'trailers', - 'transfer-encoding', - 'upgrade' - ]); - const result: Record = {}; - for (const [key, value] of Object.entries(headers)) { - if (hopByHop.has(key.toLowerCase())) continue; - if (value) result[key] = Array.isArray(value) ? value.join(', ') : value; - } - return result; -} - -// RFC 6455 WebSocket frame decoder with buffer accumulation. -// Handles fragmented frames that arrive across multiple TCP chunks. -class WsFrameDecoder { - private buf: Buffer = Buffer.alloc(0); - - // Push a new chunk; returns list of decoded payloads. - // An empty Buffer in the list signals a close frame (opcode 0x8). - push(chunk: Buffer): Buffer[] { - this.buf = Buffer.concat([this.buf, chunk]); - const payloads: Buffer[] = []; - - while (this.buf.length >= 2) { - const b0 = this.buf[0]; - const b1 = this.buf[1]; - const opcode = b0 & 0x0f; - const masked = (b1 & 0x80) !== 0; - let payloadLen = b1 & 0x7f; - let headerLen = 2; - - if (payloadLen === 126) { - if (this.buf.length < 4) break; - payloadLen = this.buf.readUInt16BE(2); - headerLen = 4; - } else if (payloadLen === 127) { - if (this.buf.length < 10) break; - // Only handle payloads up to 2^32; high 4 bytes are expected to be 0 - payloadLen = this.buf.readUInt32BE(6); - headerLen = 10; - } - - if (masked) headerLen += 4; - const totalLen = headerLen + payloadLen; - if (this.buf.length < totalLen) break; - - const payload = Buffer.allocUnsafe(payloadLen); - if (masked) { - const maskOffset = headerLen - 4; - for (let i = 0; i < payloadLen; i++) { - payload[i] = this.buf[headerLen + i] ^ this.buf[maskOffset + (i & 3)]; - } - } else { - this.buf.copy(payload, 0, headerLen, totalLen); - } - - this.buf = this.buf.subarray(totalLen); - - if (opcode === 0x8) { - // Close frame — signal EOF - payloads.push(Buffer.alloc(0)); - break; - } - // data frame (text=0x1, binary=0x2, continuation=0x0) or ping(0x9)/pong(0xa) ignored - if (opcode === 0x1 || opcode === 0x2 || opcode === 0x0) { - payloads.push(payload); - } - } - - return payloads; - } -} - -// Encode raw bytes as a WebSocket binary frame (server→client, no masking). -function encodeWsFrame(data: Buffer): Buffer { - const len = data.length; - let header: Buffer; - - if (len <= 125) { - header = Buffer.allocUnsafe(2); - header[0] = 0x82; // FIN=1, opcode=0x2 (binary) - header[1] = len; - } else if (len <= 65535) { - header = Buffer.allocUnsafe(4); - header[0] = 0x82; - header[1] = 126; - header.writeUInt16BE(len, 2); - } else { - header = Buffer.allocUnsafe(10); - header[0] = 0x82; - header[1] = 127; - header.writeUInt32BE(0, 2); - header.writeUInt32BE(len, 6); - } - - return Buffer.concat([header, data]); -} - -main().catch((err) => { - console.error('Failed to start server:', err); - process.exit(1); -}); diff --git a/projects/app/src/components/Layout/navbar.tsx b/projects/app/src/components/Layout/navbar.tsx index 047f2a654f59..ca045507f72a 100644 --- a/projects/app/src/components/Layout/navbar.tsx +++ b/projects/app/src/components/Layout/navbar.tsx @@ -116,8 +116,8 @@ const Navbar = ({ unread }: { unread: number }) => { const isDashboardPage = useMemo(() => { return router.pathname.startsWith('/dashboard'); }, [router.pathname]); - const isAppDetailPage = useMemo(() => { - return router.pathname.startsWith('/app/detail'); + const isDetailPage = useMemo(() => { + return router.pathname.startsWith('/app/detail') || router.pathname.startsWith('/skill/detail'); }, [router.pathname]); return ( @@ -129,7 +129,7 @@ const Navbar = ({ unread }: { unread: number }) => { w={'100%'} userSelect={'none'} pb={2} - bg={isDashboardPage ? 'myGray.50' : isAppDetailPage ? 'myGray.25' : 'transparent'} + bg={isDashboardPage ? 'myGray.50' : isDetailPage ? 'myGray.25' : 'transparent'} > {/* logo */} @@ -153,7 +153,7 @@ const Navbar = ({ unread }: { unread: number }) => { : { bg: 'transparent', _hover: { - bg: isDashboardPage || isAppDetailPage ? 'white' : 'rgba(255,255,255,0.9)' + bg: isDashboardPage || isDetailPage ? 'white' : 'rgba(255,255,255,0.9)' } })} {...(item.link !== router.asPath diff --git a/projects/app/src/pageComponents/dashboard/Container.tsx b/projects/app/src/pageComponents/dashboard/Container.tsx index 46487c3589c9..e044c0ccc1a7 100644 --- a/projects/app/src/pageComponents/dashboard/Container.tsx +++ b/projects/app/src/pageComponents/dashboard/Container.tsx @@ -112,7 +112,7 @@ const DashboardContainer = ({ { groupId: TabEnum.agent, groupAvatar: 'core/chat/sidebar/star', - groupName: 'Agent', + groupName: 'Agents', children: [ { isActive: !currentType, @@ -139,7 +139,7 @@ const DashboardContainer = ({ { groupId: TabEnum.skill, groupAvatar: 'common/skill', - groupName: 'Skill', + groupName: t('common:navbar.Skill'), children: [] } ] diff --git a/projects/app/src/pageComponents/dashboard/ListCreateCard.tsx b/projects/app/src/pageComponents/dashboard/ListCreateCard.tsx new file mode 100644 index 000000000000..6106b58f359c --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/ListCreateCard.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { Box } from '@chakra-ui/react'; +import MyBox from '@fastgpt/web/components/common/MyBox'; +import MyIcon from '@fastgpt/web/components/common/Icon'; +import { useTranslation } from 'next-i18next'; + +const ListCreateCard = ({ onClick, label }: { onClick: () => void; label?: string }) => { + const { t } = useTranslation(); + + return ( + + + {label ?? t('common:new_create')} + + + + + + + + + ); +}; + +export default ListCreateCard; diff --git a/projects/app/src/pageComponents/dashboard/agent/List.tsx b/projects/app/src/pageComponents/dashboard/agent/List.tsx index 369d9c538302..125e1e52c0ab 100644 --- a/projects/app/src/pageComponents/dashboard/agent/List.tsx +++ b/projects/app/src/pageComponents/dashboard/agent/List.tsx @@ -42,6 +42,7 @@ import { getWebReqUrl } from '@fastgpt/web/common/system/utils'; import { createAppTypeMap } from '@/pageComponents/app/constants'; import { useUserStore } from '@/web/support/user/useUserStore'; import EmptyTip from '@fastgpt/web/components/common/EmptyTip'; +import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard'; const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal')); const ConfigPerModal = dynamic(() => import('@/components/support/permission/ConfigPerModal')); @@ -587,7 +588,6 @@ const CreateButton = ({ appType }: { appType: AppTypeEnum | 'all' }) => { ); }; const ListCreateButton = ({ appType }: { appType: AppTypeEnum | 'all' }) => { - const { t } = useTranslation(); const router = useRouter(); const parentId = router.query.parentId; const createAppType = @@ -598,67 +598,13 @@ const ListCreateButton = ({ appType }: { appType: AppTypeEnum | 'all' }) => { : AppTypeEnum.workflowTool; return ( - { router.push( `/dashboard/create?appType=${createAppType}${parentId ? `&parentId=${parentId}` : ''}` ); }} - > - - {t('common:new_create')} - - - - - - - - + /> ); }; const ForbiddenCreateButton = () => { diff --git a/projects/app/src/pageComponents/dashboard/skill/ConfirmDeleteSkillModal.tsx b/projects/app/src/pageComponents/dashboard/skill/ConfirmDeleteSkillModal.tsx new file mode 100644 index 000000000000..be9b91bff517 --- /dev/null +++ b/projects/app/src/pageComponents/dashboard/skill/ConfirmDeleteSkillModal.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { Box, Button, Flex, HStack } from '@chakra-ui/react'; +import { Trans, useTranslation } from 'next-i18next'; +import MyModal from '@fastgpt/web/components/v2/common/MyModal'; +import MyIcon from '@fastgpt/web/components/common/Icon'; + +type Props = { + isOpen: boolean; + refsCount: number; + onClose: () => void; + onConfirm: () => Promise | unknown; +}; + +const ConfirmDeleteSkillModal = ({ isOpen, refsCount, onClose, onConfirm }: Props) => { + const { t } = useTranslation(); + const [requesting, setRequesting] = useState(false); + + return ( + + + + + + + + {t('skill:confirm_delete_title')} + + + + + }} + /> + + + + + + + + + ); +}; + +export default ConfirmDeleteSkillModal; diff --git a/projects/app/src/pageComponents/dashboard/skill/List.tsx b/projects/app/src/pageComponents/dashboard/skill/List.tsx index 4774d706d214..0bbec1804e74 100644 --- a/projects/app/src/pageComponents/dashboard/skill/List.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/List.tsx @@ -1,11 +1,13 @@ import React, { useState, useMemo, useEffect } from 'react'; -import { Box, Grid, IconButton, HStack, Flex, Tag, Spacer } from '@chakra-ui/react'; +import { Box, Grid, IconButton, HStack, Flex } from '@chakra-ui/react'; import { useRouter } from 'next/router'; import { useConfirm } from '@fastgpt/web/hooks/useConfirm'; import MyIcon from '@fastgpt/web/components/common/Icon'; import Avatar from '@fastgpt/web/components/common/Avatar'; import { useTranslation } from 'next-i18next'; import MyBox from '@fastgpt/web/components/common/MyBox'; +import UserBox from '@fastgpt/web/components/common/UserBox'; +import { useSystem } from '@fastgpt/web/hooks/useSystem'; import { useRequest } from '@fastgpt/web/hooks/useRequest'; import { useContextSelector } from 'use-context-selector'; import { SkillListContext } from './context'; @@ -34,8 +36,7 @@ import { import { SkillRoleList } from '@fastgpt/global/support/permission/agentSkill/constant'; import { ReadRoleVal } from '@fastgpt/global/support/permission/constant'; import MyPopover from '@fastgpt/web/components/common/MyPopover'; -import MyTooltip from '@fastgpt/web/components/common/MyTooltip'; -import type { AppsBySkillIdItem } from '@fastgpt/global/core/agentSkills/api'; +import type { ListAppsBySkillIdResponse } from '@fastgpt/global/core/agentSkills/api'; import dynamic from 'next/dynamic'; import type { EditResourceInfoFormType } from '@/components/common/Modal/EditResourceModal'; import { useToast } from '@fastgpt/web/hooks/useToast'; @@ -44,39 +45,53 @@ import type { ParentIdType } from '@fastgpt/global/common/parentFolder/type'; +import ListCreateCard from '@/pageComponents/dashboard/ListCreateCard'; +import ConfirmDeleteSkillModal from './ConfirmDeleteSkillModal'; + const EditResourceModal = dynamic(() => import('@/components/common/Modal/EditResourceModal')); const MoveModal = dynamic(() => import('@/components/common/folder/MoveModal')); const ConfigPerModal = dynamic(() => import('@/components/support/permission/ConfigPerModal')); -// 5 items × 36px + 4 dividers × (1px + 8px top + 8px bottom) = 248px -const RELATED_APPS_MAX_H = '248px'; +// 5 行 × 48px = 240px +const RELATED_APPS_MAX_H = '240px'; const RelatedAppsContent = ({ skillId }: { skillId: string }) => { const { t } = useTranslation(); - const [apps, setApps] = useState([]); + const [data, setData] = useState({ list: [], hiddenCount: 0 }); const [isLoading, setIsLoading] = useState(true); useEffect(() => { getAppsBySkillId(skillId) - .then(setApps) + .then(setData) .catch(() => {}) .finally(() => setIsLoading(false)); }, [skillId]); + const { list, hiddenCount } = data; + return ( - + - {apps.map((app, index) => ( - - {index > 0 && } - - + + + {list.map((app) => ( + { {app.name} - {app.sourceMember && ( - - - - {app.sourceMember.name} - - - )} - - - ))} + ))} + + + {list.map((app) => ( + + + + {app.sourceMember?.name || '-'} + + + ))} + + + {hiddenCount > 0 && ( + + {t('skill:related_apps_hidden', { count: hiddenCount })} + + )} ); }; @@ -113,14 +158,17 @@ const RelatedAppsPopover = ({ skillId, count }: { skillId: string; count: number return ( - {t('skill:related_count')} - + + {t('skill:related_count')} + {count} @@ -131,10 +179,11 @@ const RelatedAppsPopover = ({ skillId, count }: { skillId: string; count: number ); }; -const List = () => { +const List = ({ onClickCreate }: { onClickCreate?: () => void }) => { const { t } = useTranslation(); const router = useRouter(); const { toast } = useToast(); + const { isPc } = useSystem(); const { skills, loadSkills, isFetchingSkills, searchKey } = useContextSelector( SkillListContext, @@ -153,9 +202,10 @@ const List = () => { [editPerSkillId, skills] ); - const { openConfirm: openConfirmDel, ConfirmModal: DelConfirmModal } = useConfirm({ - type: 'delete' - }); + const [deleteTarget, setDeleteTarget] = useState<{ + skillId: string; + refsCount: number; + }>(); const { openConfirm: openConfirmCopy, ConfirmModal: ConfirmCopyModal } = useConfirm({ content: t('skill:copy_skill_confirm') @@ -233,7 +283,7 @@ const List = () => { if (skills.length === 0 && isFetchingSkills) return null; - if (skills.length === 0) { + if (skills.length === 0 && (!onClickCreate || !!searchKey)) { return ; } @@ -251,6 +301,7 @@ const List = () => { gridGap={3} alignItems={'stretch'} > + {onClickCreate && !searchKey && } {skills.map((skill) => { const isFolder = skill.type === AgentSkillTypeEnum.folder; const isPersonal = skill.source === AgentSkillSourceEnum.personal; @@ -259,21 +310,23 @@ const List = () => { return ( { @@ -284,12 +337,12 @@ const List = () => { } }} > - {/* Top row: avatar + name + menu */} + {/* Top row: avatar + name */} {isFolder ? ( @@ -297,179 +350,160 @@ const List = () => { )} - + {skill.name} - {isPersonal && ( - e.stopPropagation()} - > - } - aria-label={''} - /> - } - menuList={[ - { - children: [ - { - icon: 'edit', - type: 'grayBg' as const, - label: t('common:dataset.Edit Info'), - onClick: () => { - setEditedSkill({ - id: skill._id, - avatar: - skill.avatar ?? - (isFolder ? 'common/folderFill' : 'core/skill/default'), - name: skill.name, - intro: skill.description - }); - } - }, - { - icon: 'common/file/move', - type: 'grayBg' as const, - label: t('common:move_to'), - onClick: () => setMoveSkillId(skill._id) - }, - { - icon: 'key', - type: 'grayBg' as const, - label: t('skill:permission_settings'), - onClick: () => { - setEditPerSkillId(skill._id); - } - }, - // skill 专属菜单项 - ...(!isFolder - ? [ - { - icon: 'export', - type: 'grayBg' as const, - label: t('skill:export_config'), - onClick: () => onExportSkill(skill._id, skill.name) - }, - { - icon: 'copy', - type: 'grayBg' as const, - label: t('skill:copy_skill'), - onClick: () => - openConfirmCopy({ - onConfirm: () => onclickCopySkill(skill._id) - })() - } - ] - : []) - ] - }, - { - children: [ - { - type: 'danger' as const, - icon: 'delete', - label: t('common:Delete'), - disabled: !isFolder && relatedAppsCount > 0, - disabledTip: - !isFolder && relatedAppsCount > 0 - ? t('skill:delete_disabled_tip') - : undefined, - onClick: () => - openConfirmDel({ - onConfirm: () => onClickDeleteSkill(skill._id), - inputConfirmText: skill.name, - customContent: t('skill:confirm_delete_tip') - })() - } - ] - } - ]} - /> - - )} {/* Description */} - + {skill.description} {/* Bottom row */} - - {/* 关联应用数量(文件夹不显示)*/} - {!isFolder && - (relatedAppsCount > 0 ? ( - - ) : ( - - {t('skill:related_count')} - - 0 - - - ))} - - - - {/* 创建人 + 更新时间 */} - - {skill.sourceMember?.name && ( - - - - - {skill.sourceMember.name} - - - + + + + {!isFolder && ( + <> + {relatedAppsCount > 0 ? ( + + ) : ( + + {t('skill:related_count')} + + 0 + + + )} + )} - - - - + + + {isPc && ( + + + {t(formatTimeToChatTime(skill.updateTime) as any).replace('#', ':')} - + )} + {isPersonal && ( + e.stopPropagation()} + > + } + aria-label={''} + /> + } + menuList={[ + { + children: [ + { + icon: 'edit', + type: 'grayBg' as const, + label: t('common:dataset.Edit Info'), + onClick: () => { + setEditedSkill({ + id: skill._id, + avatar: + skill.avatar ?? + (isFolder ? 'common/folderFill' : 'core/skill/default'), + name: skill.name, + intro: skill.description + }); + } + }, + { + icon: 'common/file/move', + type: 'grayBg' as const, + label: t('common:move_to'), + onClick: () => setMoveSkillId(skill._id) + }, + { + icon: 'key', + type: 'grayBg' as const, + label: t('skill:permission_settings'), + onClick: () => { + setEditPerSkillId(skill._id); + } + }, + ...(!isFolder + ? [ + { + icon: 'export', + type: 'grayBg' as const, + label: t('skill:export_config'), + onClick: () => onExportSkill(skill._id, skill.name) + }, + { + icon: 'copy', + type: 'grayBg' as const, + label: t('skill:copy_skill'), + onClick: () => + openConfirmCopy({ + onConfirm: () => onclickCopySkill(skill._id) + })() + } + ] + : []) + ] + }, + { + children: [ + { + type: 'danger' as const, + icon: 'delete', + label: t('common:Delete'), + onClick: () => + setDeleteTarget({ + skillId: skill._id, + refsCount: isFolder ? 0 : relatedAppsCount + }) + } + ] + } + ]} + /> + + )} ); })} - + setDeleteTarget(undefined)} + onConfirm={() => (deleteTarget ? onClickDeleteSkill(deleteTarget.skillId) : undefined)} + /> {!!editedSkill && ( import('@/components/common/Modal/EditResourceModal')); const ConfigPerModal = dynamic(() => import('@/components/support/permission/ConfigPerModal')); @@ -83,9 +83,7 @@ const Header = () => { const [editedSkill, setEditedSkill] = useState(); const [showPermModal, setShowPermModal] = useState(false); - const { openConfirm: openConfirmDel, ConfirmModal: DelConfirmModal } = useConfirm({ - type: 'delete' - }); + const [deleteOpen, setDeleteOpen] = useState(false); const { runAsync: onClickDeleteSkill } = useRequest(deleteSkill, { onSuccess() { @@ -170,21 +168,15 @@ const Header = () => { type: 'danger' as const, icon: 'delete' as const, label: t('common:Delete'), - disabled: (skillDetail?.appCount ?? 0) > 0, - disabledTip: - (skillDetail?.appCount ?? 0) > 0 ? t('skill:delete_disabled_tip') : undefined, onClick: () => { if (!skillDetail) return; - openConfirmDel({ - onConfirm: () => onClickDeleteSkill(skillDetail._id), - inputConfirmText: skillDetail.name - })(); + setDeleteOpen(true); } } ] } ], - [t, skillDetail, onExportSkill, onClickDeleteSkill, openConfirmDel] + [t, skillDetail, onExportSkill] ); if (!skillDetail) return null; @@ -192,34 +184,37 @@ const Header = () => { return ( {/* 返回按钮 */} - + } aria-label={'back'} size={'xs'} - w={'24px'} + w={'1rem'} variant={'ghost'} onClick={() => router.push('/dashboard/skill')} /> {/* Skill 信息 */} - - + + + {skillDetail.name} - - {skillDetail.name} - - - + } + w={'34px'} + h={'34px'} + bg={'white'} + border={'1px solid'} + borderColor={'myGray.250'} + borderRadius={'sm'} + boxShadow={'0 1px 2px 0 rgba(19, 51, 107, 0.05), 0 0 1px 0 rgba(19, 51, 107, 0.08)'} + _hover={{ + bg: 'myGray.50' + }} + /> } menuList={menuList} /> @@ -261,7 +256,12 @@ const Header = () => { {showHistories && setShowHistories(false)} />} {/* 删除确认弹窗 */} - + setDeleteOpen(false)} + onConfirm={() => (skillDetail ? onClickDeleteSkill(skillDetail._id) : undefined)} + /> {/* 编辑信息弹窗 */} {!!editedSkill && ( diff --git a/projects/app/src/pageComponents/dashboard/skill/detail/config/BuildingAnimation.tsx b/projects/app/src/pageComponents/dashboard/skill/detail/config/BuildingAnimation.tsx index b5a5be7fd82a..18484a8c660f 100644 --- a/projects/app/src/pageComponents/dashboard/skill/detail/config/BuildingAnimation.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/detail/config/BuildingAnimation.tsx @@ -1,122 +1,114 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { Box } from '@chakra-ui/react'; -import { keyframes } from '@emotion/react'; - -const B = 6; // block size px -const G = 2; // gap px -const U = B + G; // 8px unit - -const BLUE = '#197DFF'; -const GREEN = '#66CC88'; - -type Block = { row: number; col: number; color: 'blue' | 'green' }; - -// 4 letter-shaped frames: F → A → 4 → T -const FRAMES: Block[][] = [ - // Frame 1: "F" - [ - { row: 0, col: 0, color: 'blue' }, - { row: 0, col: 1, color: 'blue' }, - { row: 0, col: 2, color: 'blue' }, - { row: 1, col: 0, color: 'green' }, - { row: 1, col: 1, color: 'green' }, - { row: 1, col: 2, color: 'green' }, - { row: 2, col: 0, color: 'blue' } - ], - // Frame 2: "A" - [ - { row: 0, col: 1, color: 'green' }, - { row: 1, col: 0, color: 'blue' }, - { row: 1, col: 1, color: 'green' }, - { row: 1, col: 2, color: 'blue' }, - { row: 2, col: 0, color: 'blue' }, - { row: 2, col: 2, color: 'green' } - ], - // Frame 3: "4" - [ - { row: 0, col: 0, color: 'blue' }, - { row: 0, col: 1, color: 'green' }, - { row: 1, col: 0, color: 'blue' }, - { row: 1, col: 1, color: 'blue' }, - { row: 1, col: 2, color: 'green' }, - { row: 2, col: 1, color: 'green' }, - { row: 2, col: 2, color: 'blue' } - ], - // Frame 4: "T" - [ - { row: 0, col: 0, color: 'blue' }, - { row: 0, col: 1, color: 'blue' }, - { row: 0, col: 2, color: 'green' }, - { row: 1, col: 1, color: 'green' }, - { row: 2, col: 1, color: 'blue' } - ] -]; - -const snapIn = keyframes` - 0% { opacity: 0; transform: scale(0.2); } - 65% { opacity: 1; transform: scale(1.2); } - 100% { opacity: 1; transform: scale(1); } -`; - -const snapOut = keyframes` - 0% { opacity: 1; transform: scale(1); } - 100% { opacity: 0; transform: scale(0.2); } -`; - -const HOLD_MS = 900; // each frame stays for this duration -const EXIT_MS = 180; // exit animation duration -const ENTER_MS = 180; // enter animation duration -const STAGGER_MS = 50; // delay between each block appearing + +const config = { + rotate: true, + particleCount: 30, + trailSpan: 0.38, + durationMs: 3000, + rotationDurationMs: 16000, + pulseDurationMs: 4600, + strokeWidth: 8.33, + orbitRadius: 7, + detailAmplitude: 2.7, + petalCount: 5, + curveScale: 3.9 +}; + +const TWO_PI = Math.PI * 2; +const PETAL_K = Math.round(config.petalCount); + +const point = (progress: number, detailScale: number) => { + const t = progress * TWO_PI; + const r = config.orbitRadius - config.detailAmplitude * detailScale * Math.cos(PETAL_K * t); + return { + x: 50 + Math.cos(t) * r * config.curveScale, + y: 50 + Math.sin(t) * r * config.curveScale + }; +}; + +const normalizeProgress = (progress: number) => ((progress % 1) + 1) % 1; + +const getDetailScale = (time: number) => { + const pulseProgress = (time % config.pulseDurationMs) / config.pulseDurationMs; + const pulseAngle = pulseProgress * TWO_PI; + return 0.52 + ((Math.sin(pulseAngle + 0.55) + 1) / 2) * 0.48; +}; + +const getRotation = (time: number) => { + if (!config.rotate) return 0; + return -((time % config.rotationDurationMs) / config.rotationDurationMs) * 360; +}; + +const getParticle = (index: number, progress: number, detailScale: number) => { + const tailOffset = index / (config.particleCount - 1); + const p = point(normalizeProgress(progress - tailOffset * config.trailSpan), detailScale); + const fade = Math.pow(1 - tailOffset, 0.56); + return { + x: p.x, + y: p.y, + radius: 0.6 + fade * 3.57, + opacity: 0.04 + fade * 0.96 + }; +}; const BuildingAnimation = () => { - const [frameIdx, setFrameIdx] = useState(0); - const [phase, setPhase] = useState<'enter' | 'exit'>('enter'); + const groupRef = useRef(null); + const particleRefs = useRef<(SVGCircleElement | null)[]>([]); + + const indices = useMemo(() => Array.from({ length: config.particleCount }, (_, i) => i), []); useEffect(() => { - const tick = () => { - setPhase('exit'); - setTimeout(() => { - setFrameIdx((prev) => (prev + 1) % FRAMES.length); - setPhase('enter'); - }, EXIT_MS + 30); + const startedAt = performance.now(); + let rafId = 0; + + const render = (now: number) => { + const time = now - startedAt; + const progress = (time % config.durationMs) / config.durationMs; + const detailScale = getDetailScale(time); + + if (groupRef.current) { + groupRef.current.setAttribute('transform', `rotate(${getRotation(time)} 50 50)`); + } + + for (let i = 0; i < particleRefs.current.length; i++) { + const node = particleRefs.current[i]; + if (!node) continue; + const particle = getParticle(i, progress, detailScale); + node.setAttribute('cx', particle.x.toFixed(2)); + node.setAttribute('cy', particle.y.toFixed(2)); + node.setAttribute('r', particle.radius.toFixed(2)); + node.setAttribute('opacity', particle.opacity.toFixed(3)); + } + + rafId = requestAnimationFrame(render); }; - const id = setInterval(tick, HOLD_MS + EXIT_MS); - return () => clearInterval(id); - }, []); - // Sort top-to-bottom, left-to-right so blocks "build" from top - const blocks = [...FRAMES[frameIdx]].sort((a, b) => - a.row !== b.row ? a.row - b.row : a.col - b.col - ); + rafId = requestAnimationFrame(render); + return () => cancelAnimationFrame(rafId); + }, []); return ( - - {blocks.map((block, i) => ( - - ))} + + ); }; diff --git a/projects/app/src/pageComponents/dashboard/skill/detail/config/SandboxIframe.tsx b/projects/app/src/pageComponents/dashboard/skill/detail/config/SandboxIframe.tsx index 39eb0dff1905..ed0755427f2b 100644 --- a/projects/app/src/pageComponents/dashboard/skill/detail/config/SandboxIframe.tsx +++ b/projects/app/src/pageComponents/dashboard/skill/detail/config/SandboxIframe.tsx @@ -1,24 +1,93 @@ import React from 'react'; -import { Box } from '@chakra-ui/react'; +import { Box, Spinner } from '@chakra-ui/react'; import { useContextSelector } from 'use-context-selector'; +import { useQuery } from '@tanstack/react-query'; +import { useSystemStore } from '@/web/common/system/useSystemStore'; +import { postSandboxProxyToken } from '@/web/core/skill/api'; import { SkillDetailContext } from '../context'; +import { SANDBOX_PROXY_AUTH_REFRESH_PATH } from '@fastgpt/global/core/ai/sandbox/proxyToken'; + +const getTokenRefreshInterval = (ttlSeconds: number) => { + const ttlMs = Math.max(ttlSeconds, 1) * 1000; + const safetyMs = Math.min(60 * 1000, Math.max(5 * 1000, ttlMs * 0.1)); + return Math.max(ttlMs - safetyMs, 5 * 1000); +}; const SandboxIframe = () => { - const sandboxEndpointUrl = useContextSelector(SkillDetailContext, (v) => v.sandboxEndpointUrl); + const sandboxEndpoint = useContextSelector(SkillDetailContext, (v) => v.sandboxEndpoint); + const proxyBase = useSystemStore((s) => s.feConfigs?.sandbox_proxy_base); + const proxyScheme = useSystemStore((s) => s.feConfigs?.sandbox_proxy_scheme) ?? 'http'; + const tokenTtl = useSystemStore((s) => s.feConfigs?.sandbox_proxy_token_ttl) ?? 3600; + const tokenRefreshInterval = getTokenRefreshInterval(tokenTtl); + const providerSandboxId = sandboxEndpoint?.providerSandboxId; + + const { data: tokenData } = useQuery({ + queryKey: ['sandboxProxyToken', providerSandboxId], + queryFn: () => postSandboxProxyToken({ sandboxId: providerSandboxId! }), + enabled: !!sandboxEndpoint && !!proxyBase, + // 提前过期刷新;新 token 走下面的隐藏 img 写回 cookie,不重载 iframe。 + staleTime: tokenRefreshInterval, + refetchInterval: tokenRefreshInterval, + refetchIntervalInBackground: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true + }); + + // 锁定每个 sandbox 的首个 token,iframe src 不变,后续刷新走隐藏 img。 + const [bootstrap, setBootstrap] = React.useState<{ sandboxId: string; token: string } | null>( + null + ); + if (providerSandboxId && tokenData?.token && bootstrap?.sandboxId !== providerSandboxId) { + setBootstrap({ sandboxId: providerSandboxId, token: tokenData.token }); + } + + if (!sandboxEndpoint) return null; + if (!proxyBase) { + return ( + + sandbox_proxy_base is not configured + + ); + } + if (!tokenData || !bootstrap || bootstrap.sandboxId !== providerSandboxId) { + return ( + + + + ); + } - if (!sandboxEndpointUrl) return null; + // 子域路由到 sandbox + /proxy/8080/ 转发到 code-server;用子域而非 path 是为了 code-server 的绝对 URL/WS 共用 cookie。 + const sandboxOrigin = `${proxyScheme}://${bootstrap.sandboxId}.${proxyBase}`; + const src = `${sandboxOrigin}/proxy/8080/?_t=${encodeURIComponent(bootstrap.token)}`; + const refreshSrc = + tokenData.token === bootstrap.token + ? null + : `${sandboxOrigin}${SANDBOX_PROXY_AUTH_REFRESH_PATH}?_t=${encodeURIComponent(tokenData.token)}`; return ( + {refreshSrc && ( + + )}