♻️ 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:
LiJian
2026-03-24 14:28:23 +08:00
committed by GitHub
parent 5bc015a746
commit 7c00650be5
48 changed files with 3376 additions and 28 deletions

View File

@@ -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",

View File

@@ -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": "绘画服务",

View File

@@ -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",

View File

@@ -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>

View 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:*"
}
}

View File

@@ -0,0 +1,4 @@
// Client-side components for Creds tool
// Placeholder for future Render/Streaming components
export {};

View 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();

View 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,
};
};

View 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';

View 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',
};

View 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>`;

View 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;
}

View File

@@ -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,

View File

@@ -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: {

View File

@@ -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

View File

@@ -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:*",

View File

@@ -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,
];

View File

@@ -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,

View File

@@ -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',

View File

@@ -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",

View 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;
}

View File

@@ -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';

View File

@@ -1,4 +1,3 @@
/**
* This dynamic loading module is implemented using SystemJS, caching four modules in Lobe Chat: React, ReactDOM, antd, and antd-style.
*/

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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;

View 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';

View 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};
`,
}));

View 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;

View File

@@ -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'),
}),

View File

@@ -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,

View 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',
});
}
}),
});

View File

@@ -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({

View File

@@ -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 ==============================
/**

View File

@@ -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 ?? '') : ''),
},

View File

@@ -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);

View File

@@ -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',

View File

@@ -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,