feat: implement task management store with IPC integration
- Added a new task store in `src-react/stores/task.ts` to manage tasks and their statuses. - Implemented functions for creating, executing, and retrying tasks, along with handling task progress and completion. - Introduced persistence for tasks using IPC. - Created utility functions for normalizing room types and building subtasks. - Added a new CSS file for global styles in `src-react/styles.css`. - Created runtime types in `src-react/types/runtime.ts` and exported them. - Updated the main entry points for Vue and React applications to support dynamic framework loading. - Refactored chat model interfaces and utility functions into `src/shared/chat-model.ts`. - Updated TypeScript configuration to include paths for React components and types. - Enhanced Vite configuration to support both Vue and React frameworks.
This commit is contained in:
31
src-react/lib/constants.ts
Normal file
31
src-react/lib/constants.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { CONFIG_KEYS as RUNTIME_CONFIG_KEYS } from '../types/runtime';
|
||||
|
||||
export const IPC_EVENTS = {
|
||||
HOST_API_FETCH: 'hostapi:fetch',
|
||||
GATEWAY_RPC: 'gateway:rpc',
|
||||
GATEWAY_EVENT: 'gateway:event',
|
||||
GET_CONFIG: 'get-config',
|
||||
SET_CONFIG: 'set-config',
|
||||
GET_THEME_MODE: 'get-theme-mode',
|
||||
SET_THEME_MODE: 'set-theme-mode',
|
||||
THEME_MODE_UPDATED: 'theme-mode-updated',
|
||||
GET_WINDOW_ID: 'get-window-id',
|
||||
TASK_PROGRESS: 'task:progress',
|
||||
TASK_STARTED: 'task:started',
|
||||
TASK_COMPLETED: 'task:completed',
|
||||
OPEN_CHANNEL: 'open-channel',
|
||||
EXECUTE_SCRIPT: 'execute-script',
|
||||
} as const;
|
||||
|
||||
export const CONFIG_KEYS = RUNTIME_CONFIG_KEYS;
|
||||
|
||||
export const WINDOW_NAMES = {
|
||||
MAIN: 'main',
|
||||
SETTING: 'setting',
|
||||
DIALOG: 'dialog',
|
||||
LOADING: 'loading',
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_THEME_MODE = 'system' as const;
|
||||
|
||||
export const DEFAULT_LANGUAGE = 'zh' as const;
|
||||
12
src-react/lib/gateway-client.ts
Normal file
12
src-react/lib/gateway-client.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IPC_EVENTS } from './constants';
|
||||
import { invokeIpc, onIpc } from './host-api';
|
||||
import type { GatewayEvent } from '../types/runtime';
|
||||
|
||||
export async function gatewayRpc<T = unknown>(method: string, params?: unknown): Promise<T> {
|
||||
return invokeIpc<T>(IPC_EVENTS.GATEWAY_RPC, method, params);
|
||||
}
|
||||
|
||||
export function onGatewayEvent(callback: (event: GatewayEvent) => void): () => void {
|
||||
return onIpc(IPC_EVENTS.GATEWAY_EVENT, callback as (...args: any[]) => void);
|
||||
}
|
||||
|
||||
142
src-react/lib/host-api.ts
Normal file
142
src-react/lib/host-api.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { IPC_EVENTS } from './constants';
|
||||
import type { HostApiResult } from '../types/runtime';
|
||||
|
||||
type RequestInitLike = Pick<RequestInit, 'method' | 'headers' | 'body'>;
|
||||
|
||||
type LooseIpcBridge = {
|
||||
invoke<T = unknown>(channel: string, ...args: any[]): Promise<T>;
|
||||
on?(channel: string, callback: (...args: any[]) => void): () => void;
|
||||
};
|
||||
|
||||
function normalizeHeaders(headers?: HeadersInit): Headers {
|
||||
return new Headers(headers ?? {});
|
||||
}
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
if (typeof document === 'undefined' || !document.cookie) return null;
|
||||
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}=([^;]*)`));
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
function readToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const storageCandidates = [window.sessionStorage, window.localStorage];
|
||||
for (const storage of storageCandidates) {
|
||||
for (const key of ['token', 'access_token', 'refresh_token']) {
|
||||
const value = storage.getItem(key);
|
||||
if (value) return value;
|
||||
}
|
||||
}
|
||||
|
||||
return readCookie('token') ?? readCookie('access_token') ?? readCookie('refresh_token');
|
||||
}
|
||||
|
||||
function normalizeBody(body: BodyInit | null | undefined): BodyInit | null | undefined {
|
||||
if (body == null) return body;
|
||||
if (
|
||||
typeof body === 'string' ||
|
||||
body instanceof Blob ||
|
||||
body instanceof FormData ||
|
||||
body instanceof URLSearchParams ||
|
||||
body instanceof ArrayBuffer
|
||||
) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (ArrayBuffer.isView(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return JSON.stringify(body);
|
||||
}
|
||||
|
||||
function extractResult<T>(response: unknown): T {
|
||||
if (response && typeof response === 'object') {
|
||||
const result = response as HostApiResult<T>;
|
||||
if (result.success === false || result.ok === false) {
|
||||
throw new Error(result.error || result.text || 'Request failed');
|
||||
}
|
||||
|
||||
if (typeof result.json !== 'undefined') return result.json;
|
||||
if (typeof result.data !== 'undefined') {
|
||||
const data = result.data as { json?: T } | T;
|
||||
return (data && typeof data === 'object' && 'json' in data ? data.json : data) as T;
|
||||
}
|
||||
}
|
||||
|
||||
return response as T;
|
||||
}
|
||||
|
||||
export function hasHostApiBridge(): boolean {
|
||||
return typeof window !== 'undefined' && Boolean(window.api?.invoke);
|
||||
}
|
||||
|
||||
export async function invokeIpc<T = unknown>(channel: string, ...args: any[]): Promise<T> {
|
||||
if (!hasHostApiBridge()) {
|
||||
throw new Error(`IPC bridge is unavailable for ${channel}`);
|
||||
}
|
||||
|
||||
const bridge = window.api as unknown as LooseIpcBridge;
|
||||
return bridge.invoke<T>(channel, ...args);
|
||||
}
|
||||
|
||||
export function onIpc(channel: string, callback: (...args: any[]) => void): () => void {
|
||||
if (!hasHostApiBridge() || !window.api?.on) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const bridge = window.api as unknown as LooseIpcBridge;
|
||||
return bridge.on ? bridge.on(channel, callback) : () => {};
|
||||
}
|
||||
|
||||
export async function hostApiFetch<T>(path: string, init?: RequestInitLike): Promise<T> {
|
||||
const method = init?.method ?? 'GET';
|
||||
const headers = normalizeHeaders(init?.headers);
|
||||
const token = readToken();
|
||||
|
||||
if (token && !headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const request = {
|
||||
path,
|
||||
method,
|
||||
headers: (() => {
|
||||
const headerObject: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
headerObject[key] = value;
|
||||
});
|
||||
return headerObject;
|
||||
})(),
|
||||
body: init?.body ?? null,
|
||||
};
|
||||
|
||||
if (hasHostApiBridge()) {
|
||||
const response = await invokeIpc(IPC_EVENTS.HOST_API_FETCH, request);
|
||||
return extractResult<T>(response);
|
||||
}
|
||||
|
||||
if (typeof fetch === 'function') {
|
||||
const response = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
body: normalizeBody(init?.body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(text || response.statusText || `Request failed with ${response.status}`);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
return (await response.text()) as unknown as T;
|
||||
}
|
||||
|
||||
throw new Error(`No HTTP bridge available for ${path}`);
|
||||
}
|
||||
52
src-react/lib/index.ts
Normal file
52
src-react/lib/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export {
|
||||
CONFIG_KEYS,
|
||||
DEFAULT_LANGUAGE,
|
||||
DEFAULT_THEME_MODE,
|
||||
IPC_EVENTS,
|
||||
WINDOW_NAMES,
|
||||
} from './constants';
|
||||
|
||||
export {
|
||||
hostApiFetch,
|
||||
hostApiFetch as fetchFromHost,
|
||||
hasHostApiBridge,
|
||||
invokeIpc,
|
||||
onIpc,
|
||||
} from './host-api';
|
||||
|
||||
export {
|
||||
gatewayRpc,
|
||||
gatewayRpc as callGateway,
|
||||
onGatewayEvent,
|
||||
} from './gateway-client';
|
||||
|
||||
export {
|
||||
applyThemeModeToDocument,
|
||||
detectSystemTheme,
|
||||
resolveAppliedTheme,
|
||||
watchSystemTheme,
|
||||
} from './theme';
|
||||
|
||||
export {
|
||||
detectRuntimePlatform,
|
||||
detectWindowName,
|
||||
hasIpcBridge,
|
||||
resolveWindowIdentity,
|
||||
} from './runtime';
|
||||
|
||||
export type {
|
||||
ConfigKey,
|
||||
ConfigValueKey,
|
||||
ConfigValueMap,
|
||||
GatewayEvent,
|
||||
HostApiResult,
|
||||
IpcArgs,
|
||||
IpcListener,
|
||||
LanguageCode,
|
||||
ResolvedThemeMode,
|
||||
RuntimePlatform,
|
||||
ThemeMode,
|
||||
WindowApiBridge,
|
||||
WindowIdentity,
|
||||
WindowName,
|
||||
} from '../types/runtime';
|
||||
76
src-react/lib/runtime.ts
Normal file
76
src-react/lib/runtime.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { IPC_EVENTS, WINDOW_NAMES } from './constants';
|
||||
import { invokeIpc } from './host-api';
|
||||
import type { RuntimePlatform, WindowIdentity, WindowName } from '../types/runtime';
|
||||
|
||||
function normalizePlatform(platform: string | undefined | null): RuntimePlatform {
|
||||
const value = platform?.toLowerCase() ?? '';
|
||||
if (value === 'win32' || value.includes('win')) return 'win32';
|
||||
if (value === 'darwin' || value.includes('mac')) return 'darwin';
|
||||
if (value === 'linux') return 'linux';
|
||||
if (value) return 'unknown';
|
||||
return 'web';
|
||||
}
|
||||
|
||||
export function hasIpcBridge(): boolean {
|
||||
return typeof window !== 'undefined' && Boolean(window.api?.invoke);
|
||||
}
|
||||
|
||||
export function detectRuntimePlatform(): RuntimePlatform {
|
||||
if (typeof window === 'undefined') return 'unknown';
|
||||
const exposedPlatform = window.api?.platform;
|
||||
if (exposedPlatform) return normalizePlatform(exposedPlatform);
|
||||
if (typeof navigator !== 'undefined') {
|
||||
const nav = navigator as Navigator & { userAgentData?: { platform?: string } };
|
||||
return normalizePlatform(nav.platform || nav.userAgentData?.platform);
|
||||
}
|
||||
return window.api ? 'unknown' : 'web';
|
||||
}
|
||||
|
||||
function readWindowIdFromBridge(): string | number | null {
|
||||
if (typeof window.api?.getCurrentWindowId === 'function') {
|
||||
return window.api.getCurrentWindowId();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectWindowName(windowId?: string | number | null): WindowName {
|
||||
if (typeof windowId === 'string') {
|
||||
const normalized = windowId.toLowerCase();
|
||||
if (normalized.includes('setting')) return WINDOW_NAMES.SETTING;
|
||||
if (normalized.includes('dialog')) return WINDOW_NAMES.DIALOG;
|
||||
if (normalized.includes('loading')) return WINDOW_NAMES.LOADING;
|
||||
}
|
||||
return WINDOW_NAMES.MAIN;
|
||||
}
|
||||
|
||||
export async function resolveWindowIdentity(): Promise<WindowIdentity> {
|
||||
const platform = detectRuntimePlatform();
|
||||
const isElectron = platform !== 'web' && platform !== 'unknown';
|
||||
|
||||
if (!hasIpcBridge()) {
|
||||
return {
|
||||
platform,
|
||||
windowId: null,
|
||||
windowName: WINDOW_NAMES.MAIN,
|
||||
isElectron,
|
||||
};
|
||||
}
|
||||
|
||||
let windowId: string | number | null = null;
|
||||
try {
|
||||
windowId = readWindowIdFromBridge();
|
||||
if (windowId === null) {
|
||||
windowId = await invokeIpc<string | number | null>(IPC_EVENTS.GET_WINDOW_ID);
|
||||
}
|
||||
} catch {
|
||||
windowId = null;
|
||||
}
|
||||
|
||||
return {
|
||||
platform,
|
||||
windowId,
|
||||
windowName: detectWindowName(windowId),
|
||||
isElectron,
|
||||
};
|
||||
}
|
||||
51
src-react/lib/theme.ts
Normal file
51
src-react/lib/theme.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { ResolvedThemeMode, ThemeMode } from '../types/runtime';
|
||||
|
||||
export function detectSystemTheme(): ResolvedThemeMode {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return 'light';
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
export function resolveAppliedTheme(
|
||||
themeMode: ThemeMode,
|
||||
systemTheme: ResolvedThemeMode = detectSystemTheme(),
|
||||
): ResolvedThemeMode {
|
||||
return themeMode === 'system' ? systemTheme : themeMode;
|
||||
}
|
||||
|
||||
export function applyThemeModeToDocument(
|
||||
themeMode: ThemeMode,
|
||||
systemTheme: ResolvedThemeMode = detectSystemTheme(),
|
||||
): ResolvedThemeMode {
|
||||
const appliedTheme = resolveAppliedTheme(themeMode, systemTheme);
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', appliedTheme === 'dark');
|
||||
root.dataset.theme = appliedTheme;
|
||||
root.style.colorScheme = appliedTheme;
|
||||
}
|
||||
|
||||
return appliedTheme;
|
||||
}
|
||||
|
||||
export function watchSystemTheme(onChange: (theme: ResolvedThemeMode) => void): () => void {
|
||||
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (event: MediaQueryListEvent) => {
|
||||
onChange(event.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
if (typeof mediaQuery.addEventListener === 'function') {
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return () => mediaQuery.removeEventListener('change', handler);
|
||||
}
|
||||
|
||||
mediaQuery.addListener(handler);
|
||||
return () => mediaQuery.removeListener(handler);
|
||||
}
|
||||
Reference in New Issue
Block a user