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:
duanshuwen
2026-04-17 07:09:56 +08:00
parent d233b94b2a
commit b1dea9a5c2
68 changed files with 5910 additions and 397 deletions

View 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;

View 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
View 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
View 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
View 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
View 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);
}