refactor IPC (#341)
This commit is contained in:
committed by
GitHub
Unverified
parent
c03d92e9a2
commit
3d804a9f5e
@@ -2,7 +2,7 @@
|
||||
* Channels Page
|
||||
* Manage messaging channel connections with configuration UI
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
Plus,
|
||||
Radio,
|
||||
@@ -45,6 +45,7 @@ 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');
|
||||
@@ -54,17 +55,23 @@ 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(() => {
|
||||
fetchChannels();
|
||||
void fetchChannels({ probe: false });
|
||||
}, [fetchChannels]);
|
||||
|
||||
// Fetch configured channel types from config file
|
||||
const fetchConfiguredTypes = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('channel:listConfigured') as {
|
||||
const result = await invokeIpc('channel:listConfigured') as {
|
||||
success: boolean;
|
||||
channels?: string[];
|
||||
};
|
||||
@@ -77,29 +84,86 @@ 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', () => {
|
||||
fetchChannels();
|
||||
fetchConfiguredTypes();
|
||||
if (refreshDebounceRef.current) {
|
||||
clearTimeout(refreshDebounceRef.current);
|
||||
}
|
||||
refreshDebounceRef.current = setTimeout(() => {
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, 300);
|
||||
});
|
||||
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 = channels.filter((c) => c.status === 'connected').length;
|
||||
const connectedCount = configuredChannels.filter((c) => c.status === 'connected').length;
|
||||
|
||||
if (loading) {
|
||||
if (loading && channels.length === 0) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
@@ -118,8 +182,20 @@ export function Channels() {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={fetchChannels}>
|
||||
<RefreshCw className="h-4 w-4 mr-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' : ''}`} />
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button onClick={() => setShowAddDialog(true)}>
|
||||
@@ -138,7 +214,7 @@ export function Channels() {
|
||||
<Radio className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{channels.length}</p>
|
||||
<p className="text-2xl font-bold">{configuredChannels.length}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('stats.total')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -164,7 +240,7 @@ export function Channels() {
|
||||
<PowerOff className="h-6 w-6 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{channels.length - connectedCount}</p>
|
||||
<p className="text-2xl font-bold">{configuredChannels.length - connectedCount}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('stats.disconnected')}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,7 +249,7 @@ export function Channels() {
|
||||
</div>
|
||||
|
||||
{/* Gateway Warning */}
|
||||
{gatewayStatus.state !== 'running' && (
|
||||
{showGatewayWarning && (
|
||||
<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" />
|
||||
@@ -194,7 +270,7 @@ export function Channels() {
|
||||
)}
|
||||
|
||||
{/* Configured Channels */}
|
||||
{channels.length > 0 && (
|
||||
{configuredChannels.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('configured')}</CardTitle>
|
||||
@@ -202,7 +278,7 @@ export function Channels() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((channel) => (
|
||||
{configuredChannels.map((channel) => (
|
||||
<ChannelCard
|
||||
key={channel.id}
|
||||
channel={channel}
|
||||
@@ -230,7 +306,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 = configuredTypes.includes(type);
|
||||
const isConfigured = configuredTypeSet.has(type);
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
@@ -243,7 +319,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">
|
||||
{meta.description}
|
||||
{t(meta.description)}
|
||||
</p>
|
||||
{isConfigured && (
|
||||
<Badge className="absolute top-2 right-2 text-xs bg-green-600 hover:bg-green-600">
|
||||
@@ -272,8 +348,12 @@ export function Channels() {
|
||||
setSelectedChannelType(null);
|
||||
}}
|
||||
onChannelAdded={() => {
|
||||
fetchChannels();
|
||||
fetchConfiguredTypes();
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
setTimeout(() => {
|
||||
void fetchChannels({ probe: false, silent: true });
|
||||
void fetchConfiguredTypes();
|
||||
}, 2200);
|
||||
setShowAddDialog(false);
|
||||
setSelectedChannelType(null);
|
||||
}}
|
||||
@@ -282,14 +362,16 @@ export function Channels() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!channelToDelete}
|
||||
title={t('common.confirm', 'Confirm')}
|
||||
title={t('common:actions.confirm', 'Confirm')}
|
||||
message={t('deleteConfirm')}
|
||||
confirmLabel={t('common.delete', 'Delete')}
|
||||
cancelLabel={t('common.cancel', 'Cancel')}
|
||||
confirmLabel={t('common:actions.delete', 'Delete')}
|
||||
cancelLabel={t('common:actions.cancel', 'Cancel')}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (channelToDelete) {
|
||||
await deleteChannel(channelToDelete.id);
|
||||
await fetchConfiguredTypes();
|
||||
await fetchChannels({ probe: false, silent: true });
|
||||
setChannelToDelete(null);
|
||||
}
|
||||
}}
|
||||
@@ -355,7 +437,6 @@ 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);
|
||||
@@ -382,7 +463,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setChannelName('');
|
||||
setIsExistingConfig(false);
|
||||
// Ensure we clean up any pending QR session if switching away
|
||||
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
|
||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -391,7 +472,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'channel:getFormValues',
|
||||
selectedType
|
||||
) as { success: boolean; values?: Record<string, string> };
|
||||
@@ -439,7 +520,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
toast.success(t('toast.whatsappConnected'));
|
||||
const accountId = data?.accountId || channelName.trim() || 'default';
|
||||
try {
|
||||
const saveResult = await window.electron.ipcRenderer.invoke(
|
||||
const saveResult = await invokeIpc(
|
||||
'channel:saveConfig',
|
||||
'whatsapp',
|
||||
{ enabled: true }
|
||||
@@ -452,15 +533,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
} catch (error) {
|
||||
console.error('Failed to save WhatsApp config:', error);
|
||||
}
|
||||
// Register the channel locally so it shows up immediately
|
||||
addChannel({
|
||||
type: 'whatsapp',
|
||||
name: channelName || 'WhatsApp',
|
||||
}).then(() => {
|
||||
// Restart gateway to pick up the new session
|
||||
window.electron.ipcRenderer.invoke('gateway:restart').catch(console.error);
|
||||
onChannelAdded();
|
||||
});
|
||||
// channel:saveConfig triggers main-process reload/restart handling.
|
||||
// UI state refresh is handled by parent onChannelAdded().
|
||||
onChannelAdded();
|
||||
};
|
||||
|
||||
const onError = (...args: unknown[]) => {
|
||||
@@ -480,9 +555,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
if (typeof removeSuccessListener === 'function') removeSuccessListener();
|
||||
if (typeof removeErrorListener === 'function') removeErrorListener();
|
||||
// Cancel when unmounting or switching types
|
||||
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
|
||||
invokeIpc('channel:cancelWhatsAppQr').catch(() => { });
|
||||
};
|
||||
}, [selectedType, addChannel, channelName, onChannelAdded, t]);
|
||||
}, [selectedType, channelName, onChannelAdded, t]);
|
||||
|
||||
const handleValidate = async () => {
|
||||
if (!selectedType) return;
|
||||
@@ -491,7 +566,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'channel:validateCredentials',
|
||||
selectedType,
|
||||
configValues
|
||||
@@ -538,14 +613,14 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
// For QR-based channels, request QR code
|
||||
if (meta.connectionType === 'qr') {
|
||||
const accountId = channelName.trim() || 'default';
|
||||
await window.electron.ipcRenderer.invoke('channel:requestWhatsAppQr', accountId);
|
||||
await invokeIpc('channel:requestWhatsAppQr', 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 window.electron.ipcRenderer.invoke(
|
||||
const validationResponse = await invokeIpc(
|
||||
'channel:validateCredentials',
|
||||
selectedType,
|
||||
configValues
|
||||
@@ -592,7 +667,7 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
|
||||
// Step 2: Save channel configuration via IPC
|
||||
const config: Record<string, unknown> = { ...configValues };
|
||||
const saveResult = await window.electron.ipcRenderer.invoke('channel:saveConfig', selectedType, config) as {
|
||||
const saveResult = await invokeIpc('channel:saveConfig', selectedType, config) as {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
@@ -605,20 +680,13 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
|
||||
toast.warning(saveResult.warning);
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
// Step 3: Do not call channels.add from renderer; this races with
|
||||
// gateway reload/restart windows and can create stale local entries.
|
||||
|
||||
toast.success(t('toast.channelSaved', { name: meta.name }));
|
||||
|
||||
// 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).
|
||||
// Gateway reload/restart is handled in the main-process save handler.
|
||||
// Renderer should only persist config and refresh local UI state.
|
||||
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 { invokeIpc } from '@/lib/api-client';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -100,7 +101,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
const pickFiles = useCallback(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('dialog:open', {
|
||||
const result = await invokeIpc('dialog:open', {
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
}) as { canceled: boolean; filePaths?: string[] };
|
||||
if (result.canceled || !result.filePaths?.length) return;
|
||||
@@ -125,7 +126,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
|
||||
// Stage all files via IPC
|
||||
console.log('[pickFiles] Staging files:', result.filePaths);
|
||||
const staged = await window.electron.ipcRenderer.invoke(
|
||||
const staged = await invokeIpc(
|
||||
'file:stage',
|
||||
result.filePaths,
|
||||
) as Array<{
|
||||
@@ -192,7 +193,7 @@ 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 window.electron.ipcRenderer.invoke('file:stageBuffer', {
|
||||
const staged = await invokeIpc('file:stageBuffer', {
|
||||
base64,
|
||||
fileName: file.name,
|
||||
mimeType: file.type || 'application/octet-stream',
|
||||
@@ -226,6 +227,7 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
}, []);
|
||||
|
||||
const allReady = attachments.length === 0 || attachments.every(a => a.status === 'ready');
|
||||
const hasFailedAttachments = attachments.some((a) => a.status === 'error');
|
||||
const canSend = (input.trim() || attachments.length > 0) && allReady && !disabled && !sending;
|
||||
const canStop = sending && !disabled && !!onStop;
|
||||
|
||||
@@ -391,6 +393,22 @@ export function ChatInput({ onSend, onStop, disabled = false, sending = false }:
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||
<span>Tip: switch sessions from the sidebar to keep context clean.</span>
|
||||
{hasFailedAttachments && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
onClick={() => {
|
||||
setAttachments((prev) => prev.filter((att) => att.status !== 'error'));
|
||||
void pickFiles();
|
||||
}}
|
||||
>
|
||||
Retry failed attachments
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import remarkGfm from 'remark-gfm';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import type { RawMessage, AttachedFileMeta } from '@/stores/chat';
|
||||
import { extractText, extractThinking, extractImages, extractToolUse, formatTimestamp } from './message-utils';
|
||||
|
||||
@@ -539,7 +540,7 @@ function ImageLightbox({
|
||||
|
||||
const handleShowInFolder = useCallback(() => {
|
||||
if (filePath) {
|
||||
window.electron.ipcRenderer.invoke('shell:showItemInFolder', filePath);
|
||||
invokeIpc('shell:showItemInFolder', filePath);
|
||||
}
|
||||
}, [filePath]);
|
||||
|
||||
|
||||
@@ -37,17 +37,18 @@ import { toast } from 'sonner';
|
||||
import type { CronJob, CronJobCreateInput, ScheduleType } from '@/types/cron';
|
||||
import { CHANNEL_ICONS, type ChannelType } from '@/types/channel';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { TFunction } from 'i18next';
|
||||
|
||||
// Common cron schedule presets
|
||||
const schedulePresets: { label: string; value: string; type: ScheduleType }[] = [
|
||||
{ label: 'Every minute', value: '* * * * *', type: 'interval' },
|
||||
{ label: 'Every 5 minutes', value: '*/5 * * * *', type: 'interval' },
|
||||
{ label: 'Every 15 minutes', value: '*/15 * * * *', type: 'interval' },
|
||||
{ label: 'Every hour', value: '0 * * * *', type: 'interval' },
|
||||
{ label: 'Daily at 9am', value: '0 9 * * *', type: 'daily' },
|
||||
{ label: 'Daily at 6pm', value: '0 18 * * *', type: 'daily' },
|
||||
{ label: 'Weekly (Mon 9am)', value: '0 9 * * 1', type: 'weekly' },
|
||||
{ label: 'Monthly (1st at 9am)', value: '0 9 1 * *', type: 'monthly' },
|
||||
const schedulePresets: { key: string; value: string; type: ScheduleType }[] = [
|
||||
{ key: 'everyMinute', value: '* * * * *', type: 'interval' },
|
||||
{ key: 'every5Min', value: '*/5 * * * *', type: 'interval' },
|
||||
{ key: 'every15Min', value: '*/15 * * * *', type: 'interval' },
|
||||
{ key: 'everyHour', value: '0 * * * *', type: 'interval' },
|
||||
{ key: 'daily9am', value: '0 9 * * *', type: 'daily' },
|
||||
{ key: 'daily6pm', value: '0 18 * * *', type: 'daily' },
|
||||
{ key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' },
|
||||
{ key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' },
|
||||
];
|
||||
|
||||
// Parse cron schedule to human-readable format
|
||||
@@ -55,25 +56,25 @@ const schedulePresets: { label: string; value: string; type: ScheduleType }[] =
|
||||
// { kind: "cron", expr: "...", tz?: "..." }
|
||||
// { kind: "every", everyMs: number }
|
||||
// { kind: "at", at: "..." }
|
||||
function parseCronSchedule(schedule: unknown): string {
|
||||
function parseCronSchedule(schedule: unknown, t: TFunction<'cron'>): string {
|
||||
// Handle Gateway CronSchedule object format
|
||||
if (schedule && typeof schedule === 'object') {
|
||||
const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string };
|
||||
if (s.kind === 'cron' && typeof s.expr === 'string') {
|
||||
return parseCronExpr(s.expr);
|
||||
return parseCronExpr(s.expr, t);
|
||||
}
|
||||
if (s.kind === 'every' && typeof s.everyMs === 'number') {
|
||||
const ms = s.everyMs;
|
||||
if (ms < 60_000) return `Every ${Math.round(ms / 1000)}s`;
|
||||
if (ms < 3_600_000) return `Every ${Math.round(ms / 60_000)} minutes`;
|
||||
if (ms < 86_400_000) return `Every ${Math.round(ms / 3_600_000)} hours`;
|
||||
return `Every ${Math.round(ms / 86_400_000)} days`;
|
||||
if (ms < 60_000) return t('schedule.everySeconds', { count: Math.round(ms / 1000) });
|
||||
if (ms < 3_600_000) return t('schedule.everyMinutes', { count: Math.round(ms / 60_000) });
|
||||
if (ms < 86_400_000) return t('schedule.everyHours', { count: Math.round(ms / 3_600_000) });
|
||||
return t('schedule.everyDays', { count: Math.round(ms / 86_400_000) });
|
||||
}
|
||||
if (s.kind === 'at' && typeof s.at === 'string') {
|
||||
try {
|
||||
return `Once at ${new Date(s.at).toLocaleString()}`;
|
||||
return t('schedule.onceAt', { time: new Date(s.at).toLocaleString() });
|
||||
} catch {
|
||||
return `Once at ${s.at}`;
|
||||
return t('schedule.onceAt', { time: s.at });
|
||||
}
|
||||
}
|
||||
return String(schedule);
|
||||
@@ -81,39 +82,96 @@ function parseCronSchedule(schedule: unknown): string {
|
||||
|
||||
// Handle plain cron string
|
||||
if (typeof schedule === 'string') {
|
||||
return parseCronExpr(schedule);
|
||||
return parseCronExpr(schedule, t);
|
||||
}
|
||||
|
||||
return String(schedule ?? 'Unknown');
|
||||
return String(schedule ?? t('schedule.unknown'));
|
||||
}
|
||||
|
||||
// Parse a plain cron expression string to human-readable text
|
||||
function parseCronExpr(cron: string): string {
|
||||
function parseCronExpr(cron: string, t: TFunction<'cron'>): string {
|
||||
const preset = schedulePresets.find((p) => p.value === cron);
|
||||
if (preset) return preset.label;
|
||||
if (preset) return t(`presets.${preset.key}` as const);
|
||||
|
||||
const parts = cron.split(' ');
|
||||
if (parts.length !== 5) return cron;
|
||||
|
||||
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
|
||||
|
||||
if (minute === '*' && hour === '*') return 'Every minute';
|
||||
if (minute.startsWith('*/')) return `Every ${minute.slice(2)} minutes`;
|
||||
if (hour === '*' && minute === '0') return 'Every hour';
|
||||
if (minute === '*' && hour === '*') return t('presets.everyMinute');
|
||||
if (minute.startsWith('*/')) return t('schedule.everyMinutes', { count: Number(minute.slice(2)) });
|
||||
if (hour === '*' && minute === '0') return t('presets.everyHour');
|
||||
if (dayOfWeek !== '*' && dayOfMonth === '*') {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
return `Weekly on ${days[parseInt(dayOfWeek)]} at ${hour}:${minute.padStart(2, '0')}`;
|
||||
return t('schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` });
|
||||
}
|
||||
if (dayOfMonth !== '*') {
|
||||
return `Monthly on day ${dayOfMonth} at ${hour}:${minute.padStart(2, '0')}`;
|
||||
return t('schedule.monthlyAtDay', { day: dayOfMonth, time: `${hour}:${minute.padStart(2, '0')}` });
|
||||
}
|
||||
if (hour !== '*') {
|
||||
return `Daily at ${hour}:${minute.padStart(2, '0')}`;
|
||||
return t('schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` });
|
||||
}
|
||||
|
||||
return cron;
|
||||
}
|
||||
|
||||
function estimateNextRun(scheduleExpr: string): string | null {
|
||||
const now = new Date();
|
||||
const next = new Date(now.getTime());
|
||||
|
||||
if (scheduleExpr === '* * * * *') {
|
||||
next.setSeconds(0, 0);
|
||||
next.setMinutes(next.getMinutes() + 1);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '*/5 * * * *') {
|
||||
const delta = 5 - (next.getMinutes() % 5 || 5);
|
||||
next.setSeconds(0, 0);
|
||||
next.setMinutes(next.getMinutes() + delta);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '*/15 * * * *') {
|
||||
const delta = 15 - (next.getMinutes() % 15 || 15);
|
||||
next.setSeconds(0, 0);
|
||||
next.setMinutes(next.getMinutes() + delta);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '0 * * * *') {
|
||||
next.setMinutes(0, 0, 0);
|
||||
next.setHours(next.getHours() + 1);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') {
|
||||
const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18;
|
||||
next.setSeconds(0, 0);
|
||||
next.setHours(targetHour, 0, 0, 0);
|
||||
if (next <= now) next.setDate(next.getDate() + 1);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '0 9 * * 1') {
|
||||
next.setSeconds(0, 0);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
const day = next.getDay();
|
||||
const daysUntilMonday = day === 1 ? 7 : (8 - day) % 7;
|
||||
next.setDate(next.getDate() + daysUntilMonday);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
if (scheduleExpr === '0 9 1 * *') {
|
||||
next.setSeconds(0, 0);
|
||||
next.setDate(1);
|
||||
next.setHours(9, 0, 0, 0);
|
||||
if (next <= now) next.setMonth(next.getMonth() + 1);
|
||||
return next.toLocaleString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create/Edit Task Dialog
|
||||
interface TaskDialogProps {
|
||||
job?: CronJob;
|
||||
@@ -141,6 +199,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
const [customSchedule, setCustomSchedule] = useState('');
|
||||
const [useCustom, setUseCustom] = useState(false);
|
||||
const [enabled, setEnabled] = useState(job?.enabled ?? true);
|
||||
const schedulePreview = estimateNextRun(useCustom ? customSchedule : schedule);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) {
|
||||
@@ -226,15 +285,7 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
className="justify-start"
|
||||
>
|
||||
<Timer className="h-4 w-4 mr-2" />
|
||||
{preset.label === 'Every minute' ? t('presets.everyMinute') :
|
||||
preset.label === 'Every 5 minutes' ? t('presets.every5Min') :
|
||||
preset.label === 'Every 15 minutes' ? t('presets.every15Min') :
|
||||
preset.label === 'Every hour' ? t('presets.everyHour') :
|
||||
preset.label === 'Daily at 9am' ? t('presets.daily9am') :
|
||||
preset.label === 'Daily at 6pm' ? t('presets.daily6pm') :
|
||||
preset.label === 'Weekly (Mon 9am)' ? t('presets.weeklyMon') :
|
||||
preset.label === 'Monthly (1st at 9am)' ? t('presets.monthly1st') :
|
||||
preset.label}
|
||||
{t(`presets.${preset.key}` as const)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
@@ -254,6 +305,9 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
>
|
||||
{useCustom ? t('dialog.usePresets') : t('dialog.useCustomCron')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{schedulePreview ? `${t('card.next')}: ${schedulePreview}` : t('dialog.cronPlaceholder')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Enabled */}
|
||||
@@ -270,13 +324,13 @@ function TaskDialog({ job, onClose, onSave }: TaskDialogProps) {
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
{t('common:actions.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
{t('common:status.saving', 'Saving...')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -312,7 +366,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
||||
toast.success(t('toast.triggered'));
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger cron job:', error);
|
||||
toast.error(`Failed to trigger task: ${error instanceof Error ? error.message : String(error)}`);
|
||||
toast.error(t('toast.failedTrigger', { error: error instanceof Error ? error.message : String(error) }));
|
||||
} finally {
|
||||
setTriggering(false);
|
||||
}
|
||||
@@ -345,7 +399,7 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
||||
<CardTitle className="text-lg">{job.name}</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2">
|
||||
<Timer className="h-3 w-3" />
|
||||
{parseCronSchedule(job.schedule)}
|
||||
{parseCronSchedule(job.schedule, t)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,11 +477,11 @@ function CronJobCard({ job, onToggle, onEdit, onDelete, onTrigger }: CronJobCard
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span className="ml-1">Edit</span>
|
||||
<span className="ml-1">{t('common:actions.edit', 'Edit')}</span>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDelete}>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
<span className="ml-1 text-destructive">Delete</span>
|
||||
<span className="ml-1 text-destructive">{t('common:actions.delete', 'Delete')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -643,10 +697,10 @@ export function Cron() {
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!jobToDelete}
|
||||
title={t('common.confirm', 'Confirm')}
|
||||
title={t('common:actions.confirm', 'Confirm')}
|
||||
message={t('card.deleteConfirm')}
|
||||
confirmLabel={t('common.delete', 'Delete')}
|
||||
cancelLabel={t('common.cancel', 'Cancel')}
|
||||
confirmLabel={t('common:actions.delete', 'Delete')}
|
||||
cancelLabel={t('common:actions.cancel', 'Cancel')}
|
||||
variant="destructive"
|
||||
onConfirm={async () => {
|
||||
if (jobToDelete) {
|
||||
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
Settings,
|
||||
Plus,
|
||||
Terminal,
|
||||
Coins,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -25,6 +25,9 @@ import { useChannelsStore } from '@/stores/channels';
|
||||
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 { trackUiEvent } from '@/lib/telemetry';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type UsageHistoryEntry = {
|
||||
@@ -60,12 +63,13 @@ export function Dashboard() {
|
||||
|
||||
// Fetch data only when gateway is running
|
||||
useEffect(() => {
|
||||
trackUiEvent('dashboard.page_viewed');
|
||||
if (isGatewayRunning) {
|
||||
fetchChannels();
|
||||
fetchSkills();
|
||||
window.electron.ipcRenderer.invoke('usage:recentTokenHistory')
|
||||
invokeIpc<UsageHistoryEntry[]>('usage:recentTokenHistory')
|
||||
.then((entries) => {
|
||||
setUsageHistory(Array.isArray(entries) ? entries as typeof usageHistory : []);
|
||||
setUsageHistory(Array.isArray(entries) ? entries : []);
|
||||
setUsagePage(1);
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -107,12 +111,13 @@ export function Dashboard() {
|
||||
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
};
|
||||
}>('gateway:getControlUiUrl');
|
||||
if (result.success && result.url) {
|
||||
trackUiEvent('dashboard.quick_action', { action: 'dev_console' });
|
||||
window.electron.openExternal(result.url);
|
||||
} else {
|
||||
console.error('Failed to get Dev Console URL:', result.error);
|
||||
@@ -196,27 +201,39 @@ export function Dashboard() {
|
||||
<CardDescription>{t('quickActions.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/channels">
|
||||
<Link to="/settings" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'add_provider' })}>
|
||||
<Wrench className="h-5 w-5" />
|
||||
<span>{t('quickActions.addProvider')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/channels" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'add_channel' })}>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span>{t('quickActions.addChannel')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/skills">
|
||||
<Puzzle className="h-5 w-5" />
|
||||
<span>{t('quickActions.browseSkills')}</span>
|
||||
<Link to="/cron" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'create_cron' })}>
|
||||
<Clock className="h-5 w-5" />
|
||||
<span>{t('quickActions.createCron')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/">
|
||||
<Link to="/skills" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'install_skill' })}>
|
||||
<Puzzle className="h-5 w-5" />
|
||||
<span>{t('quickActions.installSkill')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'open_chat' })}>
|
||||
<MessageSquare className="h-5 w-5" />
|
||||
<span>{t('quickActions.openChat')}</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-auto flex-col gap-2 py-4" asChild>
|
||||
<Link to="/settings">
|
||||
<Link to="/settings" onClick={() => trackUiEvent('dashboard.quick_action', { action: 'open_settings' })}>
|
||||
<Settings className="h-5 w-5" />
|
||||
<span>{t('quickActions.settings')}</span>
|
||||
</Link>
|
||||
@@ -244,13 +261,15 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{channels.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('noChannels')}</p>
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/channels">{t('addFirst')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<FeedbackState
|
||||
state="empty"
|
||||
title={t('noChannels')}
|
||||
action={(
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/channels">{t('addFirst')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{channels.slice(0, 5).map((channel) => (
|
||||
@@ -286,13 +305,15 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{skills.filter((s) => s.enabled).length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Puzzle className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('noSkills')}</p>
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/skills">{t('enableSome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<FeedbackState
|
||||
state="empty"
|
||||
title={t('noSkills')}
|
||||
action={(
|
||||
<Button variant="link" asChild className="mt-2">
|
||||
<Link to="/skills">{t('enableSome')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skills
|
||||
@@ -322,17 +343,11 @@ export function Dashboard() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{usageLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">{t('recentTokenHistory.loading')}</div>
|
||||
<FeedbackState state="loading" title={t('recentTokenHistory.loading')} />
|
||||
) : visibleUsageHistory.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('recentTokenHistory.empty')}</p>
|
||||
</div>
|
||||
<FeedbackState state="empty" title={t('recentTokenHistory.empty')} />
|
||||
) : filteredUsageHistory.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>{t('recentTokenHistory.emptyForWindow')}</p>
|
||||
</div>
|
||||
<FeedbackState state="empty" title={t('recentTokenHistory.emptyForWindow')} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Moon,
|
||||
Monitor,
|
||||
RefreshCw,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Terminal,
|
||||
ExternalLink,
|
||||
Key,
|
||||
@@ -28,6 +30,8 @@ import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useUpdateStore } from '@/stores/update';
|
||||
import { ProvidersSettings } from '@/components/settings/ProvidersSettings';
|
||||
import { UpdateSettings } from '@/components/settings/UpdateSettings';
|
||||
import { invokeIpc, toUserMessage } from '@/lib/api-client';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
type ControlUiInfo = {
|
||||
@@ -36,6 +40,8 @@ type ControlUiInfo = {
|
||||
port: number;
|
||||
};
|
||||
|
||||
type GatewayTransportPreference = 'ws-first' | 'http-first' | 'ws-only' | 'http-only' | 'ipc-only';
|
||||
|
||||
export function Settings() {
|
||||
const { t } = useTranslation('settings');
|
||||
const {
|
||||
@@ -51,12 +57,14 @@ export function Settings() {
|
||||
proxyHttpsServer,
|
||||
proxyAllServer,
|
||||
proxyBypassRules,
|
||||
gatewayTransportPreference,
|
||||
setProxyEnabled,
|
||||
setProxyServer,
|
||||
setProxyHttpServer,
|
||||
setProxyHttpsServer,
|
||||
setProxyAllServer,
|
||||
setProxyBypassRules,
|
||||
setGatewayTransportPreference,
|
||||
autoCheckUpdate,
|
||||
setAutoCheckUpdate,
|
||||
autoDownloadUpdate,
|
||||
@@ -77,8 +85,17 @@ export function Settings() {
|
||||
const [proxyAllServerDraft, setProxyAllServerDraft] = useState('');
|
||||
const [proxyBypassRulesDraft, setProxyBypassRulesDraft] = useState('');
|
||||
const [proxyEnabledDraft, setProxyEnabledDraft] = useState(false);
|
||||
const [showAdvancedProxy, setShowAdvancedProxy] = useState(false);
|
||||
const [savingProxy, setSavingProxy] = useState(false);
|
||||
|
||||
const transportOptions: Array<{ value: GatewayTransportPreference; labelKey: string; descKey: string }> = [
|
||||
{ value: 'ws-first', labelKey: 'advanced.transport.options.wsFirst', descKey: 'advanced.transport.descriptions.wsFirst' },
|
||||
{ value: 'http-first', labelKey: 'advanced.transport.options.httpFirst', descKey: 'advanced.transport.descriptions.httpFirst' },
|
||||
{ value: 'ws-only', labelKey: 'advanced.transport.options.wsOnly', descKey: 'advanced.transport.descriptions.wsOnly' },
|
||||
{ value: 'http-only', labelKey: 'advanced.transport.options.httpOnly', descKey: 'advanced.transport.descriptions.httpOnly' },
|
||||
{ value: 'ipc-only', labelKey: 'advanced.transport.options.ipcOnly', descKey: 'advanced.transport.descriptions.ipcOnly' },
|
||||
];
|
||||
|
||||
const isWindows = window.electron.platform === 'win32';
|
||||
const showCliTools = true;
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
@@ -86,7 +103,7 @@ export function Settings() {
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
try {
|
||||
const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string;
|
||||
const logs = await invokeIpc<string>('log:readFile', 100);
|
||||
setLogContent(logs);
|
||||
setShowLogs(true);
|
||||
} catch {
|
||||
@@ -97,9 +114,9 @@ export function Settings() {
|
||||
|
||||
const handleOpenLogDir = async () => {
|
||||
try {
|
||||
const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string;
|
||||
const logDir = await invokeIpc<string>('log:getDir');
|
||||
if (logDir) {
|
||||
await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir);
|
||||
await invokeIpc('shell:showItemInFolder', logDir);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -109,15 +126,16 @@ export function Settings() {
|
||||
// Open developer console
|
||||
const openDevConsole = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
token?: string;
|
||||
port?: number;
|
||||
error?: string;
|
||||
};
|
||||
}>('gateway:getControlUiUrl');
|
||||
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');
|
||||
window.electron.openExternal(result.url);
|
||||
} else {
|
||||
console.error('Failed to get Dev Console URL:', result.error);
|
||||
@@ -129,12 +147,12 @@ export function Settings() {
|
||||
|
||||
const refreshControlUiInfo = async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:getControlUiUrl') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
url?: string;
|
||||
token?: string;
|
||||
port?: number;
|
||||
};
|
||||
}>('gateway:getControlUiUrl');
|
||||
if (result.success && result.url && result.token && typeof result.port === 'number') {
|
||||
setControlUiInfo({ url: result.url, token: result.token, port: result.port });
|
||||
}
|
||||
@@ -159,11 +177,11 @@ export function Settings() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('openclaw:getCliCommand') as {
|
||||
const result = await invokeIpc<{
|
||||
success: boolean;
|
||||
command?: string;
|
||||
error?: string;
|
||||
};
|
||||
}>('openclaw:getCliCommand');
|
||||
if (cancelled) return;
|
||||
if (result.success && result.command) {
|
||||
setOpenclawCliCommand(result.command);
|
||||
@@ -235,7 +253,7 @@ export function Settings() {
|
||||
const normalizedHttpsServer = proxyHttpsServerDraft.trim();
|
||||
const normalizedAllServer = proxyAllServerDraft.trim();
|
||||
const normalizedBypassRules = proxyBypassRulesDraft.trim();
|
||||
await window.electron.ipcRenderer.invoke('settings:setMany', {
|
||||
await invokeIpc('settings:setMany', {
|
||||
proxyEnabled: proxyEnabledDraft,
|
||||
proxyServer: normalizedProxyServer,
|
||||
proxyHttpServer: normalizedHttpServer,
|
||||
@@ -252,8 +270,9 @@ export function Settings() {
|
||||
setProxyEnabled(proxyEnabledDraft);
|
||||
|
||||
toast.success(t('gateway.proxySaved'));
|
||||
trackUiEvent('settings.proxy_saved', { enabled: proxyEnabledDraft });
|
||||
} catch (error) {
|
||||
toast.error(`${t('gateway.proxySaveFailed')}: ${String(error)}`);
|
||||
toast.error(`${t('gateway.proxySaveFailed')}: ${toUserMessage(error)}`);
|
||||
} finally {
|
||||
setSavingProxy(false);
|
||||
}
|
||||
@@ -438,7 +457,22 @@ export function Settings() {
|
||||
</div>
|
||||
|
||||
{devModeUnlocked && (
|
||||
<>
|
||||
<div className="rounded-md border border-border/60 p-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowAdvancedProxy((prev) => !prev)}
|
||||
>
|
||||
{showAdvancedProxy ? (
|
||||
<ChevronDown className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{showAdvancedProxy ? t('gateway.hideAdvancedProxy') : t('gateway.showAdvancedProxy')}
|
||||
</Button>
|
||||
{showAdvancedProxy && (
|
||||
<div className="mt-3 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-http-server">{t('gateway.proxyHttpServer')}</Label>
|
||||
<Input
|
||||
@@ -477,7 +511,9 @@ export function Settings() {
|
||||
{t('gateway.proxyAllServerHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -585,6 +621,34 @@ export function Settings() {
|
||||
<CardDescription>{t('developer.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('advanced.transport.label')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('advanced.transport.desc')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
{transportOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
variant={gatewayTransportPreference === option.value ? 'default' : 'outline'}
|
||||
className="justify-between"
|
||||
onClick={() => {
|
||||
setGatewayTransportPreference(option.value);
|
||||
toast.success(t('advanced.transport.saved'));
|
||||
}}
|
||||
>
|
||||
<span>{t(option.labelKey)}</span>
|
||||
<span className="text-xs opacity-80">{t(option.descKey)}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t('developer.console')}</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
|
||||
@@ -31,6 +31,7 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SUPPORTED_LANGUAGES } from '@/i18n';
|
||||
import { toast } from 'sonner';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
interface SetupStep {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -146,6 +147,18 @@ 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
|
||||
@@ -382,7 +395,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
|
||||
// Check OpenClaw package status
|
||||
try {
|
||||
const openclawStatus = await window.electron.ipcRenderer.invoke('openclaw:status') as {
|
||||
const openclawStatus = await invokeIpc('openclaw:status') as {
|
||||
packageExists: boolean;
|
||||
isBuilt: boolean;
|
||||
dir: string;
|
||||
@@ -526,7 +539,7 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
|
||||
const handleShowLogs = async () => {
|
||||
try {
|
||||
const logs = await window.electron.ipcRenderer.invoke('log:readFile', 100) as string;
|
||||
const logs = await invokeIpc('log:readFile', 100) as string;
|
||||
setLogContent(logs);
|
||||
setShowLogs(true);
|
||||
} catch {
|
||||
@@ -537,9 +550,9 @@ function RuntimeContent({ onStatusChange }: RuntimeContentProps) {
|
||||
|
||||
const handleOpenLogDir = async () => {
|
||||
try {
|
||||
const logDir = await window.electron.ipcRenderer.invoke('log:getDir') as string;
|
||||
const logDir = await invokeIpc('log:getDir') as string;
|
||||
if (logDir) {
|
||||
await window.electron.ipcRenderer.invoke('shell:showItemInFolder', logDir);
|
||||
await invokeIpc('shell:showItemInFolder', logDir);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
@@ -727,7 +740,7 @@ function ProviderContent({
|
||||
|
||||
if (selectedProvider) {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('provider:setDefault', selectedProvider);
|
||||
await invokeIpc('provider:setDefault', selectedProvider);
|
||||
} catch (error) {
|
||||
console.error('Failed to set default provider:', error);
|
||||
}
|
||||
@@ -761,7 +774,7 @@ function ProviderContent({
|
||||
if (!selectedProvider) return;
|
||||
|
||||
try {
|
||||
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>;
|
||||
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')) {
|
||||
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
|
||||
@@ -780,7 +793,7 @@ function ProviderContent({
|
||||
setOauthError(null);
|
||||
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('provider:requestOAuth', selectedProvider);
|
||||
await invokeIpc('provider:requestOAuth', selectedProvider);
|
||||
} catch (e) {
|
||||
setOauthError(String(e));
|
||||
setOauthFlowing(false);
|
||||
@@ -791,7 +804,7 @@ function ProviderContent({
|
||||
setOauthFlowing(false);
|
||||
setOauthData(null);
|
||||
setOauthError(null);
|
||||
await window.electron.ipcRenderer.invoke('provider:cancelOAuth');
|
||||
await invokeIpc('provider:cancelOAuth');
|
||||
};
|
||||
|
||||
// On mount, try to restore previously configured provider
|
||||
@@ -799,8 +812,8 @@ function ProviderContent({
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null;
|
||||
const list = await invokeIpc('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await invokeIpc('provider:getDefault') as string | null;
|
||||
const setupProviderTypes = new Set<string>(providers.map((p) => p.id));
|
||||
const setupCandidates = list.filter((p) => setupProviderTypes.has(p.type));
|
||||
const preferred =
|
||||
@@ -813,7 +826,7 @@ function ProviderContent({
|
||||
const typeInfo = providers.find((p) => p.id === preferred.type);
|
||||
const requiresKey = typeInfo?.requiresApiKey ?? false;
|
||||
onConfiguredChange(!requiresKey || preferred.hasKey);
|
||||
const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', preferred.id) as string | null;
|
||||
const storedKey = await invokeIpc('provider:getApiKey', preferred.id) as string | null;
|
||||
if (storedKey) {
|
||||
onApiKeyChange(storedKey);
|
||||
}
|
||||
@@ -835,8 +848,8 @@ function ProviderContent({
|
||||
(async () => {
|
||||
if (!selectedProvider) return;
|
||||
try {
|
||||
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ id: string; type: string; hasKey: boolean }>;
|
||||
const defaultId = await window.electron.ipcRenderer.invoke('provider:getDefault') as string | null;
|
||||
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))
|
||||
@@ -845,11 +858,11 @@ function ProviderContent({
|
||||
const providerIdForLoad = preferredInstance?.id || selectedProvider;
|
||||
setSelectedProviderConfigId(providerIdForLoad);
|
||||
|
||||
const savedProvider = await window.electron.ipcRenderer.invoke(
|
||||
const savedProvider = await invokeIpc(
|
||||
'provider:get',
|
||||
providerIdForLoad
|
||||
) as { baseUrl?: string; model?: string } | null;
|
||||
const storedKey = await window.electron.ipcRenderer.invoke('provider:getApiKey', providerIdForLoad) as string | null;
|
||||
const storedKey = await invokeIpc('provider:getApiKey', providerIdForLoad) as string | null;
|
||||
if (!cancelled) {
|
||||
if (storedKey) {
|
||||
onApiKeyChange(storedKey);
|
||||
@@ -906,7 +919,7 @@ function ProviderContent({
|
||||
if (!selectedProvider) return;
|
||||
|
||||
try {
|
||||
const list = await window.electron.ipcRenderer.invoke('provider:list') as Array<{ type: string }>;
|
||||
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')) {
|
||||
toast.error(t('settings:aiProviders.toast.minimaxConflict'));
|
||||
@@ -927,7 +940,7 @@ function ProviderContent({
|
||||
// Validate key if the provider requires one and a key was entered
|
||||
const isApiKeyRequired = requiresKey || (supportsApiKey && authMode === 'apikey');
|
||||
if (isApiKeyRequired && apiKey) {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc(
|
||||
'provider:validateKey',
|
||||
selectedProviderConfigId || selectedProvider,
|
||||
apiKey,
|
||||
@@ -961,7 +974,7 @@ function ProviderContent({
|
||||
const effectiveApiKey = resolveProviderApiKeyForSave(selectedProvider, apiKey);
|
||||
|
||||
// Save provider config + API key, then set as default
|
||||
const saveResult = await window.electron.ipcRenderer.invoke(
|
||||
const saveResult = await invokeIpc(
|
||||
'provider:save',
|
||||
{
|
||||
id: providerIdForSave,
|
||||
@@ -980,7 +993,7 @@ function ProviderContent({
|
||||
throw new Error(saveResult.error || 'Failed to save provider config');
|
||||
}
|
||||
|
||||
const defaultResult = await window.electron.ipcRenderer.invoke(
|
||||
const defaultResult = await invokeIpc(
|
||||
'provider:setDefault',
|
||||
providerIdForSave
|
||||
) as { success: boolean; error?: string };
|
||||
@@ -1275,7 +1288,7 @@ function ProviderContent({
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={() => window.electron.ipcRenderer.invoke('shell:openExternal', oauthData.verificationUri)}
|
||||
onClick={() => invokeIpc('shell:openExternal', oauthData.verificationUri)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
Open Login Page
|
||||
@@ -1363,7 +1376,7 @@ function InstallingContent({ skills, onComplete, onSkip }: InstallingContentProp
|
||||
setOverallProgress(10);
|
||||
|
||||
// Step 2: Call the backend to install uv and setup Python
|
||||
const result = await window.electron.ipcRenderer.invoke('uv:install-all') as {
|
||||
const result = await invokeIpc('uv:install-all') as {
|
||||
success: boolean;
|
||||
error?: string
|
||||
};
|
||||
|
||||
@@ -38,6 +38,8 @@ import { useSkillsStore } from '@/stores/skills';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invokeIpc } from '@/lib/api-client';
|
||||
import { trackUiEvent } from '@/lib/telemetry';
|
||||
import { toast } from 'sonner';
|
||||
import type { Skill, MarketplaceSkill } from '@/types/skill';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -84,14 +86,14 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
|
||||
|
||||
const handleOpenClawhub = async () => {
|
||||
if (skill.slug) {
|
||||
await window.electron.ipcRenderer.invoke('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
|
||||
await invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEditor = async () => {
|
||||
if (skill.slug) {
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('clawhub:openSkillReadme', skill.slug) as { success: boolean; error?: string };
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>('clawhub:openSkillReadme', skill.slug);
|
||||
if (result.success) {
|
||||
toast.success(t('toast.openedEditor'));
|
||||
} else {
|
||||
@@ -134,7 +136,7 @@ function SkillDetailDialog({ skill, onClose, onToggle }: SkillDetailDialogProps)
|
||||
}, {} as Record<string, string>);
|
||||
|
||||
// Use direct file access instead of Gateway RPC for reliability
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
const result = await invokeIpc<{ success: boolean; error?: string }>(
|
||||
'skill:updateConfig',
|
||||
{
|
||||
skillKey: skill.id,
|
||||
@@ -381,7 +383,7 @@ function MarketplaceSkillCard({
|
||||
onUninstall
|
||||
}: MarketplaceSkillCardProps) {
|
||||
const handleCardClick = () => {
|
||||
window.electron.ipcRenderer.invoke('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
|
||||
void invokeIpc('shell:openExternal', `https://clawhub.ai/s/${skill.slug}`);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -600,6 +602,35 @@ export function Skills() {
|
||||
marketplace: skills.filter(s => !s.isBundled).length,
|
||||
};
|
||||
|
||||
const bulkToggleVisible = useCallback(async (enable: boolean) => {
|
||||
const candidates = filteredSkills.filter((skill) => !skill.isCore && skill.enabled !== enable);
|
||||
if (candidates.length === 0) {
|
||||
toast.info(enable ? t('toast.noBatchEnableTargets') : t('toast.noBatchDisableTargets'));
|
||||
return;
|
||||
}
|
||||
|
||||
let succeeded = 0;
|
||||
for (const skill of candidates) {
|
||||
try {
|
||||
if (enable) {
|
||||
await enableSkill(skill.id);
|
||||
} else {
|
||||
await disableSkill(skill.id);
|
||||
}
|
||||
succeeded += 1;
|
||||
} catch {
|
||||
// Continue to next skill and report final summary.
|
||||
}
|
||||
}
|
||||
|
||||
trackUiEvent('skills.batch_toggle', { enable, total: candidates.length, succeeded });
|
||||
if (succeeded === candidates.length) {
|
||||
toast.success(enable ? t('toast.batchEnabled', { count: succeeded }) : t('toast.batchDisabled', { count: succeeded }));
|
||||
return;
|
||||
}
|
||||
toast.warning(t('toast.batchPartial', { success: succeeded, total: candidates.length }));
|
||||
}, [disableSkill, enableSkill, filteredSkills, t]);
|
||||
|
||||
// Handle toggle
|
||||
const handleToggle = useCallback(async (skillId: string, enable: boolean) => {
|
||||
try {
|
||||
@@ -619,11 +650,11 @@ export function Skills() {
|
||||
|
||||
const handleOpenSkillsFolder = useCallback(async () => {
|
||||
try {
|
||||
const skillsDir = await window.electron.ipcRenderer.invoke('openclaw:getSkillsDir') as string;
|
||||
const skillsDir = await invokeIpc<string>('openclaw:getSkillsDir');
|
||||
if (!skillsDir) {
|
||||
throw new Error('Skills directory not available');
|
||||
}
|
||||
const result = await window.electron.ipcRenderer.invoke('shell:openPath', skillsDir) as string;
|
||||
const result = await invokeIpc<string>('shell:openPath', skillsDir);
|
||||
if (result) {
|
||||
// shell.openPath returns an error string if the path doesn't exist
|
||||
if (result.toLowerCase().includes('no such file') || result.toLowerCase().includes('not found') || result.toLowerCase().includes('failed to open')) {
|
||||
@@ -640,7 +671,7 @@ export function Skills() {
|
||||
const [skillsDirPath, setSkillsDirPath] = useState('~/.openclaw/skills');
|
||||
|
||||
useEffect(() => {
|
||||
window.electron.ipcRenderer.invoke('openclaw:getSkillsDir')
|
||||
invokeIpc<string>('openclaw:getSkillsDir')
|
||||
.then((dir) => setSkillsDirPath(dir as string))
|
||||
.catch(console.error);
|
||||
}, []);
|
||||
@@ -804,6 +835,20 @@ export function Skills() {
|
||||
<Globe className="h-3 w-3" />
|
||||
{t('filter.marketplace', { count: sourceStats.marketplace })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void bulkToggleVisible(true); }}
|
||||
>
|
||||
{t('actions.enableVisible')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => { void bulkToggleVisible(false); }}
|
||||
>
|
||||
{t('actions.disableVisible')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user