feat(core): initialize project skeleton with Electron + React + TypeScript
Set up the complete project foundation for ClawX, a graphical AI assistant: - Electron main process with IPC handlers, menu, tray, and gateway management - React renderer with routing, layout components, and page scaffolding - Zustand state management for gateway, settings, channels, skills, chat, and cron - shadcn/ui components with Tailwind CSS and CSS variable theming - Build tooling with Vite, electron-builder, and TypeScript configuration - Testing setup with Vitest and Playwright - Development configurations (ESLint, Prettier, gitignore, env example)
This commit is contained in:
90
src/stores/channels.ts
Normal file
90
src/stores/channels.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Channels State Store
|
||||
* Manages messaging channel state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { Channel } from '../types/channel';
|
||||
|
||||
interface ChannelsState {
|
||||
channels: Channel[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchChannels: () => Promise<void>;
|
||||
connectChannel: (channelId: string) => Promise<void>;
|
||||
disconnectChannel: (channelId: string) => Promise<void>;
|
||||
setChannels: (channels: Channel[]) => void;
|
||||
updateChannel: (channelId: string, updates: Partial<Channel>) => void;
|
||||
}
|
||||
|
||||
export const useChannelsStore = create<ChannelsState>((set, get) => ({
|
||||
channels: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchChannels: async () => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'channels.list'
|
||||
) as { success: boolean; result?: Channel[]; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ channels: result.result, loading: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch channels', loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
connectChannel: async (channelId) => {
|
||||
const { updateChannel } = get();
|
||||
updateChannel(channelId, { status: 'connecting' });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'channels.connect',
|
||||
{ channelId }
|
||||
) as { success: boolean; error?: string };
|
||||
|
||||
if (result.success) {
|
||||
updateChannel(channelId, { status: 'connected' });
|
||||
} else {
|
||||
updateChannel(channelId, { status: 'error', error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
updateChannel(channelId, { status: 'error', error: String(error) });
|
||||
}
|
||||
},
|
||||
|
||||
disconnectChannel: async (channelId) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'channels.disconnect',
|
||||
{ channelId }
|
||||
);
|
||||
|
||||
const { updateChannel } = get();
|
||||
updateChannel(channelId, { status: 'disconnected' });
|
||||
} catch (error) {
|
||||
console.error('Failed to disconnect channel:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setChannels: (channels) => set({ channels }),
|
||||
|
||||
updateChannel: (channelId, updates) => {
|
||||
set((state) => ({
|
||||
channels: state.channels.map((channel) =>
|
||||
channel.id === channelId ? { ...channel, ...updates } : channel
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
129
src/stores/chat.ts
Normal file
129
src/stores/chat.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Chat State Store
|
||||
* Manages chat messages and conversation state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
|
||||
/**
|
||||
* Tool call in a message
|
||||
*/
|
||||
interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
status: 'pending' | 'running' | 'completed' | 'error';
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat message
|
||||
*/
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
channel?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
messages: ChatMessage[];
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchHistory: (limit?: number) => Promise<void>;
|
||||
sendMessage: (content: string, channelId?: string) => Promise<void>;
|
||||
clearHistory: () => Promise<void>;
|
||||
addMessage: (message: ChatMessage) => void;
|
||||
updateMessage: (messageId: string, updates: Partial<ChatMessage>) => void;
|
||||
setMessages: (messages: ChatMessage[]) => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
messages: [],
|
||||
loading: false,
|
||||
sending: false,
|
||||
error: null,
|
||||
|
||||
fetchHistory: async (limit = 50) => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'chat.history',
|
||||
{ limit, offset: 0 }
|
||||
) as { success: boolean; result?: ChatMessage[]; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ messages: result.result, loading: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch history', loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (content, channelId) => {
|
||||
const { addMessage } = get();
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
channel: channelId,
|
||||
};
|
||||
addMessage(userMessage);
|
||||
|
||||
set({ sending: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'chat.send',
|
||||
{ content, channelId }
|
||||
) as { success: boolean; result?: ChatMessage; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
addMessage(result.result);
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to send message' });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error) });
|
||||
} finally {
|
||||
set({ sending: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearHistory: async () => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('gateway:rpc', 'chat.clear');
|
||||
set({ messages: [] });
|
||||
} catch (error) {
|
||||
console.error('Failed to clear history:', error);
|
||||
}
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
set((state) => ({
|
||||
messages: [...state.messages, message],
|
||||
}));
|
||||
},
|
||||
|
||||
updateMessage: (messageId, updates) => {
|
||||
set((state) => ({
|
||||
messages: state.messages.map((msg) =>
|
||||
msg.id === messageId ? { ...msg, ...updates } : msg
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
setMessages: (messages) => set({ messages }),
|
||||
}));
|
||||
100
src/stores/cron.ts
Normal file
100
src/stores/cron.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Cron State Store
|
||||
* Manages scheduled task state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '../types/cron';
|
||||
|
||||
interface CronState {
|
||||
jobs: CronJob[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchJobs: () => Promise<void>;
|
||||
createJob: (input: CronJobCreateInput) => Promise<CronJob>;
|
||||
updateJob: (id: string, input: CronJobUpdateInput) => Promise<void>;
|
||||
deleteJob: (id: string) => Promise<void>;
|
||||
toggleJob: (id: string, enabled: boolean) => Promise<void>;
|
||||
triggerJob: (id: string) => Promise<void>;
|
||||
setJobs: (jobs: CronJob[]) => void;
|
||||
}
|
||||
|
||||
export const useCronStore = create<CronState>((set, get) => ({
|
||||
jobs: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchJobs: async () => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke('cron:list') as CronJob[];
|
||||
set({ jobs: result, loading: false });
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createJob: async (input) => {
|
||||
try {
|
||||
const job = await window.electron.ipcRenderer.invoke('cron:create', input) as CronJob;
|
||||
set((state) => ({ jobs: [...state.jobs, job] }));
|
||||
return job;
|
||||
} catch (error) {
|
||||
console.error('Failed to create cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateJob: async (id, input) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:update', id, input);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, ...input, updatedAt: new Date().toISOString() } : job
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to update cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteJob: async (id) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:delete', id);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.filter((job) => job.id !== id),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
toggleJob: async (id, enabled) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:toggle', id, enabled);
|
||||
set((state) => ({
|
||||
jobs: state.jobs.map((job) =>
|
||||
job.id === id ? { ...job, enabled } : job
|
||||
),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
triggerJob: async (id) => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('cron:trigger', id);
|
||||
} catch (error) {
|
||||
console.error('Failed to trigger cron job:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setJobs: (jobs) => set({ jobs }),
|
||||
}));
|
||||
73
src/stores/gateway.ts
Normal file
73
src/stores/gateway.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Gateway State Store
|
||||
* Manages Gateway connection state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { GatewayStatus } from '../types/gateway';
|
||||
|
||||
interface GatewayState {
|
||||
status: GatewayStatus;
|
||||
isInitialized: boolean;
|
||||
|
||||
// Actions
|
||||
init: () => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
restart: () => Promise<void>;
|
||||
setStatus: (status: GatewayStatus) => void;
|
||||
}
|
||||
|
||||
export const useGatewayStore = create<GatewayState>((set, get) => ({
|
||||
status: {
|
||||
state: 'stopped',
|
||||
port: 18789,
|
||||
},
|
||||
isInitialized: false,
|
||||
|
||||
init: async () => {
|
||||
if (get().isInitialized) return;
|
||||
|
||||
try {
|
||||
// Get initial status
|
||||
const status = await window.electron.ipcRenderer.invoke('gateway:status') as GatewayStatus;
|
||||
set({ status, isInitialized: true });
|
||||
|
||||
// Listen for status changes
|
||||
window.electron.ipcRenderer.on('gateway:status-changed', (newStatus) => {
|
||||
set({ status: newStatus as GatewayStatus });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Gateway:', error);
|
||||
}
|
||||
},
|
||||
|
||||
start: async () => {
|
||||
try {
|
||||
set({ status: { ...get().status, state: 'starting' } });
|
||||
const result = await window.electron.ipcRenderer.invoke('gateway:start') as { success: boolean; error?: string };
|
||||
|
||||
if (!result.success) {
|
||||
set({ status: { ...get().status, state: 'error', error: result.error } });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ status: { ...get().status, state: 'error', error: String(error) } });
|
||||
}
|
||||
},
|
||||
|
||||
stop: async () => {
|
||||
try {
|
||||
await window.electron.ipcRenderer.invoke('gateway:stop');
|
||||
set({ status: { ...get().status, state: 'stopped' } });
|
||||
} catch (error) {
|
||||
console.error('Failed to stop Gateway:', error);
|
||||
}
|
||||
},
|
||||
|
||||
restart: async () => {
|
||||
const { stop, start } = get();
|
||||
await stop();
|
||||
await start();
|
||||
},
|
||||
|
||||
setStatus: (status) => set({ status }),
|
||||
}));
|
||||
82
src/stores/settings.ts
Normal file
82
src/stores/settings.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Settings State Store
|
||||
* Manages application settings
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type UpdateChannel = 'stable' | 'beta' | 'dev';
|
||||
|
||||
interface SettingsState {
|
||||
// General
|
||||
theme: Theme;
|
||||
language: string;
|
||||
startMinimized: boolean;
|
||||
launchAtStartup: boolean;
|
||||
|
||||
// Gateway
|
||||
gatewayAutoStart: boolean;
|
||||
gatewayPort: number;
|
||||
|
||||
// Update
|
||||
updateChannel: UpdateChannel;
|
||||
autoCheckUpdate: boolean;
|
||||
autoDownloadUpdate: boolean;
|
||||
|
||||
// UI State
|
||||
sidebarCollapsed: boolean;
|
||||
devModeUnlocked: boolean;
|
||||
|
||||
// Actions
|
||||
setTheme: (theme: Theme) => void;
|
||||
setLanguage: (language: string) => void;
|
||||
setStartMinimized: (value: boolean) => void;
|
||||
setLaunchAtStartup: (value: boolean) => void;
|
||||
setGatewayAutoStart: (value: boolean) => void;
|
||||
setGatewayPort: (port: number) => void;
|
||||
setUpdateChannel: (channel: UpdateChannel) => void;
|
||||
setAutoCheckUpdate: (value: boolean) => void;
|
||||
setAutoDownloadUpdate: (value: boolean) => void;
|
||||
setSidebarCollapsed: (value: boolean) => void;
|
||||
setDevModeUnlocked: (value: boolean) => void;
|
||||
resetSettings: () => void;
|
||||
}
|
||||
|
||||
const defaultSettings = {
|
||||
theme: 'system' as Theme,
|
||||
language: 'en',
|
||||
startMinimized: false,
|
||||
launchAtStartup: false,
|
||||
gatewayAutoStart: true,
|
||||
gatewayPort: 18789,
|
||||
updateChannel: 'stable' as UpdateChannel,
|
||||
autoCheckUpdate: true,
|
||||
autoDownloadUpdate: false,
|
||||
sidebarCollapsed: false,
|
||||
devModeUnlocked: false,
|
||||
};
|
||||
|
||||
export const useSettingsStore = create<SettingsState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...defaultSettings,
|
||||
|
||||
setTheme: (theme) => set({ theme }),
|
||||
setLanguage: (language) => set({ language }),
|
||||
setStartMinimized: (startMinimized) => set({ startMinimized }),
|
||||
setLaunchAtStartup: (launchAtStartup) => set({ launchAtStartup }),
|
||||
setGatewayAutoStart: (gatewayAutoStart) => set({ gatewayAutoStart }),
|
||||
setGatewayPort: (gatewayPort) => set({ gatewayPort }),
|
||||
setUpdateChannel: (updateChannel) => set({ updateChannel }),
|
||||
setAutoCheckUpdate: (autoCheckUpdate) => set({ autoCheckUpdate }),
|
||||
setAutoDownloadUpdate: (autoDownloadUpdate) => set({ autoDownloadUpdate }),
|
||||
setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
|
||||
setDevModeUnlocked: (devModeUnlocked) => set({ devModeUnlocked }),
|
||||
resetSettings: () => set(defaultSettings),
|
||||
}),
|
||||
{
|
||||
name: 'clawx-settings',
|
||||
}
|
||||
)
|
||||
);
|
||||
102
src/stores/skills.ts
Normal file
102
src/stores/skills.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Skills State Store
|
||||
* Manages skill/plugin state
|
||||
*/
|
||||
import { create } from 'zustand';
|
||||
import type { Skill } from '../types/skill';
|
||||
|
||||
interface SkillsState {
|
||||
skills: Skill[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchSkills: () => Promise<void>;
|
||||
enableSkill: (skillId: string) => Promise<void>;
|
||||
disableSkill: (skillId: string) => Promise<void>;
|
||||
setSkills: (skills: Skill[]) => void;
|
||||
updateSkill: (skillId: string, updates: Partial<Skill>) => void;
|
||||
}
|
||||
|
||||
export const useSkillsStore = create<SkillsState>((set, get) => ({
|
||||
skills: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
|
||||
fetchSkills: async () => {
|
||||
set({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'skills.list'
|
||||
) as { success: boolean; result?: Skill[]; error?: string };
|
||||
|
||||
if (result.success && result.result) {
|
||||
set({ skills: result.result, loading: false });
|
||||
} else {
|
||||
set({ error: result.error || 'Failed to fetch skills', loading: false });
|
||||
}
|
||||
} catch (error) {
|
||||
set({ error: String(error), loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
enableSkill: async (skillId) => {
|
||||
const { updateSkill } = get();
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'skills.enable',
|
||||
{ skillId }
|
||||
) as { success: boolean; error?: string };
|
||||
|
||||
if (result.success) {
|
||||
updateSkill(skillId, { enabled: true });
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to enable skill');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to enable skill:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
disableSkill: async (skillId) => {
|
||||
const { updateSkill, skills } = get();
|
||||
|
||||
// Check if skill is a core skill
|
||||
const skill = skills.find((s) => s.id === skillId);
|
||||
if (skill?.isCore) {
|
||||
throw new Error('Cannot disable core skill');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await window.electron.ipcRenderer.invoke(
|
||||
'gateway:rpc',
|
||||
'skills.disable',
|
||||
{ skillId }
|
||||
) as { success: boolean; error?: string };
|
||||
|
||||
if (result.success) {
|
||||
updateSkill(skillId, { enabled: false });
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to disable skill');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to disable skill:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setSkills: (skills) => set({ skills }),
|
||||
|
||||
updateSkill: (skillId, updates) => {
|
||||
set((state) => ({
|
||||
skills: state.skills.map((skill) =>
|
||||
skill.id === skillId ? { ...skill, ...updates } : skill
|
||||
),
|
||||
}));
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user