From 7c00650be578aaf5852cd7b081bfd82caf7ddd79 Mon Sep 17 00:00:00 2001 From: LiJian Date: Tue, 24 Mar 2026 14:28:23 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20add=20the=20us?= =?UTF-8?q?er=20creds=20modules=20&=20skill=20should=20auto=20inject=20the?= =?UTF-8?q?=20need=20creds=20(#13124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add the user creds modules & skill should auto inject the need creds * feat: add the builtin creds tools * fix: add some prompt in creds & codesandbox * fix: open this settings/creds in community plan * fix: refacoter the settings/creds the ui * feat: improve the tools inject system Role * feat: change the settings/creds mananger ui * fix: add the creds upload Files api * feat: should call back the files creds url --- locales/en-US/setting.json | 58 +++ locales/zh-CN/setting.json | 58 +++ package.json | 3 +- .../src/systemRole.ts | 5 + packages/builtin-tool-creds/package.json | 16 + .../builtin-tool-creds/src/client/index.ts | 4 + .../builtin-tool-creds/src/executor/index.ts | 406 ++++++++++++++++++ packages/builtin-tool-creds/src/helpers.ts | 109 +++++ packages/builtin-tool-creds/src/index.ts | 22 + packages/builtin-tool-creds/src/manifest.ts | 117 +++++ packages/builtin-tool-creds/src/systemRole.ts | 97 +++++ packages/builtin-tool-creds/src/types.ts | 148 +++++++ .../builtin-tool-skill-store/src/manifest.ts | 3 +- .../builtin-tool-skills/src/manifest.base.ts | 9 +- packages/builtin-tool-tools/src/systemRole.ts | 60 ++- packages/builtin-tools/package.json | 1 + packages/builtin-tools/src/identifiers.ts | 16 +- packages/builtin-tools/src/index.ts | 7 +- packages/const/src/lobehubSkill.ts | 14 +- packages/types/package.json | 2 +- packages/types/src/creds/index.ts | 135 ++++++ packages/types/src/index.ts | 1 + .../DefaultType/SystemJsRender/utils.ts | 1 - src/locales/default/setting.ts | 68 +++ .../CreateCredModal/CredTypeSelector.tsx | 97 +++++ .../features/CreateCredModal/FileCredForm.tsx | 166 +++++++ .../features/CreateCredModal/KVCredForm.tsx | 147 +++++++ .../CreateCredModal/OAuthCredForm.tsx | 150 +++++++ .../creds/features/CreateCredModal/index.tsx | 100 +++++ .../settings/creds/features/CredDisplay.tsx | 57 +++ .../settings/creds/features/CredItem.tsx | 132 ++++++ .../settings/creds/features/CredsList.tsx | 118 +++++ .../features/EditCredModal/EditKVForm.tsx | 175 ++++++++ .../features/EditCredModal/EditMetaForm.tsx | 86 ++++ .../creds/features/EditCredModal/index.tsx | 48 +++ .../settings/creds/features/ViewCredModal.tsx | 118 +++++ .../(main)/settings/creds/features/index.ts | 6 + .../(main)/settings/creds/features/style.ts | 38 ++ src/routes/(main)/settings/creds/index.tsx | 45 ++ .../(main)/settings/features/componentMap.ts | 3 + .../(main)/settings/hooks/useCategory.tsx | 6 + src/server/routers/lambda/market/creds.ts | 402 +++++++++++++++++ src/server/routers/lambda/market/index.ts | 7 +- src/server/services/market/index.ts | 62 +++ src/services/chat/mecha/contextEngineering.ts | 34 +- src/services/chat/mecha/skillPreload.ts | 44 +- src/store/global/initialState.ts | 1 + .../tool/slices/builtin/executors/index.ts | 2 + 48 files changed, 3376 insertions(+), 28 deletions(-) create mode 100644 packages/builtin-tool-creds/package.json create mode 100644 packages/builtin-tool-creds/src/client/index.ts create mode 100644 packages/builtin-tool-creds/src/executor/index.ts create mode 100644 packages/builtin-tool-creds/src/helpers.ts create mode 100644 packages/builtin-tool-creds/src/index.ts create mode 100644 packages/builtin-tool-creds/src/manifest.ts create mode 100644 packages/builtin-tool-creds/src/systemRole.ts create mode 100644 packages/builtin-tool-creds/src/types.ts create mode 100644 packages/types/src/creds/index.ts create mode 100644 src/routes/(main)/settings/creds/features/CreateCredModal/CredTypeSelector.tsx create mode 100644 src/routes/(main)/settings/creds/features/CreateCredModal/FileCredForm.tsx create mode 100644 src/routes/(main)/settings/creds/features/CreateCredModal/KVCredForm.tsx create mode 100644 src/routes/(main)/settings/creds/features/CreateCredModal/OAuthCredForm.tsx create mode 100644 src/routes/(main)/settings/creds/features/CreateCredModal/index.tsx create mode 100644 src/routes/(main)/settings/creds/features/CredDisplay.tsx create mode 100644 src/routes/(main)/settings/creds/features/CredItem.tsx create mode 100644 src/routes/(main)/settings/creds/features/CredsList.tsx create mode 100644 src/routes/(main)/settings/creds/features/EditCredModal/EditKVForm.tsx create mode 100644 src/routes/(main)/settings/creds/features/EditCredModal/EditMetaForm.tsx create mode 100644 src/routes/(main)/settings/creds/features/EditCredModal/index.tsx create mode 100644 src/routes/(main)/settings/creds/features/ViewCredModal.tsx create mode 100644 src/routes/(main)/settings/creds/features/index.ts create mode 100644 src/routes/(main)/settings/creds/features/style.ts create mode 100644 src/routes/(main)/settings/creds/index.tsx create mode 100644 src/server/routers/lambda/market/creds.ts diff --git a/locales/en-US/setting.json b/locales/en-US/setting.json index d92ffef344..5c10748eee 100644 --- a/locales/en-US/setting.json +++ b/locales/en-US/setting.json @@ -193,6 +193,63 @@ "analytics.title": "Analytics", "checking": "Checking...", "checkingPermissions": "Checking permissions...", + "creds.actions.delete": "Delete", + "creds.actions.deleteConfirm.cancel": "Cancel", + "creds.actions.deleteConfirm.content": "This credential will be permanently deleted. This action cannot be undone.", + "creds.actions.deleteConfirm.ok": "Delete", + "creds.actions.deleteConfirm.title": "Delete Credential?", + "creds.actions.edit": "Edit", + "creds.create": "New Credential", + "creds.createModal.fillForm": "Fill Details", + "creds.createModal.selectType": "Select Type", + "creds.createModal.title": "Create Credential", + "creds.edit.title": "Edit Credential", + "creds.empty": "No credentials configured yet", + "creds.file.authRequired": "Please sign in to the Market first", + "creds.file.uploadFailed": "File upload failed", + "creds.file.uploadSuccess": "File uploaded successfully", + "creds.file.uploading": "Uploading...", + "creds.form.addPair": "Add Key-Value Pair", + "creds.form.back": "Back", + "creds.form.cancel": "Cancel", + "creds.form.connectionRequired": "Please select an OAuth connection", + "creds.form.description": "Description", + "creds.form.descriptionPlaceholder": "Optional description for this credential", + "creds.form.file": "Credential File", + "creds.form.fileRequired": "Please upload a file", + "creds.form.key": "Identifier", + "creds.form.keyPattern": "Identifier can only contain letters, numbers, underscores, and hyphens", + "creds.form.keyRequired": "Identifier is required", + "creds.form.name": "Display Name", + "creds.form.nameRequired": "Display name is required", + "creds.form.save": "Save", + "creds.form.selectConnection": "Select OAuth Connection", + "creds.form.selectConnectionPlaceholder": "Choose a connected account", + "creds.form.selectedFile": "Selected file", + "creds.form.submit": "Create", + "creds.form.uploadDesc": "Supports JSON, PEM, and other credential file formats", + "creds.form.uploadHint": "Click or drag file to upload", + "creds.form.valuePlaceholder": "Enter value", + "creds.form.values": "Key-Value Pairs", + "creds.oauth.noConnections": "No OAuth connections available. Please connect an account first.", + "creds.signIn": "Sign In to Market", + "creds.signInRequired": "Please sign in to the Market to manage your credentials", + "creds.table.actions": "Actions", + "creds.table.key": "Identifier", + "creds.table.lastUsed": "Last Used", + "creds.table.name": "Name", + "creds.table.neverUsed": "Never", + "creds.table.preview": "Preview", + "creds.table.type": "Type", + "creds.typeDesc.file": "Upload credential files like service accounts or certificates", + "creds.typeDesc.kv-env": "Store API keys and tokens as environment variables", + "creds.typeDesc.kv-header": "Store authorization values as HTTP headers", + "creds.typeDesc.oauth": "Link to an existing OAuth connection", + "creds.types.all": "All", + "creds.types.file": "File", + "creds.types.kv-env": "Environment", + "creds.types.kv-header": "Header", + "creds.types.oauth": "OAuth", "danger.clear.action": "Clear Now", "danger.clear.confirm": "Clear all chat data? This can't be undone.", "danger.clear.desc": "Delete all data, including agents, files, messages, and skills. Your account will NOT be deleted.", @@ -731,6 +788,7 @@ "tab.appearance": "Appearance", "tab.chatAppearance": "Chat Appearance", "tab.common": "Appearance", + "tab.creds": "Credentials", "tab.experiment": "Experiment", "tab.hotkey": "Hotkeys", "tab.image": "Image Generation", diff --git a/locales/zh-CN/setting.json b/locales/zh-CN/setting.json index dd9d74afcf..d510bdc1af 100644 --- a/locales/zh-CN/setting.json +++ b/locales/zh-CN/setting.json @@ -193,6 +193,63 @@ "analytics.title": "数据统计", "checking": "检查中…", "checkingPermissions": "检查权限中…", + "creds.actions.delete": "删除", + "creds.actions.deleteConfirm.cancel": "取消", + "creds.actions.deleteConfirm.content": "此凭证将被永久删除,此操作无法撤销。", + "creds.actions.deleteConfirm.ok": "删除", + "creds.actions.deleteConfirm.title": "确定删除凭证?", + "creds.actions.edit": "编辑", + "creds.create": "新建凭证", + "creds.createModal.fillForm": "填写详情", + "creds.createModal.selectType": "选择类型", + "creds.createModal.title": "创建凭证", + "creds.edit.title": "编辑凭证", + "creds.empty": "暂无凭证配置", + "creds.file.authRequired": "请先登录 Market", + "creds.file.uploadFailed": "文件上传失败", + "creds.file.uploadSuccess": "文件上传成功", + "creds.file.uploading": "上传中...", + "creds.form.addPair": "添加键值对", + "creds.form.back": "返回", + "creds.form.cancel": "取消", + "creds.form.connectionRequired": "请选择一个 OAuth 连接", + "creds.form.description": "描述", + "creds.form.descriptionPlaceholder": "可选的凭证描述", + "creds.form.file": "凭证文件", + "creds.form.fileRequired": "请上传文件", + "creds.form.key": "标识符", + "creds.form.keyPattern": "标识符只能包含字母、数字、下划线和连字符", + "creds.form.keyRequired": "请输入标识符", + "creds.form.name": "显示名称", + "creds.form.nameRequired": "请输入显示名称", + "creds.form.save": "保存", + "creds.form.selectConnection": "选择 OAuth 连接", + "creds.form.selectConnectionPlaceholder": "选择已连接的账户", + "creds.form.selectedFile": "已选文件", + "creds.form.submit": "创建", + "creds.form.uploadDesc": "支持 JSON、PEM 等凭证文件格式", + "creds.form.uploadHint": "点击或拖拽文件上传", + "creds.form.valuePlaceholder": "输入值", + "creds.form.values": "键值对", + "creds.oauth.noConnections": "暂无可用的 OAuth 连接,请先连接账户。", + "creds.signIn": "登录 Market", + "creds.signInRequired": "请登录 Market 以管理您的凭证", + "creds.table.actions": "操作", + "creds.table.key": "标识符", + "creds.table.lastUsed": "上次使用", + "creds.table.name": "名称", + "creds.table.neverUsed": "从未使用", + "creds.table.preview": "预览", + "creds.table.type": "类型", + "creds.typeDesc.file": "上传服务账户或证书等凭证文件", + "creds.typeDesc.kv-env": "将 API 密钥和令牌存储为环境变量", + "creds.typeDesc.kv-header": "将授权值存储为 HTTP 请求头", + "creds.typeDesc.oauth": "关联已有的 OAuth 连接", + "creds.types.all": "全部", + "creds.types.file": "文件", + "creds.types.kv-env": "环境变量", + "creds.types.kv-header": "请求头", + "creds.types.oauth": "OAuth", "danger.clear.action": "立即清除", "danger.clear.confirm": "确定要清除所有聊天数据吗?此操作无法撤销。", "danger.clear.desc": "删除所有数据,包括智能体、文件、消息和技能。您的账户不会被删除。", @@ -731,6 +788,7 @@ "tab.appearance": "外观", "tab.chatAppearance": "聊天外观", "tab.common": "外观", + "tab.creds": "凭证管理", "tab.experiment": "实验", "tab.hotkey": "快捷键", "tab.image": "绘画服务", diff --git a/package.json b/package.json index c4df7c3075..cee0434c3d 100644 --- a/package.json +++ b/package.json @@ -206,6 +206,7 @@ "@lobechat/builtin-tool-agent-management": "workspace:*", "@lobechat/builtin-tool-calculator": "workspace:*", "@lobechat/builtin-tool-cloud-sandbox": "workspace:*", + "@lobechat/builtin-tool-creds": "workspace:*", "@lobechat/builtin-tool-group-agent-builder": "workspace:*", "@lobechat/builtin-tool-group-management": "workspace:*", "@lobechat/builtin-tool-gtd": "workspace:*", @@ -258,7 +259,7 @@ "@lobehub/desktop-ipc-typings": "workspace:*", "@lobehub/editor": "^4.3.1", "@lobehub/icons": "^5.0.0", - "@lobehub/market-sdk": "^0.31.3", + "@lobehub/market-sdk": "0.31.11", "@lobehub/tts": "^5.1.2", "@lobehub/ui": "^5.5.0", "@modelcontextprotocol/sdk": "^1.26.0", diff --git a/packages/builtin-tool-cloud-sandbox/src/systemRole.ts b/packages/builtin-tool-cloud-sandbox/src/systemRole.ts index f67d7cf821..e86f811db9 100644 --- a/packages/builtin-tool-cloud-sandbox/src/systemRole.ts +++ b/packages/builtin-tool-cloud-sandbox/src/systemRole.ts @@ -8,6 +8,11 @@ export const systemPrompt = `You have access to a Cloud Sandbox that provides a - Sessions may expire after inactivity; files will be recreated if needed - The sandbox has its own isolated file system starting at the root directory - Commands will time out after 60 seconds by default +- **Default shell is /bin/sh** (typically dash or ash), NOT bash. The \`source\` command may not work as expected. If you need bash-specific features or \`source\`, wrap your command with bash: \`bash -c "source ~/.creds/env && your_command"\` + +**Credential Injection Locations:** +- Environment-based credentials (oauth, kv-env, kv-header) are written to \`~/.creds/env\` +- File-based credentials are extracted to \`~/.creds/files/{key}/{filename}\` diff --git a/packages/builtin-tool-creds/package.json b/packages/builtin-tool-creds/package.json new file mode 100644 index 0000000000..a7a32501c8 --- /dev/null +++ b/packages/builtin-tool-creds/package.json @@ -0,0 +1,16 @@ +{ + "name": "@lobechat/builtin-tool-creds", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": { + ".": "./src/index.ts", + "./client": "./src/client/index.ts", + "./executor": "./src/executor/index.ts" + }, + "main": "./src/index.ts", + "dependencies": {}, + "devDependencies": { + "@lobechat/types": "workspace:*" + } +} diff --git a/packages/builtin-tool-creds/src/client/index.ts b/packages/builtin-tool-creds/src/client/index.ts new file mode 100644 index 0000000000..0b8004d23d --- /dev/null +++ b/packages/builtin-tool-creds/src/client/index.ts @@ -0,0 +1,4 @@ +// Client-side components for Creds tool +// Placeholder for future Render/Streaming components + +export {}; diff --git a/packages/builtin-tool-creds/src/executor/index.ts b/packages/builtin-tool-creds/src/executor/index.ts new file mode 100644 index 0000000000..ba2e8c990c --- /dev/null +++ b/packages/builtin-tool-creds/src/executor/index.ts @@ -0,0 +1,406 @@ +import { getLobehubSkillProviderById } from '@lobechat/const'; +import type { BuiltinToolContext, BuiltinToolResult } from '@lobechat/types'; +import { BaseExecutor } from '@lobechat/types'; +import debug from 'debug'; + +import { lambdaClient, toolsClient } from '@/libs/trpc/client'; +import { useUserStore } from '@/store/user'; +import { userProfileSelectors } from '@/store/user/slices/auth/selectors'; + +import { CredsIdentifier } from '../manifest'; +import { + CredsApiName, + type GetPlaintextCredParams, + type InitiateOAuthConnectParams, + type InjectCredsToSandboxParams, + type SaveCredsParams, +} from '../types'; + +const log = debug('lobe-creds:executor'); + +class CredsExecutor extends BaseExecutor { + readonly identifier = CredsIdentifier; + protected readonly apiEnum = CredsApiName; + + /** + * Initiate OAuth connection flow + * Opens authorization popup and waits for user to complete authorization + */ + initiateOAuthConnect = async ( + params: InitiateOAuthConnectParams, + _ctx?: BuiltinToolContext, + ): Promise => { + try { + const { provider } = params; + + // Get provider config for display name + const providerConfig = getLobehubSkillProviderById(provider); + if (!providerConfig) { + return { + error: { + message: `Unknown OAuth provider: ${provider}. Available providers: github, linear, microsoft, twitter`, + type: 'UnknownProvider', + }, + success: false, + }; + } + + // Check if already connected + const statusResponse = await toolsClient.market.connectGetStatus.query({ provider }); + if (statusResponse.connected) { + return { + content: `You are already connected to ${providerConfig.label}. The credential is available for use.`, + state: { + alreadyConnected: true, + providerName: providerConfig.label, + }, + success: true, + }; + } + + // Get the authorization URL from the market API + const redirectUri = `${typeof window !== 'undefined' ? window.location.origin : ''}/oauth/callback/success?provider=${provider}`; + const response = await toolsClient.market.connectGetAuthorizeUrl.query({ + provider, + redirectUri, + }); + + // Open OAuth popup and wait for result + const result = await this.openOAuthPopupAndWait(response.authorizeUrl, provider); + + if (result.success) { + return { + content: `Successfully connected to ${providerConfig.label}! The credential is now available for use.`, + state: { + connected: true, + providerName: providerConfig.label, + }, + success: true, + }; + } else { + return { + content: result.cancelled + ? `Authorization was cancelled. You can try again when you're ready to connect to ${providerConfig.label}.` + : `Failed to connect to ${providerConfig.label}. Please try again.`, + state: { + cancelled: result.cancelled, + connected: false, + providerName: providerConfig.label, + }, + success: true, + }; + } + } catch (error) { + return { + error: { + message: error instanceof Error ? error.message : 'Failed to initiate OAuth connection', + type: 'InitiateOAuthFailed', + }, + success: false, + }; + } + }; + + /** + * Open OAuth popup window and wait for authorization result + */ + private openOAuthPopupAndWait = ( + authorizeUrl: string, + provider: string, + ): Promise<{ cancelled?: boolean; success: boolean }> => { + return new Promise((resolve) => { + // Open popup window + const popup = window.open(authorizeUrl, '_blank', 'width=600,height=700'); + + if (!popup) { + // Popup blocked - fall back to checking status after a delay + resolve({ cancelled: true, success: false }); + return; + } + + let resolved = false; + const cleanup = () => { + if (resolved) return; + resolved = true; + window.removeEventListener('message', handleMessage); + if (windowCheckInterval) clearInterval(windowCheckInterval); + }; + + // Listen for postMessage from OAuth callback + const handleMessage = async (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + + if ( + event.data?.type === 'LOBEHUB_SKILL_AUTH_SUCCESS' && + event.data?.provider === provider + ) { + cleanup(); + resolve({ success: true }); + } + }; + + window.addEventListener('message', handleMessage); + + // Monitor popup window closure + const windowCheckInterval = setInterval(async () => { + if (popup.closed) { + clearInterval(windowCheckInterval); + + if (resolved) return; + + // Check if authorization succeeded before window closed + try { + const status = await toolsClient.market.connectGetStatus.query({ provider }); + cleanup(); + resolve({ success: status.connected }); + } catch { + cleanup(); + resolve({ cancelled: true, success: false }); + } + } + }, 500); + + // Timeout after 5 minutes + setTimeout( + () => { + if (!resolved) { + cleanup(); + if (!popup.closed) popup.close(); + resolve({ cancelled: true, success: false }); + } + }, + 5 * 60 * 1000, + ); + }); + }; + + /** + * Get plaintext credential value by key + */ + getPlaintextCred = async ( + params: GetPlaintextCredParams, + _ctx?: BuiltinToolContext, + ): Promise => { + try { + log('[CredsExecutor] getPlaintextCred - key:', params.key); + + // Get the decrypted credential directly by key + const result = await lambdaClient.market.creds.getByKey.query({ + decrypt: true, + key: params.key, + }); + + const credType = (result as any).type; + const credName = (result as any).name || params.key; + + log('[CredsExecutor] getPlaintextCred - type:', credType); + + // Handle file type credentials + if (credType === 'file') { + const fileUrl = (result as any).fileUrl; + const fileName = (result as any).fileName; + + log('[CredsExecutor] getPlaintextCred - fileUrl:', fileUrl ? 'present' : 'missing'); + + if (!fileUrl) { + return { + content: `File credential "${credName}" (key: ${params.key}) found but file URL is not available.`, + error: { + message: 'File URL not available', + type: 'FileUrlNotAvailable', + }, + success: false, + }; + } + + return { + content: `Successfully retrieved file credential "${credName}" (key: ${params.key}). File: ${fileName || 'unknown'}. The file download URL is available in the state.`, + state: { + fileName, + fileUrl, + key: params.key, + name: credName, + type: 'file', + }, + success: true, + }; + } + + // Handle KV types (kv-env, kv-header, oauth) + // Market API returns 'plaintext' field, SDK might transform to 'values' + const values = (result as any).values || (result as any).plaintext || {}; + const valueKeys = Object.keys(values); + + log('[CredsExecutor] getPlaintextCred - result keys:', valueKeys); + + // Return content with masked values for security, but include actual values in state + const maskedValues = valueKeys.map((k) => `${k}: ****`).join(', '); + + return { + content: `Successfully retrieved credential "${credName}" (key: ${params.key}). Contains ${valueKeys.length} value(s): ${maskedValues}. The actual values are available in the state for use.`, + state: { + key: params.key, + name: credName, + type: credType, + values, + }, + success: true, + }; + } catch (error) { + log('[CredsExecutor] getPlaintextCred - error:', error); + + // Check if it's a NOT_FOUND error + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + const isNotFound = errorMessage.includes('not found') || errorMessage.includes('NOT_FOUND'); + + return { + content: isNotFound + ? `Credential not found: ${params.key}. Please check if the credential exists in Settings > Credentials.` + : `Failed to get credential: ${errorMessage}`, + error: { + message: errorMessage, + type: isNotFound ? 'CredentialNotFound' : 'GetCredentialFailed', + }, + success: false, + }; + } + }; + + /** + * Inject credentials to sandbox environment + * Calls the SDK inject API to get decrypted credentials for sandbox injection. + */ + injectCredsToSandbox = async ( + params: InjectCredsToSandboxParams, + ctx?: BuiltinToolContext, + ): Promise => { + try { + // Get topicId from context (like cloud-sandbox does) + const topicId = ctx?.topicId; + if (!topicId) { + return { + content: 'Cannot inject credentials: topicId is not available in the current context.', + error: { + message: 'topicId is required but not available', + type: 'MissingTopicId', + }, + success: false, + }; + } + + // Get userId from user store (like cloud-sandbox does) + const userId = userProfileSelectors.userId(useUserStore.getState()); + if (!userId) { + return { + content: 'Cannot inject credentials: user is not authenticated.', + error: { + message: 'userId is required but not available', + type: 'MissingUserId', + }, + success: false, + }; + } + + log('[CredsExecutor] injectCredsToSandbox - keys:', params.keys, 'topicId:', topicId); + + // Call the inject API with keys, topicId and userId from context + const result = await lambdaClient.market.creds.inject.mutate({ + keys: params.keys, + sandbox: true, + topicId, + userId, + }); + + const credentials = (result as any).credentials || {}; + const notFound = (result as any).notFound || []; + const unsupportedInSandbox = (result as any).unsupportedInSandbox || []; + + log('[CredsExecutor] injectCredsToSandbox - result:', { + envKeys: Object.keys(credentials.env || {}), + filesCount: credentials.files?.length || 0, + notFound, + unsupportedInSandbox, + }); + + // Build response content + const injectedKeys = params.keys.filter((k) => !notFound.includes(k)); + let content = ''; + + if (injectedKeys.length > 0) { + content = `Credentials injected successfully: ${injectedKeys.join(', ')}.`; + } + + if (notFound.length > 0) { + content += ` Not found: ${notFound.join(', ')}. Please configure them in Settings > Credentials.`; + } + + if (unsupportedInSandbox.length > 0) { + content += ` Not supported in sandbox: ${unsupportedInSandbox.join(', ')}.`; + } + + return { + content: content.trim(), + state: { + credentials, + injected: injectedKeys, + notFound, + success: notFound.length === 0, + unsupportedInSandbox, + }, + success: true, + }; + } catch (error) { + log('[CredsExecutor] injectCredsToSandbox - error:', error); + return { + content: `Failed to inject credentials: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: { + message: error instanceof Error ? error.message : 'Failed to inject credentials', + type: 'InjectCredentialsFailed', + }, + success: false, + }; + } + }; + + /** + * Save new credentials + */ + saveCreds = async ( + params: SaveCredsParams, + _ctx?: BuiltinToolContext, + ): Promise => { + try { + log('[CredsExecutor] saveCreds - key:', params.key, 'name:', params.name); + + await lambdaClient.market.creds.createKV.mutate({ + description: params.description, + key: params.key, + name: params.name, + type: params.type as 'kv-env' | 'kv-header', + values: params.values, + }); + + return { + content: `Credential "${params.name}" saved successfully with key "${params.key}"`, + state: { + key: params.key, + message: `Credential "${params.name}" saved successfully`, + success: true, + }, + success: true, + }; + } catch (error) { + log('[CredsExecutor] saveCreds - error:', error); + return { + content: `Failed to save credential: ${error instanceof Error ? error.message : 'Unknown error'}`, + error: { + message: error instanceof Error ? error.message : 'Failed to save credential', + type: 'SaveCredentialFailed', + }, + success: false, + }; + } + }; +} + +export const credsExecutor = new CredsExecutor(); diff --git a/packages/builtin-tool-creds/src/helpers.ts b/packages/builtin-tool-creds/src/helpers.ts new file mode 100644 index 0000000000..853af5c5a3 --- /dev/null +++ b/packages/builtin-tool-creds/src/helpers.ts @@ -0,0 +1,109 @@ +import type { CredType } from '@lobechat/types'; + +/** + * Summary of a user credential for display in the tool prompt + */ +export interface CredSummary { + description?: string; + key: string; + name: string; + type: CredType; +} + +/** + * Context for injecting creds data into the tool content + */ +export interface UserCredsContext { + creds: CredSummary[]; + settingsUrl: string; +} + +/** + * Group credentials by type for better organization + */ +export const groupCredsByType = (creds: CredSummary[]): Record => { + const groups: Record = { + 'file': [], + 'kv-env': [], + 'kv-header': [], + 'oauth': [], + }; + + for (const cred of creds) { + groups[cred.type].push(cred); + } + + return groups; +}; + +/** + * Format a single credential for display + */ +const formatCred = (cred: CredSummary): string => { + const desc = cred.description ? ` - ${cred.description}` : ''; + return ` - ${cred.name} (key: ${cred.key})${desc}`; +}; + +/** + * Generate the creds list string for injection into the prompt + */ +export const generateCredsList = (creds: CredSummary[]): string => { + if (creds.length === 0) { + return 'No credentials configured yet. Guide the user to set up credentials when needed.'; + } + + const groups = groupCredsByType(creds); + const sections: string[] = []; + + if (groups['kv-env'].length > 0) { + sections.push(`**Environment Variables:**\n${groups['kv-env'].map(formatCred).join('\n')}`); + } + + if (groups['kv-header'].length > 0) { + sections.push(`**HTTP Headers:**\n${groups['kv-header'].map(formatCred).join('\n')}`); + } + + if (groups['oauth'].length > 0) { + sections.push(`**OAuth Connections:**\n${groups['oauth'].map(formatCred).join('\n')}`); + } + + if (groups['file'].length > 0) { + sections.push(`**File Credentials:**\n${groups['file'].map(formatCred).join('\n')}`); + } + + return sections.join('\n\n'); +}; + +/** + * Inject user creds context into the tool content + * This replaces {{CREDS_LIST}} and {{SETTINGS_URL}} placeholders + */ +export const injectCredsContext = (content: string, context: UserCredsContext): string => { + const credsList = generateCredsList(context.creds); + + return content + .replaceAll('{{CREDS_LIST}}', credsList) + .replaceAll('{{SETTINGS_URL}}', context.settingsUrl); +}; + +/** + * Check if a skill's required credentials are satisfied + */ +export interface CredRequirement { + key: string; + name: string; + type: CredType; +} + +export const checkCredsSatisfied = ( + requirements: CredRequirement[], + availableCreds: CredSummary[], +): { missing: CredRequirement[]; satisfied: boolean } => { + const availableKeys = new Set(availableCreds.map((c) => c.key)); + const missing = requirements.filter((req) => !availableKeys.has(req.key)); + + return { + missing, + satisfied: missing.length === 0, + }; +}; diff --git a/packages/builtin-tool-creds/src/index.ts b/packages/builtin-tool-creds/src/index.ts new file mode 100644 index 0000000000..a8ebf585f4 --- /dev/null +++ b/packages/builtin-tool-creds/src/index.ts @@ -0,0 +1,22 @@ +export { + checkCredsSatisfied, + type CredRequirement, + type CredSummary, + generateCredsList, + groupCredsByType, + injectCredsContext, + type UserCredsContext, +} from './helpers'; +export { CredsIdentifier, CredsManifest } from './manifest'; +export { systemPrompt } from './systemRole'; +export { + CredsApiName, + type CredsApiNameType, + type CredSummaryForContext, + type GetPlaintextCredParams, + type GetPlaintextCredState, + type InjectCredsToSandboxParams, + type InjectCredsToSandboxState, + type SaveCredsParams, + type SaveCredsState, +} from './types'; diff --git a/packages/builtin-tool-creds/src/manifest.ts b/packages/builtin-tool-creds/src/manifest.ts new file mode 100644 index 0000000000..da1d8753eb --- /dev/null +++ b/packages/builtin-tool-creds/src/manifest.ts @@ -0,0 +1,117 @@ +import type { BuiltinToolManifest } from '@lobechat/types'; +import type { JSONSchema7 } from 'json-schema'; + +import { systemPrompt } from './systemRole'; +import { CredsApiName } from './types'; + +export const CredsIdentifier = 'lobe-creds'; + +export const CredsManifest: BuiltinToolManifest = { + api: [ + { + description: + 'Initiate OAuth connection flow for a third-party service (e.g., Linear, Microsoft Outlook, Twitter/X). Returns an authorization URL that the user must click to authorize. After authorization, the credential will be automatically saved.', + name: CredsApiName.initiateOAuthConnect, + parameters: { + additionalProperties: false, + properties: { + provider: { + description: + 'The OAuth provider ID. Available providers: "linear" (issue tracking), "microsoft" (Outlook Calendar), "twitter" (X/Twitter)', + enum: ['linear', 'microsoft', 'twitter', 'github'], + type: 'string', + }, + }, + required: ['provider'], + type: 'object', + } satisfies JSONSchema7, + }, + { + description: + 'Retrieve the plaintext value of a stored credential by its key. Use this when you need to access a credential for making API calls or other operations. Only call this when you actually need the credential value.', + name: CredsApiName.getPlaintextCred, + parameters: { + additionalProperties: false, + properties: { + key: { + description: 'The unique key of the credential to retrieve', + type: 'string', + }, + reason: { + description: 'Brief explanation of why this credential is needed (for audit purposes)', + type: 'string', + }, + }, + required: ['key'], + type: 'object', + } satisfies JSONSchema7, + }, + { + description: + 'Inject credentials into the sandbox environment as environment variables. Only available when sandbox mode is enabled. Use this before running code that requires credentials.', + name: CredsApiName.injectCredsToSandbox, + parameters: { + additionalProperties: false, + properties: { + keys: { + description: 'Array of credential keys to inject into the sandbox', + items: { + type: 'string', + }, + type: 'array', + }, + }, + required: ['keys'], + type: 'object', + } satisfies JSONSchema7, + }, + { + description: + 'Save a new credential securely. Use this when the user wants to store sensitive information like API keys, tokens, or secrets. The credential will be encrypted and stored securely.', + name: CredsApiName.saveCreds, + parameters: { + additionalProperties: false, + properties: { + description: { + description: 'Optional description explaining what this credential is used for', + type: 'string', + }, + key: { + description: + 'Unique identifier key for the credential (e.g., "openai", "github-token"). Use lowercase with hyphens.', + pattern: '^[a-z][a-z0-9-]*$', + type: 'string', + }, + name: { + description: 'Human-readable display name for the credential', + type: 'string', + }, + type: { + description: 'The type of credential being saved', + enum: ['kv-env', 'kv-header'], + type: 'string', + }, + values: { + additionalProperties: { + type: 'string', + }, + description: + 'Key-value pairs of the credential. For kv-env, the key should be the environment variable name (e.g., {"OPENAI_API_KEY": "sk-..."})', + type: 'object', + }, + }, + required: ['key', 'name', 'type', 'values'], + type: 'object', + } satisfies JSONSchema7, + }, + ], + identifier: CredsIdentifier, + meta: { + avatar: '🔐', + description: + 'Manage user credentials for authentication, environment variable injection, and API verification. Use this tool when tasks require API keys, OAuth tokens, or secrets - such as calling third-party APIs, authenticating with external services, or injecting credentials into sandbox environments.', + title: 'Credentials', + }, + systemRole: systemPrompt, + type: 'builtin', +}; diff --git a/packages/builtin-tool-creds/src/systemRole.ts b/packages/builtin-tool-creds/src/systemRole.ts new file mode 100644 index 0000000000..2c9d51fdb6 --- /dev/null +++ b/packages/builtin-tool-creds/src/systemRole.ts @@ -0,0 +1,97 @@ +export const systemPrompt = `You have access to a LobeHub Credentials Tool. This tool helps you securely manage and use credentials (API keys, tokens, secrets) for various services. + + +Current user: {{username}} +Session date: {{date}} +Sandbox mode: {{sandbox_enabled}} + + + +{{CREDS_LIST}} + + + +- **kv-env**: Environment variable credentials (API keys, tokens). Injected as environment variables. +- **kv-header**: HTTP header credentials. Injected as request headers. +- **oauth**: OAuth-based authentication. Provides secure access to third-party services. +- **file**: File-based credentials (certificates, key files). + + + +1. **Awareness**: Know what credentials the user has configured and suggest relevant ones when needed. +2. **Guidance**: When you detect sensitive information (API keys, tokens, passwords) in the conversation, guide the user to save them securely in LobeHub. +3. **Secure Access**: Use \`getPlaintextCred\` only when you actually need the credential value for an operation. +4. **Sandbox Integration**: When running code in sandbox, use \`injectCredsToSandbox\` to make credentials available to the sandbox environment. + + + +- **initiateOAuthConnect**: Start OAuth authorization flow for third-party services. Returns an authorization URL for the user to click. +- **getPlaintextCred**: Retrieve the plaintext value of a credential by key. Only use when you need to actually use the credential. +- **injectCredsToSandbox**: Inject credentials into the sandbox environment. Only available when sandbox mode is enabled. +- **saveCreds**: Save new credentials securely. Use when user wants to store sensitive information. + + + +LobeHub provides built-in OAuth integrations for the following services: +- **github**: GitHub repository and code management. Connect to access repositories, create issues, manage pull requests. +- **linear**: Linear issue tracking and project management. Connect to create/manage issues, track projects. +- **microsoft**: Microsoft Outlook Calendar. Connect to view/create calendar events, manage meetings. +- **twitter**: X (Twitter) social media. Connect to post tweets, manage timeline, engage with audience. + +When a user mentions they want to use one of these services, use \`initiateOAuthConnect\` to provide them with an authorization link. After they authorize, the credential will be automatically saved and available for use. + + + +- **Never display credential values** in your responses. Refer to credentials by their key or name only. +- **Minimize credential access**: Only call \`getPlaintextCred\` when you genuinely need the value for an operation. +- **Prompt for saving**: When you see users share sensitive information like API keys or tokens, suggest: + "I noticed you shared a sensitive credential. Would you like me to save it securely in LobeHub? This way you can reuse it without sharing it again." +- **Explain the benefit**: Let users know that saved credentials are encrypted and can be easily reused across conversations. + + + +Proactively suggest saving credentials when you detect: +- API keys (e.g., "sk-...", "api_...", patterns like "OPENAI_API_KEY=...") +- Access tokens or bearer tokens +- Secret keys or private keys +- Database connection strings with passwords +- OAuth client secrets +- Any explicitly labeled secrets or passwords + +When suggesting to save, always: +1. Explain that the credential will be encrypted and stored securely +2. Ask the user for a meaningful name and optional description +3. Use the \`saveCreds\` tool to store it + + + +When sandbox mode is enabled and you need to run code that requires credentials: +1. Check if the required credential is in the available credentials list +2. Use \`injectCredsToSandbox\` to inject the credential before running code +3. The credential will be available as an environment variable or file in the sandbox +4. Never pass credential values directly in code - always use environment variables or file paths + +**Important Notes:** +- \`executeCode\` runs in an isolated process that may NOT have access to injected environment variables. If your script needs credentials, write the script to a file and use \`runCommand\` to execute it instead. + +**Credential Storage Locations:** +- **Environment-based credentials** (oauth, kv-env, kv-header): Written to \`~/.creds/env\` file +- **File-based credentials** (file): Extracted to \`~/.creds/files/\` directory + +**Environment Variable Naming:** +- **oauth**: \`{{KEY}}_ACCESS_TOKEN\` (e.g., \`GITHUB_ACCESS_TOKEN\`) +- **kv-env**: Each key-value pair becomes an environment variable as defined (e.g., \`OPENAI_API_KEY\`) +- **kv-header**: \`{{KEY}}_{{HEADER_NAME}}\` format (e.g., \`GITHUB_AUTH_HEADER_AUTHORIZATION\`) + +**File Credential Usage:** +- File credentials are extracted to \`~/.creds/files/{key}/{filename}\` +- Example: A credential with key \`gcp-service-account\` and file \`credentials.json\` → \`~/.creds/files/gcp-service-account/credentials.json\` +- Use the file path directly in your code (e.g., \`GOOGLE_APPLICATION_CREDENTIALS=~/.creds/files/gcp-service-account/credentials.json\`) + + + +- When credentials are relevant, mention which ones are available and how they can be used. +- When accessing credentials, briefly explain why access is needed. +- When guiding users to save credentials, be helpful but not pushy. +- Keep credential-related discussions concise and security-focused. +`; diff --git a/packages/builtin-tool-creds/src/types.ts b/packages/builtin-tool-creds/src/types.ts new file mode 100644 index 0000000000..7465665a5a --- /dev/null +++ b/packages/builtin-tool-creds/src/types.ts @@ -0,0 +1,148 @@ +import type { CredType } from '@lobechat/types'; + +export const CredsApiName = { + /** + * Get plaintext value of a credential + * Use when AI needs to access credential value for API calls + */ + getPlaintextCred: 'getPlaintextCred', + + /** + * Initiate OAuth connection flow + * Returns authorization URL for user to click and authorize + */ + initiateOAuthConnect: 'initiateOAuthConnect', + + /** + * Inject credentials to sandbox environment + * Only available when sandbox mode is enabled + */ + injectCredsToSandbox: 'injectCredsToSandbox', + + /** + * Save a new credential + * Use when user wants to store sensitive info securely + */ + saveCreds: 'saveCreds', +} as const; + +export type CredsApiNameType = (typeof CredsApiName)[keyof typeof CredsApiName]; + +// ==================== Tool Parameter Types ==================== + +export interface GetPlaintextCredParams { + /** + * The unique key of the credential to retrieve + */ + key: string; + /** + * Reason for accessing this credential (for audit purposes) + */ + reason?: string; +} + +export interface InitiateOAuthConnectParams { + /** + * The OAuth provider ID (e.g., 'linear', 'microsoft', 'twitter') + */ + provider: string; +} + +export interface InitiateOAuthConnectState { + /** + * The OAuth authorization URL for the user to click + */ + authorizeUrl: string; + /** + * Authorization code (for tracking) + */ + code?: string; + /** + * Expiration time in seconds + */ + expiresIn?: number; + /** + * Provider display name + */ + providerName: string; +} + +export interface GetPlaintextCredState { + /** + * The credential key + */ + key: string; + /** + * The plaintext values (key-value pairs) + */ + values?: Record; +} + +export interface InjectCredsToSandboxParams { + /** + * The credential keys to inject + */ + keys: string[]; +} + +export interface InjectCredsToSandboxState { + /** + * Injected credential keys + */ + injected: string[]; + /** + * Keys that failed to inject (not found or not available) + */ + missing: string[]; + /** + * Whether injection was successful + */ + success: boolean; +} + +export interface SaveCredsParams { + /** + * Optional description for the credential + */ + description?: string; + /** + * Unique key for the credential (used for reference) + */ + key: string; + /** + * Display name for the credential + */ + name: string; + /** + * The type of credential + */ + type: CredType; + /** + * Key-value pairs of the credential (for kv-env and kv-header types) + */ + values: Record; +} + +export interface SaveCredsState { + /** + * The created credential key + */ + key?: string; + /** + * Error message if save failed + */ + message?: string; + /** + * Whether save was successful + */ + success: boolean; +} + +// ==================== Context Types ==================== + +export interface CredSummaryForContext { + description?: string; + key: string; + name: string; + type: CredType; +} diff --git a/packages/builtin-tool-skill-store/src/manifest.ts b/packages/builtin-tool-skill-store/src/manifest.ts index a331a06052..834f7cecc9 100644 --- a/packages/builtin-tool-skill-store/src/manifest.ts +++ b/packages/builtin-tool-skill-store/src/manifest.ts @@ -95,7 +95,8 @@ export const SkillStoreManifest: BuiltinToolManifest = { identifier: SkillStoreIdentifier, meta: { avatar: '🏪', - description: 'Browse and install agent skills from the LobeHub marketplace', + description: + 'Browse and install agent skills from the LobeHub marketplace. MUST USE this tool when users mention: "SKILL.md", "LobeHub Skills", "skill store", "install skill", "search skill", or need extended capabilities.', title: 'Skill Store', }, systemRole: systemPrompt, diff --git a/packages/builtin-tool-skills/src/manifest.base.ts b/packages/builtin-tool-skills/src/manifest.base.ts index 9e2b70e48c..52527cf81c 100644 --- a/packages/builtin-tool-skills/src/manifest.base.ts +++ b/packages/builtin-tool-skills/src/manifest.base.ts @@ -61,13 +61,15 @@ export const exportFileApi: LobeChatPluginApi = { }; export const runCommandApi: LobeChatPluginApi = { - description: 'Execute a shell command. Returns the command output, stderr, and exit code.', + description: + 'Execute a shell command. Returns the command output, stderr, and exit code. Note: Default shell is /bin/sh (dash/ash), not bash. The `source` command may not work; use `bash -c "source file && cmd"` if needed.', humanIntervention: 'required', name: SkillsApiName.runCommand, parameters: { properties: { command: { - description: 'The shell command to execute.', + description: + 'The shell command to execute. Note: Default shell is /bin/sh, not bash. Use `bash -c "..."` for bash-specific features.', type: 'string', }, description: { @@ -83,7 +85,8 @@ export const runCommandApi: LobeChatPluginApi = { export const execScriptBaseParams = { command: { - description: 'The shell command to execute.', + description: + 'The shell command to execute. Note: Default shell is /bin/sh, not bash. Use `bash -c "..."` for bash-specific features like `source`.', type: 'string' as const, }, description: { diff --git a/packages/builtin-tool-tools/src/systemRole.ts b/packages/builtin-tool-tools/src/systemRole.ts index 6b11530d2c..a5f9beed8c 100644 --- a/packages/builtin-tool-tools/src/systemRole.ts +++ b/packages/builtin-tool-tools/src/systemRole.ts @@ -18,19 +18,67 @@ export const systemPrompt = `You have access to a Tool Discovery system that all -When the user's task involves a specialized domain (e.g. creating presentations/PPT, generating PDFs, charts, diagrams, or other domain-specific work), and the \`\` list does NOT contain a matching tool, you should search the LobeHub Skill Marketplace for a dedicated skill before falling back to generic tools. +**CRITICAL: Always activate \`lobe-skill-store\` FIRST when ANY of the following conditions are met:** + +**Trigger keywords/patterns (MUST activate lobe-skill-store immediately):** +- User mentions: "SKILL.md", "LobeHub Skills", "skill store", "install skill", "search skill" +- User provides a GitHub link to install a skill (e.g., github.com/xxx/xxx containing SKILL.md) +- User mentions installing from LobeHub marketplace +- User provides LobeHub skill URLs like: \`https://lobehub.com/skills/{identifier}/skill.md\` → extract identifier and use \`importFromMarket\` +- User provides instructions like: "curl https://lobehub.com/skills/..." → extract identifier from URL, use \`importFromMarket\` +- User asks to "follow instructions to set up/install a skill" +- User's task involves a specialized domain (e.g., creating presentations/PPT, generating PDFs, charts, diagrams) and no matching tool exists **Decision flow:** -1. Check \`\` for a relevant tool → if found, use \`activateTools\` -2. If no matching tool is found AND \`lobe-skill-store\` is available → call \`searchSkill\` to search the marketplace -3. If a relevant skill is found → call \`importFromMarket\` to install it, then use it -4. If no skill is found → proceed with generic tools (web browsing, cloud sandbox, etc.) +1. **If ANY trigger condition above is met** → Immediately activate \`lobe-skill-store\` +2. **For LobeHub skill URLs** (e.g., \`https://lobehub.com/skills/{identifier}/skill.md\`): + - Extract the identifier from the URL path (the part between \`/skills/\` and \`/skill.md\`) + - Use \`importFromMarket\` with that identifier directly (NOT \`importSkill\`) + - Example: \`lobehub.com/skills/openclaw-openclaw-github/skill.md\` → identifier is \`openclaw-openclaw-github\` +3. For GitHub repository URLs → use \`importSkill\` with type "url" +4. For marketplace searches → use \`searchSkill\` then \`importFromMarket\` +5. Check \`\` for other relevant tools → if found, use \`activateTools\` +6. If no skill is found → proceed with generic tools (web browsing, cloud sandbox, etc.) -This ensures the user benefits from purpose-built skills rather than relying on generic tools for specialized tasks. +**Important:** +- Do NOT manually curl/fetch SKILL.md files or try to parse them yourself +- For \`lobehub.com/skills/xxx/skill.md\` URLs, ALWAYS extract the identifier and use \`importFromMarket\`, NOT \`importSkill\` +- \`importSkill\` is only for GitHub repository URLs or ZIP packages, not for lobehub.com skill URLs + +**CRITICAL: Activate \`lobe-creds\` when ANY of the following conditions are met:** + +**Trigger conditions (MUST activate lobe-creds immediately):** +- User needs to authenticate with a third-party service (OAuth, API keys, tokens) +- User mentions: "API key", "access token", "credentials", "authenticate", "login to service" +- Task requires environment variables (e.g., \`OPENAI_API_KEY\`, \`GITHUB_TOKEN\`) +- User wants to store or manage sensitive information securely +- Sandbox code execution requires credentials/secrets to be injected +- User asks to connect to services like GitHub, Linear, Twitter, Microsoft, etc. + +**Decision flow:** +1. **If ANY trigger condition above is met** → Immediately activate \`lobe-creds\` +2. Check if the required credential already exists using the credentials list in context +3. If credential exists → use \`getPlaintextCred\` or \`injectCredsToSandbox\` (for sandbox execution) +4. If credential doesn't exist: + - For OAuth services (GitHub, Linear, Microsoft, Twitter) → use \`initiateOAuthConnect\` + - For API keys/tokens → guide user to save with \`saveCreds\` +5. For sandbox code that needs credentials → use \`injectCredsToSandbox\` to inject them as environment variables + +**Important:** +- Never ask users to paste API keys directly in chat — always use \`lobe-creds\` to store them securely +- \`lobe-creds\` works together with \`lobe-cloud-sandbox\` for secure credential injection + +**Credential Injection Locations:** +- Environment-based credentials (oauth, kv-env, kv-header) → \`~/.creds/env\` — use \`runCommand\` with \`bash -c "source ~/.creds/env && your_command"\` +- File-based credentials → \`~/.creds/files/{key}/{filename}\` — use file path directly in your code + + - **IMPORTANT: Plan ahead and activate all needed tools upfront in a single call.** Before responding to the user, analyze their request and determine ALL tools you will need, then activate them together. Do NOT activate tools incrementally during a multi-step task. +- **SKILL-FIRST: Any mention of skills, SKILL.md, GitHub skill links, or LobeHub marketplace → activate \`lobe-skill-store\` FIRST, no exceptions.** +- **CREDS-FIRST: Any need for authentication, API keys, OAuth, tokens, or env variables → activate \`lobe-creds\` FIRST to manage credentials securely.** - Check the \`\` list before activating tools - For specialized tasks, search the Skill Marketplace first — a dedicated skill is almost always better than a generic approach - Only activate tools that are relevant to the user's current request diff --git a/packages/builtin-tools/package.json b/packages/builtin-tools/package.json index fedd73338d..fe2da2e2ac 100644 --- a/packages/builtin-tools/package.json +++ b/packages/builtin-tools/package.json @@ -19,6 +19,7 @@ "@lobechat/builtin-tool-agent-builder": "workspace:*", "@lobechat/builtin-tool-agent-documents": "workspace:*", "@lobechat/builtin-tool-cloud-sandbox": "workspace:*", + "@lobechat/builtin-tool-creds": "workspace:*", "@lobechat/builtin-tool-group-agent-builder": "workspace:*", "@lobechat/builtin-tool-group-management": "workspace:*", "@lobechat/builtin-tool-gtd": "workspace:*", diff --git a/packages/builtin-tools/src/identifiers.ts b/packages/builtin-tools/src/identifiers.ts index 31b34ee0b9..058be3de87 100644 --- a/packages/builtin-tools/src/identifiers.ts +++ b/packages/builtin-tools/src/identifiers.ts @@ -3,6 +3,7 @@ import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents'; import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management'; import { CalculatorManifest } from '@lobechat/builtin-tool-calculator'; import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox'; +import { CredsManifest } from '@lobechat/builtin-tool-creds'; import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-builder'; import { GroupManagementManifest } from '@lobechat/builtin-tool-group-management'; import { GTDManifest } from '@lobechat/builtin-tool-gtd'; @@ -22,18 +23,19 @@ export const builtinToolIdentifiers: string[] = [ AgentDocumentsManifest.identifier, AgentManagementManifest.identifier, CalculatorManifest.identifier, - LocalSystemManifest.identifier, - WebBrowsingManifest.identifier, - KnowledgeBaseManifest.identifier, CloudSandboxManifest.identifier, - PageAgentManifest.identifier, - SkillsManifest.identifier, + CredsManifest.identifier, GroupAgentBuilderManifest.identifier, GroupManagementManifest.identifier, GTDManifest.identifier, + KnowledgeBaseManifest.identifier, + LocalSystemManifest.identifier, + LobeToolsManifest.identifier, MemoryManifest.identifier, NotebookManifest.identifier, - TopicReferenceManifest.identifier, - LobeToolsManifest.identifier, + PageAgentManifest.identifier, + SkillsManifest.identifier, SkillStoreManifest.identifier, + TopicReferenceManifest.identifier, + WebBrowsingManifest.identifier, ]; diff --git a/packages/builtin-tools/src/index.ts b/packages/builtin-tools/src/index.ts index 78790fcc93..01baf701e9 100644 --- a/packages/builtin-tools/src/index.ts +++ b/packages/builtin-tools/src/index.ts @@ -3,6 +3,7 @@ import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents'; import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management'; import { CalculatorManifest } from '@lobechat/builtin-tool-calculator'; import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox'; +import { CredsManifest } from '@lobechat/builtin-tool-creds'; import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-builder'; import { GroupManagementManifest } from '@lobechat/builtin-tool-group-management'; import { GTDManifest } from '@lobechat/builtin-tool-gtd'; @@ -58,7 +59,6 @@ export const builtinTools: LobeBuiltinTool[] = [ type: 'builtin', }, { - discoverable: false, hidden: true, identifier: SkillStoreManifest.identifier, manifest: SkillStoreManifest, @@ -89,6 +89,11 @@ export const builtinTools: LobeBuiltinTool[] = [ manifest: CloudSandboxManifest, type: 'builtin', }, + { + identifier: CredsManifest.identifier, + manifest: CredsManifest, + type: 'builtin', + }, { hidden: true, identifier: KnowledgeBaseManifest.identifier, diff --git a/packages/const/src/lobehubSkill.ts b/packages/const/src/lobehubSkill.ts index 744dcd4678..7b6cee1988 100644 --- a/packages/const/src/lobehubSkill.ts +++ b/packages/const/src/lobehubSkill.ts @@ -1,5 +1,5 @@ import type { IconType } from '@icons-pack/react-simple-icons'; -import { SiLinear, SiX } from '@icons-pack/react-simple-icons'; +import { SiGithub, SiLinear, SiX } from '@icons-pack/react-simple-icons'; export interface LobehubSkillProviderType { /** @@ -45,6 +45,18 @@ export interface LobehubSkillProviderType { * - Add new providers here when Market adds support */ export const LOBEHUB_SKILL_PROVIDERS: LobehubSkillProviderType[] = [ + { + author: 'LobeHub', + authorUrl: 'https://lobehub.com', + defaultVisible: true, + description: + 'GitHub is a platform for version control and collaboration, enabling developers to host, review, and manage code repositories.', + icon: SiGithub, + id: 'github', + label: 'GitHub', + readme: + 'Connect to GitHub to access your repositories, create and manage issues, review pull requests, and collaborate on code—all through natural conversation with your AI assistant.', + }, { author: 'LobeHub', authorUrl: 'https://lobehub.com', diff --git a/packages/types/package.json b/packages/types/package.json index d8a7253869..2100b3f276 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -8,7 +8,7 @@ "@lobechat/python-interpreter": "workspace:*", "@lobechat/web-crawler": "workspace:*", "@lobehub/chat-plugin-sdk": "^1.32.4", - "@lobehub/market-sdk": "^0.31.3", + "@lobehub/market-sdk": "0.31.11", "@lobehub/market-types": "^1.12.3", "model-bank": "workspace:*", "type-fest": "^4.41.0", diff --git a/packages/types/src/creds/index.ts b/packages/types/src/creds/index.ts new file mode 100644 index 0000000000..3e90efbbec --- /dev/null +++ b/packages/types/src/creds/index.ts @@ -0,0 +1,135 @@ +/** + * Credential Types for Market SDK Integration + */ + +// ===== Credential Type ===== + +export type CredType = 'kv-env' | 'kv-header' | 'oauth' | 'file'; + +// ===== Credential Summary (for list display) ===== + +export interface UserCredSummary { + createdAt: string; + description?: string; + // File type specific + fileName?: string; + fileSize?: number; + id: number; + key: string; + lastUsedAt?: string; + maskedPreview?: string; // Masked preview, e.g., "sk-****xxxx" + name: string; + // OAuth type specific + oauthAvatar?: string; + oauthProvider?: string; + oauthUsername?: string; + type: CredType; + updatedAt: string; +} + +// ===== Credential with Plaintext (for editing) ===== + +export interface CredWithPlaintext extends UserCredSummary { + plaintext?: Record; // Decrypted key-value pairs for KV types +} + +// ===== Create Request Types ===== + +export interface CreateKVCredRequest { + description?: string; + key: string; + name: string; + type: 'kv-env' | 'kv-header'; + values: Record; +} + +export interface CreateOAuthCredRequest { + description?: string; + key: string; + name: string; + oauthConnectionId: number; +} + +export interface CreateFileCredRequest { + description?: string; + fileHashId: string; + fileName: string; + key: string; + name: string; +} + +// ===== Update Request ===== + +export interface UpdateCredRequest { + description?: string; + name?: string; + values?: Record; // Only for KV types +} + +// ===== Get Options ===== + +export interface GetCredOptions { + decrypt?: boolean; +} + +// ===== List Response ===== + +export interface ListCredsResponse { + data: UserCredSummary[]; +} + +// ===== Delete Response ===== + +export interface DeleteCredResponse { + success: boolean; +} + +// ===== Skill Credential Status ===== + +export interface SkillCredStatus { + boundCred?: UserCredSummary; + description?: string; + key: string; + name: string; + required: boolean; + satisfied: boolean; + type: CredType; +} + +// ===== Inject Request/Response ===== + +export interface InjectCredsRequest { + sandbox?: boolean; + skillIdentifier: string; +} + +export interface InjectCredsResponse { + credentials: { + env: Record; + files: Array<{ + content: string; // S3 URL + envName?: string; + fileName: string; + key: string; + mimeType: string; + }>; + headers: Record; + }; + missing: Array<{ + key: string; + name: string; + type: CredType; + }>; + success: boolean; + unsupportedInSandbox: string[]; +} + +// ===== OAuth Connection (for creating OAuth creds) ===== + +export interface OAuthConnection { + avatar?: string; + id: number; + providerId: string; + providerName?: string; + username?: string; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 479491789d..d26b86c80a 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -9,6 +9,7 @@ export * from './auth'; export * from './chunk'; export * from './clientDB'; export * from './conversation'; +export * from './creds'; export * from './discover'; export * from './document'; export * from './eval'; diff --git a/src/features/PluginsUI/Render/DefaultType/SystemJsRender/utils.ts b/src/features/PluginsUI/Render/DefaultType/SystemJsRender/utils.ts index e61d0538da..9012cbf610 100644 --- a/src/features/PluginsUI/Render/DefaultType/SystemJsRender/utils.ts +++ b/src/features/PluginsUI/Render/DefaultType/SystemJsRender/utils.ts @@ -1,4 +1,3 @@ - /** * This dynamic loading module is implemented using SystemJS, caching four modules in Lobe Chat: React, ReactDOM, antd, and antd-style. */ diff --git a/src/locales/default/setting.ts b/src/locales/default/setting.ts index 756715589a..7593fb8ef8 100644 --- a/src/locales/default/setting.ts +++ b/src/locales/default/setting.ts @@ -205,6 +205,73 @@ export default { 'analytics.telemetry.title': 'Send Anonymous Usage Data', 'analytics.title': 'Analytics', 'checking': 'Checking...', + + // Credentials Management + 'creds.actions.delete': 'Delete', + 'creds.actions.deleteConfirm.cancel': 'Cancel', + 'creds.actions.deleteConfirm.content': + 'This credential will be permanently deleted. This action cannot be undone.', + 'creds.actions.deleteConfirm.ok': 'Delete', + 'creds.actions.deleteConfirm.title': 'Delete Credential?', + 'creds.actions.edit': 'Edit', + 'creds.actions.view': 'View', + 'creds.create': 'New Credential', + 'creds.createModal.fillForm': 'Fill Details', + 'creds.createModal.selectType': 'Select Type', + 'creds.createModal.title': 'Create Credential', + 'creds.edit.title': 'Edit Credential', + 'creds.empty': 'No credentials configured yet', + 'creds.file.authRequired': 'Please sign in to the Market first', + 'creds.file.uploadFailed': 'File upload failed', + 'creds.file.uploadSuccess': 'File uploaded successfully', + 'creds.file.uploading': 'Uploading...', + 'creds.signIn': 'Sign In to Market', + 'creds.signInRequired': 'Please sign in to the Market to manage your credentials', + 'creds.form.addPair': 'Add Key-Value Pair', + 'creds.form.back': 'Back', + 'creds.form.cancel': 'Cancel', + 'creds.form.connectionRequired': 'Please select an OAuth connection', + 'creds.form.description': 'Description', + 'creds.form.descriptionPlaceholder': 'Optional description for this credential', + 'creds.form.file': 'Credential File', + 'creds.form.fileRequired': 'Please upload a file', + 'creds.form.key': 'Identifier', + 'creds.form.keyPattern': 'Identifier can only contain letters, numbers, underscores, and hyphens', + 'creds.form.keyRequired': 'Identifier is required', + 'creds.form.name': 'Display Name', + 'creds.form.nameRequired': 'Display name is required', + 'creds.form.save': 'Save', + 'creds.form.selectConnection': 'Select OAuth Connection', + 'creds.form.selectConnectionPlaceholder': 'Choose a connected account', + 'creds.form.selectedFile': 'Selected file', + 'creds.form.submit': 'Create', + 'creds.form.uploadDesc': 'Supports JSON, PEM, and other credential file formats', + 'creds.form.uploadHint': 'Click or drag file to upload', + 'creds.form.valuePlaceholder': 'Enter value', + 'creds.form.values': 'Key-Value Pairs', + 'creds.oauth.noConnections': 'No OAuth connections available. Please connect an account first.', + 'creds.table.actions': 'Actions', + 'creds.table.key': 'Identifier', + 'creds.table.lastUsed': 'Last Used', + 'creds.table.name': 'Name', + 'creds.table.neverUsed': 'Never', + 'creds.table.preview': 'Preview', + 'creds.table.type': 'Type', + 'creds.typeDesc.file': 'Upload credential files like service accounts or certificates', + 'creds.typeDesc.kv-env': 'Store API keys and tokens as environment variables', + 'creds.typeDesc.kv-header': 'Store authorization values as HTTP headers', + 'creds.typeDesc.oauth': 'Link to an existing OAuth connection', + 'creds.types.all': 'All', + 'creds.types.file': 'File', + 'creds.types.kv-env': 'Environment', + 'creds.types.kv-header': 'Header', + 'creds.types.oauth': 'OAuth', + 'creds.view.error': 'Failed to load credential', + 'creds.view.noValues': 'No Values', + 'creds.view.oauthNote': 'OAuth credentials are managed by the connected service.', + 'creds.view.title': 'View Credential: {{name}}', + 'creds.view.values': 'Credential Values', + 'creds.view.warning': 'These values are sensitive. Do not share them with others.', 'checkingPermissions': 'Checking permissions...', 'danger.clear.action': 'Clear Now', 'danger.clear.confirm': "Clear all chat data? This can't be undone.", @@ -845,6 +912,7 @@ When I am ___, I need ___ 'tab.appearance': 'Appearance', 'tab.chatAppearance': 'Chat Appearance', 'tab.common': 'Appearance', + 'tab.creds': 'Credentials', 'tab.experiment': 'Experiment', 'tab.hotkey': 'Hotkeys', 'tab.image': 'Image Generation', diff --git a/src/routes/(main)/settings/creds/features/CreateCredModal/CredTypeSelector.tsx b/src/routes/(main)/settings/creds/features/CreateCredModal/CredTypeSelector.tsx new file mode 100644 index 0000000000..ae52ca0d61 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/CreateCredModal/CredTypeSelector.tsx @@ -0,0 +1,97 @@ +'use client'; + +import { type CredType } from '@lobechat/types'; +import { Flexbox } from '@lobehub/ui'; +import { Card } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { File, Globe, Key, TerminalSquare } from 'lucide-react'; +import { type FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + card: css` + cursor: pointer; + transition: all 0.2s; + + &:hover { + border-color: ${cssVar.colorPrimary}; + box-shadow: 0 2px 8px rgb(0 0 0 / 10%); + } + `, + description: css` + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + `, + grid: css` + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 16px; + `, + icon: css` + display: flex; + align-items: center; + justify-content: center; + + width: 48px; + height: 48px; + margin-block-end: 12px; + border-radius: 12px; + + background: ${cssVar.colorFillSecondary}; + `, + title: css` + margin-block-end: 4px; + font-weight: 500; + `, +})); + +interface CredTypeSelectorProps { + onSelect: (type: CredType) => void; +} + +const typeConfigs: Array<{ + description: string; + icon: React.ReactNode; + type: CredType; +}> = [ + { + description: 'creds.typeDesc.kv-env', + icon: , + type: 'kv-env', + }, + { + description: 'creds.typeDesc.kv-header', + icon: , + type: 'kv-header', + }, + { + description: 'creds.typeDesc.oauth', + icon: , + type: 'oauth', + }, + { + description: 'creds.typeDesc.file', + icon: , + type: 'file', + }, +]; + +const CredTypeSelector: FC = ({ onSelect }) => { + const { t } = useTranslation('setting'); + + return ( +
+ {typeConfigs.map(({ type, icon, description }) => ( + onSelect(type)}> + +
{icon}
+
{t(`creds.types.${type}`)}
+
{t(description as any)}
+
+
+ ))} +
+ ); +}; + +export default CredTypeSelector; diff --git a/src/routes/(main)/settings/creds/features/CreateCredModal/FileCredForm.tsx b/src/routes/(main)/settings/creds/features/CreateCredModal/FileCredForm.tsx new file mode 100644 index 0000000000..39c1db0b9a --- /dev/null +++ b/src/routes/(main)/settings/creds/features/CreateCredModal/FileCredForm.tsx @@ -0,0 +1,166 @@ +'use client'; + +import { InboxOutlined } from '@ant-design/icons'; +import { Button } from '@lobehub/ui'; +import { useMutation } from '@tanstack/react-query'; +import { Form, Input, message, Upload } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { type FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { lambdaClient } from '@/libs/trpc/client'; + +const styles = createStaticStyles(({ css }) => ({ + footer: css` + display: flex; + gap: 8px; + justify-content: flex-end; + margin-block-start: 24px; + `, +})); + +interface FileCredFormProps { + onBack: () => void; + onSuccess: () => void; +} + +interface FormValues { + description?: string; + key: string; + name: string; +} + +const FileCredForm: FC = ({ onBack, onSuccess }) => { + const { t } = useTranslation('setting'); + const [form] = Form.useForm(); + const [fileHashId, setFileHashId] = useState(null); + const [fileName, setFileName] = useState(''); + const [isUploading, setIsUploading] = useState(false); + + const createMutation = useMutation({ + mutationFn: (values: FormValues) => { + if (!fileHashId || !fileName) { + throw new Error('File is required'); + } + + return lambdaClient.market.creds.createFile.mutate({ + description: values.description, + fileHashId, + fileName, + key: values.key, + name: values.name, + }); + }, + onSuccess: () => { + onSuccess(); + }, + }); + + const handleUpload = async (file: File) => { + setIsUploading(true); + + try { + // Convert file to base64 + const arrayBuffer = await file.arrayBuffer(); + const bytes = new Uint8Array(arrayBuffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = btoa(binary); + + // Upload via TRPC + const result = await lambdaClient.market.creds.uploadFile.mutate({ + file: base64, + fileName: file.name, + fileType: file.type || 'application/octet-stream', + }); + + setFileName(result.fileName); + setFileHashId(result.fileHashId); + message.success(t('creds.file.uploadSuccess')); + } catch (error) { + console.error('[FileCredForm] Upload failed:', error); + message.error(error instanceof Error ? error.message : t('creds.file.uploadFailed')); + } finally { + setIsUploading(false); + } + + return false; // Prevent default upload + }; + + const handleSubmit = (values: FormValues) => { + if (!fileHashId) { + message.error(t('creds.form.fileRequired')); + return; + } + createMutation.mutate(values); + }; + + return ( + form={form} layout="vertical" onFinish={handleSubmit}> + + { + setFileHashId(null); + setFileName(''); + }} + > +

+ +

+

+ {isUploading ? t('creds.file.uploading') : t('creds.form.uploadHint')} +

+

{t('creds.form.uploadDesc')}

+
+ {fileName && ( +
+ {t('creds.form.selectedFile')}: {fileName} +
+ )} +
+ + + + + + + + + + + + + +
+ + +
+ + ); +}; + +export default FileCredForm; diff --git a/src/routes/(main)/settings/creds/features/CreateCredModal/KVCredForm.tsx b/src/routes/(main)/settings/creds/features/CreateCredModal/KVCredForm.tsx new file mode 100644 index 0000000000..a884cd55b3 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/CreateCredModal/KVCredForm.tsx @@ -0,0 +1,147 @@ +'use client'; + +import { Button, Flexbox } from '@lobehub/ui'; +import { useMutation } from '@tanstack/react-query'; +import { Form, Input } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { Minus, Plus } from 'lucide-react'; +import { type FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { lambdaClient } from '@/libs/trpc/client'; + +const styles = createStaticStyles(({ css }) => ({ + footer: css` + display: flex; + gap: 8px; + justify-content: flex-end; + margin-block-start: 24px; + `, + kvPair: css` + display: flex; + gap: 8px; + align-items: flex-start; + `, +})); + +interface KVCredFormProps { + onBack: () => void; + onSuccess: () => void; + type: 'kv-env' | 'kv-header'; +} + +interface FormValues { + description?: string; + key: string; + kvPairs: Array<{ key: string; value: string }>; + name: string; +} + +const KVCredForm: FC = ({ type, onBack, onSuccess }) => { + const { t } = useTranslation('setting'); + const [form] = Form.useForm(); + + const createMutation = useMutation({ + mutationFn: (values: FormValues) => { + const kvPairs = values.kvPairs || []; + const valuesObj = kvPairs.reduce( + (acc, pair) => { + if (pair.key && pair.value) { + acc[pair.key] = pair.value; + } + return acc; + }, + {} as Record, + ); + + return lambdaClient.market.creds.createKV.mutate({ + description: values.description, + key: values.key, + name: values.name, + type, + values: valuesObj, + }); + }, + onSuccess: () => { + onSuccess(); + }, + }); + + const handleSubmit = (values: FormValues) => { + createMutation.mutate(values); + }; + + return ( + + form={form} + initialValues={{ kvPairs: [{ key: '', value: '' }] }} + layout="vertical" + onFinish={handleSubmit} + > + + + + + + + + + + + {(fields, { add, remove }) => ( + + {fields.map(({ key, name, ...restField }) => ( +
+ + + + + + + {fields.length > 1 && ( +
+ ))} + +
+ )} +
+
+ + + + + +
+ + +
+ + ); +}; + +export default KVCredForm; diff --git a/src/routes/(main)/settings/creds/features/CreateCredModal/OAuthCredForm.tsx b/src/routes/(main)/settings/creds/features/CreateCredModal/OAuthCredForm.tsx new file mode 100644 index 0000000000..64563b39bd --- /dev/null +++ b/src/routes/(main)/settings/creds/features/CreateCredModal/OAuthCredForm.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { Button, Flexbox } from '@lobehub/ui'; +import { useMutation } from '@tanstack/react-query'; +import { Avatar, Empty, Form, Input, Select, Spin } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { type FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { lambdaClient, lambdaQuery } from '@/libs/trpc/client'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + connectionOption: css` + display: flex; + gap: 8px; + align-items: center; + `, + footer: css` + display: flex; + gap: 8px; + justify-content: flex-end; + margin-block-start: 24px; + `, + provider: css` + font-weight: 500; + `, + username: css` + color: ${cssVar.colorTextSecondary}; + `, +})); + +interface OAuthCredFormProps { + onBack: () => void; + onSuccess: () => void; +} + +interface FormValues { + description?: string; + key: string; + name: string; + oauthConnectionId: number; +} + +const OAuthCredForm: FC = ({ onBack, onSuccess }) => { + const { t } = useTranslation('setting'); + const [form] = Form.useForm(); + + const { data: connectionsData, isLoading } = + lambdaQuery.market.creds.listOAuthConnections.useQuery(); + + const connections = connectionsData?.connections ?? []; + + const createMutation = useMutation({ + mutationFn: (values: FormValues) => { + return lambdaClient.market.creds.createOAuth.mutate({ + description: values.description, + key: values.key, + name: values.name, + oauthConnectionId: values.oauthConnectionId, + }); + }, + onSuccess: () => { + onSuccess(); + }, + }); + + const handleSubmit = (values: FormValues) => { + createMutation.mutate(values); + }; + + if (isLoading) { + return ( + + + + ); + } + + if (connections.length === 0) { + return ( + + +
+ +
+
+ ); + } + + return ( + form={form} layout="vertical" onFinish={handleSubmit}> + + + + + + + + + + + + + + + + +
+ + +
+ + ); +}; + +export default OAuthCredForm; diff --git a/src/routes/(main)/settings/creds/features/CreateCredModal/index.tsx b/src/routes/(main)/settings/creds/features/CreateCredModal/index.tsx new file mode 100644 index 0000000000..bf718ff0e8 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/CreateCredModal/index.tsx @@ -0,0 +1,100 @@ +'use client'; + +import { type CredType } from '@lobechat/types'; +import { Modal } from '@lobehub/ui'; +import { Steps } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { type FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import CredTypeSelector from './CredTypeSelector'; +import FileCredForm from './FileCredForm'; +import KVCredForm from './KVCredForm'; +import OAuthCredForm from './OAuthCredForm'; + +const styles = createStaticStyles(({ css }) => ({ + content: css` + padding-block: 24px; + `, + steps: css` + margin-block-end: 24px; + `, +})); + +interface CreateCredModalProps { + onCancel: () => void; + onSuccess: () => void; + open: boolean; +} + +const CreateCredModal: FC = ({ open, onCancel, onSuccess }) => { + const { t } = useTranslation('setting'); + const [step, setStep] = useState(0); + const [credType, setCredType] = useState(null); + + const handleTypeSelect = (type: CredType) => { + setCredType(type); + setStep(1); + }; + + const handleBack = () => { + setStep(0); + setCredType(null); + }; + + const handleClose = () => { + setStep(0); + setCredType(null); + onCancel(); + }; + + const handleSuccess = () => { + setStep(0); + setCredType(null); + onSuccess(); + }; + + const renderForm = () => { + switch (credType) { + case 'kv-env': + case 'kv-header': { + return ; + } + case 'oauth': { + return ; + } + case 'file': { + return ; + } + default: { + return null; + } + } + }; + + return ( + +
+ + + {step === 0 ? : renderForm()} +
+
+ ); +}; + +export default CreateCredModal; diff --git a/src/routes/(main)/settings/creds/features/CredDisplay.tsx b/src/routes/(main)/settings/creds/features/CredDisplay.tsx new file mode 100644 index 0000000000..154af04387 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/CredDisplay.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { type UserCredSummary } from '@lobechat/types'; +import { Flexbox } from '@lobehub/ui'; +import { Typography } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { type FC } from 'react'; + +const styles = createStaticStyles(({ css }) => ({ + container: css` + display: inline-flex; + gap: 4px; + align-items: center; + `, + value: css` + font-family: monospace; + font-size: 12px; + `, +})); + +interface CredDisplayProps { + cred: UserCredSummary; +} + +const CredDisplay: FC = ({ cred }) => { + // For OAuth type, show username + if (cred.type === 'oauth') { + return ( + + {cred.oauthUsername ? `@${cred.oauthUsername}` : cred.oauthProvider || '-'} + + ); + } + + // For file type, show filename + if (cred.type === 'file') { + return ( + + {cred.fileName || '-'} + {cred.fileSize && ( + + ({(cred.fileSize / 1024).toFixed(1)} KB) + + )} + + ); + } + + // For KV types, show masked preview + return ( + + {cred.maskedPreview || '-'} + + ); +}; + +export default CredDisplay; diff --git a/src/routes/(main)/settings/creds/features/CredItem.tsx b/src/routes/(main)/settings/creds/features/CredItem.tsx new file mode 100644 index 0000000000..dfdcd065f7 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/CredItem.tsx @@ -0,0 +1,132 @@ +'use client'; + +import { type UserCredSummary } from '@lobechat/types'; +import { Avatar, Button, DropdownMenu, Flexbox, Icon, stopPropagation } from '@lobehub/ui'; +import { App, Tag } from 'antd'; +import { + Eye, + File, + Globe, + Key, + MoreHorizontalIcon, + Pencil, + TerminalSquare, + Trash2, +} from 'lucide-react'; +import { type FC, memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { styles } from './style'; + +interface CredItemProps { + cred: UserCredSummary; + onDelete: (id: number) => void; + onEdit: (cred: UserCredSummary) => void; + onView: (cred: UserCredSummary) => void; +} + +const typeIcons: Record = { + 'file': , + 'kv-env': , + 'kv-header': , + 'oauth': , +}; + +const typeColors: Record = { + 'file': 'purple', + 'kv-env': 'blue', + 'kv-header': 'cyan', + 'oauth': 'green', +}; + +const CredItem: FC = memo(({ cred, onEdit, onDelete, onView }) => { + const { t } = useTranslation('setting'); + const { modal } = App.useApp(); + + const handleDelete = () => { + modal.confirm({ + centered: true, + content: t('creds.actions.deleteConfirm.content'), + okButtonProps: { danger: true }, + okText: t('creds.actions.deleteConfirm.ok'), + onOk: () => onDelete(cred.id), + title: t('creds.actions.deleteConfirm.title'), + type: 'error', + }); + }; + + const canView = cred.type === 'kv-env' || cred.type === 'kv-header'; + + const menuItems = [ + ...(canView + ? [ + { + icon: , + key: 'view', + label: t('creds.actions.view'), + onClick: () => onView(cred), + }, + ] + : []), + { + icon: , + key: 'edit', + label: t('creds.actions.edit'), + onClick: () => onEdit(cred), + }, + { + danger: true, + icon: , + key: 'delete', + label: t('creds.actions.delete'), + onClick: handleDelete, + }, + ]; + + const renderAvatar = () => { + if (cred.type === 'oauth' && cred.oauthAvatar) { + return ; + } + return ( + {typeIcons[cred.type]} + ); + }; + + return ( + + +
{renderAvatar()}
+ + + {cred.name} + {t(`creds.types.${cred.type}`)} + + + {cred.key} + {cred.description && ( + <> + · + {cred.description} + + )} + + +
+ + + + + ); + } + + return ( +
+ {isLoading ? ( + + + + ) : credentials.length === 0 ? ( + + ) : ( + + {credentials.map((cred) => ( + deleteMutation.mutate(id)} + onEdit={setEditingCred} + onView={setViewingCred} + /> + ))} + + )} + + setEditingCred(null)} + onSuccess={handleEditSuccess} + /> + setViewingCred(null)} /> +
+ ); +}; + +export default CredsList; diff --git a/src/routes/(main)/settings/creds/features/EditCredModal/EditKVForm.tsx b/src/routes/(main)/settings/creds/features/EditCredModal/EditKVForm.tsx new file mode 100644 index 0000000000..39957e2342 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/EditCredModal/EditKVForm.tsx @@ -0,0 +1,175 @@ +'use client'; + +import { type UserCredSummary } from '@lobechat/types'; +import { Button, Flexbox } from '@lobehub/ui'; +import { useMutation } from '@tanstack/react-query'; +import { Form, Input, Spin } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { Minus, Plus } from 'lucide-react'; +import { type FC, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { lambdaClient } from '@/libs/trpc/client'; + +const styles = createStaticStyles(({ css }) => ({ + footer: css` + display: flex; + gap: 8px; + justify-content: flex-end; + margin-block-start: 24px; + `, + kvPair: css` + display: flex; + gap: 8px; + align-items: flex-start; + `, +})); + +interface EditKVFormProps { + cred: UserCredSummary; + onCancel: () => void; + onSuccess: () => void; +} + +interface FormValues { + description?: string; + kvPairs: Array<{ key: string; value: string }>; + name: string; +} + +const EditKVForm: FC = ({ cred, onCancel, onSuccess }) => { + const { t } = useTranslation('setting'); + const [form] = Form.useForm(); + const [isLoading, setIsLoading] = useState(true); + + // Fetch decrypted values on mount + useEffect(() => { + const fetchDecryptedValues = async () => { + try { + const result = await lambdaClient.market.creds.get.query({ + decrypt: true, + id: cred.id, + }); + + // Convert values object to array of key-value pairs + const values = (result as any).values || {}; + const kvPairs = Object.entries(values).map(([key, value]) => ({ + key, + value: value as string, + })); + + form.setFieldsValue({ + description: cred.description, + kvPairs: kvPairs.length > 0 ? kvPairs : [{ key: '', value: '' }], + name: cred.name, + }); + } catch { + // If decryption fails, just show empty values + form.setFieldsValue({ + description: cred.description, + kvPairs: [{ key: '', value: '' }], + name: cred.name, + }); + } finally { + setIsLoading(false); + } + }; + + fetchDecryptedValues(); + }, [cred.id, cred.name, cred.description, form]); + + const updateMutation = useMutation({ + mutationFn: (values: FormValues) => { + const kvPairs = values.kvPairs || []; + const valuesObj = kvPairs.reduce( + (acc, pair) => { + if (pair.key && pair.value) { + acc[pair.key] = pair.value; + } + return acc; + }, + {} as Record, + ); + + return lambdaClient.market.creds.update.mutate({ + description: values.description, + id: cred.id, + name: values.name, + values: valuesObj, + }); + }, + onSuccess: () => { + onSuccess(); + }, + }); + + const handleSubmit = (values: FormValues) => { + updateMutation.mutate(values); + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + form={form} layout="vertical" onFinish={handleSubmit}> + + + + + + + {(fields, { add, remove }) => ( + + {fields.map(({ key, name, ...restField }) => ( +
+ + + + + + + {fields.length > 1 && ( +
+ ))} + +
+ )} +
+
+ + + + + +
+ + +
+ + ); +}; + +export default EditKVForm; diff --git a/src/routes/(main)/settings/creds/features/EditCredModal/EditMetaForm.tsx b/src/routes/(main)/settings/creds/features/EditCredModal/EditMetaForm.tsx new file mode 100644 index 0000000000..54cd711200 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/EditCredModal/EditMetaForm.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { type UserCredSummary } from '@lobechat/types'; +import { Button } from '@lobehub/ui'; +import { useMutation } from '@tanstack/react-query'; +import { Form, Input } from 'antd'; +import { createStaticStyles } from 'antd-style'; +import { type FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { lambdaClient } from '@/libs/trpc/client'; + +const styles = createStaticStyles(({ css }) => ({ + footer: css` + display: flex; + gap: 8px; + justify-content: flex-end; + margin-block-start: 24px; + `, +})); + +interface EditMetaFormProps { + cred: UserCredSummary; + onCancel: () => void; + onSuccess: () => void; +} + +interface FormValues { + description?: string; + name: string; +} + +const EditMetaForm: FC = ({ cred, onCancel, onSuccess }) => { + const { t } = useTranslation('setting'); + const [form] = Form.useForm(); + + const updateMutation = useMutation({ + mutationFn: (values: FormValues) => { + return lambdaClient.market.creds.update.mutate({ + description: values.description, + id: cred.id, + name: values.name, + }); + }, + onSuccess: () => { + onSuccess(); + }, + }); + + const handleSubmit = (values: FormValues) => { + updateMutation.mutate(values); + }; + + return ( + + form={form} + layout="vertical" + initialValues={{ + description: cred.description, + name: cred.name, + }} + onFinish={handleSubmit} + > + + + + + + + + +
+ + +
+ + ); +}; + +export default EditMetaForm; diff --git a/src/routes/(main)/settings/creds/features/EditCredModal/index.tsx b/src/routes/(main)/settings/creds/features/EditCredModal/index.tsx new file mode 100644 index 0000000000..e97e072924 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/EditCredModal/index.tsx @@ -0,0 +1,48 @@ +'use client'; + +import { type UserCredSummary } from '@lobechat/types'; +import { Modal } from '@lobehub/ui'; +import { type FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import EditKVForm from './EditKVForm'; +import EditMetaForm from './EditMetaForm'; + +interface EditCredModalProps { + cred: UserCredSummary | null; + onClose: () => void; + onSuccess: () => void; + open: boolean; +} + +const EditCredModal: FC = ({ open, onClose, onSuccess, cred }) => { + const { t } = useTranslation('setting'); + + if (!cred) return null; + + const isKVType = cred.type === 'kv-env' || cred.type === 'kv-header'; + + const handleSuccess = () => { + onSuccess(); + onClose(); + }; + + return ( + + {isKVType ? ( + + ) : ( + + )} + + ); +}; + +export default EditCredModal; diff --git a/src/routes/(main)/settings/creds/features/ViewCredModal.tsx b/src/routes/(main)/settings/creds/features/ViewCredModal.tsx new file mode 100644 index 0000000000..d32f59b47f --- /dev/null +++ b/src/routes/(main)/settings/creds/features/ViewCredModal.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { type UserCredSummary } from '@lobechat/types'; +import { CopyButton } from '@lobehub/ui'; +import { useQuery } from '@tanstack/react-query'; +import { Alert, Descriptions, Modal, Skeleton, Typography } from 'antd'; +import { type FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { lambdaClient } from '@/libs/trpc/client'; + +const { Text } = Typography; + +interface ViewCredModalProps { + cred: UserCredSummary | null; + onClose: () => void; + open: boolean; +} + +const ViewCredModal: FC = ({ cred, open, onClose }) => { + const { t } = useTranslation('setting'); + + const { data, isLoading, error } = useQuery({ + enabled: open && !!cred, + queryFn: () => + lambdaClient.market.creds.get.query({ + decrypt: true, + id: cred!.id, + }), + queryKey: ['cred-plaintext', cred?.id], + }); + + const values = (data as any)?.values || {}; + const valueEntries = Object.entries(values); + + return ( + + {isLoading ? ( + + ) : error ? ( + + ) : ( + <> + + + {cred?.name} + + {cred?.key} + + + {cred?.type ? t(`creds.types.${cred.type}` as any) : '-'} + + + + {valueEntries.length > 0 && ( + + {valueEntries.map(([key, value]) => ( + + + {String(value)} + + + + ))} + + )} + + {valueEntries.length === 0 && cred?.type === 'oauth' && ( + + )} + + )} + + ); +}; + +export default ViewCredModal; diff --git a/src/routes/(main)/settings/creds/features/index.ts b/src/routes/(main)/settings/creds/features/index.ts new file mode 100644 index 0000000000..151122293a --- /dev/null +++ b/src/routes/(main)/settings/creds/features/index.ts @@ -0,0 +1,6 @@ +export { default as CreateCredModal } from './CreateCredModal'; +export { default as CredDisplay } from './CredDisplay'; +export { default as CredItem } from './CredItem'; +export { default as CredsList } from './CredsList'; +export { default as EditCredModal } from './EditCredModal'; +export { default as ViewCredModal } from './ViewCredModal'; diff --git a/src/routes/(main)/settings/creds/features/style.ts b/src/routes/(main)/settings/creds/features/style.ts new file mode 100644 index 0000000000..8a141e6b31 --- /dev/null +++ b/src/routes/(main)/settings/creds/features/style.ts @@ -0,0 +1,38 @@ +import { createStaticStyles } from 'antd-style'; + +export const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + padding-block: 12px; + padding-inline: 0; + `, + description: css` + overflow: hidden; + + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + text-overflow: ellipsis; + white-space: nowrap; + `, + icon: css` + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + + width: 48px; + height: 48px; + border-radius: 12px; + + background: ${cssVar.colorFillTertiary}; + `, + key: css` + font-family: monospace; + font-size: 12px; + color: ${cssVar.colorTextSecondary}; + `, + title: css` + font-size: 15px; + font-weight: 500; + color: ${cssVar.colorText}; + `, +})); diff --git a/src/routes/(main)/settings/creds/index.tsx b/src/routes/(main)/settings/creds/index.tsx new file mode 100644 index 0000000000..ae5c4bcbc4 --- /dev/null +++ b/src/routes/(main)/settings/creds/index.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Button, Icon } from '@lobehub/ui'; +import { Plus } from 'lucide-react'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import SettingHeader from '@/routes/(main)/settings/features/SettingHeader'; + +import CreateCredModal from './features/CreateCredModal'; +import CredsList from './features/CredsList'; + +const Page = () => { + const { t } = useTranslation('setting'); + const [createModalOpen, setCreateModalOpen] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + const handleCreateSuccess = () => { + setCreateModalOpen(false); + setRefreshKey((k) => k + 1); + }; + + return ( + <> + } size="large" onClick={() => setCreateModalOpen(true)}> + {t('creds.create')} + + } + /> + + setCreateModalOpen(false)} + onSuccess={handleCreateSuccess} + /> + + ); +}; + +Page.displayName = 'CredsSetting'; + +export default Page; diff --git a/src/routes/(main)/settings/features/componentMap.ts b/src/routes/(main)/settings/features/componentMap.ts index 1760d2cef6..2e64e67b56 100644 --- a/src/routes/(main)/settings/features/componentMap.ts +++ b/src/routes/(main)/settings/features/componentMap.ts @@ -50,6 +50,9 @@ export const componentMap = { [SettingsTabs.APIKey]: dynamic(() => import('../apikey'), { loading: loading('Settings > APIKey'), }), + [SettingsTabs.Creds]: dynamic(() => import('../creds'), { + loading: loading('Settings > Creds'), + }), [SettingsTabs.Security]: dynamic(() => import('../security'), { loading: loading('Settings > Security'), }), diff --git a/src/routes/(main)/settings/hooks/useCategory.tsx b/src/routes/(main)/settings/hooks/useCategory.tsx index 8f959bdd6c..756cfc094e 100644 --- a/src/routes/(main)/settings/hooks/useCategory.tsx +++ b/src/routes/(main)/settings/hooks/useCategory.tsx @@ -14,6 +14,7 @@ import { Info, KeyboardIcon, KeyIcon, + KeyRound, Map, PaletteIcon, Sparkles, @@ -146,6 +147,11 @@ export const useCategory = () => { key: SettingsTabs.Memory, label: t('tab.memory'), }, + { + icon: KeyRound, + key: SettingsTabs.Creds, + label: t('tab.creds'), + }, showApiKeyManage && { icon: KeyIcon, key: SettingsTabs.APIKey, diff --git a/src/server/routers/lambda/market/creds.ts b/src/server/routers/lambda/market/creds.ts new file mode 100644 index 0000000000..6ca9bf84a2 --- /dev/null +++ b/src/server/routers/lambda/market/creds.ts @@ -0,0 +1,402 @@ +import { TRPCError } from '@trpc/server'; +import debug from 'debug'; +import { z } from 'zod'; + +import { publicProcedure, router } from '@/libs/trpc/lambda'; +import { marketUserInfo, requireMarketAuth, serverDatabase } from '@/libs/trpc/lambda/middleware'; +import { MarketService } from '@/server/services/market'; + +const log = debug('lambda-router:market:creds'); + +// Creds procedure with market authentication +const credsProcedure = publicProcedure + .use(serverDatabase) + .use(marketUserInfo) + .use(requireMarketAuth) + .use(async ({ ctx, next }) => { + return next({ + ctx: { + marketService: new MarketService({ + accessToken: ctx.marketAccessToken, + userInfo: ctx.marketUserInfo, + }), + }, + }); + }); + +export const credsRouter = router({ + // Create file credential + createFile: credsProcedure + .input( + z.object({ + description: z.string().optional(), + fileHashId: z.string().length(64), + fileName: z.string().min(1), + key: z.string().min(1).max(100), + name: z.string().min(1).max(255), + }), + ) + .mutation(async ({ ctx, input }) => { + log('createFile input: %O', { ...input, fileHashId: '[HIDDEN]' }); + + try { + const result = await ctx.marketService.market.creds.createFile(input); + log('createFile success: id=%d', result.id); + return result; + } catch (error) { + log('createFile error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create file credential', + }); + } + }), + + // Create KV credential (kv-env or kv-header) + createKV: credsProcedure + .input( + z.object({ + description: z.string().optional(), + key: z.string().min(1).max(100), + name: z.string().min(1).max(255), + type: z.enum(['kv-env', 'kv-header']), + values: z.record(z.string()), + }), + ) + .mutation(async ({ ctx, input }) => { + log('createKV input: %O', { ...input, values: '[HIDDEN]' }); + + try { + const result = await ctx.marketService.market.creds.createKV(input); + log('createKV success: id=%d', result.id); + return result; + } catch (error) { + log('createKV error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create KV credential', + }); + } + }), + + // Create OAuth credential + createOAuth: credsProcedure + .input( + z.object({ + description: z.string().optional(), + key: z.string().min(1).max(100), + name: z.string().min(1).max(255), + oauthConnectionId: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + log('createOAuth input: %O', input); + + try { + const result = await ctx.marketService.market.creds.createOAuth(input); + log('createOAuth success: id=%d', result.id); + return result; + } catch (error) { + log('createOAuth error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create OAuth credential', + }); + } + }), + + // Delete credential by ID + delete: credsProcedure.input(z.object({ id: z.number() })).mutation(async ({ ctx, input }) => { + log('delete input: %O', input); + + try { + const result = await ctx.marketService.market.creds.delete(input.id); + log('delete success'); + return result; + } catch (error) { + log('delete error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to delete credential', + }); + } + }), + + // Delete credential by key + deleteByKey: credsProcedure + .input(z.object({ key: z.string() })) + .mutation(async ({ ctx, input }) => { + log('deleteByKey input: %O', input); + + try { + const result = await ctx.marketService.market.creds.deleteByKey(input.key); + log('deleteByKey success'); + return result; + } catch (error) { + log('deleteByKey error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to delete credential by key', + }); + } + }), + + // Get single credential (optionally with decrypted values) + get: credsProcedure + .input( + z.object({ + decrypt: z.boolean().optional(), + id: z.number(), + }), + ) + .query(async ({ ctx, input }) => { + log('get input: %O', input); + + try { + const result = await ctx.marketService.market.creds.get(input.id, { + decrypt: input.decrypt, + }); + log('get success: id=%d', input.id); + return result; + } catch (error) { + log('get error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to get credential', + }); + } + }), + + // Get single credential by key (optionally with decrypted values) + getByKey: credsProcedure + .input( + z.object({ + decrypt: z.boolean().optional(), + key: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + log('getByKey input: %O', input); + + try { + // First find the credential by key from the list + const listResult = await ctx.marketService.market.creds.list(); + const cred = listResult.data?.find((c) => c.key === input.key); + + if (!cred) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Credential not found: ${input.key}`, + }); + } + + // Then get the full credential with optional decryption + const result = await ctx.marketService.market.creds.get(cred.id, { + decrypt: input.decrypt, + }); + log('getByKey success: key=%s, id=%d', input.key, cred.id); + return result; + } catch (error) { + if (error instanceof TRPCError) throw error; + log('getByKey error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to get credential by key', + }); + } + }), + + // Get skill credential status + getSkillCredStatus: credsProcedure + .input(z.object({ skillIdentifier: z.string() })) + .query(async ({ ctx, input }) => { + log('getSkillCredStatus input: %O', input); + + try { + const result = await ctx.marketService.market.creds.getSkillCredStatus( + input.skillIdentifier, + ); + log('getSkillCredStatus success: %d items', result.length); + return result; + } catch (error) { + log('getSkillCredStatus error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to get skill credential status', + }); + } + }), + + // Inject credentials by keys (explicit injection) + inject: credsProcedure + .input( + z.object({ + keys: z.array(z.string()), + sandbox: z.boolean().optional().default(true), + topicId: z.string(), + userId: z.string().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + log('inject input: %O', input); + + try { + const userId = input.userId || ctx.userId; + if (!userId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'userId is required for credential injection', + }); + } + + const result = await ctx.marketService.market.creds.inject({ + keys: input.keys, + sandbox: input.sandbox, + topicId: input.topicId, + userId, + }); + log('inject success: %O', { + notFound: result.notFound?.length, + success: result.success, + }); + return result; + } catch (error) { + log('inject error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to inject credentials', + }); + } + }), + + // Inject credentials for skill execution (auto-inject based on skill declaration) + injectForSkill: credsProcedure + .input( + z.object({ + sandbox: z.boolean().optional().default(true), + skillIdentifier: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + log('injectForSkill input: %O', input); + + try { + // Note: SDK method is injectForSkill for skill-based injection + const result = await (ctx.marketService.market.creds as any).injectForSkill(input); + log('injectForSkill success: %O', { + missing: result.missing?.length, + success: result.success, + }); + return result; + } catch (error) { + log('injectForSkill error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to inject credentials for skill', + }); + } + }), + + // List all credentials + list: credsProcedure.query(async ({ ctx }) => { + log('list called'); + + try { + const result = await ctx.marketService.market.creds.list(); + log('list success: %d credentials', result.data?.length ?? 0); + return result; + } catch (error) { + log('list error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to list credentials', + }); + } + }), + + // List OAuth connections (for creating OAuth credentials) + listOAuthConnections: credsProcedure.query(async ({ ctx }) => { + log('listOAuthConnections called'); + + try { + const result = await ctx.marketService.market.connect.listConnections(); + log('listOAuthConnections success: %d connections', result.connections?.length ?? 0); + return result; + } catch (error) { + log('listOAuthConnections error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to list OAuth connections', + }); + } + }), + + // Upload credential file + uploadFile: credsProcedure + .input( + z.object({ + file: z.string(), // base64 encoded file content + fileName: z.string().min(1), + fileType: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + log('uploadFile input: fileName=%s, fileType=%s', input.fileName, input.fileType); + + try { + const result = await ctx.marketService.uploadCredFile(input); + log('uploadFile success: fileHashId=%s', result.fileHashId); + return result; + } catch (error) { + log('uploadFile error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: error instanceof Error ? error.message : 'Failed to upload file', + }); + } + }), + + // Update credential + update: credsProcedure + .input( + z.object({ + description: z.string().optional(), + id: z.number(), + name: z.string().optional(), + values: z.record(z.string()).optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { id, ...data } = input; + log('update input: id=%d, data=%O', id, { + ...data, + values: data.values ? '[HIDDEN]' : undefined, + }); + + try { + const result = await ctx.marketService.market.creds.update(id, data); + log('update success'); + return result; + } catch (error) { + log('update error: %O', error); + throw new TRPCError({ + cause: error, + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to update credential', + }); + } + }), +}); diff --git a/src/server/routers/lambda/market/index.ts b/src/server/routers/lambda/market/index.ts index d11b2b70af..24e681386c 100644 --- a/src/server/routers/lambda/market/index.ts +++ b/src/server/routers/lambda/market/index.ts @@ -19,6 +19,7 @@ import { import { agentRouter } from './agent'; import { agentGroupRouter } from './agentGroup'; +import { credsRouter } from './creds'; import { oidcRouter } from './oidc'; import { skillRouter } from './skill'; import { socialRouter } from './social'; @@ -54,10 +55,12 @@ export const marketRouter = router({ // ============================== Agent Group Management (authenticated) ============================== agentGroup: agentGroupRouter, + // ============================== Credential Management ============================== + creds: credsRouter, + // ============================== Skill Management ============================== skill: skillRouter, - getAgentsByPlugin: marketProcedure .input( z.object({ @@ -82,7 +85,7 @@ export const marketRouter = router({ }), // ============================== Assistant Market ============================== -getAssistantCategories: marketProcedure + getAssistantCategories: marketProcedure .input( z .object({ diff --git a/src/server/services/market/index.ts b/src/server/services/market/index.ts index 4803f2f364..295bfd2149 100644 --- a/src/server/services/market/index.ts +++ b/src/server/services/market/index.ts @@ -557,6 +557,68 @@ export class MarketService { } } + // ============================== Creds Methods ============================== + + /** + * Upload credential file to Market API + * This method directly calls the Market API since SDK doesn't support file upload yet + * + * @param file - File content as base64 string + * @param fileName - Original file name + * @param fileType - MIME type of the file + * @returns Upload result with fileHashId + */ + async uploadCredFile(params: { + file: string; // base64 encoded file content + fileName: string; + fileType: string; + }): Promise<{ fileHashId: string; fileName: string; fileSize: number; fileType: string }> { + const { file, fileName, fileType } = params; + + log('uploadCredFile: fileName=%s, fileType=%s', fileName, fileType); + + // Convert base64 to Blob + const binaryString = atob(file); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + const blob = new Blob([bytes], { type: fileType }); + + // Create FormData + const formData = new FormData(); + formData.append('file', blob, fileName); + + // Extract only auth headers (not Content-Type, which would break multipart/form-data) + // @ts-ignore - market.headers contains auth headers + const sdkHeaders = this.market.headers as Record; + const authHeaders: Record = {}; + for (const [key, value] of Object.entries(sdkHeaders)) { + // Only include authorization-related headers, skip Content-Type + if (key.toLowerCase() !== 'content-type') { + authHeaders[key] = value; + } + } + + // Call Market API directly + const uploadUrl = `${MARKET_BASE_URL}/api/v1/user/creds/upload`; + const response = await fetch(uploadUrl, { + body: formData, + headers: authHeaders, + method: 'POST', + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + log('uploadCredFile error: %O', errorData); + throw new Error(errorData.message || `Upload failed with status ${response.status}`); + } + + const result = await response.json(); + log('uploadCredFile success: fileHashId=%s', result.fileHashId); + return result; + } + // ============================== Direct SDK Access ============================== /** diff --git a/src/services/chat/mecha/contextEngineering.ts b/src/services/chat/mecha/contextEngineering.ts index ca082d45dc..f708501f5d 100644 --- a/src/services/chat/mecha/contextEngineering.ts +++ b/src/services/chat/mecha/contextEngineering.ts @@ -1,5 +1,6 @@ import { AgentBuilderIdentifier } from '@lobechat/builtin-tool-agent-builder'; import { AgentManagementIdentifier } from '@lobechat/builtin-tool-agent-management'; +import { CredsIdentifier, type CredSummary, generateCredsList } from '@lobechat/builtin-tool-creds'; import { GroupAgentBuilderIdentifier } from '@lobechat/builtin-tool-group-agent-builder'; import { GTDIdentifier } from '@lobechat/builtin-tool-gtd'; import { LobeToolIdentifier } from '@lobechat/builtin-tool-tools'; @@ -17,7 +18,11 @@ import type { ToolDiscoveryConfig, UserMemoryData, } from '@lobechat/context-engine'; -import { AGENT_DOCUMENT_INJECTION_POSITIONS, MessagesEngine, resolveTopicReferences } from '@lobechat/context-engine'; +import { + AGENT_DOCUMENT_INJECTION_POSITIONS, + MessagesEngine, + resolveTopicReferences, +} from '@lobechat/context-engine'; import { historySummaryPrompt } from '@lobechat/prompts'; import type { OpenAIChatMessage, @@ -29,6 +34,7 @@ import debug from 'debug'; import { isCanUseFC } from '@/helpers/isCanUseFC'; import { VARIABLE_GENERATORS } from '@/helpers/parserPlaceholder'; +import { lambdaClient } from '@/libs/trpc/client'; import { agentDocumentService } from '@/services/agentDocument'; import { notebookService } from '@/services/notebook'; import { getAgentStoreState } from '@/store/agent'; @@ -423,6 +429,30 @@ export const contextEngineering = async ({ } } + // Resolve user credentials context for creds tool + // Creds tool must be enabled to fetch credentials + const isCredsEnabled = tools?.includes(CredsIdentifier) ?? false; + let credsList: CredSummary[] | undefined; + + if (isCredsEnabled) { + try { + const credsResult = await lambdaClient.market.creds.list.query(); + const userCreds = (credsResult as any)?.data ?? []; + credsList = userCreds.map( + (cred: any): CredSummary => ({ + description: cred.description, + key: cred.key, + name: cred.name, + type: cred.type, + }), + ); + log('Creds context resolved: count=%d', credsList?.length ?? 0); + } catch (error) { + // Silently fail - creds context is optional + log('Failed to resolve creds context:', error); + } + } + const userMemoryConfig = enableUserMemories && userMemoryData ? { @@ -632,6 +662,8 @@ export const contextEngineering = async ({ // Variable generators variableGenerators: { ...VARIABLE_GENERATORS, + // NOTICE: required by builtin-tool-creds/src/systemRole.ts + CREDS_LIST: () => (credsList ? generateCredsList(credsList) : ''), // NOTICE(@nekomeowww): required by builtin-tool-memory/src/systemRole.ts memory_effort: () => (userMemoryConfig ? (memoryContext?.effort ?? '') : ''), }, diff --git a/src/services/chat/mecha/skillPreload.ts b/src/services/chat/mecha/skillPreload.ts index 8614096e0f..abac1322b3 100644 --- a/src/services/chat/mecha/skillPreload.ts +++ b/src/services/chat/mecha/skillPreload.ts @@ -1,6 +1,12 @@ +import { + CredsIdentifier, + type CredSummary, + injectCredsContext, + type UserCredsContext, +} from '@lobechat/builtin-tool-creds'; import { SkillsApiName, SkillsIdentifier } from '@lobechat/builtin-tool-skills'; import { resourcesTreePrompt } from '@lobechat/prompts'; -import type { RuntimeSelectedSkill, SendPreloadMessage } from '@lobechat/types'; +import type { RuntimeSelectedSkill, SendPreloadMessage, UserCredSummary } from '@lobechat/types'; import { nanoid } from '@lobechat/utils'; import { agentSkillService } from '@/services/skill'; @@ -15,6 +21,10 @@ interface PreloadedSkill { interface PrepareSelectedSkillPreloadParams { message: string; selectedSkills?: RuntimeSelectedSkill[]; + /** + * User credentials for creds skill injection + */ + userCreds?: UserCredSummary[]; } const ACTION_TAG_REGEX = /]*)\/>/g; @@ -69,8 +79,27 @@ const resolveSelectedSkills = ( }, []); }; +/** + * Convert UserCredSummary to CredSummary for injection + */ +const mapToCredSummary = (cred: UserCredSummary): CredSummary => ({ + description: cred.description, + key: cred.key, + name: cred.name, + type: cred.type, +}); + +/** + * Build creds context for injection + */ +const buildCredsContext = (userCreds?: UserCredSummary[]): UserCredsContext => ({ + creds: (userCreds || []).map(mapToCredSummary), + settingsUrl: '/settings/creds', +}); + const loadSkillContent = async ( selectedSkill: RuntimeSelectedSkill, + userCreds?: UserCredSummary[], ): Promise => { const toolState = getToolStoreState(); @@ -79,8 +108,16 @@ const loadSkillContent = async ( ); if (builtinSkill) { + let content = builtinSkill.content; + + // Inject creds context for the creds skill + if (builtinSkill.identifier === CredsIdentifier) { + const credsContext = buildCredsContext(userCreds); + content = injectCredsContext(content, credsContext); + } + return { - content: builtinSkill.content, + content, identifier: builtinSkill.identifier, name: builtinSkill.name, }; @@ -145,6 +182,7 @@ const buildPersistedPreloadMessages = (skills: PreloadedSkill[]): SendPreloadMes export const prepareSelectedSkillPreload = async ({ message, selectedSkills, + userCreds, }: PrepareSelectedSkillPreloadParams): Promise => { const resolvedSelectedSkills = resolveSelectedSkills(message, selectedSkills); @@ -154,7 +192,7 @@ export const prepareSelectedSkillPreload = async ({ const resolvedSkills = ( await Promise.all( - resolvedSelectedSkills.map((selectedSkill) => loadSkillContent(selectedSkill)), + resolvedSelectedSkills.map((selectedSkill) => loadSkillContent(selectedSkill, userCreds)), ) ).filter((skill): skill is PreloadedSkill => !!skill); diff --git a/src/store/global/initialState.ts b/src/store/global/initialState.ts index ad863ed86b..abe0306818 100644 --- a/src/store/global/initialState.ts +++ b/src/store/global/initialState.ts @@ -50,6 +50,7 @@ export enum SettingsTabs { /** @deprecated Use Appearance instead */ Common = 'common', Credits = 'credits', + Creds = 'creds', Hotkey = 'hotkey', /** @deprecated Use ServiceModel instead */ Image = 'image', diff --git a/src/store/tool/slices/builtin/executors/index.ts b/src/store/tool/slices/builtin/executors/index.ts index 5ea1a2f08a..e0dfbf4e65 100644 --- a/src/store/tool/slices/builtin/executors/index.ts +++ b/src/store/tool/slices/builtin/executors/index.ts @@ -8,6 +8,7 @@ import { agentBuilderExecutor } from '@lobechat/builtin-tool-agent-builder/execu import { agentManagementExecutor } from '@lobechat/builtin-tool-agent-management/executor'; import { calculatorExecutor } from '@lobechat/builtin-tool-calculator/executor'; import { cloudSandboxExecutor } from '@lobechat/builtin-tool-cloud-sandbox/executor'; +import { credsExecutor } from '@lobechat/builtin-tool-creds/executor'; import { groupAgentBuilderExecutor } from '@lobechat/builtin-tool-group-agent-builder/executor'; import { groupManagementExecutor } from '@lobechat/builtin-tool-group-management/executor'; import { gtdExecutor } from '@lobechat/builtin-tool-gtd/executor'; @@ -132,6 +133,7 @@ registerExecutors([ agentManagementExecutor, calculatorExecutor, cloudSandboxExecutor, + credsExecutor, groupAgentBuilderExecutor, groupManagementExecutor, gtdExecutor,