feat: enhance after-pack script to copy OpenClaw runtime dependencies
- Added a new script `bundle-openclaw.mjs` to bundle OpenClaw runtime dependencies. - Updated `after-pack.cjs` to copy bundled OpenClaw runtime and its node_modules. - Improved cleanup of unnecessary development files in node_modules. - Adjusted paths for resources in the packaging process. style: update loading indicator styles in ChatHistoryPanel - Changed the border radius and padding for the loading indicator in ChatHistoryPanel. fix: improve ProvidersSection to handle provider account syncing - Added logic to sync model configuration to provider accounts. - Introduced error handling and loading states during the sync process. - Enhanced vendor resolution and account management logic. fix: fallback session handling in chat store - Implemented fallback session logic in loadSessions to ensure a valid session is always available on error.
This commit is contained in:
@@ -163,7 +163,7 @@ function ChatHistoryPanel({
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto pt-4">
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-[18px] border border-dashed border-[#dbe7f4] bg-white px-4 py-8 text-sm text-[#94a3b8] shadow-[0_4px_14px_rgba(15,23,42,0.04)] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-400">
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-[#dbe7f4] bg-white p-4 text-sm text-[#94a3b8] shadow-[0_4px_14px_rgba(15,23,42,0.04)] dark:border-[#2a2a2d] dark:bg-[#202024] dark:text-gray-400">
|
||||
<LoaderCircle className="h-5 w-5 animate-spin text-[#8bb7ff]" />
|
||||
{t('conversation.historyPanel.loading')}
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type {
|
||||
ProviderAccount,
|
||||
ProviderAuthMode,
|
||||
ProviderType,
|
||||
ProviderVendorInfo,
|
||||
ProviderWithKeyInfo,
|
||||
} from '@runtime/lib/providers';
|
||||
import { hotelStaffChatModelConfigUsingGet } from '../../../api/chat';
|
||||
import type { ModelConfigDTO } from '../../../api/types';
|
||||
import { onGatewayEvent } from '../../../lib/gateway-client';
|
||||
import { hostApiFetch } from '../../../lib/host-api';
|
||||
import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../../lib/runtime-events';
|
||||
import { generateUUID } from '../../../utils/generateUUID';
|
||||
import { useModelsCopy } from '../copy';
|
||||
|
||||
const DEFAULT_VENDOR_ID: ProviderType = 'minimax-portal-cn';
|
||||
|
||||
function formatErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
if (typeof error === 'string' && error) return error;
|
||||
@@ -17,6 +28,17 @@ function normalizeText(value: string | undefined): string | null {
|
||||
return trimmed || null;
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string | undefined): string {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized) return '';
|
||||
return normalized.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function hasRealSecret(value: string | undefined): boolean {
|
||||
const secret = normalizeText(value);
|
||||
return Boolean(secret && !secret.includes('*') && !secret.includes('•'));
|
||||
}
|
||||
|
||||
function normalizeModelConfig(response: unknown): ModelConfigDTO {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return {};
|
||||
@@ -31,10 +53,14 @@ function normalizeModelConfig(response: unknown): ModelConfigDTO {
|
||||
|| typeof candidate.model === 'string'
|
||||
) {
|
||||
return {
|
||||
providerName: candidate.providerName,
|
||||
apiKey: candidate.apiKey,
|
||||
baseUrl: candidate.baseUrl,
|
||||
model: candidate.model,
|
||||
// providerName: candidate.providerName,
|
||||
// apiKey: candidate.apiKey,
|
||||
// baseUrl: candidate.baseUrl,
|
||||
// model: candidate.model,
|
||||
providerName: 'MiniMax (CN)',
|
||||
apiKey: 'sk-cp-5v2049pnAT4EffqxNIA9UH8GpkWMnZ8nq8swabxsur_2cu9CHpSbw9lCJsk5oHHM6BxybdHMhe0_Bh1Q9ghUavt-fC4XJbJZPdT6nw1NEwgl9ILd75aUbMc',
|
||||
baseUrl: 'https://api.minimaxi.com/v1',
|
||||
model: 'MiniMax-M2.7',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -57,17 +83,248 @@ function hasModelConfig(config: ModelConfigDTO | null): boolean {
|
||||
function maskSecret(value: string | undefined): string | null {
|
||||
const secret = normalizeText(value);
|
||||
if (!secret) return null;
|
||||
if (secret.includes('*')) return secret;
|
||||
if (secret.includes('*') || secret.includes('•')) return secret;
|
||||
if (secret.length <= 4) return '*'.repeat(secret.length);
|
||||
if (secret.length <= 8) return `${secret.slice(0, 2)}***${secret.slice(-2)}`;
|
||||
return `${secret.slice(0, 4)}***${secret.slice(-4)}`;
|
||||
}
|
||||
|
||||
function resolveTargetAccount(
|
||||
accounts: ProviderAccount[],
|
||||
defaultAccountId: string | null,
|
||||
): ProviderAccount | null {
|
||||
if (defaultAccountId) {
|
||||
const defaultAccount = accounts.find((account) => account.id === defaultAccountId);
|
||||
if (defaultAccount) {
|
||||
return defaultAccount;
|
||||
}
|
||||
}
|
||||
|
||||
return accounts[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveVendor(
|
||||
config: ModelConfigDTO,
|
||||
vendors: ProviderVendorInfo[],
|
||||
): ProviderVendorInfo | null {
|
||||
const providerName = normalizeText(config.providerName);
|
||||
if (providerName) {
|
||||
const vendorByName = vendors.find((vendor) => vendor.name === providerName);
|
||||
if (vendorByName) {
|
||||
return vendorByName;
|
||||
}
|
||||
}
|
||||
|
||||
const expectedBaseUrl = normalizeBaseUrl(config.baseUrl);
|
||||
if (expectedBaseUrl) {
|
||||
try {
|
||||
const expectedHost = new URL(expectedBaseUrl).host;
|
||||
const vendorByHost = vendors.find((vendor) => {
|
||||
const vendorBaseUrl = normalizeBaseUrl(vendor.defaultBaseUrl);
|
||||
if (!vendorBaseUrl) return false;
|
||||
try {
|
||||
return new URL(vendorBaseUrl).host === expectedHost;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (vendorByHost) {
|
||||
return vendorByHost;
|
||||
}
|
||||
} catch {
|
||||
const vendorByPrefix = vendors.find((vendor) => {
|
||||
const vendorBaseUrl = normalizeBaseUrl(vendor.defaultBaseUrl);
|
||||
return Boolean(vendorBaseUrl) && expectedBaseUrl.startsWith(vendorBaseUrl);
|
||||
});
|
||||
if (vendorByPrefix) {
|
||||
return vendorByPrefix;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return vendors.find((vendor) => vendor.id === DEFAULT_VENDOR_ID) ?? vendors[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveAccountAuthMode(
|
||||
vendor: ProviderVendorInfo,
|
||||
apiKey: string | undefined,
|
||||
): ProviderAuthMode {
|
||||
if (hasRealSecret(apiKey) && (vendor.requiresApiKey || vendor.supportsApiKey)) {
|
||||
return 'api_key';
|
||||
}
|
||||
|
||||
return vendor.defaultAuthMode;
|
||||
}
|
||||
|
||||
function buildConfigSignature(
|
||||
config: ModelConfigDTO,
|
||||
vendorId: ProviderType,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
vendorId,
|
||||
providerName: normalizeText(config.providerName) || '',
|
||||
apiKey: hasRealSecret(config.apiKey) ? normalizeText(config.apiKey) : '',
|
||||
baseUrl: normalizeBaseUrl(config.baseUrl),
|
||||
model: normalizeText(config.model) || '',
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProvidersSection() {
|
||||
const t = useModelsCopy();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfigDTO | null>(null);
|
||||
const lastAppliedConfigRef = useRef<string | null>(null);
|
||||
|
||||
async function syncModelConfigToProviderAccounts(config: ModelConfigDTO): Promise<void> {
|
||||
if (!hasModelConfig(config)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSyncing(true);
|
||||
|
||||
try {
|
||||
const [accounts, providers, vendors, defaultProvider] = await Promise.all([
|
||||
hostApiFetch<ProviderAccount[]>('/api/provider-accounts'),
|
||||
hostApiFetch<ProviderWithKeyInfo[]>('/api/providers'),
|
||||
hostApiFetch<ProviderVendorInfo[]>('/api/provider-vendors'),
|
||||
hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default'),
|
||||
]);
|
||||
|
||||
const providerAccounts = Array.isArray(accounts) ? accounts : [];
|
||||
const providerStatuses = Array.isArray(providers) ? providers : [];
|
||||
const providerVendors = Array.isArray(vendors) ? vendors : [];
|
||||
|
||||
const vendor = resolveVendor(config, providerVendors);
|
||||
if (!vendor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signature = buildConfigSignature(config, vendor.id);
|
||||
const defaultAccountId = defaultProvider?.accountId ?? null;
|
||||
const currentAccount = resolveTargetAccount(providerAccounts, defaultAccountId);
|
||||
const currentProvider = currentAccount
|
||||
? providerStatuses.find((provider) => provider.id === currentAccount.id) ?? null
|
||||
: null;
|
||||
|
||||
const desiredProviderName = normalizeText(config.providerName) || vendor.name;
|
||||
const desiredModel = normalizeText(config.model) || normalizeText(vendor.defaultModelId) || '';
|
||||
const desiredEffectiveBaseUrl = normalizeBaseUrl(config.baseUrl) || normalizeBaseUrl(vendor.defaultBaseUrl);
|
||||
const defaultVendorBaseUrl = normalizeBaseUrl(vendor.defaultBaseUrl);
|
||||
const desiredStoredBaseUrl = desiredEffectiveBaseUrl && desiredEffectiveBaseUrl !== defaultVendorBaseUrl
|
||||
? desiredEffectiveBaseUrl
|
||||
: '';
|
||||
const shouldReuseCurrentAccount = Boolean(currentAccount && currentAccount.vendorId === vendor.id);
|
||||
const needsAccountUpdate = Boolean(
|
||||
shouldReuseCurrentAccount
|
||||
&& currentAccount
|
||||
&& (
|
||||
normalizeText(currentAccount.label) !== desiredProviderName
|
||||
|| normalizeText(currentAccount.model) !== desiredModel
|
||||
|| normalizeBaseUrl(currentAccount.baseUrl) !== desiredStoredBaseUrl
|
||||
|| resolveAccountAuthMode(vendor, config.apiKey) !== currentAccount.authMode
|
||||
|| defaultAccountId !== currentAccount.id
|
||||
)
|
||||
);
|
||||
const needsKeyUpdate = hasRealSecret(config.apiKey) && (
|
||||
!currentProvider?.hasKey
|
||||
|| lastAppliedConfigRef.current !== signature
|
||||
);
|
||||
|
||||
if (!currentAccount && !hasRealSecret(config.apiKey) && (vendor.requiresApiKey || vendor.supportsApiKey)) {
|
||||
throw new Error(
|
||||
t(
|
||||
'models.providers.notices.syncMissingKey',
|
||||
undefined,
|
||||
'Provider config does not contain a usable API key, so it could not be synced to local provider accounts.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!shouldReuseCurrentAccount && !hasRealSecret(config.apiKey) && currentAccount && (vendor.requiresApiKey || vendor.supportsApiKey)) {
|
||||
throw new Error(
|
||||
t(
|
||||
'models.providers.notices.syncMissingKey',
|
||||
undefined,
|
||||
'Provider config does not contain a usable API key, so it could not be synced to local provider accounts.',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!needsAccountUpdate && !needsKeyUpdate && shouldReuseCurrentAccount) {
|
||||
lastAppliedConfigRef.current = signature;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
let nextAccountId = currentAccount?.id ?? null;
|
||||
|
||||
if (shouldReuseCurrentAccount && currentAccount) {
|
||||
await hostApiFetch(`/api/provider-accounts/${encodeURIComponent(currentAccount.id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
updates: {
|
||||
label: desiredProviderName,
|
||||
authMode: resolveAccountAuthMode(vendor, config.apiKey),
|
||||
baseUrl: desiredStoredBaseUrl,
|
||||
model: desiredModel,
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
updatedAt: now,
|
||||
},
|
||||
...(needsKeyUpdate && normalizeText(config.apiKey)
|
||||
? { apiKey: normalizeText(config.apiKey) }
|
||||
: {}),
|
||||
}),
|
||||
});
|
||||
nextAccountId = currentAccount.id;
|
||||
} else {
|
||||
const createdAccountId = `${vendor.id}-${generateUUID()}`;
|
||||
await hostApiFetch('/api/provider-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
account: {
|
||||
id: createdAccountId,
|
||||
vendorId: vendor.id,
|
||||
label: desiredProviderName,
|
||||
authMode: resolveAccountAuthMode(vendor, config.apiKey),
|
||||
baseUrl: desiredStoredBaseUrl,
|
||||
model: desiredModel,
|
||||
enabled: true,
|
||||
isDefault: true,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} satisfies ProviderAccount,
|
||||
...(normalizeText(config.apiKey) ? { apiKey: normalizeText(config.apiKey) } : {}),
|
||||
}),
|
||||
});
|
||||
nextAccountId = createdAccountId;
|
||||
}
|
||||
|
||||
if (nextAccountId && defaultAccountId !== nextAccountId) {
|
||||
await hostApiFetch('/api/provider-accounts/default', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ accountId: nextAccountId }),
|
||||
});
|
||||
}
|
||||
|
||||
lastAppliedConfigRef.current = signature;
|
||||
} catch (syncError) {
|
||||
setError(
|
||||
formatErrorMessage(
|
||||
syncError,
|
||||
t(
|
||||
'models.providers.notices.syncError',
|
||||
undefined,
|
||||
'Failed to sync provider config to local provider accounts.',
|
||||
),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelConfig(showLoading = true): Promise<void> {
|
||||
if (showLoading) {
|
||||
@@ -76,8 +333,10 @@ export default function ProvidersSection() {
|
||||
|
||||
try {
|
||||
const response = await hotelStaffChatModelConfigUsingGet({});
|
||||
setModelConfig(normalizeModelConfig(response));
|
||||
const normalizedConfig = normalizeModelConfig(response);
|
||||
setModelConfig(normalizedConfig);
|
||||
setError(null);
|
||||
await syncModelConfigToProviderAccounts(normalizedConfig);
|
||||
} catch (loadError) {
|
||||
setError(formatErrorMessage(loadError, t('models.providers.notices.loadError')));
|
||||
} finally {
|
||||
@@ -142,6 +401,16 @@ export default function ProvidersSection() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{syncing && !loading ? (
|
||||
<div className="rounded-[14px] border border-blue-200 bg-blue-50 px-4 py-3 text-[13px] text-blue-600 dark:border-blue-500/25 dark:bg-blue-500/10 dark:text-blue-300">
|
||||
{t(
|
||||
'models.providers.notices.syncing',
|
||||
undefined,
|
||||
'Syncing the current provider config to local provider accounts...',
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8 text-[#99A0AE] dark:text-gray-500">
|
||||
<span className="mr-3 inline-flex h-5 w-5 animate-spin rounded-full border-2 border-current border-r-transparent" />
|
||||
|
||||
@@ -479,6 +479,21 @@ async function loadSessions(): Promise<void> {
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
const fallbackSessionKey = state.currentSessionKey || getDefaultMainSessionKey();
|
||||
const fallbackSession =
|
||||
fallbackSessionKey && !state.sessions.some((session) => session.key === fallbackSessionKey)
|
||||
? [{ key: fallbackSessionKey, displayName: t('conversation.newConversation') }]
|
||||
: state.sessions;
|
||||
|
||||
patchState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
error: String(error),
|
||||
sessions: fallbackSession,
|
||||
currentSessionKey: fallbackSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(fallbackSessionKey),
|
||||
});
|
||||
} finally {
|
||||
lastLoadSessionsAt = Date.now();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user