committed by
GitHub
Unverified
parent
3d804a9f5e
commit
2c5c82bb74
@@ -24,7 +24,7 @@ import { useChatStore } from '@/stores/chat';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type SessionBucketKey =
|
||||
@@ -115,11 +115,11 @@ export function Sidebar() {
|
||||
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await invokeIpc('gateway:getControlUiUrl') as {
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
};
|
||||
}>('/api/gateway/control-ui');
|
||||
if (result.success && result.url) {
|
||||
window.electron.openExternal(result.url);
|
||||
} else {
|
||||
@@ -297,4 +297,4 @@ export function Sidebar() {
|
||||
/>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Providers Settings Component
|
||||
* Manage AI provider configurations and API keys
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Trash2,
|
||||
@@ -24,7 +24,12 @@ import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useProviderStore, type ProviderConfig, type ProviderWithKeyInfo } from '@/stores/providers';
|
||||
import {
|
||||
useProviderStore,
|
||||
type ProviderAccount,
|
||||
type ProviderConfig,
|
||||
type ProviderVendorInfo,
|
||||
} from '@/stores/providers';
|
||||
import {
|
||||
PROVIDER_TYPE_INFO,
|
||||
type ProviderType,
|
||||
@@ -34,11 +39,18 @@ import {
|
||||
shouldShowProviderModelId,
|
||||
shouldInvertInDark,
|
||||
} from '@/lib/providers';
|
||||
import {
|
||||
buildProviderAccountId,
|
||||
buildProviderListItems,
|
||||
type ProviderListItem,
|
||||
} from '@/lib/provider-accounts';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
|
||||
function normalizeFallbackProviderIds(ids?: string[]): string[] {
|
||||
return Array.from(new Set((ids ?? []).filter(Boolean)));
|
||||
@@ -60,55 +72,82 @@ function fallbackModelsEqual(a?: string[], b?: string[]): boolean {
|
||||
return left.length === right.length && left.every((model, index) => model === right[index]);
|
||||
}
|
||||
|
||||
function getAuthModeLabel(
|
||||
authMode: ProviderAccount['authMode'],
|
||||
t: (key: string) => string
|
||||
): string {
|
||||
switch (authMode) {
|
||||
case 'api_key':
|
||||
return t('aiProviders.authModes.apiKey');
|
||||
case 'oauth_device':
|
||||
return t('aiProviders.authModes.oauthDevice');
|
||||
case 'oauth_browser':
|
||||
return t('aiProviders.authModes.oauthBrowser');
|
||||
case 'local':
|
||||
return t('aiProviders.authModes.local');
|
||||
default:
|
||||
return authMode;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProvidersSettings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||
const {
|
||||
providers,
|
||||
defaultProviderId,
|
||||
statuses,
|
||||
accounts,
|
||||
vendors,
|
||||
defaultAccountId,
|
||||
loading,
|
||||
fetchProviders,
|
||||
addProvider,
|
||||
deleteProvider,
|
||||
updateProviderWithKey,
|
||||
setDefaultProvider,
|
||||
validateApiKey,
|
||||
refreshProviderSnapshot,
|
||||
createAccount,
|
||||
removeAccount,
|
||||
updateAccount,
|
||||
setDefaultAccount,
|
||||
validateAccountApiKey,
|
||||
} = useProviderStore();
|
||||
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [editingProvider, setEditingProvider] = useState<string | null>(null);
|
||||
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
|
||||
const existingVendorIds = new Set(accounts.map((account) => account.vendorId));
|
||||
const displayProviders = useMemo(
|
||||
() => buildProviderListItems(accounts, statuses, vendors, defaultAccountId),
|
||||
[accounts, statuses, vendors, defaultAccountId],
|
||||
);
|
||||
|
||||
// Fetch providers on mount
|
||||
useEffect(() => {
|
||||
fetchProviders();
|
||||
}, [fetchProviders]);
|
||||
refreshProviderSnapshot();
|
||||
}, [refreshProviderSnapshot]);
|
||||
|
||||
const handleAddProvider = async (
|
||||
type: ProviderType,
|
||||
name: string,
|
||||
apiKey: string,
|
||||
options?: { baseUrl?: string; model?: string }
|
||||
options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] }
|
||||
) => {
|
||||
// Only custom supports multiple instances.
|
||||
// Built-in providers remain singleton by type.
|
||||
const id = type === 'custom' ? `custom-${crypto.randomUUID()}` : type;
|
||||
const vendor = vendorMap.get(type);
|
||||
const id = buildProviderAccountId(type, null, vendors);
|
||||
const effectiveApiKey = resolveProviderApiKeyForSave(type, apiKey);
|
||||
try {
|
||||
await addProvider(
|
||||
{
|
||||
id,
|
||||
type,
|
||||
name,
|
||||
baseUrl: options?.baseUrl,
|
||||
model: options?.model,
|
||||
enabled: true,
|
||||
},
|
||||
effectiveApiKey
|
||||
);
|
||||
await createAccount({
|
||||
id,
|
||||
vendorId: type,
|
||||
label: name,
|
||||
authMode: options?.authMode || vendor?.defaultAuthMode || (type === 'ollama' ? 'local' : 'api_key'),
|
||||
baseUrl: options?.baseUrl,
|
||||
apiProtocol: type === 'custom' || type === 'ollama' ? 'openai-completions' : undefined,
|
||||
model: options?.model,
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}, effectiveApiKey);
|
||||
|
||||
// Auto-set as default if no default is currently configured
|
||||
if (!defaultProviderId) {
|
||||
await setDefaultProvider(id);
|
||||
if (!defaultAccountId) {
|
||||
await setDefaultAccount(id);
|
||||
}
|
||||
|
||||
setShowAddDialog(false);
|
||||
@@ -120,7 +159,7 @@ export function ProvidersSettings() {
|
||||
|
||||
const handleDeleteProvider = async (providerId: string) => {
|
||||
try {
|
||||
await deleteProvider(providerId);
|
||||
await removeAccount(providerId);
|
||||
toast.success(t('aiProviders.toast.deleted'));
|
||||
} catch (error) {
|
||||
toast.error(`${t('aiProviders.toast.failedDelete')}: ${error}`);
|
||||
@@ -129,7 +168,7 @@ export function ProvidersSettings() {
|
||||
|
||||
const handleSetDefault = async (providerId: string) => {
|
||||
try {
|
||||
await setDefaultProvider(providerId);
|
||||
await setDefaultAccount(providerId);
|
||||
toast.success(t('aiProviders.toast.defaultUpdated'));
|
||||
} catch (error) {
|
||||
toast.error(`${t('aiProviders.toast.failedDefault')}: ${error}`);
|
||||
@@ -149,7 +188,7 @@ export function ProvidersSettings() {
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
) : displayProviders.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<Key className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
@@ -165,26 +204,35 @@ export function ProvidersSettings() {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{providers.map((provider) => (
|
||||
{displayProviders.map((item) => (
|
||||
<ProviderCard
|
||||
key={provider.id}
|
||||
provider={provider}
|
||||
allProviders={providers}
|
||||
isDefault={provider.id === defaultProviderId}
|
||||
isEditing={editingProvider === provider.id}
|
||||
onEdit={() => setEditingProvider(provider.id)}
|
||||
key={item.account.id}
|
||||
item={item}
|
||||
allProviders={displayProviders}
|
||||
isDefault={item.account.id === defaultAccountId}
|
||||
isEditing={editingProvider === item.account.id}
|
||||
onEdit={() => setEditingProvider(item.account.id)}
|
||||
onCancelEdit={() => setEditingProvider(null)}
|
||||
onDelete={() => handleDeleteProvider(provider.id)}
|
||||
onSetDefault={() => handleSetDefault(provider.id)}
|
||||
onDelete={() => handleDeleteProvider(item.account.id)}
|
||||
onSetDefault={() => handleSetDefault(item.account.id)}
|
||||
onSaveEdits={async (payload) => {
|
||||
await updateProviderWithKey(
|
||||
provider.id,
|
||||
payload.updates || {},
|
||||
const updates: Partial<ProviderAccount> = {};
|
||||
if (payload.updates) {
|
||||
if (payload.updates.baseUrl !== undefined) updates.baseUrl = payload.updates.baseUrl;
|
||||
if (payload.updates.model !== undefined) updates.model = payload.updates.model;
|
||||
if (payload.updates.fallbackModels !== undefined) updates.fallbackModels = payload.updates.fallbackModels;
|
||||
if (payload.updates.fallbackProviderIds !== undefined) {
|
||||
updates.fallbackAccountIds = payload.updates.fallbackProviderIds;
|
||||
}
|
||||
}
|
||||
await updateAccount(
|
||||
item.account.id,
|
||||
updates,
|
||||
payload.newApiKey
|
||||
);
|
||||
setEditingProvider(null);
|
||||
}}
|
||||
onValidateKey={(key, options) => validateApiKey(provider.id, key, options)}
|
||||
onValidateKey={(key, options) => validateAccountApiKey(item.account.id, key, options)}
|
||||
devModeUnlocked={devModeUnlocked}
|
||||
/>
|
||||
))}
|
||||
@@ -194,10 +242,11 @@ export function ProvidersSettings() {
|
||||
{/* Add Provider Dialog */}
|
||||
{showAddDialog && (
|
||||
<AddProviderDialog
|
||||
existingTypes={new Set(providers.map((p) => p.type))}
|
||||
existingVendorIds={existingVendorIds}
|
||||
vendors={vendors}
|
||||
onClose={() => setShowAddDialog(false)}
|
||||
onAdd={handleAddProvider}
|
||||
onValidateKey={(type, key, options) => validateApiKey(type, key, options)}
|
||||
onValidateKey={(type, key, options) => validateAccountApiKey(type, key, options)}
|
||||
devModeUnlocked={devModeUnlocked}
|
||||
/>
|
||||
)}
|
||||
@@ -206,8 +255,8 @@ export function ProvidersSettings() {
|
||||
}
|
||||
|
||||
interface ProviderCardProps {
|
||||
provider: ProviderWithKeyInfo;
|
||||
allProviders: ProviderWithKeyInfo[];
|
||||
item: ProviderListItem;
|
||||
allProviders: ProviderListItem[];
|
||||
isDefault: boolean;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
@@ -225,7 +274,7 @@ interface ProviderCardProps {
|
||||
|
||||
|
||||
function ProviderCard({
|
||||
provider,
|
||||
item,
|
||||
allProviders,
|
||||
isDefault,
|
||||
isEditing,
|
||||
@@ -238,20 +287,21 @@ function ProviderCard({
|
||||
devModeUnlocked,
|
||||
}: ProviderCardProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const { account, vendor, status } = item;
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState(provider.baseUrl || '');
|
||||
const [modelId, setModelId] = useState(provider.model || '');
|
||||
const [baseUrl, setBaseUrl] = useState(account.baseUrl || '');
|
||||
const [modelId, setModelId] = useState(account.model || '');
|
||||
const [fallbackModelsText, setFallbackModelsText] = useState(
|
||||
normalizeFallbackModels(provider.fallbackModels).join('\n')
|
||||
normalizeFallbackModels(account.fallbackModels).join('\n')
|
||||
);
|
||||
const [fallbackProviderIds, setFallbackProviderIds] = useState<string[]>(
|
||||
normalizeFallbackProviderIds(provider.fallbackProviderIds)
|
||||
normalizeFallbackProviderIds(account.fallbackAccountIds)
|
||||
);
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === provider.type);
|
||||
const typeInfo = PROVIDER_TYPE_INFO.find((t) => t.id === account.vendorId);
|
||||
const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked);
|
||||
const canEditModelConfig = Boolean(typeInfo?.showBaseUrl || showModelIdField);
|
||||
|
||||
@@ -259,14 +309,14 @@ function ProviderCard({
|
||||
if (isEditing) {
|
||||
setNewKey('');
|
||||
setShowKey(false);
|
||||
setBaseUrl(provider.baseUrl || '');
|
||||
setModelId(provider.model || '');
|
||||
setFallbackModelsText(normalizeFallbackModels(provider.fallbackModels).join('\n'));
|
||||
setFallbackProviderIds(normalizeFallbackProviderIds(provider.fallbackProviderIds));
|
||||
setBaseUrl(account.baseUrl || '');
|
||||
setModelId(account.model || '');
|
||||
setFallbackModelsText(normalizeFallbackModels(account.fallbackModels).join('\n'));
|
||||
setFallbackProviderIds(normalizeFallbackProviderIds(account.fallbackAccountIds));
|
||||
}
|
||||
}, [isEditing, provider.baseUrl, provider.fallbackModels, provider.fallbackProviderIds, provider.model]);
|
||||
}, [isEditing, account.baseUrl, account.fallbackModels, account.fallbackAccountIds, account.model]);
|
||||
|
||||
const fallbackOptions = allProviders.filter((candidate) => candidate.id !== provider.id);
|
||||
const fallbackOptions = allProviders.filter((candidate) => candidate.account.id !== account.id);
|
||||
|
||||
const toggleFallbackProvider = (providerId: string) => {
|
||||
setFallbackProviderIds((current) => (
|
||||
@@ -304,16 +354,16 @@ function ProviderCard({
|
||||
}
|
||||
|
||||
const updates: Partial<ProviderConfig> = {};
|
||||
if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (provider.baseUrl || undefined)) {
|
||||
if (typeInfo?.showBaseUrl && (baseUrl.trim() || undefined) !== (account.baseUrl || undefined)) {
|
||||
updates.baseUrl = baseUrl.trim() || undefined;
|
||||
}
|
||||
if (showModelIdField && (modelId.trim() || undefined) !== (provider.model || undefined)) {
|
||||
if (showModelIdField && (modelId.trim() || undefined) !== (account.model || undefined)) {
|
||||
updates.model = modelId.trim() || undefined;
|
||||
}
|
||||
if (!fallbackModelsEqual(normalizedFallbackModels, provider.fallbackModels)) {
|
||||
if (!fallbackModelsEqual(normalizedFallbackModels, account.fallbackModels)) {
|
||||
updates.fallbackModels = normalizedFallbackModels;
|
||||
}
|
||||
if (!fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds)) {
|
||||
if (!fallbackProviderIdsEqual(fallbackProviderIds, account.fallbackAccountIds)) {
|
||||
updates.fallbackProviderIds = normalizeFallbackProviderIds(fallbackProviderIds);
|
||||
}
|
||||
if (Object.keys(updates).length > 0) {
|
||||
@@ -323,8 +373,8 @@ function ProviderCard({
|
||||
|
||||
// Keep Ollama key optional in UI, but persist a placeholder when
|
||||
// editing legacy configs that have no stored key.
|
||||
if (provider.type === 'ollama' && !provider.hasKey && !payload.newApiKey) {
|
||||
payload.newApiKey = resolveProviderApiKeyForSave(provider.type, '') as string;
|
||||
if (account.vendorId === 'ollama' && !status?.hasKey && !payload.newApiKey) {
|
||||
payload.newApiKey = resolveProviderApiKeyForSave(account.vendorId, '') as string;
|
||||
}
|
||||
|
||||
if (!payload.newApiKey && !payload.updates) {
|
||||
@@ -350,16 +400,23 @@ function ProviderCard({
|
||||
{/* Top row: icon + name */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{getProviderIconUrl(provider.type) ? (
|
||||
<img src={getProviderIconUrl(provider.type)} alt={typeInfo?.name || provider.type} className={cn('h-5 w-5', shouldInvertInDark(provider.type) && 'dark:invert')} />
|
||||
{getProviderIconUrl(account.vendorId) ? (
|
||||
<img src={getProviderIconUrl(account.vendorId)} alt={typeInfo?.name || account.vendorId} className={cn('h-5 w-5', shouldInvertInDark(account.vendorId) && 'dark:invert')} />
|
||||
) : (
|
||||
<span className="text-xl">{typeInfo?.icon || '⚙️'}</span>
|
||||
<span className="text-xl">{vendor?.icon || typeInfo?.icon || '⚙️'}</span>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{provider.name}</span>
|
||||
<span className="font-semibold">{account.label}</span>
|
||||
<Badge variant="secondary">{vendor?.name || account.vendorId}</Badge>
|
||||
<Badge variant="outline">{getAuthModeLabel(account.authMode, t)}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
<p className="text-xs text-muted-foreground capitalize">{account.vendorId}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{t('aiProviders.dialog.modelId')}: {account.model || t('aiProviders.card.none')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground capitalize">{provider.type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -415,14 +472,16 @@ function ProviderCard({
|
||||
) : (
|
||||
<div className="space-y-2 rounded-md border p-2">
|
||||
{fallbackOptions.map((candidate) => (
|
||||
<label key={candidate.id} className="flex items-center gap-2 text-sm">
|
||||
<label key={candidate.account.id} className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fallbackProviderIds.includes(candidate.id)}
|
||||
onChange={() => toggleFallbackProvider(candidate.id)}
|
||||
checked={fallbackProviderIds.includes(candidate.account.id)}
|
||||
onChange={() => toggleFallbackProvider(candidate.account.id)}
|
||||
/>
|
||||
<span className="font-medium">{candidate.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{candidate.model || candidate.type}</span>
|
||||
<span className="font-medium">{candidate.account.label}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{candidate.account.model || candidate.vendor?.name || candidate.account.vendorId}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
@@ -434,12 +493,12 @@ function ProviderCard({
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('aiProviders.dialog.apiKey')}</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{provider.hasKey
|
||||
{status?.hasKey
|
||||
? t('aiProviders.dialog.apiKeyConfigured')
|
||||
: t('aiProviders.dialog.apiKeyMissing')}
|
||||
</p>
|
||||
</div>
|
||||
{provider.hasKey ? (
|
||||
{status?.hasKey ? (
|
||||
<Badge variant="secondary">{t('aiProviders.card.configured')}</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -484,10 +543,10 @@ function ProviderCard({
|
||||
|| saving
|
||||
|| (
|
||||
!newKey.trim()
|
||||
&& (baseUrl.trim() || undefined) === (provider.baseUrl || undefined)
|
||||
&& (modelId.trim() || undefined) === (provider.model || undefined)
|
||||
&& fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), provider.fallbackModels)
|
||||
&& fallbackProviderIdsEqual(fallbackProviderIds, provider.fallbackProviderIds)
|
||||
&& (baseUrl.trim() || undefined) === (account.baseUrl || undefined)
|
||||
&& (modelId.trim() || undefined) === (account.model || undefined)
|
||||
&& fallbackModelsEqual(normalizeFallbackModels(fallbackModelsText.split('\n')), account.fallbackModels)
|
||||
&& fallbackProviderIdsEqual(fallbackProviderIds, account.fallbackAccountIds)
|
||||
)
|
||||
|| Boolean(showModelIdField && !modelId.trim())
|
||||
}
|
||||
@@ -512,7 +571,7 @@ function ProviderCard({
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/50 px-3 py-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{typeInfo?.isOAuth ? (
|
||||
{account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' ? (
|
||||
<>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
|
||||
@@ -521,13 +580,13 @@ function ProviderCard({
|
||||
<>
|
||||
<Key className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-mono text-muted-foreground truncate">
|
||||
{provider.hasKey
|
||||
? (provider.keyMasked && provider.keyMasked.length > 12
|
||||
? `${provider.keyMasked.substring(0, 4)}...${provider.keyMasked.substring(provider.keyMasked.length - 4)}`
|
||||
: provider.keyMasked)
|
||||
{status?.hasKey
|
||||
? (status.keyMasked && status.keyMasked.length > 12
|
||||
? `${status.keyMasked.substring(0, 4)}...${status.keyMasked.substring(status.keyMasked.length - 4)}`
|
||||
: status.keyMasked)
|
||||
: t('aiProviders.card.noKey')}
|
||||
</span>
|
||||
{provider.hasKey && (
|
||||
{status?.hasKey && (
|
||||
<Badge variant="secondary" className="text-xs shrink-0">{t('aiProviders.card.configured')}</Badge>
|
||||
)}
|
||||
</>
|
||||
@@ -535,11 +594,11 @@ function ProviderCard({
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{t('aiProviders.card.fallbacks', {
|
||||
count: (provider.fallbackModels?.length ?? 0) + (provider.fallbackProviderIds?.length ?? 0),
|
||||
count: (account.fallbackModels?.length ?? 0) + (account.fallbackAccountIds?.length ?? 0),
|
||||
names: [
|
||||
...normalizeFallbackModels(provider.fallbackModels),
|
||||
...normalizeFallbackProviderIds(provider.fallbackProviderIds)
|
||||
.map((fallbackId) => allProviders.find((candidate) => candidate.id === fallbackId)?.name)
|
||||
...normalizeFallbackModels(account.fallbackModels),
|
||||
...normalizeFallbackProviderIds(account.fallbackAccountIds)
|
||||
.map((fallbackId) => allProviders.find((candidate) => candidate.account.id === fallbackId)?.account.label)
|
||||
.filter(Boolean),
|
||||
].join(', ') || t('aiProviders.card.none'),
|
||||
})}
|
||||
@@ -578,13 +637,14 @@ function ProviderCard({
|
||||
}
|
||||
|
||||
interface AddProviderDialogProps {
|
||||
existingTypes: Set<string>;
|
||||
existingVendorIds: Set<string>;
|
||||
vendors: ProviderVendorInfo[];
|
||||
onClose: () => void;
|
||||
onAdd: (
|
||||
type: ProviderType,
|
||||
name: string,
|
||||
apiKey: string,
|
||||
options?: { baseUrl?: string; model?: string }
|
||||
options?: { baseUrl?: string; model?: string; authMode?: ProviderAccount['authMode'] }
|
||||
) => Promise<void>;
|
||||
onValidateKey: (
|
||||
type: string,
|
||||
@@ -595,7 +655,8 @@ interface AddProviderDialogProps {
|
||||
}
|
||||
|
||||
function AddProviderDialog({
|
||||
existingTypes,
|
||||
existingVendorIds,
|
||||
vendors,
|
||||
onClose,
|
||||
onAdd,
|
||||
onValidateKey,
|
||||
@@ -626,11 +687,19 @@ function AddProviderDialog({
|
||||
const showModelIdField = shouldShowProviderModelId(typeInfo, devModeUnlocked);
|
||||
const isOAuth = typeInfo?.isOAuth ?? false;
|
||||
const supportsApiKey = typeInfo?.supportsApiKey ?? false;
|
||||
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
|
||||
const selectedVendor = selectedType ? vendorMap.get(selectedType) : undefined;
|
||||
const preferredOAuthMode = selectedVendor?.supportedAuthModes.includes('oauth_browser')
|
||||
? 'oauth_browser'
|
||||
: (selectedVendor?.supportedAuthModes.includes('oauth_device')
|
||||
? 'oauth_device'
|
||||
: (selectedType === 'google' ? 'oauth_browser' : null));
|
||||
// Effective OAuth mode: pure OAuth providers, or dual-mode with oauth selected
|
||||
const useOAuthFlow = isOAuth && (!supportsApiKey || authMode === 'oauth');
|
||||
|
||||
// Keep a ref to the latest values so the effect closure can access them
|
||||
// Keep refs to the latest values so event handlers see the current dialog state.
|
||||
const latestRef = React.useRef({ selectedType, typeInfo, onAdd, onClose, t });
|
||||
const pendingOAuthRef = React.useRef<{ accountId: string; label: string } | null>(null);
|
||||
useEffect(() => {
|
||||
latestRef.current = { selectedType, typeInfo, onAdd, onClose, t };
|
||||
});
|
||||
@@ -642,12 +711,14 @@ function AddProviderDialog({
|
||||
setOauthError(null);
|
||||
};
|
||||
|
||||
const handleSuccess = async () => {
|
||||
const handleSuccess = async (data: unknown) => {
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setValidationError(null);
|
||||
|
||||
const { onClose: close, t: translate } = latestRef.current;
|
||||
const payload = (data as { accountId?: string } | undefined) || undefined;
|
||||
const accountId = payload?.accountId || pendingOAuthRef.current?.accountId;
|
||||
|
||||
// device-oauth.ts already saved the provider config to the backend,
|
||||
// including the dynamically resolved baseUrl for the region (e.g. CN vs Global).
|
||||
@@ -655,17 +726,17 @@ function AddProviderDialog({
|
||||
// So we just fetch the latest list from the backend to update the UI.
|
||||
try {
|
||||
const store = useProviderStore.getState();
|
||||
await store.fetchProviders();
|
||||
await store.refreshProviderSnapshot();
|
||||
|
||||
// Auto-set as default if no default is currently configured
|
||||
if (!store.defaultProviderId && latestRef.current.selectedType) {
|
||||
// Provider type is expected to match provider ID for built-in OAuth providers
|
||||
await store.setDefaultProvider(latestRef.current.selectedType);
|
||||
if (!store.defaultAccountId && accountId) {
|
||||
await store.setDefaultAccount(accountId);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh providers after OAuth:', err);
|
||||
}
|
||||
|
||||
pendingOAuthRef.current = null;
|
||||
close();
|
||||
toast.success(translate('aiProviders.toast.added'));
|
||||
};
|
||||
@@ -673,29 +744,28 @@ function AddProviderDialog({
|
||||
const handleError = (data: unknown) => {
|
||||
setOauthError((data as { message: string }).message);
|
||||
setOauthData(null);
|
||||
pendingOAuthRef.current = null;
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on('oauth:code', handleCode);
|
||||
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
|
||||
window.electron.ipcRenderer.on('oauth:error', handleError);
|
||||
const offCode = subscribeHostEvent('oauth:code', handleCode);
|
||||
const offSuccess = subscribeHostEvent('oauth:success', handleSuccess);
|
||||
const offError = subscribeHostEvent('oauth:error', handleError);
|
||||
|
||||
return () => {
|
||||
if (typeof window.electron.ipcRenderer.off === 'function') {
|
||||
window.electron.ipcRenderer.off('oauth:code', handleCode);
|
||||
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
|
||||
window.electron.ipcRenderer.off('oauth:error', handleError);
|
||||
}
|
||||
offCode();
|
||||
offSuccess();
|
||||
offError();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleStartOAuth = async () => {
|
||||
if (!selectedType) return;
|
||||
|
||||
if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
|
||||
if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
|
||||
toast.error(t('aiProviders.toast.minimaxConflict'));
|
||||
return;
|
||||
}
|
||||
if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) {
|
||||
if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
|
||||
toast.error(t('aiProviders.toast.minimaxConflict'));
|
||||
return;
|
||||
}
|
||||
@@ -705,10 +775,19 @@ function AddProviderDialog({
|
||||
setOauthError(null);
|
||||
|
||||
try {
|
||||
await invokeIpc('provider:requestOAuth', selectedType);
|
||||
const vendor = vendorMap.get(selectedType);
|
||||
const supportsMultipleAccounts = vendor?.supportsMultipleAccounts ?? selectedType === 'custom';
|
||||
const accountId = supportsMultipleAccounts ? `${selectedType}-${crypto.randomUUID()}` : selectedType;
|
||||
const label = name || (typeInfo?.id === 'custom' ? t('aiProviders.custom') : typeInfo?.name) || selectedType;
|
||||
pendingOAuthRef.current = { accountId, label };
|
||||
await hostApiFetch('/api/providers/oauth/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: selectedType, accountId, label }),
|
||||
});
|
||||
} catch (e) {
|
||||
setOauthError(String(e));
|
||||
setOauthFlowing(false);
|
||||
pendingOAuthRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -716,22 +795,28 @@ function AddProviderDialog({
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setOauthError(null);
|
||||
await invokeIpc('provider:cancelOAuth');
|
||||
pendingOAuthRef.current = null;
|
||||
await hostApiFetch('/api/providers/oauth/cancel', {
|
||||
method: 'POST',
|
||||
});
|
||||
};
|
||||
|
||||
// Only custom can be added multiple times.
|
||||
const availableTypes = PROVIDER_TYPE_INFO.filter(
|
||||
(t) => t.id === 'custom' || !existingTypes.has(t.id),
|
||||
);
|
||||
const availableTypes = PROVIDER_TYPE_INFO.filter((type) => {
|
||||
const vendor = vendorMap.get(type.id);
|
||||
if (!vendor) {
|
||||
return !existingVendorIds.has(type.id) || type.id === 'custom';
|
||||
}
|
||||
return vendor.supportsMultipleAccounts || !existingVendorIds.has(type.id);
|
||||
});
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selectedType) return;
|
||||
|
||||
if (selectedType === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
|
||||
if (selectedType === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
|
||||
toast.error(t('aiProviders.toast.minimaxConflict'));
|
||||
return;
|
||||
}
|
||||
if (selectedType === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) {
|
||||
if (selectedType === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
|
||||
toast.error(t('aiProviders.toast.minimaxConflict'));
|
||||
return;
|
||||
}
|
||||
@@ -772,6 +857,11 @@ function AddProviderDialog({
|
||||
{
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
model: resolveProviderModelForSave(typeInfo, modelId, devModeUnlocked),
|
||||
authMode: useOAuthFlow ? (preferredOAuthMode || 'oauth_device') : selectedType === 'ollama'
|
||||
? 'local'
|
||||
: (isOAuth && supportsApiKey && authMode === 'apikey')
|
||||
? 'api_key'
|
||||
: vendorMap.get(selectedType)?.defaultAuthMode || 'api_key',
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
@@ -1059,4 +1149,4 @@ function AddProviderDialog({
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,19 @@
|
||||
"aiProviders": {
|
||||
"title": "AI Providers",
|
||||
"description": "Configure your AI model providers and API keys",
|
||||
"overview": {
|
||||
"title": "Provider Accounts",
|
||||
"description": "A summary of the provider accounts and models currently configured.",
|
||||
"noModelSelected": "No model selected",
|
||||
"multiAccountReady": "Multi-account ready",
|
||||
"singletonVendor": "Single-account vendor"
|
||||
},
|
||||
"authModes": {
|
||||
"apiKey": "API Key",
|
||||
"oauthDevice": "OAuth Device",
|
||||
"oauthBrowser": "OAuth Browser",
|
||||
"local": "Local"
|
||||
},
|
||||
"sections": {
|
||||
"model": "Model Settings",
|
||||
"fallback": "Fallback Settings"
|
||||
@@ -85,6 +98,7 @@
|
||||
"cancel": "Cancel",
|
||||
"codeCopied": "Code copied to clipboard",
|
||||
"authFailed": "Authentication Failed",
|
||||
"browserFlowUnavailable": "Browser OAuth is not wired for this provider yet.",
|
||||
"tryAgain": "Try Again",
|
||||
"approveLogin": "Approve Login",
|
||||
"step1": "Copy the authorization code below.",
|
||||
|
||||
@@ -13,6 +13,19 @@
|
||||
"aiProviders": {
|
||||
"title": "AI プロバイダー",
|
||||
"description": "AI モデルプロバイダーと API キーを設定",
|
||||
"overview": {
|
||||
"title": "プロバイダーアカウント",
|
||||
"description": "現在設定されているプロバイダーアカウントとモデルの概要です。",
|
||||
"noModelSelected": "モデル未選択",
|
||||
"multiAccountReady": "複数アカウント対応",
|
||||
"singletonVendor": "単一アカウントのプロバイダー"
|
||||
},
|
||||
"authModes": {
|
||||
"apiKey": "API キー",
|
||||
"oauthDevice": "OAuth デバイス",
|
||||
"oauthBrowser": "OAuth ブラウザ",
|
||||
"local": "ローカル"
|
||||
},
|
||||
"sections": {
|
||||
"model": "モデル設定",
|
||||
"fallback": "フォールバック設定"
|
||||
@@ -84,6 +97,7 @@
|
||||
"cancel": "キャンセル",
|
||||
"codeCopied": "コードをクリップボードにコピーしました",
|
||||
"authFailed": "認証に失敗しました",
|
||||
"browserFlowUnavailable": "このプロバイダーのブラウザ OAuth はまだ接続されていません。",
|
||||
"tryAgain": "再試行",
|
||||
"approveLogin": "ログインを承認",
|
||||
"step1": "以下の認証コードをコピーしてください。",
|
||||
|
||||
@@ -13,6 +13,19 @@
|
||||
"aiProviders": {
|
||||
"title": "AI 模型提供商",
|
||||
"description": "配置 AI 模型提供商和 API 密钥",
|
||||
"overview": {
|
||||
"title": "提供商账户",
|
||||
"description": "这里汇总当前已配置的 provider 账户与模型信息。",
|
||||
"noModelSelected": "未选择模型",
|
||||
"multiAccountReady": "支持多账户",
|
||||
"singletonVendor": "单例提供商"
|
||||
},
|
||||
"authModes": {
|
||||
"apiKey": "API 密钥",
|
||||
"oauthDevice": "OAuth 设备登录",
|
||||
"oauthBrowser": "OAuth 浏览器登录",
|
||||
"local": "本地"
|
||||
},
|
||||
"sections": {
|
||||
"model": "模型配置",
|
||||
"fallback": "回退配置"
|
||||
@@ -85,6 +98,7 @@
|
||||
"cancel": "取消",
|
||||
"codeCopied": "代码已复制到剪贴板",
|
||||
"authFailed": "认证失败",
|
||||
"browserFlowUnavailable": "该提供商的浏览器 OAuth 登录链路暂未接通。",
|
||||
"tryAgain": "重试",
|
||||
"approveLogin": "确认登录",
|
||||
"step1": "复制下方的授权码。",
|
||||
|
||||
239
src/lib/gateway-client.ts
Normal file
239
src/lib/gateway-client.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { hostApiFetch } from './host-api';
|
||||
|
||||
type GatewayInfo = {
|
||||
wsUrl: string;
|
||||
token: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
type GatewayEventHandler = (payload: unknown) => void;
|
||||
|
||||
class GatewayBrowserClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private connectPromise: Promise<void> | null = null;
|
||||
private gatewayInfo: GatewayInfo | null = null;
|
||||
private pendingRequests = new Map<string, PendingRequest>();
|
||||
private eventHandlers = new Map<string, Set<GatewayEventHandler>>();
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
if (this.connectPromise) {
|
||||
await this.connectPromise;
|
||||
return;
|
||||
}
|
||||
|
||||
this.connectPromise = this.openSocket();
|
||||
try {
|
||||
await this.connectPromise;
|
||||
} finally {
|
||||
this.connectPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
for (const [, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Gateway connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
async rpc<T>(method: string, params?: unknown, timeoutMs = 30000): Promise<T> {
|
||||
await this.connect();
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error('Gateway socket is not connected');
|
||||
}
|
||||
|
||||
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const request = {
|
||||
type: 'req',
|
||||
id,
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Gateway RPC timeout: ${method}`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: resolve as (value: unknown) => void,
|
||||
reject,
|
||||
timeout,
|
||||
});
|
||||
this.ws!.send(JSON.stringify(request));
|
||||
});
|
||||
}
|
||||
|
||||
on(eventName: string, handler: GatewayEventHandler): () => void {
|
||||
const handlers = this.eventHandlers.get(eventName) || new Set<GatewayEventHandler>();
|
||||
handlers.add(handler);
|
||||
this.eventHandlers.set(eventName, handlers);
|
||||
|
||||
return () => {
|
||||
const current = this.eventHandlers.get(eventName);
|
||||
current?.delete(handler);
|
||||
if (current && current.size === 0) {
|
||||
this.eventHandlers.delete(eventName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async openSocket(): Promise<void> {
|
||||
this.gatewayInfo = await hostApiFetch<GatewayInfo>('/api/app/gateway-info');
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const ws = new WebSocket(this.gatewayInfo!.wsUrl);
|
||||
let resolved = false;
|
||||
let challengeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (challengeTimer) {
|
||||
clearTimeout(challengeTimer);
|
||||
challengeTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
challengeTimer = setTimeout(() => {
|
||||
rejectOnce(new Error('Gateway connect challenge timeout'));
|
||||
ws.close();
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(String(event.data)) as Record<string, unknown>;
|
||||
if (message.type === 'event' && message.event === 'connect.challenge') {
|
||||
const nonce = (message.payload as { nonce?: string } | undefined)?.nonce;
|
||||
if (!nonce) {
|
||||
rejectOnce(new Error('Gateway connect.challenge missing nonce'));
|
||||
return;
|
||||
}
|
||||
const connectFrame = {
|
||||
type: 'req',
|
||||
id: `connect-${Date.now()}`,
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'gateway-client',
|
||||
displayName: 'ClawX',
|
||||
version: '0.1.0',
|
||||
platform: navigator.platform,
|
||||
mode: 'ui',
|
||||
},
|
||||
auth: {
|
||||
token: this.gatewayInfo?.token,
|
||||
},
|
||||
caps: [],
|
||||
role: 'operator',
|
||||
scopes: ['operator.admin'],
|
||||
},
|
||||
};
|
||||
ws.send(JSON.stringify(connectFrame));
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'res' && typeof message.id === 'string') {
|
||||
if (String(message.id).startsWith('connect-')) {
|
||||
this.ws = ws;
|
||||
resolveOnce();
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pendingRequests.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeout);
|
||||
this.pendingRequests.delete(message.id);
|
||||
if (message.ok === false || message.error) {
|
||||
const errorMessage = typeof message.error === 'object' && message.error !== null
|
||||
? String((message.error as { message?: string }).message || JSON.stringify(message.error))
|
||||
: String(message.error || 'Gateway request failed');
|
||||
pending.reject(new Error(errorMessage));
|
||||
} else {
|
||||
pending.resolve(message.payload);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'event' && typeof message.event === 'string') {
|
||||
this.emitEvent(message.event, message.payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof message.method === 'string') {
|
||||
this.emitEvent(message.method, message.params);
|
||||
}
|
||||
} catch (error) {
|
||||
rejectOnce(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
rejectOnce(new Error('Gateway WebSocket error'));
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.ws = null;
|
||||
if (!resolved) {
|
||||
rejectOnce(new Error('Gateway WebSocket closed before connect'));
|
||||
return;
|
||||
}
|
||||
for (const [, request] of this.pendingRequests) {
|
||||
clearTimeout(request.timeout);
|
||||
request.reject(new Error('Gateway connection closed'));
|
||||
}
|
||||
this.pendingRequests.clear();
|
||||
this.emitEvent('__close__', null);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private emitEvent(eventName: string, payload: unknown): void {
|
||||
const handlers = this.eventHandlers.get(eventName);
|
||||
if (!handlers) return;
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
handler(payload);
|
||||
} catch {
|
||||
// ignore handler failures
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const gatewayClient = new GatewayBrowserClient();
|
||||
42
src/lib/host-api.ts
Normal file
42
src/lib/host-api.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const HOST_API_PORT = 3210;
|
||||
const HOST_API_BASE = `http://127.0.0.1:${HOST_API_PORT}`;
|
||||
|
||||
async function parseResponse<T>(response: Response): Promise<T> {
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const payload = await response.json() as { error?: string };
|
||||
if (payload?.error) {
|
||||
message = payload.error;
|
||||
}
|
||||
} catch {
|
||||
// ignore body parse failure
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
}
|
||||
|
||||
export async function hostApiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${HOST_API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
return parseResponse<T>(response);
|
||||
}
|
||||
|
||||
export function createHostEventSource(path = '/api/events'): EventSource {
|
||||
return new EventSource(`${HOST_API_BASE}${path}`);
|
||||
}
|
||||
|
||||
export function getHostApiBase(): string {
|
||||
return HOST_API_BASE;
|
||||
}
|
||||
25
src/lib/host-events.ts
Normal file
25
src/lib/host-events.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { createHostEventSource } from './host-api';
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
|
||||
function getEventSource(): EventSource {
|
||||
if (!eventSource) {
|
||||
eventSource = createHostEventSource();
|
||||
}
|
||||
return eventSource;
|
||||
}
|
||||
|
||||
export function subscribeHostEvent<T = unknown>(
|
||||
eventName: string,
|
||||
handler: (payload: T) => void,
|
||||
): () => void {
|
||||
const source = getEventSource();
|
||||
const listener = (event: Event) => {
|
||||
const payload = JSON.parse((event as MessageEvent).data) as T;
|
||||
handler(payload);
|
||||
};
|
||||
source.addEventListener(eventName, listener);
|
||||
return () => {
|
||||
source.removeEventListener(eventName, listener);
|
||||
};
|
||||
}
|
||||
122
src/lib/provider-accounts.ts
Normal file
122
src/lib/provider-accounts.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import type {
|
||||
ProviderAccount,
|
||||
ProviderType,
|
||||
ProviderVendorInfo,
|
||||
ProviderWithKeyInfo,
|
||||
} from '@/lib/providers';
|
||||
|
||||
export interface ProviderSnapshot {
|
||||
accounts: ProviderAccount[];
|
||||
statuses: ProviderWithKeyInfo[];
|
||||
vendors: ProviderVendorInfo[];
|
||||
defaultAccountId: string | null;
|
||||
}
|
||||
|
||||
export interface ProviderListItem {
|
||||
account: ProviderAccount;
|
||||
vendor?: ProviderVendorInfo;
|
||||
status?: ProviderWithKeyInfo;
|
||||
}
|
||||
|
||||
export async function fetchProviderSnapshot(): Promise<ProviderSnapshot> {
|
||||
const [accounts, statuses, vendors, defaultInfo] = 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'),
|
||||
]);
|
||||
|
||||
return {
|
||||
accounts,
|
||||
statuses,
|
||||
vendors,
|
||||
defaultAccountId: defaultInfo.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasConfiguredCredentials(
|
||||
account: ProviderAccount,
|
||||
status?: ProviderWithKeyInfo,
|
||||
): boolean {
|
||||
if (account.authMode === 'oauth_device' || account.authMode === 'oauth_browser' || account.authMode === 'local') {
|
||||
return true;
|
||||
}
|
||||
return status?.hasKey ?? false;
|
||||
}
|
||||
|
||||
export function pickPreferredAccount(
|
||||
accounts: ProviderAccount[],
|
||||
defaultAccountId: string | null,
|
||||
vendorId: ProviderType | string,
|
||||
statusMap: Map<string, ProviderWithKeyInfo>,
|
||||
): ProviderAccount | null {
|
||||
const sameVendor = accounts.filter((account) => account.vendorId === vendorId);
|
||||
if (sameVendor.length === 0) return null;
|
||||
|
||||
return (
|
||||
(defaultAccountId ? sameVendor.find((account) => account.id === defaultAccountId) : undefined)
|
||||
|| sameVendor.find((account) => hasConfiguredCredentials(account, statusMap.get(account.id)))
|
||||
|| sameVendor[0]
|
||||
);
|
||||
}
|
||||
|
||||
export function buildProviderAccountId(
|
||||
vendorId: ProviderType,
|
||||
existingAccountId: string | null,
|
||||
vendors: ProviderVendorInfo[],
|
||||
): string {
|
||||
if (existingAccountId) {
|
||||
return existingAccountId;
|
||||
}
|
||||
|
||||
const vendor = vendors.find((candidate) => candidate.id === vendorId);
|
||||
return vendor?.supportsMultipleAccounts ? `${vendorId}-${crypto.randomUUID()}` : vendorId;
|
||||
}
|
||||
|
||||
export function legacyProviderToAccount(provider: ProviderWithKeyInfo): ProviderAccount {
|
||||
return {
|
||||
id: provider.id,
|
||||
vendorId: provider.type,
|
||||
label: provider.name,
|
||||
authMode: provider.type === 'ollama' ? 'local' : 'api_key',
|
||||
baseUrl: provider.baseUrl,
|
||||
model: provider.model,
|
||||
fallbackModels: provider.fallbackModels,
|
||||
fallbackAccountIds: provider.fallbackProviderIds,
|
||||
enabled: provider.enabled,
|
||||
isDefault: false,
|
||||
createdAt: provider.createdAt,
|
||||
updatedAt: provider.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildProviderListItems(
|
||||
accounts: ProviderAccount[],
|
||||
statuses: ProviderWithKeyInfo[],
|
||||
vendors: ProviderVendorInfo[],
|
||||
defaultAccountId: string | null,
|
||||
): ProviderListItem[] {
|
||||
const vendorMap = new Map(vendors.map((vendor) => [vendor.id, vendor]));
|
||||
const statusMap = new Map(statuses.map((status) => [status.id, status]));
|
||||
|
||||
if (accounts.length > 0) {
|
||||
return accounts
|
||||
.map((account) => ({
|
||||
account,
|
||||
vendor: vendorMap.get(account.vendorId),
|
||||
status: statusMap.get(account.id),
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.account.id === defaultAccountId) return -1;
|
||||
if (right.account.id === defaultAccountId) return 1;
|
||||
return right.account.updatedAt.localeCompare(left.account.updatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
return statuses.map((status) => ({
|
||||
account: legacyProviderToAccount(status),
|
||||
vendor: vendorMap.get(status.type),
|
||||
status,
|
||||
}));
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* Provider Types & UI Metadata — single source of truth for the frontend.
|
||||
*
|
||||
* NOTE: When adding a new provider type, also update
|
||||
* electron/utils/provider-registry.ts (env vars, models, configs).
|
||||
* NOTE: Backend provider metadata is being refactored toward the new
|
||||
* account-based registry, but the renderer still keeps a local compatibility
|
||||
* layer so TypeScript project boundaries remain stable during the migration.
|
||||
*/
|
||||
|
||||
export const PROVIDER_TYPES = [
|
||||
@@ -21,6 +22,20 @@ export const PROVIDER_TYPES = [
|
||||
] as const;
|
||||
export type ProviderType = (typeof PROVIDER_TYPES)[number];
|
||||
|
||||
export const BUILTIN_PROVIDER_TYPES = [
|
||||
'anthropic',
|
||||
'openai',
|
||||
'google',
|
||||
'openrouter',
|
||||
'ark',
|
||||
'moonshot',
|
||||
'siliconflow',
|
||||
'minimax-portal',
|
||||
'minimax-portal-cn',
|
||||
'qwen-portal',
|
||||
'ollama',
|
||||
] as const;
|
||||
|
||||
export const OLLAMA_PLACEHOLDER_API_KEY = 'ollama-local';
|
||||
|
||||
export interface ProviderConfig {
|
||||
@@ -46,37 +61,80 @@ export interface ProviderTypeInfo {
|
||||
name: string;
|
||||
icon: string;
|
||||
placeholder: string;
|
||||
/** Model brand name for display (e.g. "Claude", "GPT") */
|
||||
model?: string;
|
||||
requiresApiKey: boolean;
|
||||
/** Pre-filled base URL (for proxy/compatible providers like SiliconFlow) */
|
||||
defaultBaseUrl?: string;
|
||||
/** Whether the user can edit the base URL in setup */
|
||||
showBaseUrl?: boolean;
|
||||
/** Whether to show a Model ID input field (for providers where user picks the model) */
|
||||
showModelId?: boolean;
|
||||
/** Whether the Model ID input should only be shown in developer mode */
|
||||
showModelIdInDevModeOnly?: boolean;
|
||||
/** Default / example model ID placeholder */
|
||||
modelIdPlaceholder?: string;
|
||||
/** Default model ID to pre-fill */
|
||||
defaultModelId?: string;
|
||||
/** Whether this provider uses OAuth device flow instead of an API key */
|
||||
isOAuth?: boolean;
|
||||
/** Whether this provider also accepts a direct API key (in addition to OAuth) */
|
||||
supportsApiKey?: boolean;
|
||||
/** URL where users can apply for the API Key */
|
||||
apiKeyUrl?: string;
|
||||
}
|
||||
|
||||
export type ProviderAuthMode =
|
||||
| 'api_key'
|
||||
| 'oauth_device'
|
||||
| 'oauth_browser'
|
||||
| 'local';
|
||||
|
||||
export type ProviderVendorCategory =
|
||||
| 'official'
|
||||
| 'compatible'
|
||||
| 'local'
|
||||
| 'custom';
|
||||
|
||||
export interface ProviderVendorInfo extends ProviderTypeInfo {
|
||||
category: ProviderVendorCategory;
|
||||
envVar?: string;
|
||||
supportedAuthModes: ProviderAuthMode[];
|
||||
defaultAuthMode: ProviderAuthMode;
|
||||
supportsMultipleAccounts: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderAccount {
|
||||
id: string;
|
||||
vendorId: ProviderType;
|
||||
label: string;
|
||||
authMode: ProviderAuthMode;
|
||||
baseUrl?: string;
|
||||
apiProtocol?: 'openai-completions' | 'openai-responses' | 'anthropic-messages';
|
||||
model?: string;
|
||||
fallbackModels?: string[];
|
||||
fallbackAccountIds?: string[];
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
metadata?: {
|
||||
region?: string;
|
||||
email?: string;
|
||||
resourceUrl?: string;
|
||||
customModels?: string[];
|
||||
};
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
import { providerIcons } from '@/assets/providers';
|
||||
|
||||
/** All supported provider types with UI metadata */
|
||||
export const PROVIDER_TYPE_INFO: ProviderTypeInfo[] = [
|
||||
{ id: 'anthropic', name: 'Anthropic', icon: '🤖', placeholder: 'sk-ant-api03-...', model: 'Claude', requiresApiKey: true },
|
||||
{ id: 'openai', name: 'OpenAI', icon: '💚', placeholder: 'sk-proj-...', model: 'GPT', requiresApiKey: true },
|
||||
{ id: 'google', name: 'Google', icon: '🔷', placeholder: 'AIza...', model: 'Gemini', requiresApiKey: true },
|
||||
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' },
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: '🔷',
|
||||
placeholder: 'AIza...',
|
||||
model: 'Gemini',
|
||||
requiresApiKey: true,
|
||||
isOAuth: true,
|
||||
supportsApiKey: true,
|
||||
defaultModelId: 'gemini-3.1-pro-preview',
|
||||
apiKeyUrl: 'https://aistudio.google.com/app/apikey',
|
||||
},
|
||||
{ id: 'openrouter', name: 'OpenRouter', icon: '🌐', placeholder: 'sk-or-v1-...', model: 'Multi-Model', requiresApiKey: true, showModelId: true, modelIdPlaceholder: 'anthropic/claude-opus-4.6', defaultModelId: 'anthropic/claude-opus-4.6' },
|
||||
{ id: 'ark', name: 'ByteDance Ark', icon: 'A', placeholder: 'your-ark-api-key', model: 'Doubao', requiresApiKey: true, defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', showBaseUrl: true, showModelId: true, modelIdPlaceholder: 'ep-20260228000000-xxxxx' },
|
||||
{ id: 'moonshot', name: 'Moonshot (CN)', icon: '🌙', placeholder: 'sk-...', model: 'Kimi', requiresApiKey: true, defaultBaseUrl: 'https://api.moonshot.cn/v1', defaultModelId: 'kimi-k2.5' },
|
||||
{ id: 'siliconflow', name: 'SiliconFlow (CN)', icon: '🌊', placeholder: 'sk-...', model: 'Multi-Model', requiresApiKey: true, defaultBaseUrl: 'https://api.siliconflow.cn/v1', showModelId: true, showModelIdInDevModeOnly: true, modelIdPlaceholder: 'deepseek-ai/DeepSeek-V3', defaultModelId: 'deepseek-ai/DeepSeek-V3' },
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Channels Page
|
||||
* Manage messaging channel connections with configuration UI
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Radio,
|
||||
@@ -33,6 +33,9 @@ import { useChannelsStore } from '@/stores/channels';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { StatusBadge, type Status } from '@/components/common/StatusBadge';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import {
|
||||
CHANNEL_ICONS,
|
||||
CHANNEL_NAMES,
|
||||
@@ -45,7 +48,6 @@ import {
|
||||
} from '@/types/channel';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
export function Channels() {
|
||||
const { t } = useTranslation('channels');
|
||||
@@ -55,26 +57,20 @@ export function Channels() {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [selectedChannelType, setSelectedChannelType] = useState<ChannelType | null>(null);
|
||||
const [configuredTypes, setConfiguredTypes] = useState<string[]>([]);
|
||||
const [channelSnapshot, setChannelSnapshot] = useState<Channel[]>([]);
|
||||
const [configuredTypesSnapshot, setConfiguredTypesSnapshot] = useState<string[]>([]);
|
||||
const [channelToDelete, setChannelToDelete] = useState<{ id: string } | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [showGatewayWarning, setShowGatewayWarning] = useState(false);
|
||||
const refreshDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const lastGatewayStateRef = useRef(gatewayStatus.state);
|
||||
|
||||
// Fetch channels on mount
|
||||
useEffect(() => {
|
||||
void fetchChannels({ probe: false });
|
||||
fetchChannels();
|
||||
}, [fetchChannels]);
|
||||
|
||||
// Fetch configured channel types from config file
|
||||
const fetchConfiguredTypes = useCallback(async () => {
|
||||
try {
|
||||
const result = await invokeIpc('channel:listConfigured') as {
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
channels?: string[];
|
||||
};
|
||||
}>('/api/channels/configured');
|
||||
if (result.success && result.channels) {
|
||||
setConfiguredTypes(result.channels);
|
||||
}
|
||||
@@ -84,86 +80,29 @@ export function Channels() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
void fetchConfiguredTypes();
|
||||
}, [fetchConfiguredTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.electron.ipcRenderer.on('gateway:channel-status', () => {
|
||||
if (refreshDebounceRef.current) {
|
||||
clearTimeout(refreshDebounceRef.current);
|
||||
}
|
||||
refreshDebounceRef.current = setTimeout(() => {
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, 300);
|
||||
const unsubscribe = subscribeHostEvent('gateway:channel-status', () => {
|
||||
fetchChannels();
|
||||
fetchConfiguredTypes();
|
||||
});
|
||||
return () => {
|
||||
if (refreshDebounceRef.current) {
|
||||
clearTimeout(refreshDebounceRef.current);
|
||||
refreshDebounceRef.current = null;
|
||||
}
|
||||
if (typeof unsubscribe === 'function') {
|
||||
unsubscribe();
|
||||
}
|
||||
};
|
||||
}, [fetchChannels, fetchConfiguredTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (gatewayStatus.state === 'running') {
|
||||
setChannelSnapshot(channels);
|
||||
setConfiguredTypesSnapshot(configuredTypes);
|
||||
}
|
||||
}, [gatewayStatus.state, channels, configuredTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
const previousState = lastGatewayStateRef.current;
|
||||
const currentState = gatewayStatus.state;
|
||||
const justReconnected =
|
||||
currentState === 'running' &&
|
||||
previousState !== 'running';
|
||||
lastGatewayStateRef.current = currentState;
|
||||
|
||||
if (!justReconnected) return;
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, [gatewayStatus.state, fetchChannels, fetchConfiguredTypes]);
|
||||
|
||||
// Delay warning to avoid flicker during expected short reload/restart windows.
|
||||
useEffect(() => {
|
||||
const shouldWarn = gatewayStatus.state === 'stopped' || gatewayStatus.state === 'error';
|
||||
const timer = setTimeout(() => {
|
||||
setShowGatewayWarning(shouldWarn);
|
||||
}, shouldWarn ? 1800 : 0);
|
||||
return () => clearTimeout(timer);
|
||||
}, [gatewayStatus.state]);
|
||||
|
||||
// Get channel types to display
|
||||
const displayedChannelTypes = getPrimaryChannels();
|
||||
const isGatewayTransitioning =
|
||||
gatewayStatus.state === 'starting' || gatewayStatus.state === 'reconnecting';
|
||||
const channelsForView =
|
||||
isGatewayTransitioning && channels.length === 0 ? channelSnapshot : channels;
|
||||
const configuredTypesForView =
|
||||
isGatewayTransitioning && configuredTypes.length === 0 ? configuredTypesSnapshot : configuredTypes;
|
||||
|
||||
// Single source of truth for configured status across cards, stats and badges.
|
||||
const configuredTypeSet = useMemo(() => {
|
||||
const set = new Set<string>(configuredTypesForView);
|
||||
if (set.size === 0 && channelsForView.length > 0) {
|
||||
channelsForView.forEach((channel) => set.add(channel.type));
|
||||
}
|
||||
return set;
|
||||
}, [configuredTypesForView, channelsForView]);
|
||||
|
||||
const configuredChannels = useMemo(
|
||||
() => channelsForView.filter((channel) => configuredTypeSet.has(channel.type)),
|
||||
[channelsForView, configuredTypeSet]
|
||||
);
|
||||
|
||||
// Connected/disconnected channel counts
|
||||
const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length;
|
||||
const connectedCount = channels.filter((c) => c.status === 'connected').length;
|
||||
|
||||
if (loading && channels.length === 0) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
@@ -182,20 +121,8 @@ export function Channels() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setRefreshing(true);
|
||||
await fetchChannels({ probe: true, silent: true });
|
||||
await fetchConfiguredTypes();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}}
|
||||
disabled={refreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2${refreshing ? ' animate-spin' : ''}`} />
|
||||
<Button variant="outline" onClick={fetchChannels}>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
@@ -214,7 +141,7 @@ export function Channels() {
|
||||
<Radio className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{configuredChannels.length}</p>
|
||||
<p className="text-2xl font-bold">{channels.length}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -240,7 +167,7 @@ export function Channels() {
|
||||
<PowerOff className="h-6 w-6 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{configuredChannels.length - connectedCount}</p>
|
||||
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -249,7 +176,7 @@ export function Channels() {
|
||||
</div>
|
||||
|
||||
{/* Gateway Warning */}
|
||||
{showGatewayWarning && (
|
||||
{gatewayStatus.state !== 'running' && (
|
||||
<Card className="border-yellow-500 bg-yellow-50 dark:bg-yellow-900/10">
|
||||
<CardContent className="py-4 flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||
@@ -270,7 +197,7 @@ export function Channels() {
|
||||
)}
|
||||
|
||||
{/* Configured Channels */}
|
||||
{configuredChannels.length > 0 && (
|
||||
{channels.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('configured')}</CardTitle>
|
||||
@@ -278,7 +205,7 @@ export function Channels() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{configuredChannels.map((channel) => (
|
||||
{channels.map((channel) => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
@@ -306,7 +233,7 @@ export function Channels() {
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{displayedChannelTypes.map((type) => {
|
||||
const meta = CHANNEL_META[type];
|
||||
const isConfigured = configuredTypeSet.has(type);
|
||||
const isConfigured = configuredTypes.includes(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
@@ -319,7 +246,7 @@ export function Channels() {
|
||||
<span className="text-3xl">{meta.icon}</span>
|
||||
<p className="font-medium mt-2">{meta.name}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{t(meta.description)}
|
||||
{meta.description}
|
||||
</p>
|
||||
{isConfigured && (
|
||||
<Badge className="absolute top-2 right-2 text-xs bg-green-600 hover:bg-green-600">
|
||||
@@ -348,12 +275,8 @@ export function Channels() {
|
||||
setSelectedChannelType(null);
|
||||
}}
|
||||
onChannelAdded={() => {
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
setTimeout(() => {
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, 2200);
|
||||
fetchChannels();
|
||||
fetchConfiguredTypes();
|
||||
setShowAddDialog(false);
|
||||
setSelectedChannelType(null);
|
||||
}}
|
||||
@@ -362,16 +285,14 @@ export function Channels() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!channelToDelete}
|
||||
title={t('common:actions.confirm', 'Confirm')}
|
||||
title={t('common.confirm', 'Confirm')}
|
||||
message={t('deleteConfirm')}
|
||||
confirmLabel={t('common:actions.delete', 'Delete')}
|
||||
cancelLabel={t('common:actions.cancel', 'Cancel')}
|
||||
confirmLabel={t('common.delete', 'Delete')}
|
||||
cancelLabel={t('common.cancel', 'Cancel')}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (channelToDelete) {
|
||||
await deleteChannel(channelToDelete.id);
|
||||
await fetchConfiguredTypes();
|
||||
await fetchChannels({ probe: false, silent: true });
|
||||
setChannelToDelete(null);
|
||||
}
|
||||
}}
|
||||
@@ -437,6 +358,7 @@ interface AddChannelDialogProps {
|
||||
|
||||
function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }: AddChannelDialogProps) {
|
||||
const { t } = useTranslation('channels');
|
||||
const { addChannel } = useChannelsStore();
|
||||
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||||
const [channelName, setChannelName] = useState('');
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
@@ -463,7 +385,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setChannelName('');
|
||||
setIsExistingConfig(false);
|
||||
// Ensure we clean up any pending QR session if switching away
|
||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -520,11 +442,10 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
toast.success(t('toast.whatsappConnected'));
|
||||
const accountId = data?.accountId || channelName.trim() || 'default';
|
||||
try {
|
||||
const saveResult = await invokeIpc(
|
||||
'channel:saveConfig',
|
||||
'whatsapp',
|
||||
{ enabled: true }
|
||||
) as { success?: boolean; error?: string };
|
||||
const saveResult = await hostApiFetch<{ success?: boolean; error?: string }>('/api/channels/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channelType: 'whatsapp', config: { enabled: true } }),
|
||||
});
|
||||
if (!saveResult?.success) {
|
||||
console.error('Failed to save WhatsApp config:', saveResult?.error);
|
||||
} else {
|
||||
@@ -533,9 +454,15 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
} catch (error) {
|
||||
console.error('Failed to save WhatsApp config:', error);
|
||||
}
|
||||
// channel:saveConfig triggers main-process reload/restart handling.
|
||||
// UI state refresh is handled by parent onChannelAdded().
|
||||
onChannelAdded();
|
||||
// Register the channel locally so it shows up immediately
|
||||
addChannel({
|
||||
type: 'whatsapp',
|
||||
name: channelName || 'WhatsApp',
|
||||
}).then(() => {
|
||||
// Restart gateway to pick up the new session
|
||||
useGatewayStore.getState().restart().catch(console.error);
|
||||
onChannelAdded();
|
||||
});
|
||||
};
|
||||
|
||||
const onError = (...args: unknown[]) => {
|
||||
@@ -546,18 +473,18 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setConnecting(false);
|
||||
};
|
||||
|
||||
const removeQrListener = window.electron.ipcRenderer.on('channel:whatsapp-qr', onQr);
|
||||
const removeSuccessListener = window.electron.ipcRenderer.on('channel:whatsapp-success', onSuccess);
|
||||
const removeErrorListener = window.electron.ipcRenderer.on('channel:whatsapp-error', onError);
|
||||
const removeQrListener = subscribeHostEvent('channel:whatsapp-qr', onQr);
|
||||
const removeSuccessListener = subscribeHostEvent('channel:whatsapp-success', onSuccess);
|
||||
const removeErrorListener = subscribeHostEvent('channel:whatsapp-error', onError);
|
||||
|
||||
return () => {
|
||||
if (typeof removeQrListener === 'function') removeQrListener();
|
||||
if (typeof removeSuccessListener === 'function') removeSuccessListener();
|
||||
if (typeof removeErrorListener === 'function') removeErrorListener();
|
||||
// Cancel when unmounting or switching types
|
||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||
hostApiFetch('/api/channels/whatsapp/cancel', { method: 'POST' }).catch(() => { });
|
||||
};
|
||||
}, [selectedType, channelName, onChannelAdded, t]);
|
||||
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedType) return;
|
||||
@@ -566,17 +493,16 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await invokeIpc(
|
||||
'channel:validateCredentials',
|
||||
selectedType,
|
||||
configValues
|
||||
) as {
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
valid?: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
details?: Record<string, string>;
|
||||
};
|
||||
}>('/api/channels/credentials/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channelType: selectedType, config: configValues }),
|
||||
});
|
||||
|
||||
const warnings = result.warnings || [];
|
||||
if (result.valid && result.details) {
|
||||
@@ -613,24 +539,26 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
// For QR-based channels, request QR code
|
||||
if (meta.connectionType === 'qr') {
|
||||
const accountId = channelName.trim() || 'default';
|
||||
await invokeIpc('channel:requestWhatsAppQr', accountId);
|
||||
await hostApiFetch('/api/channels/whatsapp/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ accountId }),
|
||||
});
|
||||
// The QR code will be set via event listener
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Validate credentials against the actual service API
|
||||
if (meta.connectionType === 'token') {
|
||||
const validationResponse = await invokeIpc(
|
||||
'channel:validateCredentials',
|
||||
selectedType,
|
||||
configValues
|
||||
) as {
|
||||
const validationResponse = await hostApiFetch<{
|
||||
success: boolean;
|
||||
valid?: boolean;
|
||||
errors?: string[];
|
||||
warnings?: string[];
|
||||
details?: Record<string, string>;
|
||||
};
|
||||
}>('/api/channels/credentials/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channelType: selectedType, config: configValues }),
|
||||
});
|
||||
|
||||
if (!validationResponse.valid) {
|
||||
setValidationResult({
|
||||
@@ -667,12 +595,15 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
|
||||
// Step 2: Save channel configuration via IPC
|
||||
const config: Record<string, unknown> = { ...configValues };
|
||||
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
|
||||
const saveResult = await hostApiFetch<{
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
pluginInstalled?: boolean;
|
||||
};
|
||||
}>('/api/channels/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channelType: selectedType, config }),
|
||||
});
|
||||
if (!saveResult?.success) {
|
||||
throw new Error(saveResult?.error || 'Failed to save channel config');
|
||||
}
|
||||
@@ -680,13 +611,20 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
toast.warning(saveResult.warning);
|
||||
}
|
||||
|
||||
// Step 3: Do not call channels.add from renderer; this races with
|
||||
// gateway reload/restart windows and can create stale local entries.
|
||||
// Step 3: Add a local channel entry for the UI
|
||||
await addChannel({
|
||||
type: selectedType,
|
||||
name: channelName || CHANNEL_NAMES[selectedType],
|
||||
token: configValues[meta.configFields[0]?.key] || undefined,
|
||||
});
|
||||
|
||||
toast.success(t('toast.channelSaved', { name: meta.name }));
|
||||
|
||||
// Gateway reload/restart is handled in the main-process save handler.
|
||||
// Renderer should only persist config and refresh local UI state.
|
||||
// Gateway restart is now handled server-side via debouncedRestart()
|
||||
// inside the channel:saveConfig IPC handler, so we don't need to
|
||||
// trigger it explicitly here. This avoids cascading restarts when
|
||||
// multiple config changes happen in quick succession (e.g. during
|
||||
// the setup wizard).
|
||||
toast.success(t('toast.channelConnecting', { name: meta.name }));
|
||||
|
||||
// Brief delay so user can see the success state before dialog closes
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { Send, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
@@ -126,17 +127,17 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
// Stage all files via IPC
|
||||
console.log('[pickFiles] Staging files:', result.filePaths);
|
||||
const staged = await invokeIpc(
|
||||
'file:stage',
|
||||
result.filePaths,
|
||||
) as Array<{
|
||||
const staged = await hostApiFetch<Array<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
stagedPath: string;
|
||||
preview: string | null;
|
||||
}>;
|
||||
}>>('/api/files/stage-paths', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filePaths: result.filePaths }),
|
||||
});
|
||||
console.log('[pickFiles] Stage result:', staged?.map(s => ({ id: s?.id, fileName: s?.fileName, mimeType: s?.mimeType, fileSize: s?.fileSize, stagedPath: s?.stagedPath, hasPreview: !!s?.preview })));
|
||||
|
||||
// Update each placeholder with real data
|
||||
@@ -193,18 +194,21 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
console.log(`[stageBuffer] Reading file: ${file.name} (${file.type}, ${file.size} bytes)`);
|
||||
const base64 = await readFileAsBase64(file);
|
||||
console.log(`[stageBuffer] Base64 length: ${base64?.length ?? 'null'}`);
|
||||
const staged = await invokeIpc('file:stageBuffer', {
|
||||
base64,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
}) as {
|
||||
const staged = await hostApiFetch<{
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
stagedPath: string;
|
||||
preview: string | null;
|
||||
};
|
||||
}>('/api/files/stage-buffer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
base64,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
}),
|
||||
});
|
||||
console.log(`[stageBuffer] Staged: id=${staged?.id}, path=${staged?.stagedPath}, size=${staged?.fileSize}`);
|
||||
setAttachments(prev => prev.map(a =>
|
||||
a.id === tempId ? { ...staged, status: 'ready' as const } : a,
|
||||
|
||||
@@ -26,7 +26,7 @@ import { useSkillsStore } from '@/stores/skills';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { StatusBadge } from '@/components/common/StatusBadge';
|
||||
import { FeedbackState } from '@/components/common/FeedbackState';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -67,7 +67,7 @@ export function Dashboard() {
|
||||
if (isGatewayRunning) {
|
||||
fetchChannels();
|
||||
fetchSkills();
|
||||
invokeIpc<UsageHistoryEntry[]>('usage:recentTokenHistory')
|
||||
hostApiFetch<UsageHistoryEntry[]>('/api/usage/recent-token-history')
|
||||
.then((entries) => {
|
||||
setUsageHistory(Array.isArray(entries) ? entries : []);
|
||||
setUsagePage(1);
|
||||
@@ -111,11 +111,11 @@ export function Dashboard() {
|
||||
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await invokeIpc<{
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}>('gateway:getControlUiUrl');
|
||||
}>('/api/gateway/control-ui');
|
||||
if (result.success && result.url) {
|
||||
trackUiEvent('dashboard.quick_action', { action: 'dev_console' });
|
||||
window.electron.openExternal(result.url);
|
||||
|
||||
@@ -34,6 +34,7 @@ import { invokeIpc, toUserMessage } from '@/lib/api-client';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
type ControlUiInfo = {
|
||||
url: string;
|
||||
token: string;
|
||||
@@ -103,8 +104,8 @@ export function Settings() {
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
try {
|
||||
const logs = await invokeIpc<string>('log:readFile', 100);
|
||||
setLogContent(logs);
|
||||
const logs = await hostApiFetch<{ content: string }>('/api/logs?tailLines=100');
|
||||
setLogContent(logs.content);
|
||||
setShowLogs(true);
|
||||
} catch {
|
||||
setLogContent('(Failed to load logs)');
|
||||
@@ -114,7 +115,7 @@ export function Settings() {
|
||||
|
||||
const handleOpenLogDir = async () => {
|
||||
try {
|
||||
const logDir = await invokeIpc<string>('log:getDir');
|
||||
const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir');
|
||||
if (logDir) {
|
||||
await invokeIpc('shell:showItemInFolder', logDir);
|
||||
}
|
||||
@@ -126,13 +127,13 @@ export function Settings() {
|
||||
// Open developer console
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await invokeIpc<{
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
token?: string;
|
||||
port?: number;
|
||||
error?: string;
|
||||
}>('gateway:getControlUiUrl');
|
||||
}>('/api/gateway/control-ui');
|
||||
if (result.success && result.url && result.token && typeof result.port === 'number') {
|
||||
setControlUiInfo({ url: result.url, token: result.token, port: result.port });
|
||||
trackUiEvent('settings.open_dev_console');
|
||||
@@ -147,12 +148,12 @@ export function Settings() {
|
||||
|
||||
const refreshControlUiInfo = async () => {
|
||||
try {
|
||||
const result = await invokeIpc<{
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
token?: string;
|
||||
port?: number;
|
||||
}>('gateway:getControlUiUrl');
|
||||
}>('/api/gateway/control-ui');
|
||||
if (result.success && result.url && result.token && typeof result.port === 'number') {
|
||||
setControlUiInfo({ url: result.url, token: result.token, port: result.port });
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { subscribeHostEvent } from '@/lib/host-events';
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -91,6 +93,8 @@ const defaultSkills: DefaultSkill[] = [
|
||||
|
||||
import {
|
||||
SETUP_PROVIDERS,
|
||||
type ProviderAccount,
|
||||
type ProviderType,
|
||||
type ProviderTypeInfo,
|
||||
getProviderIconUrl,
|
||||
resolveProviderApiKeyForSave,
|
||||
@@ -98,6 +102,12 @@ import {
|
||||
shouldInvertInDark,
|
||||
shouldShowProviderModelId,
|
||||
} from '@/lib/providers';
|
||||
import {
|
||||
buildProviderAccountId,
|
||||
fetchProviderSnapshot,
|
||||
hasConfiguredCredentials,
|
||||
pickPreferredAccount,
|
||||
} from '@/lib/provider-accounts';
|
||||
import clawxIcon from '@/assets/logo.svg';
|
||||
|
||||
// Use the shared provider registry for setup providers
|
||||
@@ -147,18 +157,6 @@ export function Setup() {
|
||||
}
|
||||
}, [safeStepIndex, providerConfigured, runtimeChecksPassed]);
|
||||
|
||||
// Keep setup flow linear: advance to provider step automatically
|
||||
// once runtime checks become healthy.
|
||||
useEffect(() => {
|
||||
if (safeStepIndex !== STEP.RUNTIME || !runtimeChecksPassed) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
setCurrentStep(STEP.PROVIDER);
|
||||
}, 600);
|
||||
return () => clearTimeout(timer);
|
||||
}, [runtimeChecksPassed, safeStepIndex]);
|
||||
|
||||
const handleNext = async () => {
|
||||
if (isLastStep) {
|
||||
// Complete setup
|
||||
@@ -539,8 +537,8 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
try {
|
||||
const logs = await invokeIpc('log:readFile', 100) as string;
|
||||
setLogContent(logs);
|
||||
const logs = await hostApiFetch<{ content: string }>('/api/logs?tailLines=100');
|
||||
setLogContent(logs.content);
|
||||
setShowLogs(true);
|
||||
} catch {
|
||||
setLogContent('(Failed to load logs)');
|
||||
@@ -550,7 +548,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
|
||||
const handleOpenLogDir = async () => {
|
||||
try {
|
||||
const logDir = await invokeIpc('log:getDir') as string;
|
||||
const { dir: logDir } = await hostApiFetch<{ dir: string | null }>('/api/logs/dir');
|
||||
if (logDir) {
|
||||
await invokeIpc('shell:showItemInFolder', logDir);
|
||||
}
|
||||
@@ -709,7 +707,7 @@ function ProviderContent({
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [keyValid, setKeyValid] = useState<boolean | null>(null);
|
||||
const [selectedProviderConfigId, setSelectedProviderConfigId] = useState<string | null>(null);
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [modelId, setModelId] = useState('');
|
||||
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
||||
@@ -725,6 +723,7 @@ function ProviderContent({
|
||||
expiresIn: number;
|
||||
} | null>(null);
|
||||
const [oauthError, setOauthError] = useState<string | null>(null);
|
||||
const pendingOAuthRef = useRef<{ accountId: string; label: string } | null>(null);
|
||||
|
||||
// Manage OAuth events
|
||||
useEffect(() => {
|
||||
@@ -733,19 +732,27 @@ function ProviderContent({
|
||||
setOauthError(null);
|
||||
};
|
||||
|
||||
const handleSuccess = async () => {
|
||||
const handleSuccess = async (data: unknown) => {
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setKeyValid(true);
|
||||
|
||||
if (selectedProvider) {
|
||||
const payload = (data as { accountId?: string } | undefined) || undefined;
|
||||
const accountId = payload?.accountId || pendingOAuthRef.current?.accountId;
|
||||
|
||||
if (accountId) {
|
||||
try {
|
||||
await invokeIpc('provider:setDefault', selectedProvider);
|
||||
await hostApiFetch('/api/provider-accounts/default', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ accountId }),
|
||||
});
|
||||
setSelectedAccountId(accountId);
|
||||
} catch (error) {
|
||||
console.error('Failed to set default provider:', error);
|
||||
console.error('Failed to set default provider account:', error);
|
||||
}
|
||||
}
|
||||
|
||||
pendingOAuthRef.current = null;
|
||||
onConfiguredChange(true);
|
||||
toast.success(t('provider.valid'));
|
||||
};
|
||||
@@ -753,34 +760,31 @@ function ProviderContent({
|
||||
const handleError = (data: unknown) => {
|
||||
setOauthError((data as { message: string }).message);
|
||||
setOauthData(null);
|
||||
pendingOAuthRef.current = null;
|
||||
};
|
||||
|
||||
window.electron.ipcRenderer.on('oauth:code', handleCode);
|
||||
window.electron.ipcRenderer.on('oauth:success', handleSuccess);
|
||||
window.electron.ipcRenderer.on('oauth:error', handleError);
|
||||
const offCode = subscribeHostEvent('oauth:code', handleCode);
|
||||
const offSuccess = subscribeHostEvent('oauth:success', handleSuccess);
|
||||
const offError = subscribeHostEvent('oauth:error', handleError);
|
||||
|
||||
return () => {
|
||||
// Clean up manually if the API provides removeListener, though `on` in preloads might not return an unsub.
|
||||
// Easiest is to just let it be, or if they have `off`:
|
||||
if (typeof window.electron.ipcRenderer.off === 'function') {
|
||||
window.electron.ipcRenderer.off('oauth:code', handleCode);
|
||||
window.electron.ipcRenderer.off('oauth:success', handleSuccess);
|
||||
window.electron.ipcRenderer.off('oauth:error', handleError);
|
||||
}
|
||||
offCode();
|
||||
offSuccess();
|
||||
offError();
|
||||
};
|
||||
}, [onConfiguredChange, t, selectedProvider]);
|
||||
}, [onConfiguredChange, t]);
|
||||
|
||||
const handleStartOAuth = async () => {
|
||||
if (!selectedProvider) return;
|
||||
|
||||
try {
|
||||
const list = await invokeIpc('provider:list') as Array<{ type: string }>;
|
||||
const existingTypes = new Set(list.map(l => l.type));
|
||||
if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
|
||||
const snapshot = await fetchProviderSnapshot();
|
||||
const existingVendorIds = new Set(snapshot.accounts.map((account) => account.vendorId));
|
||||
if (selectedProvider === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
|
||||
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
|
||||
return;
|
||||
}
|
||||
if (selectedProvider === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) {
|
||||
if (selectedProvider === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
|
||||
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
|
||||
return;
|
||||
}
|
||||
@@ -793,10 +797,22 @@ function ProviderContent({
|
||||
setOauthError(null);
|
||||
|
||||
try {
|
||||
await invokeIpc('provider:requestOAuth', selectedProvider);
|
||||
const snapshot = await fetchProviderSnapshot();
|
||||
const accountId = buildProviderAccountId(
|
||||
selectedProvider as ProviderType,
|
||||
selectedAccountId,
|
||||
snapshot.vendors,
|
||||
);
|
||||
const label = selectedProviderData?.name || selectedProvider;
|
||||
pendingOAuthRef.current = { accountId, label };
|
||||
await hostApiFetch('/api/providers/oauth/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: selectedProvider, accountId, label }),
|
||||
});
|
||||
} catch (e) {
|
||||
setOauthError(String(e));
|
||||
setOauthFlowing(false);
|
||||
pendingOAuthRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -804,7 +820,8 @@ function ProviderContent({
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setOauthError(null);
|
||||
await invokeIpc('provider:cancelOAuth');
|
||||
pendingOAuthRef.current = null;
|
||||
await hostApiFetch('/api/providers/oauth/cancel', { method: 'POST' });
|
||||
};
|
||||
|
||||
// On mount, try to restore previously configured provider
|
||||
@@ -812,26 +829,28 @@ function ProviderContent({
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await invokeIpc('provider:getDefault') as string | null;
|
||||
const snapshot = await fetchProviderSnapshot();
|
||||
const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status]));
|
||||
const setupProviderTypes = new Set<string>(providers.map((p) => p.id));
|
||||
const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type));
|
||||
const setupCandidates = snapshot.accounts.filter((account) => setupProviderTypes.has(account.vendorId));
|
||||
const preferred =
|
||||
(defaultId && setupCandidates.find((p) => p.id === defaultId))
|
||||
|| setupCandidates.find((p) => p.hasKey)
|
||||
(snapshot.defaultAccountId
|
||||
&& setupCandidates.find((account) => account.id === snapshot.defaultAccountId))
|
||||
|| setupCandidates.find((account) => hasConfiguredCredentials(account, statusMap.get(account.id)))
|
||||
|| setupCandidates[0];
|
||||
if (preferred && !cancelled) {
|
||||
onSelectProvider(preferred.type);
|
||||
setSelectedProviderConfigId(preferred.id);
|
||||
const typeInfo = providers.find((p) => p.id === preferred.type);
|
||||
onSelectProvider(preferred.vendorId);
|
||||
setSelectedAccountId(preferred.id);
|
||||
const typeInfo = providers.find((p) => p.id === preferred.vendorId);
|
||||
const requiresKey = typeInfo?.requiresApiKey ?? false;
|
||||
onConfiguredChange(!requiresKey || preferred.hasKey);
|
||||
const storedKey = await invokeIpc('provider:getApiKey', preferred.id) as string | null;
|
||||
if (storedKey) {
|
||||
onApiKeyChange(storedKey);
|
||||
}
|
||||
onConfiguredChange(!requiresKey || hasConfiguredCredentials(preferred, statusMap.get(preferred.id)));
|
||||
const storedKey = (await hostApiFetch<{ apiKey: string | null }>(
|
||||
`/api/providers/${encodeURIComponent(preferred.id)}/api-key`,
|
||||
)).apiKey;
|
||||
onApiKeyChange(storedKey || '');
|
||||
} else if (!cancelled) {
|
||||
onConfiguredChange(false);
|
||||
onApiKeyChange('');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
@@ -848,25 +867,25 @@ function ProviderContent({
|
||||
(async () => {
|
||||
if (!selectedProvider) return;
|
||||
try {
|
||||
const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await invokeIpc('provider:getDefault') as string | null;
|
||||
const sameType = list.filter((p) => p.type === selectedProvider);
|
||||
const preferredInstance =
|
||||
(defaultId && sameType.find((p) => p.id === defaultId))
|
||||
|| sameType.find((p) => p.hasKey)
|
||||
|| sameType[0];
|
||||
const providerIdForLoad = preferredInstance?.id || selectedProvider;
|
||||
setSelectedProviderConfigId(providerIdForLoad);
|
||||
const snapshot = await fetchProviderSnapshot();
|
||||
const statusMap = new Map(snapshot.statuses.map((status) => [status.id, status]));
|
||||
const preferredAccount = pickPreferredAccount(
|
||||
snapshot.accounts,
|
||||
snapshot.defaultAccountId,
|
||||
selectedProvider,
|
||||
statusMap,
|
||||
);
|
||||
const accountIdForLoad = preferredAccount?.id || selectedProvider;
|
||||
setSelectedAccountId(preferredAccount?.id || null);
|
||||
|
||||
const savedProvider = await invokeIpc(
|
||||
'provider:get',
|
||||
providerIdForLoad
|
||||
) as { baseUrl?: string; model?: string } | null;
|
||||
const storedKey = await invokeIpc('provider:getApiKey', providerIdForLoad) as string | null;
|
||||
const savedProvider = await hostApiFetch<{ baseUrl?: string; model?: string } | null>(
|
||||
`/api/providers/${encodeURIComponent(accountIdForLoad)}`,
|
||||
);
|
||||
const storedKey = (await hostApiFetch<{ apiKey: string | null }>(
|
||||
`/api/providers/${encodeURIComponent(accountIdForLoad)}/api-key`,
|
||||
)).apiKey;
|
||||
if (!cancelled) {
|
||||
if (storedKey) {
|
||||
onApiKeyChange(storedKey);
|
||||
}
|
||||
onApiKeyChange(storedKey || '');
|
||||
|
||||
const info = providers.find((p) => p.id === selectedProvider);
|
||||
setBaseUrl(savedProvider?.baseUrl || info?.defaultBaseUrl || '');
|
||||
@@ -919,13 +938,13 @@ function ProviderContent({
|
||||
if (!selectedProvider) return;
|
||||
|
||||
try {
|
||||
const list = await invokeIpc('provider:list') as Array<{ type: string }>;
|
||||
const existingTypes = new Set(list.map(l => l.type));
|
||||
if (selectedProvider === 'minimax-portal' && existingTypes.has('minimax-portal-cn')) {
|
||||
const snapshot = await fetchProviderSnapshot();
|
||||
const existingVendorIds = new Set(snapshot.accounts.map((account) => account.vendorId));
|
||||
if (selectedProvider === 'minimax-portal' && existingVendorIds.has('minimax-portal-cn')) {
|
||||
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
|
||||
return;
|
||||
}
|
||||
if (selectedProvider === 'minimax-portal-cn' && existingTypes.has('minimax-portal')) {
|
||||
if (selectedProvider === 'minimax-portal-cn' && existingVendorIds.has('minimax-portal')) {
|
||||
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
|
||||
return;
|
||||
}
|
||||
@@ -942,7 +961,7 @@ function ProviderContent({
|
||||
if (isApiKeyRequired && apiKey) {
|
||||
const result = await invokeIpc(
|
||||
'provider:validateKey',
|
||||
selectedProviderConfigId || selectedProvider,
|
||||
selectedAccountId || selectedProvider,
|
||||
apiKey,
|
||||
{ baseUrl: baseUrl.trim() || undefined }
|
||||
) as { valid: boolean; error?: string };
|
||||
@@ -963,46 +982,70 @@ function ProviderContent({
|
||||
modelId,
|
||||
devModeUnlocked
|
||||
);
|
||||
|
||||
const providerIdForSave =
|
||||
selectedProvider === 'custom'
|
||||
? (selectedProviderConfigId?.startsWith('custom-')
|
||||
? selectedProviderConfigId
|
||||
: `custom-${crypto.randomUUID()}`)
|
||||
: selectedProvider;
|
||||
const snapshot = await fetchProviderSnapshot();
|
||||
const accountIdForSave = buildProviderAccountId(
|
||||
selectedProvider as ProviderType,
|
||||
selectedAccountId,
|
||||
snapshot.vendors,
|
||||
);
|
||||
|
||||
const effectiveApiKey = resolveProviderApiKeyForSave(selectedProvider, apiKey);
|
||||
const accountPayload: ProviderAccount = {
|
||||
id: accountIdForSave,
|
||||
vendorId: selectedProvider as ProviderType,
|
||||
label: selectedProvider === 'custom'
|
||||
? t('settings:aiProviders.custom')
|
||||
: (selectedProviderData?.name || selectedProvider),
|
||||
authMode: selectedProvider === 'ollama'
|
||||
? 'local'
|
||||
: 'api_key',
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
model: effectiveModelId,
|
||||
enabled: true,
|
||||
isDefault: false,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Save provider config + API key, then set as default
|
||||
const saveResult = await invokeIpc(
|
||||
'provider:save',
|
||||
{
|
||||
id: providerIdForSave,
|
||||
name: selectedProvider === 'custom' ? t('settings:aiProviders.custom') : (selectedProviderData?.name || selectedProvider),
|
||||
type: selectedProvider,
|
||||
baseUrl: baseUrl.trim() || undefined,
|
||||
model: effectiveModelId,
|
||||
enabled: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
effectiveApiKey
|
||||
) as { success: boolean; error?: string };
|
||||
const saveResult = selectedAccountId
|
||||
? await hostApiFetch<{ success: boolean; error?: string }>(
|
||||
`/api/provider-accounts/${encodeURIComponent(accountIdForSave)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
updates: {
|
||||
label: accountPayload.label,
|
||||
authMode: accountPayload.authMode,
|
||||
baseUrl: accountPayload.baseUrl,
|
||||
model: accountPayload.model,
|
||||
enabled: accountPayload.enabled,
|
||||
},
|
||||
apiKey: effectiveApiKey,
|
||||
}),
|
||||
},
|
||||
)
|
||||
: await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ account: accountPayload, apiKey: effectiveApiKey }),
|
||||
});
|
||||
|
||||
if (!saveResult.success) {
|
||||
throw new Error(saveResult.error || 'Failed to save provider config');
|
||||
}
|
||||
|
||||
const defaultResult = await invokeIpc(
|
||||
'provider:setDefault',
|
||||
providerIdForSave
|
||||
) as { success: boolean; error?: string };
|
||||
const defaultResult = await hostApiFetch<{ success: boolean; error?: string }>(
|
||||
'/api/provider-accounts/default',
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ accountId: accountIdForSave }),
|
||||
},
|
||||
);
|
||||
|
||||
if (!defaultResult.success) {
|
||||
throw new Error(defaultResult.error || 'Failed to set default provider');
|
||||
}
|
||||
|
||||
setSelectedProviderConfigId(providerIdForSave);
|
||||
setSelectedAccountId(accountIdForSave);
|
||||
onConfiguredChange(true);
|
||||
toast.success(t('provider.valid'));
|
||||
} catch (error) {
|
||||
@@ -1024,7 +1067,7 @@ function ProviderContent({
|
||||
|
||||
const handleSelectProvider = (providerId: string) => {
|
||||
onSelectProvider(providerId);
|
||||
setSelectedProviderConfigId(null);
|
||||
setSelectedAccountId(null);
|
||||
onConfiguredChange(false);
|
||||
onApiKeyChange('');
|
||||
setKeyValid(null);
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useGatewayStore } from '@/stores/gateway';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { toast } from 'sonner';
|
||||
import type { Skill, MarketplaceSkill } from '@/types/skill';
|
||||
@@ -93,7 +94,10 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
|
||||
const handleOpenEditor = async () => {
|
||||
if (skill.slug) {
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:openSkillReadme', skill.slug);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/open-readme', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ slug: skill.slug }),
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success(t('toast.openedEditor'));
|
||||
} else {
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* Manages messaging channel state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useGatewayStore } from './gateway';
|
||||
import type { Channel, ChannelType } from '../types/channel';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
interface AddChannelParams {
|
||||
type: ChannelType;
|
||||
@@ -18,7 +19,7 @@ interface ChannelsState {
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchChannels: (options?: { probe?: boolean; silent?: boolean }) => Promise<void>;
|
||||
fetchChannels: () => Promise<void>;
|
||||
addChannel: (params: AddChannelParams) => Promise<Channel>;
|
||||
deleteChannel: (channelId: string) => Promise<void>;
|
||||
connectChannel: (channelId: string) => Promise<void>;
|
||||
@@ -34,20 +35,10 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchChannels: async (options) => {
|
||||
const probe = options?.probe ?? false;
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
set({ loading: true, error: null });
|
||||
}
|
||||
fetchChannels: async () => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.status',
|
||||
{ probe }
|
||||
) as {
|
||||
success: boolean;
|
||||
result?: {
|
||||
const data = await useGatewayStore.getState().rpc<{
|
||||
channelOrder?: string[];
|
||||
channels?: Record<string, unknown>;
|
||||
channelAccounts?: Record<string, Array<{
|
||||
@@ -63,12 +54,8 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
lastOutboundAt?: number | null;
|
||||
}>>;
|
||||
channelDefaultAccountId?: Record<string, string>;
|
||||
};
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (result.success && result.result) {
|
||||
const data = result.result;
|
||||
}>('channels.status', { probe: true });
|
||||
if (data) {
|
||||
const channels: Channel[] = [];
|
||||
|
||||
// Parse the complex channels.status response into simple Channel objects
|
||||
@@ -131,30 +118,26 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
});
|
||||
}
|
||||
|
||||
set((state) => ({ channels, loading: silent ? state.loading : false }));
|
||||
set({ channels, loading: false });
|
||||
} else {
|
||||
// Gateway not available - try to show channels from local config
|
||||
set((state) => ({ channels: [], loading: silent ? state.loading : false }));
|
||||
set({ channels: [], loading: false });
|
||||
}
|
||||
} catch {
|
||||
// Gateway not connected, show empty
|
||||
set((state) => ({ channels: [], loading: silent ? state.loading : false }));
|
||||
set({ channels: [], loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
addChannel: async (params) => {
|
||||
try {
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.add',
|
||||
params
|
||||
) as { success: boolean; result?: Channel; error?: string };
|
||||
const result = await useGatewayStore.getState().rpc<Channel>('channels.add', params);
|
||||
|
||||
if (result.success && result.result) {
|
||||
if (result) {
|
||||
set((state) => ({
|
||||
channels: [...state.channels, result.result!],
|
||||
channels: [...state.channels, result],
|
||||
}));
|
||||
return result.result;
|
||||
return result;
|
||||
} else {
|
||||
// If gateway is not available, create a local channel for now
|
||||
const newChannel: Channel = {
|
||||
@@ -189,17 +172,15 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
|
||||
try {
|
||||
// Delete the channel configuration from openclaw.json
|
||||
await invokeIpc('channel:deleteConfig', channelType);
|
||||
await hostApiFetch(`/api/channels/config/${encodeURIComponent(channelType)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to delete channel config:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.delete',
|
||||
{ channelId: channelType }
|
||||
);
|
||||
await useGatewayStore.getState().rpc('channels.delete', { channelId: channelType });
|
||||
} catch (error) {
|
||||
// Continue with local deletion even if gateway fails
|
||||
console.error('Failed to delete channel from gateway:', error);
|
||||
@@ -216,17 +197,8 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
updateChannel(channelId, { status: 'connecting', error: undefined });
|
||||
|
||||
try {
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.connect',
|
||||
{ channelId }
|
||||
) as { success: boolean; error?: string };
|
||||
|
||||
if (result.success) {
|
||||
updateChannel(channelId, { status: 'connected' });
|
||||
} else {
|
||||
updateChannel(channelId, { status: 'error', error: result.error });
|
||||
}
|
||||
await useGatewayStore.getState().rpc('channels.connect', { channelId });
|
||||
updateChannel(channelId, { status: 'connected' });
|
||||
} catch (error) {
|
||||
updateChannel(channelId, { status: 'error', error: String(error) });
|
||||
}
|
||||
@@ -236,11 +208,7 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
const { updateChannel } = get();
|
||||
|
||||
try {
|
||||
await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'channels.disconnect',
|
||||
{ channelId }
|
||||
);
|
||||
await useGatewayStore.getState().rpc('channels.disconnect', { channelId });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect channel:', error);
|
||||
}
|
||||
@@ -249,17 +217,10 @@ export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
},
|
||||
|
||||
requestQrCode: async (channelType) => {
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
return await useGatewayStore.getState().rpc<{ qrCode: string; sessionId: string }>(
|
||||
'channels.requestQr',
|
||||
{ type: channelType }
|
||||
) as { success: boolean; result?: { qrCode: string; sessionId: string }; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
return result.result;
|
||||
}
|
||||
|
||||
throw new Error(result.error || 'Failed to request QR code');
|
||||
{ type: channelType },
|
||||
);
|
||||
},
|
||||
|
||||
setChannels: (channels) => set({ channels }),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Chat State Store
|
||||
* Manages chat messages, sessions, streaming, and thinking state.
|
||||
* Communicates with OpenClaw Gateway via gateway:rpc IPC.
|
||||
* Communicates with OpenClaw Gateway via renderer WebSocket RPC.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useGatewayStore } from './gateway';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -597,10 +598,13 @@ async function loadMissingPreviews(messages: RawMessage[]): Promise<boolean> {
|
||||
if (needPreview.length === 0) return false;
|
||||
|
||||
try {
|
||||
const thumbnails = await invokeIpc(
|
||||
'media:getThumbnails',
|
||||
needPreview,
|
||||
) as Record<string, { preview: string | null; fileSize: number }>;
|
||||
const thumbnails = await hostApiFetch<Record<string, { preview: string | null; fileSize: number }>>(
|
||||
'/api/files/thumbnails',
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paths: needPreview }),
|
||||
},
|
||||
);
|
||||
|
||||
let updated = false;
|
||||
for (const msg of messages) {
|
||||
@@ -929,14 +933,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
loadSessions: async () => {
|
||||
try {
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
'sessions.list',
|
||||
{}
|
||||
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
const data = result.result;
|
||||
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>('sessions.list', {});
|
||||
if (data) {
|
||||
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
const sessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
|
||||
key: String(s.key || ''),
|
||||
@@ -1002,13 +1000,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
void Promise.all(
|
||||
sessionsToLabel.map(async (session) => {
|
||||
try {
|
||||
const r = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
const r = await useGatewayStore.getState().rpc<Record<string, unknown>>(
|
||||
'chat.history',
|
||||
{ sessionKey: session.key, limit: 1000 },
|
||||
) as { success: boolean; result?: Record<string, unknown> };
|
||||
if (!r.success || !r.result) return;
|
||||
const msgs = Array.isArray(r.result.messages) ? r.result.messages as RawMessage[] : [];
|
||||
);
|
||||
const msgs = Array.isArray(r.messages) ? r.messages as RawMessage[] : [];
|
||||
const firstUser = msgs.find((m) => m.role === 'user');
|
||||
const lastMsg = msgs[msgs.length - 1];
|
||||
set((s) => {
|
||||
@@ -1078,10 +1074,13 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
// The main process renames <suffix>.jsonl → <suffix>.deleted.jsonl so that
|
||||
// sessions.list and token-usage queries both skip it automatically.
|
||||
try {
|
||||
const result = await invokeIpc('session:delete', key) as {
|
||||
const result = await hostApiFetch<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
}>('/api/sessions/delete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionKey: key }),
|
||||
});
|
||||
if (!result.success) {
|
||||
console.warn(`[deleteSession] IPC reported failure for ${key}:`, result.error);
|
||||
}
|
||||
@@ -1186,14 +1185,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
if (!quiet) set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
const data = await useGatewayStore.getState().rpc<Record<string, unknown>>(
|
||||
'chat.history',
|
||||
{ sessionKey: currentSessionKey, limit: 200 }
|
||||
) as { success: boolean; result?: Record<string, unknown>; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
const data = result.result;
|
||||
{ sessionKey: currentSessionKey, limit: 200 },
|
||||
);
|
||||
if (data) {
|
||||
const rawMessages = Array.isArray(data.messages) ? data.messages as RawMessage[] : [];
|
||||
|
||||
// Before filtering: attach images/files from tool_result messages to the next assistant message
|
||||
@@ -1426,23 +1422,25 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const CHAT_SEND_TIMEOUT_MS = 120_000;
|
||||
|
||||
if (hasMedia) {
|
||||
result = await invokeIpc(
|
||||
'chat:sendWithMedia',
|
||||
result = await hostApiFetch<{ success: boolean; result?: { runId?: string }; error?: string }>(
|
||||
'/api/chat/send-with-media',
|
||||
{
|
||||
sessionKey: currentSessionKey,
|
||||
message: trimmed || 'Process the attached file(s).',
|
||||
deliver: false,
|
||||
idempotencyKey,
|
||||
media: attachments.map((a) => ({
|
||||
filePath: a.stagedPath,
|
||||
mimeType: a.mimeType,
|
||||
fileName: a.fileName,
|
||||
})),
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionKey: currentSessionKey,
|
||||
message: trimmed || 'Process the attached file(s).',
|
||||
deliver: false,
|
||||
idempotencyKey,
|
||||
media: attachments.map((a) => ({
|
||||
filePath: a.stagedPath,
|
||||
mimeType: a.mimeType,
|
||||
fileName: a.fileName,
|
||||
})),
|
||||
}),
|
||||
},
|
||||
) as { success: boolean; result?: { runId?: string }; error?: string };
|
||||
);
|
||||
} else {
|
||||
result = await invokeIpc(
|
||||
'gateway:rpc',
|
||||
const rpcResult = await useGatewayStore.getState().rpc<{ runId?: string }>(
|
||||
'chat.send',
|
||||
{
|
||||
sessionKey: currentSessionKey,
|
||||
@@ -1451,7 +1449,8 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
idempotencyKey,
|
||||
},
|
||||
CHAT_SEND_TIMEOUT_MS,
|
||||
) as { success: boolean; result?: { runId?: string }; error?: string };
|
||||
);
|
||||
result = { success: true, result: rpcResult };
|
||||
}
|
||||
|
||||
console.log(`[sendMessage] RPC result: success=${result.success}, runId=${result.result?.runId || 'none'}`);
|
||||
@@ -1478,8 +1477,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
set({ streamingTools: [] });
|
||||
|
||||
try {
|
||||
await invokeIpc(
|
||||
'gateway:rpc',
|
||||
await useGatewayStore.getState().rpc(
|
||||
'chat.abort',
|
||||
{ sessionKey: currentSessionKey },
|
||||
);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* Manages scheduled task state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
interface CronState {
|
||||
jobs: CronJob[];
|
||||
@@ -30,7 +30,7 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await invokeIpc<CronJob[]>('cron:list');
|
||||
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
||||
set({ jobs: result, loading: false });
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
@@ -39,7 +39,10 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
createJob: async (input) => {
|
||||
try {
|
||||
const job = await invokeIpc<CronJob>('cron:create', input);
|
||||
const job = await hostApiFetch<CronJob>('/api/cron/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
set((state) => ({ jobs: [...state.jobs, job] }));
|
||||
return job;
|
||||
} catch (error) {
|
||||
@@ -50,7 +53,10 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
updateJob: async (id, input) => {
|
||||
try {
|
||||
await invokeIpc('cron:update', id, input);
|
||||
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
|
||||
@@ -64,7 +70,9 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
deleteJob: async (id) => {
|
||||
try {
|
||||
await invokeIpc('cron:delete', id);
|
||||
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
set((state) => ({
|
||||
jobs: state.jobs.filter((job) => job.id !== id),
|
||||
}));
|
||||
@@ -76,7 +84,10 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
toggleJob: async (id, enabled) => {
|
||||
try {
|
||||
await invokeIpc('cron:toggle', id, enabled);
|
||||
await hostApiFetch('/api/cron/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
});
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, enabled } : job
|
||||
@@ -90,11 +101,14 @@ export const useCronStore = create<CronState>((set) => ({
|
||||
|
||||
triggerJob: async (id) => {
|
||||
try {
|
||||
const result = await invokeIpc<unknown>('cron:trigger', id);
|
||||
const result = await hostApiFetch('/api/cron/trigger', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
console.log('Cron trigger result:', result);
|
||||
// Refresh jobs after trigger to update lastRun/nextRun state
|
||||
try {
|
||||
const jobs = await invokeIpc<CronJob[]>('cron:list');
|
||||
const jobs = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
||||
set({ jobs });
|
||||
} catch {
|
||||
// Ignore refresh error
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* Gateway State Store
|
||||
* Manages Gateway connection state and communication
|
||||
* Uses Host API + SSE for lifecycle/status and a direct renderer WebSocket for runtime RPC.
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { GatewayStatus } from '../types/gateway';
|
||||
import { createHostEventSource, hostApiFetch } from '@/lib/host-api';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import type { GatewayStatus } from '../types/gateway';
|
||||
|
||||
let gatewayInitPromise: Promise<void> | null = null;
|
||||
let gatewayEventSource: EventSource | null = null;
|
||||
|
||||
interface GatewayHealth {
|
||||
ok: boolean;
|
||||
@@ -19,8 +21,6 @@ interface GatewayState {
|
||||
health: GatewayHealth | null;
|
||||
isInitialized: boolean;
|
||||
lastError: string | null;
|
||||
|
||||
// Actions
|
||||
init: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
@@ -31,6 +31,102 @@ interface GatewayState {
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
function handleGatewayNotification(notification: { method?: string; params?: Record<string, unknown> } | undefined): void {
|
||||
const payload = notification;
|
||||
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const p = payload.params;
|
||||
const data = (p.data && typeof p.data === 'object') ? (p.data as Record<string, unknown>) : {};
|
||||
const phase = data.phase ?? p.phase;
|
||||
const hasChatData = (p.state ?? data.state) || (p.message ?? data.message);
|
||||
|
||||
if (hasChatData) {
|
||||
const normalizedEvent: Record<string, unknown> = {
|
||||
...data,
|
||||
runId: p.runId ?? data.runId,
|
||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||
stream: p.stream ?? data.stream,
|
||||
seq: p.seq ?? data.seq,
|
||||
state: p.state ?? data.state,
|
||||
message: p.message ?? data.message,
|
||||
};
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent(normalizedEvent);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
const runId = p.runId ?? data.runId;
|
||||
const sessionKey = p.sessionKey ?? data.sessionKey;
|
||||
if (phase === 'started' && runId != null && sessionKey != null) {
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'started',
|
||||
runId,
|
||||
sessionKey,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (phase === 'completed' || phase === 'done' || phase === 'finished' || phase === 'end') {
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
const state = useChatStore.getState();
|
||||
state.loadHistory(true);
|
||||
if (state.sending) {
|
||||
useChatStore.setState({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function handleGatewayChatMessage(data: unknown): void {
|
||||
import('./chat').then(({ useChatStore }) => {
|
||||
const chatData = data as Record<string, unknown>;
|
||||
const payload = ('message' in chatData && typeof chatData.message === 'object')
|
||||
? chatData.message as Record<string, unknown>
|
||||
: chatData;
|
||||
|
||||
if (payload.state) {
|
||||
useChatStore.getState().handleChatEvent(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'final',
|
||||
message: payload,
|
||||
runId: chatData.runId ?? payload.runId,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function mapChannelStatus(status: string): 'connected' | 'connecting' | 'disconnected' | 'error' {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
case 'running':
|
||||
return 'connected';
|
||||
case 'connecting':
|
||||
case 'starting':
|
||||
return 'connecting';
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return 'disconnected';
|
||||
}
|
||||
}
|
||||
|
||||
export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
status: {
|
||||
state: 'stopped',
|
||||
@@ -49,141 +145,41 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
|
||||
gatewayInitPromise = (async () => {
|
||||
try {
|
||||
// Get initial status first
|
||||
const status = await invokeIpc('gateway:status') as GatewayStatus;
|
||||
const status = await hostApiFetch<GatewayStatus>('/api/gateway/status');
|
||||
set({ status, isInitialized: true });
|
||||
|
||||
// Listen for status changes
|
||||
window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => {
|
||||
set({ status: newStatus as GatewayStatus });
|
||||
});
|
||||
|
||||
// Listen for errors
|
||||
window.electron.ipcRenderer.on('gateway:error', (error) => {
|
||||
set({ lastError: String(error) });
|
||||
});
|
||||
|
||||
// Some Gateway builds stream chat events via generic "agent" notifications.
|
||||
// Normalize and forward them to the chat store.
|
||||
// The Gateway may put event fields (state, message, etc.) either inside
|
||||
// params.data or directly on params — we must handle both layouts.
|
||||
window.electron.ipcRenderer.on('gateway:notification', (notification) => {
|
||||
const payload = notification as { method?: string; params?: Record<string, unknown> } | undefined;
|
||||
if (!payload || payload.method !== 'agent' || !payload.params || typeof payload.params !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
const p = payload.params;
|
||||
const data = (p.data && typeof p.data === 'object') ? (p.data as Record<string, unknown>) : {};
|
||||
const phase = data.phase ?? p.phase;
|
||||
|
||||
const hasChatData = (p.state ?? data.state) || (p.message ?? data.message);
|
||||
if (hasChatData) {
|
||||
const normalizedEvent: Record<string, unknown> = {
|
||||
...data,
|
||||
runId: p.runId ?? data.runId,
|
||||
sessionKey: p.sessionKey ?? data.sessionKey,
|
||||
stream: p.stream ?? data.stream,
|
||||
seq: p.seq ?? data.seq,
|
||||
state: p.state ?? data.state,
|
||||
message: p.message ?? data.message,
|
||||
};
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent(normalizedEvent);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// When a run starts (e.g. user clicked Send on console), show loading in the app immediately.
|
||||
const runId = p.runId ?? data.runId;
|
||||
const sessionKey = p.sessionKey ?? data.sessionKey;
|
||||
if (phase === 'started' && runId != null && sessionKey != null) {
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'started',
|
||||
runId,
|
||||
sessionKey,
|
||||
});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// When the agent run completes, reload history to get the final response.
|
||||
if (phase === 'completed' || phase === 'done' || phase === 'finished' || phase === 'end') {
|
||||
import('./chat')
|
||||
.then(({ useChatStore }) => {
|
||||
const state = useChatStore.getState();
|
||||
// Always reload history on agent completion, regardless of
|
||||
// the `sending` flag. After a transient error the flag may
|
||||
// already be false, but the Gateway may have retried and
|
||||
// completed successfully in the background.
|
||||
state.loadHistory(true);
|
||||
if (state.sending) {
|
||||
useChatStore.setState({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
if (!gatewayEventSource) {
|
||||
gatewayEventSource = createHostEventSource();
|
||||
gatewayEventSource.addEventListener('gateway:status', (event) => {
|
||||
set({ status: JSON.parse((event as MessageEvent).data) as GatewayStatus });
|
||||
});
|
||||
gatewayEventSource.addEventListener('gateway:error', (event) => {
|
||||
const payload = JSON.parse((event as MessageEvent).data) as { message?: string };
|
||||
set({ lastError: payload.message || 'Gateway error' });
|
||||
});
|
||||
gatewayEventSource.addEventListener('gateway:notification', (event) => {
|
||||
handleGatewayNotification(JSON.parse((event as MessageEvent).data) as {
|
||||
method?: string;
|
||||
params?: Record<string, unknown>;
|
||||
});
|
||||
});
|
||||
gatewayEventSource.addEventListener('gateway:chat-message', (event) => {
|
||||
handleGatewayChatMessage(JSON.parse((event as MessageEvent).data));
|
||||
});
|
||||
gatewayEventSource.addEventListener('gateway:channel-status', (event) => {
|
||||
import('./channels')
|
||||
.then(({ useChannelsStore }) => {
|
||||
const update = JSON.parse((event as MessageEvent).data) as { channelId?: string; status?: string };
|
||||
if (!update.channelId || !update.status) return;
|
||||
const state = useChannelsStore.getState();
|
||||
const channel = state.channels.find((item) => item.type === update.channelId);
|
||||
if (channel) {
|
||||
state.updateChannel(channel.id, { status: mapChannelStatus(update.status) });
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for chat events from the gateway and forward to chat store.
|
||||
// The data arrives as { message: payload } from handleProtocolEvent.
|
||||
// The payload may be a full event wrapper ({ state, runId, message })
|
||||
// or the raw chat message itself. We need to handle both.
|
||||
window.electron.ipcRenderer.on('gateway:chat-message', (data) => {
|
||||
try {
|
||||
import('./chat').then(({ useChatStore }) => {
|
||||
const chatData = data as Record<string, unknown>;
|
||||
const payload = ('message' in chatData && typeof chatData.message === 'object')
|
||||
? chatData.message as Record<string, unknown>
|
||||
: chatData;
|
||||
|
||||
if (payload.state) {
|
||||
useChatStore.getState().handleChatEvent(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
// Raw message without state wrapper — treat as final
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'final',
|
||||
message: payload,
|
||||
runId: chatData.runId ?? payload.runId,
|
||||
});
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
// Silently ignore forwarding failures
|
||||
}
|
||||
});
|
||||
|
||||
// Catch-all: handle unmatched gateway messages that fell through
|
||||
// all protocol/notification handlers in the main process.
|
||||
// This prevents events from being silently lost.
|
||||
window.electron.ipcRenderer.on('gateway:message', (data) => {
|
||||
if (!data || typeof data !== 'object') return;
|
||||
const msg = data as Record<string, unknown>;
|
||||
|
||||
// Try to detect if this is a chat-related event and forward it
|
||||
if (msg.state && msg.message) {
|
||||
import('./chat').then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent(msg);
|
||||
}).catch(() => {});
|
||||
} else if (msg.role && msg.content) {
|
||||
import('./chat').then(({ useChatStore }) => {
|
||||
useChatStore.getState().handleChatEvent({
|
||||
state: 'final',
|
||||
message: msg,
|
||||
});
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Gateway:', error);
|
||||
set({ lastError: String(error) });
|
||||
@@ -198,25 +194,26 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
start: async () => {
|
||||
try {
|
||||
set({ status: { ...get().status, state: 'starting' }, lastError: null });
|
||||
const result = await invokeIpc('gateway:start') as { success: boolean; error?: string };
|
||||
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/gateway/start', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!result.success) {
|
||||
set({
|
||||
status: { ...get().status, state: 'error', error: result.error },
|
||||
lastError: result.error || 'Failed to start Gateway'
|
||||
lastError: result.error || 'Failed to start Gateway',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
status: { ...get().status, state: 'error', error: String(error) },
|
||||
lastError: String(error)
|
||||
lastError: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
stop: async () => {
|
||||
try {
|
||||
await invokeIpc('gateway:stop');
|
||||
await hostApiFetch('/api/gateway/stop', { method: 'POST' });
|
||||
set({ status: { ...get().status, state: 'stopped' }, lastError: null });
|
||||
} catch (error) {
|
||||
console.error('Failed to stop Gateway:', error);
|
||||
@@ -227,39 +224,28 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
restart: async () => {
|
||||
try {
|
||||
set({ status: { ...get().status, state: 'starting' }, lastError: null });
|
||||
const result = await invokeIpc('gateway:restart') as { success: boolean; error?: string };
|
||||
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/gateway/restart', {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!result.success) {
|
||||
set({
|
||||
status: { ...get().status, state: 'error', error: result.error },
|
||||
lastError: result.error || 'Failed to restart Gateway'
|
||||
lastError: result.error || 'Failed to restart Gateway',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
set({
|
||||
status: { ...get().status, state: 'error', error: String(error) },
|
||||
lastError: String(error)
|
||||
lastError: String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
checkHealth: async () => {
|
||||
try {
|
||||
const result = await invokeIpc('gateway:health') as {
|
||||
success: boolean;
|
||||
ok: boolean;
|
||||
error?: string;
|
||||
uptime?: number
|
||||
};
|
||||
|
||||
const health: GatewayHealth = {
|
||||
ok: result.ok,
|
||||
error: result.error,
|
||||
uptime: result.uptime,
|
||||
};
|
||||
|
||||
set({ health });
|
||||
return health;
|
||||
const result = await hostApiFetch<GatewayHealth>('/api/gateway/health');
|
||||
set({ health: result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
const health: GatewayHealth = { ok: false, error: String(error) };
|
||||
set({ health });
|
||||
@@ -268,20 +254,17 @@ export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
},
|
||||
|
||||
rpc: async <T>(method: string, params?: unknown, timeoutMs?: number): Promise<T> => {
|
||||
const result = await invokeIpc('gateway:rpc', method, params, timeoutMs) as {
|
||||
const response = await invokeIpc<{
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || `RPC call failed: ${method}`);
|
||||
}>('gateway:rpc', method, params, timeoutMs);
|
||||
if (!response.success) {
|
||||
throw new Error(response.error || `Gateway RPC failed: ${method}`);
|
||||
}
|
||||
|
||||
return result.result as T;
|
||||
return response.result as T;
|
||||
},
|
||||
|
||||
setStatus: (status) => set({ status }),
|
||||
|
||||
clearError: () => set({ lastError: null }),
|
||||
}));
|
||||
|
||||
@@ -3,23 +3,53 @@
|
||||
* Manages AI provider configurations
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import type {
|
||||
ProviderAccount,
|
||||
ProviderConfig,
|
||||
ProviderVendorInfo,
|
||||
ProviderWithKeyInfo,
|
||||
} from '@/lib/providers';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import {
|
||||
fetchProviderSnapshot,
|
||||
} from '@/lib/provider-accounts';
|
||||
|
||||
// Re-export types for consumers that imported from here
|
||||
export type { ProviderConfig, ProviderWithKeyInfo } from '@/lib/providers';
|
||||
export type {
|
||||
ProviderAccount,
|
||||
ProviderConfig,
|
||||
ProviderVendorInfo,
|
||||
ProviderWithKeyInfo,
|
||||
} from '@/lib/providers';
|
||||
export type { ProviderSnapshot } from '@/lib/provider-accounts';
|
||||
|
||||
interface ProviderState {
|
||||
providers: ProviderWithKeyInfo[];
|
||||
defaultProviderId: string | null;
|
||||
statuses: ProviderWithKeyInfo[];
|
||||
accounts: ProviderAccount[];
|
||||
vendors: ProviderVendorInfo[];
|
||||
defaultAccountId: string | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
refreshProviderSnapshot: () => Promise<void>;
|
||||
createAccount: (account: ProviderAccount, apiKey?: string) => Promise<void>;
|
||||
removeAccount: (accountId: string) => Promise<void>;
|
||||
validateAccountApiKey: (
|
||||
accountId: string,
|
||||
apiKey: string,
|
||||
options?: { baseUrl?: string }
|
||||
) => Promise<{ valid: boolean; error?: string }>;
|
||||
getAccountApiKey: (accountId: string) => Promise<string | null>;
|
||||
|
||||
// Legacy compatibility aliases
|
||||
fetchProviders: () => Promise<void>;
|
||||
addProvider: (config: Omit<ProviderConfig, 'createdAt' | 'updatedAt'>, apiKey?: string) => Promise<void>;
|
||||
addAccount: (account: ProviderAccount, apiKey?: string) => Promise<void>;
|
||||
updateProvider: (providerId: string, updates: Partial<ProviderConfig>, apiKey?: string) => Promise<void>;
|
||||
updateAccount: (accountId: string, updates: Partial<ProviderAccount>, apiKey?: string) => Promise<void>;
|
||||
deleteProvider: (providerId: string) => Promise<void>;
|
||||
deleteAccount: (accountId: string) => Promise<void>;
|
||||
setApiKey: (providerId: string, apiKey: string) => Promise<void>;
|
||||
updateProviderWithKey: (
|
||||
providerId: string,
|
||||
@@ -28,6 +58,7 @@ interface ProviderState {
|
||||
) => Promise<void>;
|
||||
deleteApiKey: (providerId: string) => Promise<void>;
|
||||
setDefaultProvider: (providerId: string) => Promise<void>;
|
||||
setDefaultAccount: (accountId: string) => Promise<void>;
|
||||
validateApiKey: (
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
@@ -37,27 +68,32 @@ interface ProviderState {
|
||||
}
|
||||
|
||||
export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
providers: [],
|
||||
defaultProviderId: null,
|
||||
statuses: [],
|
||||
accounts: [],
|
||||
vendors: [],
|
||||
defaultAccountId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchProviders: async () => {
|
||||
refreshProviderSnapshot: async () => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const providers = await invokeIpc<ProviderWithKeyInfo[]>('provider:list');
|
||||
const defaultId = await invokeIpc<string | null>('provider:getDefault');
|
||||
const snapshot = await fetchProviderSnapshot();
|
||||
|
||||
set({
|
||||
providers,
|
||||
defaultProviderId: defaultId,
|
||||
statuses: snapshot.statuses,
|
||||
accounts: snapshot.accounts,
|
||||
vendors: snapshot.vendors,
|
||||
defaultAccountId: snapshot.defaultAccountId,
|
||||
loading: false
|
||||
});
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
fetchProviders: async () => get().refreshProviderSnapshot(),
|
||||
|
||||
addProvider: async (config, apiKey) => {
|
||||
try {
|
||||
@@ -67,23 +103,46 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:save', fullConfig, apiKey);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/providers', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ config: fullConfig, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save provider');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().fetchProviders();
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to add provider:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createAccount: async (account, apiKey) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ account, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to create provider account');
|
||||
}
|
||||
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to add account:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
addAccount: async (account, apiKey) => get().createAccount(account, apiKey),
|
||||
|
||||
updateProvider: async (providerId, updates, apiKey) => {
|
||||
try {
|
||||
const existing = get().providers.find((p) => p.id === providerId);
|
||||
const existing = get().statuses.find((p) => p.id === providerId);
|
||||
if (!existing) {
|
||||
throw new Error('Provider not found');
|
||||
}
|
||||
@@ -96,46 +155,91 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:save', updatedConfig, apiKey);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ updates: updatedConfig, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update provider');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().fetchProviders();
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to update provider:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateAccount: async (accountId, updates, apiKey) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/provider-accounts/${encodeURIComponent(accountId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ updates, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update provider account');
|
||||
}
|
||||
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to update account:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteProvider: async (providerId) => {
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:delete', providerId);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete provider');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().fetchProviders();
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete provider:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
removeAccount: async (accountId) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/provider-accounts/${encodeURIComponent(accountId)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete provider account');
|
||||
}
|
||||
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete account:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteAccount: async (accountId) => get().removeAccount(accountId),
|
||||
|
||||
setApiKey: async (providerId, apiKey) => {
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:setApiKey', providerId, apiKey);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ updates: {}, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to set API key');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().fetchProviders();
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to set API key:', error);
|
||||
throw error;
|
||||
@@ -144,18 +248,16 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
updateProviderWithKey: async (providerId, updates, apiKey) => {
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>(
|
||||
'provider:updateWithKey',
|
||||
providerId,
|
||||
updates,
|
||||
apiKey
|
||||
);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(`/api/providers/${encodeURIComponent(providerId)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ updates, apiKey }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to update provider');
|
||||
}
|
||||
|
||||
await get().fetchProviders();
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to update provider with key:', error);
|
||||
throw error;
|
||||
@@ -164,14 +266,17 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
deleteApiKey: async (providerId) => {
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:deleteApiKey', providerId);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>(
|
||||
`/api/providers/${encodeURIComponent(providerId)}?apiKeyOnly=1`,
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to delete API key');
|
||||
}
|
||||
|
||||
// Refresh the list
|
||||
await get().fetchProviders();
|
||||
await get().refreshProviderSnapshot();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete API key:', error);
|
||||
throw error;
|
||||
@@ -180,38 +285,62 @@ export const useProviderStore = create<ProviderState>((set, get) => ({
|
||||
|
||||
setDefaultProvider: async (providerId) => {
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('provider:setDefault', providerId);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/providers/default', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ providerId }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to set default provider');
|
||||
}
|
||||
|
||||
set({ defaultProviderId: providerId });
|
||||
set({ defaultAccountId: providerId });
|
||||
} catch (error) {
|
||||
console.error('Failed to set default provider:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
validateApiKey: async (providerId, apiKey, options) => {
|
||||
|
||||
setDefaultAccount: async (accountId) => {
|
||||
try {
|
||||
const result = await invokeIpc<{ valid: boolean; error?: string }>(
|
||||
'provider:validateKey',
|
||||
providerId,
|
||||
apiKey,
|
||||
options
|
||||
);
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/provider-accounts/default', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ accountId }),
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to set default provider account');
|
||||
}
|
||||
|
||||
set({ defaultAccountId: accountId });
|
||||
} catch (error) {
|
||||
console.error('Failed to set default account:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
validateAccountApiKey: async (providerId, apiKey, options) => {
|
||||
try {
|
||||
const result = await hostApiFetch<{ valid: boolean; error?: string }>('/api/providers/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ providerId, apiKey, options }),
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
return { valid: false, error: String(error) };
|
||||
}
|
||||
},
|
||||
|
||||
validateApiKey: async (providerId, apiKey, options) => get().validateAccountApiKey(providerId, apiKey, options),
|
||||
|
||||
getApiKey: async (providerId) => {
|
||||
getAccountApiKey: async (providerId) => {
|
||||
try {
|
||||
return await invokeIpc<string | null>('provider:getApiKey', providerId);
|
||||
const result = await hostApiFetch<{ apiKey: string | null }>(`/api/providers/${encodeURIComponent(providerId)}/api-key`);
|
||||
return result.apiKey;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
getApiKey: async (providerId) => get().getAccountApiKey(providerId),
|
||||
}));
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import i18n from '@/i18n';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
@@ -99,7 +100,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
|
||||
init: async () => {
|
||||
try {
|
||||
const settings = await invokeIpc<Partial<typeof defaultSettings>>('settings:getAll');
|
||||
const settings = await hostApiFetch<Partial<typeof defaultSettings>>('/api/settings');
|
||||
set((state) => ({ ...state, ...settings }));
|
||||
if (settings.language) {
|
||||
i18n.changeLanguage(settings.language);
|
||||
@@ -111,11 +112,30 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
},
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setLanguage: (language) => { i18n.changeLanguage(language); set({ language }); void invokeIpc('settings:set', 'language', language).catch(() => {}); },
|
||||
setLanguage: (language) => {
|
||||
i18n.changeLanguage(language);
|
||||
set({ language });
|
||||
void hostApiFetch('/api/settings/language', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: language }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
setStartMinimized: (startMinimized) => set({ startMinimized }),
|
||||
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
|
||||
setGatewayAutoStart: (gatewayAutoStart) => { set({ gatewayAutoStart }); void invokeIpc('settings:set', 'gatewayAutoStart', gatewayAutoStart).catch(() => {}); },
|
||||
setGatewayPort: (gatewayPort) => { set({ gatewayPort }); void invokeIpc('settings:set', 'gatewayPort', gatewayPort).catch(() => {}); },
|
||||
setGatewayAutoStart: (gatewayAutoStart) => {
|
||||
set({ gatewayAutoStart });
|
||||
void hostApiFetch('/api/settings/gatewayAutoStart', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: gatewayAutoStart }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
setGatewayPort: (gatewayPort) => {
|
||||
set({ gatewayPort });
|
||||
void hostApiFetch('/api/settings/gatewayPort', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ value: gatewayPort }),
|
||||
}).catch(() => {});
|
||||
},
|
||||
setProxyEnabled: (proxyEnabled) => set({ proxyEnabled }),
|
||||
setProxyServer: (proxyServer) => set({ proxyServer }),
|
||||
setProxyHttpServer: (proxyHttpServer) => set({ proxyHttpServer }),
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* Manages skill/plugin state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { hostApiFetch } from '@/lib/host-api';
|
||||
import { useGatewayStore } from './gateway';
|
||||
import type { Skill, MarketplaceSkill } from '../types/skill';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
type GatewaySkillStatus = {
|
||||
skillKey: string;
|
||||
@@ -24,12 +25,6 @@ type GatewaySkillsStatusResult = {
|
||||
skills?: GatewaySkillStatus[];
|
||||
};
|
||||
|
||||
type GatewayRpcResponse<T> = {
|
||||
success: boolean;
|
||||
result?: T;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type ClawHubListResult = {
|
||||
slug: string;
|
||||
version?: string;
|
||||
@@ -71,27 +66,20 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
}
|
||||
try {
|
||||
// 1. Fetch from Gateway (running skills)
|
||||
const gatewayResult = await invokeIpc<GatewayRpcResponse<GatewaySkillsStatusResult>>(
|
||||
'gateway:rpc',
|
||||
'skills.status'
|
||||
);
|
||||
const gatewayData = await useGatewayStore.getState().rpc<GatewaySkillsStatusResult>('skills.status');
|
||||
|
||||
// 2. Fetch from ClawHub (installed on disk)
|
||||
const clawhubResult = await invokeIpc<{ success: boolean; results?: ClawHubListResult[]; error?: string }>(
|
||||
'clawhub:list'
|
||||
);
|
||||
const clawhubResult = await hostApiFetch<{ success: boolean; results?: ClawHubListResult[]; error?: string }>('/api/clawhub/list');
|
||||
|
||||
// 3. Fetch configurations directly from Electron (since Gateway doesn't return them)
|
||||
const configResult = await invokeIpc<Record<string, { apiKey?: string; env?: Record<string, string> }>>(
|
||||
'skill:getAllConfigs'
|
||||
);
|
||||
const configResult = await hostApiFetch<Record<string, { apiKey?: string; env?: Record<string, string> }>>('/api/skills/configs');
|
||||
|
||||
let combinedSkills: Skill[] = [];
|
||||
const currentSkills = get().skills;
|
||||
|
||||
// Map gateway skills info
|
||||
if (gatewayResult.success && gatewayResult.result?.skills) {
|
||||
combinedSkills = gatewayResult.result.skills.map((s: GatewaySkillStatus) => {
|
||||
if (gatewayData.skills) {
|
||||
combinedSkills = gatewayData.skills.map((s: GatewaySkillStatus) => {
|
||||
// Merge with direct config if available
|
||||
const directConfig = configResult[s.skillKey] || {};
|
||||
|
||||
@@ -156,7 +144,10 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
searchSkills: async (query: string) => {
|
||||
set({ searching: true, searchError: null });
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('clawhub:search', { query });
|
||||
const result = await hostApiFetch<{ success: boolean; results?: MarketplaceSkill[]; error?: string }>('/api/clawhub/search', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
if (result.success) {
|
||||
set({ searchResults: result.results || [] });
|
||||
} else {
|
||||
@@ -178,7 +169,10 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
installSkill: async (slug: string, version?: string) => {
|
||||
set((state) => ({ installing: { ...state.installing, [slug]: true } }));
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:install', { slug, version });
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/install', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ slug, version }),
|
||||
});
|
||||
if (!result.success) {
|
||||
if (result.error?.includes('Timeout')) {
|
||||
throw new Error('installTimeoutError');
|
||||
@@ -205,7 +199,10 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
uninstallSkill: async (slug: string) => {
|
||||
set((state) => ({ installing: { ...state.installing, [slug]: true } }));
|
||||
try {
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:uninstall', { slug });
|
||||
const result = await hostApiFetch<{ success: boolean; error?: string }>('/api/clawhub/uninstall', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Uninstall failed');
|
||||
}
|
||||
@@ -227,17 +224,8 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
const { updateSkill } = get();
|
||||
|
||||
try {
|
||||
const result = await invokeIpc<GatewayRpcResponse<unknown>>(
|
||||
'gateway:rpc',
|
||||
'skills.update',
|
||||
{ skillKey: skillId, enabled: true }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
updateSkill(skillId, { enabled: true });
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to enable skill');
|
||||
}
|
||||
await useGatewayStore.getState().rpc('skills.update', { skillKey: skillId, enabled: true });
|
||||
updateSkill(skillId, { enabled: true });
|
||||
} catch (error) {
|
||||
console.error('Failed to enable skill:', error);
|
||||
throw error;
|
||||
@@ -253,17 +241,8 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invokeIpc<GatewayRpcResponse<unknown>>(
|
||||
'gateway:rpc',
|
||||
'skills.update',
|
||||
{ skillKey: skillId, enabled: false }
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
updateSkill(skillId, { enabled: false });
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to disable skill');
|
||||
}
|
||||
await useGatewayStore.getState().rpc('skills.update', { skillKey: skillId, enabled: false });
|
||||
updateSkill(skillId, { enabled: false });
|
||||
} catch (error) {
|
||||
console.error('Failed to disable skill:', error);
|
||||
throw error;
|
||||
@@ -279,4 +258,4 @@ export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
}));
|
||||
Reference in New Issue
Block a user