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:
duanshuwen
2026-04-22 21:56:37 +08:00
parent 2f675afe47
commit ea1fd18e6f
22 changed files with 8947 additions and 94 deletions

View File

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

View File

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

View File

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