committed by
GitHub
Unverified
parent
3d804a9f5e
commit
2c5c82bb74
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user