mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
♻️ refactor: add the user creds modules & skill should auto inject the need creds (#13124)
* 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
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": "绘画服务",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}\`
|
||||
</sandbox_environment>
|
||||
|
||||
|
||||
|
||||
16
packages/builtin-tool-creds/package.json
Normal file
16
packages/builtin-tool-creds/package.json
Normal file
@@ -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:*"
|
||||
}
|
||||
}
|
||||
4
packages/builtin-tool-creds/src/client/index.ts
Normal file
4
packages/builtin-tool-creds/src/client/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Client-side components for Creds tool
|
||||
// Placeholder for future Render/Streaming components
|
||||
|
||||
export {};
|
||||
406
packages/builtin-tool-creds/src/executor/index.ts
Normal file
406
packages/builtin-tool-creds/src/executor/index.ts
Normal file
@@ -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<typeof CredsApiName> {
|
||||
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<BuiltinToolResult> => {
|
||||
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<BuiltinToolResult> => {
|
||||
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<BuiltinToolResult> => {
|
||||
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<BuiltinToolResult> => {
|
||||
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();
|
||||
109
packages/builtin-tool-creds/src/helpers.ts
Normal file
109
packages/builtin-tool-creds/src/helpers.ts
Normal file
@@ -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<CredType, CredSummary[]> => {
|
||||
const groups: Record<CredType, CredSummary[]> = {
|
||||
'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,
|
||||
};
|
||||
};
|
||||
22
packages/builtin-tool-creds/src/index.ts
Normal file
22
packages/builtin-tool-creds/src/index.ts
Normal file
@@ -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';
|
||||
117
packages/builtin-tool-creds/src/manifest.ts
Normal file
117
packages/builtin-tool-creds/src/manifest.ts
Normal file
@@ -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',
|
||||
};
|
||||
97
packages/builtin-tool-creds/src/systemRole.ts
Normal file
97
packages/builtin-tool-creds/src/systemRole.ts
Normal file
@@ -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.
|
||||
|
||||
<session_context>
|
||||
Current user: {{username}}
|
||||
Session date: {{date}}
|
||||
Sandbox mode: {{sandbox_enabled}}
|
||||
</session_context>
|
||||
|
||||
<available_credentials>
|
||||
{{CREDS_LIST}}
|
||||
</available_credentials>
|
||||
|
||||
<credential_types>
|
||||
- **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).
|
||||
</credential_types>
|
||||
|
||||
<core_responsibilities>
|
||||
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.
|
||||
</core_responsibilities>
|
||||
|
||||
<tooling>
|
||||
- **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.
|
||||
</tooling>
|
||||
|
||||
<oauth_providers>
|
||||
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.
|
||||
</oauth_providers>
|
||||
|
||||
<security_guidelines>
|
||||
- **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.
|
||||
</security_guidelines>
|
||||
|
||||
<credential_saving_triggers>
|
||||
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
|
||||
</credential_saving_triggers>
|
||||
|
||||
<sandbox_integration>
|
||||
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\`)
|
||||
</sandbox_integration>
|
||||
|
||||
<response_expectations>
|
||||
- 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.
|
||||
</response_expectations>`;
|
||||
148
packages/builtin-tool-creds/src/types.ts
Normal file
148
packages/builtin-tool-creds/src/types.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -18,19 +18,67 @@ export const systemPrompt = `You have access to a Tool Discovery system that all
|
||||
</tool_selection_guidelines>
|
||||
|
||||
<skill_store_discovery>
|
||||
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 \`<available_tools>\` 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 \`<available_tools>\` 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 \`<available_tools>\` 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
|
||||
</skill_store_discovery>
|
||||
|
||||
<credentials_management>
|
||||
**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
|
||||
</credentials_management>
|
||||
|
||||
<best_practices>
|
||||
- **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 \`<available_tools>\` 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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
135
packages/types/src/creds/index.ts
Normal file
135
packages/types/src/creds/index.ts
Normal file
@@ -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<string, string>; // 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<string, string>;
|
||||
}
|
||||
|
||||
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<string, string>; // 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<string, string>;
|
||||
files: Array<{
|
||||
content: string; // S3 URL
|
||||
envName?: string;
|
||||
fileName: string;
|
||||
key: string;
|
||||
mimeType: string;
|
||||
}>;
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
/**
|
||||
* This dynamic loading module is implemented using SystemJS, caching four modules in Lobe Chat: React, ReactDOM, antd, and antd-style.
|
||||
*/
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: <TerminalSquare size={24} />,
|
||||
type: 'kv-env',
|
||||
},
|
||||
{
|
||||
description: 'creds.typeDesc.kv-header',
|
||||
icon: <Globe size={24} />,
|
||||
type: 'kv-header',
|
||||
},
|
||||
{
|
||||
description: 'creds.typeDesc.oauth',
|
||||
icon: <Key size={24} />,
|
||||
type: 'oauth',
|
||||
},
|
||||
{
|
||||
description: 'creds.typeDesc.file',
|
||||
icon: <File size={24} />,
|
||||
type: 'file',
|
||||
},
|
||||
];
|
||||
|
||||
const CredTypeSelector: FC<CredTypeSelectorProps> = ({ onSelect }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
|
||||
return (
|
||||
<div className={styles.grid}>
|
||||
{typeConfigs.map(({ type, icon, description }) => (
|
||||
<Card className={styles.card} key={type} size="small" onClick={() => onSelect(type)}>
|
||||
<Flexbox align="center">
|
||||
<div className={styles.icon}>{icon}</div>
|
||||
<div className={styles.title}>{t(`creds.types.${type}`)}</div>
|
||||
<div className={styles.description}>{t(description as any)}</div>
|
||||
</Flexbox>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredTypeSelector;
|
||||
@@ -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<FileCredFormProps> = ({ onBack, onSuccess }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [fileHashId, setFileHashId] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string>('');
|
||||
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<FormValues> form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item required label={t('creds.form.file')}>
|
||||
<Upload.Dragger
|
||||
beforeUpload={handleUpload}
|
||||
disabled={isUploading}
|
||||
maxCount={1}
|
||||
showUploadList={fileName ? { showRemoveIcon: true } : false}
|
||||
onRemove={() => {
|
||||
setFileHashId(null);
|
||||
setFileName('');
|
||||
}}
|
||||
>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<InboxOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">
|
||||
{isUploading ? t('creds.file.uploading') : t('creds.form.uploadHint')}
|
||||
</p>
|
||||
<p className="ant-upload-hint">{t('creds.form.uploadDesc')}</p>
|
||||
</Upload.Dragger>
|
||||
{fileName && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
{t('creds.form.selectedFile')}: {fileName}
|
||||
</div>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('creds.form.key')}
|
||||
name="key"
|
||||
rules={[
|
||||
{ required: true, message: t('creds.form.keyRequired') },
|
||||
{ pattern: /^[\w-]+$/, message: t('creds.form.keyPattern') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="e.g., gcp-service-account" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('creds.form.name')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('creds.form.nameRequired') }]}
|
||||
>
|
||||
<Input placeholder="e.g., GCP Service Account" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('creds.form.description')} name="description">
|
||||
<Input.TextArea placeholder={t('creds.form.descriptionPlaceholder')} rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button onClick={onBack}>{t('creds.form.back')}</Button>
|
||||
<Button
|
||||
disabled={!fileHashId}
|
||||
htmlType="submit"
|
||||
loading={createMutation.isPending}
|
||||
type="primary"
|
||||
>
|
||||
{t('creds.form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileCredForm;
|
||||
@@ -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<KVCredFormProps> = ({ type, onBack, onSuccess }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
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<string, string>,
|
||||
);
|
||||
|
||||
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<FormValues>
|
||||
form={form}
|
||||
initialValues={{ kvPairs: [{ key: '', value: '' }] }}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('creds.form.key')}
|
||||
name="key"
|
||||
rules={[
|
||||
{ required: true, message: t('creds.form.keyRequired') },
|
||||
{ pattern: /^[\w-]+$/, message: t('creds.form.keyPattern') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="e.g., openai" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('creds.form.name')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('creds.form.nameRequired') }]}
|
||||
>
|
||||
<Input placeholder="e.g., OpenAI API Key" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('creds.form.values')}>
|
||||
<Form.List name="kvPairs">
|
||||
{(fields, { add, remove }) => (
|
||||
<Flexbox gap={8}>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div className={styles.kvPair} key={key}>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'key']}
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<Input placeholder={type === 'kv-env' ? 'ENV_VAR_NAME' : 'Header-Name'} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'value']}
|
||||
style={{ flex: 2, marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password placeholder={t('creds.form.valuePlaceholder')} />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<Button icon={Minus} size="small" type="text" onClick={() => remove(name)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button block icon={Plus} type="dashed" onClick={() => add({ key: '', value: '' })}>
|
||||
{t('creds.form.addPair')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('creds.form.description')} name="description">
|
||||
<Input.TextArea placeholder={t('creds.form.descriptionPlaceholder')} rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button onClick={onBack}>{t('creds.form.back')}</Button>
|
||||
<Button htmlType="submit" loading={createMutation.isPending} type="primary">
|
||||
{t('creds.form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default KVCredForm;
|
||||
@@ -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<OAuthCredFormProps> = ({ onBack, onSuccess }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
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 (
|
||||
<Flexbox align="center" justify="center" style={{ padding: 48 }}>
|
||||
<Spin />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
if (connections.length === 0) {
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
<Empty description={t('creds.oauth.noConnections')} />
|
||||
<div className={styles.footer}>
|
||||
<Button onClick={onBack}>{t('creds.form.back')}</Button>
|
||||
</div>
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form<FormValues> form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label={t('creds.form.selectConnection')}
|
||||
name="oauthConnectionId"
|
||||
rules={[{ required: true, message: t('creds.form.connectionRequired') }]}
|
||||
>
|
||||
<Select placeholder={t('creds.form.selectConnectionPlaceholder')}>
|
||||
{connections.map((conn: any) => {
|
||||
const provider = conn.providerId || 'OAuth';
|
||||
const displayName =
|
||||
conn.providerName || conn.providerUserName || conn.email || conn.name;
|
||||
return (
|
||||
<Select.Option key={conn.id} value={conn.id}>
|
||||
<span className={styles.connectionOption}>
|
||||
{conn.avatar && <Avatar size="small" src={conn.avatar} />}
|
||||
<span>
|
||||
<span className={styles.provider}>{provider}</span>
|
||||
{displayName && <span className={styles.username}> - {displayName}</span>}
|
||||
</span>
|
||||
</span>
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('creds.form.key')}
|
||||
name="key"
|
||||
rules={[
|
||||
{ required: true, message: t('creds.form.keyRequired') },
|
||||
{ pattern: /^[\w-]+$/, message: t('creds.form.keyPattern') },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="e.g., github-oauth" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={t('creds.form.name')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('creds.form.nameRequired') }]}
|
||||
>
|
||||
<Input placeholder="e.g., GitHub Connection" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('creds.form.description')} name="description">
|
||||
<Input.TextArea placeholder={t('creds.form.descriptionPlaceholder')} rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button onClick={onBack}>{t('creds.form.back')}</Button>
|
||||
<Button htmlType="submit" loading={createMutation.isPending} type="primary">
|
||||
{t('creds.form.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default OAuthCredForm;
|
||||
@@ -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<CreateCredModalProps> = ({ open, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [step, setStep] = useState(0);
|
||||
const [credType, setCredType] = useState<CredType | null>(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 <KVCredForm type={credType} onBack={handleBack} onSuccess={handleSuccess} />;
|
||||
}
|
||||
case 'oauth': {
|
||||
return <OAuthCredForm onBack={handleBack} onSuccess={handleSuccess} />;
|
||||
}
|
||||
case 'file': {
|
||||
return <FileCredForm onBack={handleBack} onSuccess={handleSuccess} />;
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
footer={null}
|
||||
open={open}
|
||||
title={t('creds.createModal.title')}
|
||||
width={600}
|
||||
onCancel={handleClose}
|
||||
>
|
||||
<div className={styles.content}>
|
||||
<Steps
|
||||
className={styles.steps}
|
||||
current={step}
|
||||
size="small"
|
||||
items={[
|
||||
{ title: t('creds.createModal.selectType') },
|
||||
{ title: t('creds.createModal.fillForm') },
|
||||
]}
|
||||
/>
|
||||
|
||||
{step === 0 ? <CredTypeSelector onSelect={handleTypeSelect} /> : renderForm()}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCredModal;
|
||||
57
src/routes/(main)/settings/creds/features/CredDisplay.tsx
Normal file
57
src/routes/(main)/settings/creds/features/CredDisplay.tsx
Normal file
@@ -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<CredDisplayProps> = ({ cred }) => {
|
||||
// For OAuth type, show username
|
||||
if (cred.type === 'oauth') {
|
||||
return (
|
||||
<span className={styles.value}>
|
||||
{cred.oauthUsername ? `@${cred.oauthUsername}` : cred.oauthProvider || '-'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// For file type, show filename
|
||||
if (cred.type === 'file') {
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<span className={styles.value}>{cred.fileName || '-'}</span>
|
||||
{cred.fileSize && (
|
||||
<Typography.Text type="secondary">
|
||||
({(cred.fileSize / 1024).toFixed(1)} KB)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
// For KV types, show masked preview
|
||||
return (
|
||||
<Flexbox className={styles.container}>
|
||||
<span className={styles.value}>{cred.maskedPreview || '-'}</span>
|
||||
</Flexbox>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredDisplay;
|
||||
132
src/routes/(main)/settings/creds/features/CredItem.tsx
Normal file
132
src/routes/(main)/settings/creds/features/CredItem.tsx
Normal file
@@ -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<string, React.ReactNode> = {
|
||||
'file': <File size={20} />,
|
||||
'kv-env': <TerminalSquare size={20} />,
|
||||
'kv-header': <Globe size={20} />,
|
||||
'oauth': <Key size={20} />,
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
'file': 'purple',
|
||||
'kv-env': 'blue',
|
||||
'kv-header': 'cyan',
|
||||
'oauth': 'green',
|
||||
};
|
||||
|
||||
const CredItem: FC<CredItemProps> = 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: <Icon icon={Eye} />,
|
||||
key: 'view',
|
||||
label: t('creds.actions.view'),
|
||||
onClick: () => onView(cred),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
icon: <Icon icon={Pencil} />,
|
||||
key: 'edit',
|
||||
label: t('creds.actions.edit'),
|
||||
onClick: () => onEdit(cred),
|
||||
},
|
||||
{
|
||||
danger: true,
|
||||
icon: <Icon icon={Trash2} />,
|
||||
key: 'delete',
|
||||
label: t('creds.actions.delete'),
|
||||
onClick: handleDelete,
|
||||
},
|
||||
];
|
||||
|
||||
const renderAvatar = () => {
|
||||
if (cred.type === 'oauth' && cred.oauthAvatar) {
|
||||
return <Avatar avatar={cred.oauthAvatar} size={32} />;
|
||||
}
|
||||
return (
|
||||
<span style={{ color: 'var(--lobe-color-text-secondary)' }}>{typeIcons[cred.type]}</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
horizontal
|
||||
align="center"
|
||||
className={styles.container}
|
||||
gap={16}
|
||||
justify="space-between"
|
||||
>
|
||||
<Flexbox horizontal align="center" gap={16} style={{ flex: 1, overflow: 'hidden' }}>
|
||||
<div className={styles.icon}>{renderAvatar()}</div>
|
||||
<Flexbox gap={4} style={{ overflow: 'hidden' }}>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<span className={styles.title}>{cred.name}</span>
|
||||
<Tag color={typeColors[cred.type]}>{t(`creds.types.${cred.type}`)}</Tag>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={8}>
|
||||
<code className={styles.key}>{cred.key}</code>
|
||||
{cred.description && (
|
||||
<>
|
||||
<span style={{ color: 'var(--lobe-color-text-quaternary)' }}>·</span>
|
||||
<span className={styles.description}>{cred.description}</span>
|
||||
</>
|
||||
)}
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
<Flexbox horizontal align="center" gap={8} onClick={stopPropagation}>
|
||||
<DropdownMenu items={menuItems} placement="bottomRight">
|
||||
<Button icon={MoreHorizontalIcon} />
|
||||
</DropdownMenu>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
CredItem.displayName = 'CredItem';
|
||||
|
||||
export default CredItem;
|
||||
118
src/routes/(main)/settings/creds/features/CredsList.tsx
Normal file
118
src/routes/(main)/settings/creds/features/CredsList.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { type UserCredSummary } from '@lobechat/types';
|
||||
import { Button, Flexbox } from '@lobehub/ui';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Empty, Spin } from 'antd';
|
||||
import { createStaticStyles } from 'antd-style';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { type FC, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useMarketAuth } from '@/layout/AuthProvider/MarketAuth';
|
||||
import { lambdaClient, lambdaQuery } from '@/libs/trpc/client';
|
||||
|
||||
import CredItem from './CredItem';
|
||||
import EditCredModal from './EditCredModal';
|
||||
import ViewCredModal from './ViewCredModal';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
container: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
`,
|
||||
empty: css`
|
||||
padding-block: 48px;
|
||||
padding-inline: 0;
|
||||
`,
|
||||
signInPrompt: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
padding: 48px;
|
||||
`,
|
||||
}));
|
||||
|
||||
const CredsList: FC = () => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [editingCred, setEditingCred] = useState<UserCredSummary | null>(null);
|
||||
const [viewingCred, setViewingCred] = useState<UserCredSummary | null>(null);
|
||||
const { isAuthenticated, isLoading: isAuthLoading, signIn } = useMarketAuth();
|
||||
|
||||
const { data, isLoading, refetch } = lambdaQuery.market.creds.list.useQuery(undefined, {
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => lambdaClient.market.creds.delete.mutate({ id }),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = data?.data ?? [];
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
setEditingCred(null);
|
||||
refetch();
|
||||
};
|
||||
|
||||
// Show loading while checking auth status
|
||||
if (isAuthLoading) {
|
||||
return (
|
||||
<Flexbox align="center" justify="center" style={{ padding: 48 }}>
|
||||
<Spin />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
// Show sign-in prompt if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className={styles.signInPrompt}>
|
||||
<Empty description={t('creds.signInRequired')} />
|
||||
<Button icon={LogIn} type="primary" onClick={() => signIn()}>
|
||||
{t('creds.signIn')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{isLoading ? (
|
||||
<Flexbox align="center" justify="center" style={{ padding: 48 }}>
|
||||
<Spin />
|
||||
</Flexbox>
|
||||
) : credentials.length === 0 ? (
|
||||
<Empty className={styles.empty} description={t('creds.empty')} />
|
||||
) : (
|
||||
<Flexbox gap={0}>
|
||||
{credentials.map((cred) => (
|
||||
<CredItem
|
||||
cred={cred}
|
||||
key={cred.id}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
onEdit={setEditingCred}
|
||||
onView={setViewingCred}
|
||||
/>
|
||||
))}
|
||||
</Flexbox>
|
||||
)}
|
||||
|
||||
<EditCredModal
|
||||
cred={editingCred}
|
||||
open={!!editingCred}
|
||||
onClose={() => setEditingCred(null)}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
<ViewCredModal cred={viewingCred} open={!!viewingCred} onClose={() => setViewingCred(null)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredsList;
|
||||
@@ -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<EditKVFormProps> = ({ cred, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
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<string, string>,
|
||||
);
|
||||
|
||||
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 (
|
||||
<Flexbox align="center" justify="center" style={{ padding: 48 }}>
|
||||
<Spin />
|
||||
</Flexbox>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form<FormValues> form={form} layout="vertical" onFinish={handleSubmit}>
|
||||
<Form.Item
|
||||
label={t('creds.form.name')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('creds.form.nameRequired') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('creds.form.values')}>
|
||||
<Form.List name="kvPairs">
|
||||
{(fields, { add, remove }) => (
|
||||
<Flexbox gap={8}>
|
||||
{fields.map(({ key, name, ...restField }) => (
|
||||
<div className={styles.kvPair} key={key}>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'key']}
|
||||
style={{ flex: 1, marginBottom: 0 }}
|
||||
>
|
||||
<Input placeholder={cred.type === 'kv-env' ? 'ENV_VAR_NAME' : 'Header-Name'} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...restField}
|
||||
name={[name, 'value']}
|
||||
style={{ flex: 2, marginBottom: 0 }}
|
||||
>
|
||||
<Input.Password placeholder={t('creds.form.valuePlaceholder')} />
|
||||
</Form.Item>
|
||||
{fields.length > 1 && (
|
||||
<Button icon={Minus} size="small" type="text" onClick={() => remove(name)} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<Button block icon={Plus} type="dashed" onClick={() => add({ key: '', value: '' })}>
|
||||
{t('creds.form.addPair')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('creds.form.description')} name="description">
|
||||
<Input.TextArea placeholder={t('creds.form.descriptionPlaceholder')} rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button onClick={onCancel}>{t('creds.form.cancel')}</Button>
|
||||
<Button htmlType="submit" loading={updateMutation.isPending} type="primary">
|
||||
{t('creds.form.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditKVForm;
|
||||
@@ -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<EditMetaFormProps> = ({ cred, onCancel, onSuccess }) => {
|
||||
const { t } = useTranslation('setting');
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
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<FormValues>
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
description: cred.description,
|
||||
name: cred.name,
|
||||
}}
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.Item
|
||||
label={t('creds.form.name')}
|
||||
name="name"
|
||||
rules={[{ required: true, message: t('creds.form.nameRequired') }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('creds.form.description')} name="description">
|
||||
<Input.TextArea placeholder={t('creds.form.descriptionPlaceholder')} rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button onClick={onCancel}>{t('creds.form.cancel')}</Button>
|
||||
<Button htmlType="submit" loading={updateMutation.isPending} type="primary">
|
||||
{t('creds.form.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditMetaForm;
|
||||
@@ -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<EditCredModalProps> = ({ 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 (
|
||||
<Modal
|
||||
destroyOnClose
|
||||
footer={null}
|
||||
open={open}
|
||||
title={t('creds.edit.title')}
|
||||
width={520}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{isKVType ? (
|
||||
<EditKVForm cred={cred} onCancel={onClose} onSuccess={handleSuccess} />
|
||||
) : (
|
||||
<EditMetaForm cred={cred} onCancel={onClose} onSuccess={handleSuccess} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditCredModal;
|
||||
118
src/routes/(main)/settings/creds/features/ViewCredModal.tsx
Normal file
118
src/routes/(main)/settings/creds/features/ViewCredModal.tsx
Normal file
@@ -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<ViewCredModalProps> = ({ 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 (
|
||||
<Modal
|
||||
footer={null}
|
||||
open={open}
|
||||
title={t('creds.view.title', { name: cred?.name })}
|
||||
width={560}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
) : error ? (
|
||||
<Alert
|
||||
showIcon
|
||||
description={(error as Error).message}
|
||||
message={t('creds.view.error')}
|
||||
type="error"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Alert
|
||||
showIcon
|
||||
message={t('creds.view.warning')}
|
||||
style={{ marginBottom: 16 }}
|
||||
type="warning"
|
||||
/>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label={t('creds.table.name')}>{cred?.name}</Descriptions.Item>
|
||||
<Descriptions.Item label={t('creds.table.key')}>
|
||||
<code>{cred?.key}</code>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('creds.table.type')}>
|
||||
{cred?.type ? t(`creds.types.${cred.type}` as any) : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{valueEntries.length > 0 && (
|
||||
<Descriptions
|
||||
bordered
|
||||
column={1}
|
||||
size="small"
|
||||
style={{ marginTop: 16 }}
|
||||
title={t('creds.view.values')}
|
||||
>
|
||||
{valueEntries.map(([key, value]) => (
|
||||
<Descriptions.Item
|
||||
contentStyle={{ display: 'flex', alignItems: 'center', gap: 8 }}
|
||||
key={key}
|
||||
label={key}
|
||||
labelStyle={{ width: 120 }}
|
||||
>
|
||||
<Text
|
||||
copyable={false}
|
||||
style={{
|
||||
flex: 1,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 12,
|
||||
wordBreak: 'break-all',
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
</Text>
|
||||
<CopyButton content={String(value)} size="small" />
|
||||
</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
{valueEntries.length === 0 && cred?.type === 'oauth' && (
|
||||
<Alert
|
||||
showIcon
|
||||
description={t('creds.view.oauthNote')}
|
||||
message={t('creds.view.noValues')}
|
||||
style={{ marginTop: 16 }}
|
||||
type="info"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewCredModal;
|
||||
6
src/routes/(main)/settings/creds/features/index.ts
Normal file
6
src/routes/(main)/settings/creds/features/index.ts
Normal file
@@ -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';
|
||||
38
src/routes/(main)/settings/creds/features/style.ts
Normal file
38
src/routes/(main)/settings/creds/features/style.ts
Normal file
@@ -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};
|
||||
`,
|
||||
}));
|
||||
45
src/routes/(main)/settings/creds/index.tsx
Normal file
45
src/routes/(main)/settings/creds/index.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t('tab.creds')}
|
||||
extra={
|
||||
<Button icon={<Icon icon={Plus} />} size="large" onClick={() => setCreateModalOpen(true)}>
|
||||
{t('creds.create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<CredsList key={refreshKey} />
|
||||
<CreateCredModal
|
||||
open={createModalOpen}
|
||||
onCancel={() => setCreateModalOpen(false)}
|
||||
onSuccess={handleCreateSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Page.displayName = 'CredsSetting';
|
||||
|
||||
export default Page;
|
||||
@@ -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'),
|
||||
}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
402
src/server/routers/lambda/market/creds.ts
Normal file
402
src/server/routers/lambda/market/creds.ts
Normal file
@@ -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',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -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<string, string>;
|
||||
const authHeaders: Record<string, string> = {};
|
||||
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 ==============================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 ?? '') : ''),
|
||||
},
|
||||
|
||||
@@ -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 = /<action\b([^>]*)\/>/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<PreloadedSkill | undefined> => {
|
||||
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<SendPreloadMessage[]> => {
|
||||
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);
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user