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:
Haze
2026-02-05 23:09:17 +08:00
Unverified
parent 9442e5f77a
commit b8ab0208d0
71 changed files with 14086 additions and 3 deletions

90
src/stores/channels.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
),
}));
},
}));