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:
202
src-react/stores/channel.ts
Normal file
202
src-react/stores/channel.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { AutomationScript } from '@lib/script-types';
|
||||
import { scriptApi } from '@lib/script-api';
|
||||
import { resolveChannel } from '@constant/channel';
|
||||
import { CONFIG_KEYS, IPC_EVENTS } from '../lib/constants';
|
||||
import { invokeIpc } from '../lib/host-api';
|
||||
|
||||
export interface ChannelItem {
|
||||
id: string;
|
||||
channelName: string;
|
||||
channelUrl: string;
|
||||
}
|
||||
|
||||
export interface ChannelStoreState {
|
||||
initialized: boolean;
|
||||
loading: boolean;
|
||||
selectedChannels: ChannelItem[];
|
||||
availableChannels: ChannelItem[];
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let state: ChannelStoreState = {
|
||||
initialized: false,
|
||||
loading: false,
|
||||
selectedChannels: [],
|
||||
availableChannels: [],
|
||||
error: null,
|
||||
};
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function patchState(patch: Partial<ChannelStoreState>): ChannelStoreState {
|
||||
state = { ...state, ...patch };
|
||||
emit();
|
||||
return state;
|
||||
}
|
||||
|
||||
function normalizeChannelItem(item: Partial<ChannelItem> | null | undefined): ChannelItem | null {
|
||||
const channelUrl = String(item?.channelUrl ?? '').trim();
|
||||
if (!channelUrl) return null;
|
||||
|
||||
const channelName = String(item?.channelName ?? channelUrl).trim() || channelUrl;
|
||||
const id = String(item?.id ?? channelUrl).trim() || channelUrl;
|
||||
|
||||
return {
|
||||
id,
|
||||
channelName,
|
||||
channelUrl,
|
||||
};
|
||||
}
|
||||
|
||||
function dedupeChannels(items: Array<Partial<ChannelItem> | null | undefined>): ChannelItem[] {
|
||||
const channelMap = new Map<string, ChannelItem>();
|
||||
|
||||
for (const item of items) {
|
||||
const normalized = normalizeChannelItem(item);
|
||||
if (!normalized || channelMap.has(normalized.channelUrl)) continue;
|
||||
channelMap.set(normalized.channelUrl, normalized);
|
||||
}
|
||||
|
||||
return Array.from(channelMap.values());
|
||||
}
|
||||
|
||||
function mapScriptsToChannels(scripts: AutomationScript[]): ChannelItem[] {
|
||||
const items: ChannelItem[] = [];
|
||||
|
||||
for (const script of scripts) {
|
||||
if (!script.channel) continue;
|
||||
|
||||
const resolved = resolveChannel(script.channel);
|
||||
const channelUrl = String(resolved.url ?? '').trim();
|
||||
if (!channelUrl) continue;
|
||||
|
||||
items.push({
|
||||
id: channelUrl,
|
||||
channelName: String(resolved.name ?? channelUrl).trim() || channelUrl,
|
||||
channelUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return dedupeChannels(items);
|
||||
}
|
||||
|
||||
async function loadSelectedChannels(): Promise<ChannelItem[]> {
|
||||
const saved = await invokeIpc<ChannelItem[]>(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.SELECTED_CHANNELS);
|
||||
return Array.isArray(saved) ? dedupeChannels(saved) : [];
|
||||
}
|
||||
|
||||
async function loadAvailableChannels(): Promise<ChannelItem[]> {
|
||||
const scripts = await scriptApi.list();
|
||||
return mapScriptsToChannels(Array.isArray(scripts) ? scripts : []);
|
||||
}
|
||||
|
||||
async function hydrate(): Promise<void> {
|
||||
patchState({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const [selectedChannels, availableChannels] = await Promise.all([
|
||||
loadSelectedChannels(),
|
||||
loadAvailableChannels(),
|
||||
]);
|
||||
|
||||
patchState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
selectedChannels,
|
||||
availableChannels,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
patchState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
selectedChannels: [],
|
||||
availableChannels: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAvailableChannels(): Promise<ChannelItem[]> {
|
||||
patchState({ loading: true, error: null });
|
||||
|
||||
try {
|
||||
const availableChannels = await loadAvailableChannels();
|
||||
patchState({
|
||||
loading: false,
|
||||
availableChannels,
|
||||
error: null,
|
||||
});
|
||||
return availableChannels;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
patchState({
|
||||
loading: false,
|
||||
error: message,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSelectedChannels(items: ChannelItem[]): Promise<ChannelItem[]> {
|
||||
const nextItems = dedupeChannels(items);
|
||||
patchState({ selectedChannels: nextItems, error: null });
|
||||
|
||||
await invokeIpc(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.SELECTED_CHANNELS, nextItems);
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
function setSelectedChannels(items: ChannelItem[]): ChannelItem[] {
|
||||
const nextItems = dedupeChannels(items);
|
||||
patchState({ selectedChannels: nextItems });
|
||||
return nextItems;
|
||||
}
|
||||
|
||||
function addSelectedChannel(item: ChannelItem): ChannelItem[] {
|
||||
return setSelectedChannels([...state.selectedChannels, item]);
|
||||
}
|
||||
|
||||
function removeSelectedChannel(id: string): ChannelItem[] {
|
||||
return setSelectedChannels(state.selectedChannels.filter((item) => item.id !== id));
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): ChannelStoreState {
|
||||
return state;
|
||||
}
|
||||
|
||||
async function initChannelStore(): Promise<void> {
|
||||
if (!initPromise) {
|
||||
initPromise = hydrate();
|
||||
}
|
||||
|
||||
await initPromise;
|
||||
}
|
||||
|
||||
export const channelStore = {
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getState: () => state,
|
||||
init: initChannelStore,
|
||||
loadSelectedChannels,
|
||||
refreshAvailableChannels,
|
||||
saveSelectedChannels,
|
||||
setSelectedChannels,
|
||||
addSelectedChannel,
|
||||
removeSelectedChannel,
|
||||
};
|
||||
|
||||
export function useChannelStore(): ChannelStoreState {
|
||||
return useSyncExternalStore(channelStore.subscribe, channelStore.getSnapshot, channelStore.getSnapshot);
|
||||
}
|
||||
788
src-react/stores/chat.ts
Normal file
788
src-react/stores/chat.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { ChatSession, RawMessage, ToolStatus } from '@shared/chat-model';
|
||||
import { extractText, isToolOnlyMessage } from '@shared/chat-model';
|
||||
import { gatewayRpc, onGatewayEvent } from '../lib/gateway-client';
|
||||
import { hostApiFetch } from '../lib/host-api';
|
||||
import type { GatewayEvent } from '../types/runtime';
|
||||
|
||||
const DEFAULT_SESSION_KEY = 'agent:main:main';
|
||||
const DEFAULT_AGENT_ID = '1953462165250859011';
|
||||
const SESSION_LOAD_MIN_INTERVAL_MS = 1200;
|
||||
const HISTORY_LOAD_MIN_INTERVAL_MS = 800;
|
||||
const CHAT_EVENT_DEDUPE_TTL_MS = 30000;
|
||||
|
||||
export interface StagedAttachment {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
stagedPath: string;
|
||||
preview: string | null;
|
||||
}
|
||||
|
||||
export interface ChatStoreState {
|
||||
initialized: boolean;
|
||||
messages: RawMessage[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
sending: boolean;
|
||||
activeRunId: string | null;
|
||||
streamingMessage: RawMessage | null;
|
||||
streamingTools: ToolStatus[];
|
||||
pendingFinal: boolean;
|
||||
lastUserMessageAt: number | null;
|
||||
sessions: ChatSession[];
|
||||
currentSessionKey: string;
|
||||
currentAgentId: string;
|
||||
sessionLabels: Record<string, string>;
|
||||
sessionLastActivity: Record<string, number>;
|
||||
gatewayStatus: 'connected' | 'disconnected' | 'reconnecting';
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
const historyLoadInFlight = new Map<string, Promise<void>>();
|
||||
const lastHistoryLoadAtBySession = new Map<string, number>();
|
||||
const chatEventDedupe = new Map<string, number>();
|
||||
|
||||
let gatewaySubscribed = false;
|
||||
let loadSessionsInFlight: Promise<void> | null = null;
|
||||
let lastLoadSessionsAt = 0;
|
||||
let state: ChatStoreState = {
|
||||
initialized: false,
|
||||
messages: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
sessions: [],
|
||||
currentSessionKey: DEFAULT_SESSION_KEY,
|
||||
currentAgentId: DEFAULT_AGENT_ID,
|
||||
sessionLabels: {},
|
||||
sessionLastActivity: {},
|
||||
gatewayStatus: 'disconnected',
|
||||
};
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function patchState(patch: Partial<ChatStoreState>): ChatStoreState {
|
||||
state = { ...state, ...patch };
|
||||
emit();
|
||||
return state;
|
||||
}
|
||||
|
||||
function getAgentIdFromSessionKey(sessionKey: string): string {
|
||||
if (!sessionKey.startsWith('agent:')) return DEFAULT_AGENT_ID;
|
||||
const parts = sessionKey.split(':');
|
||||
return parts[1] || DEFAULT_AGENT_ID;
|
||||
}
|
||||
|
||||
function clearSessionEntryFromMap<T extends Record<string, unknown>>(entries: T, sessionKey: string): T {
|
||||
return Object.fromEntries(Object.entries(entries).filter(([key]) => key !== sessionKey)) as T;
|
||||
}
|
||||
|
||||
function ensureSessionEntry(sessions: ChatSession[], sessionKey: string, displayName?: string): ChatSession[] {
|
||||
if (sessions.some((session) => session.key === sessionKey)) return sessions;
|
||||
return [...sessions, { key: sessionKey, displayName: displayName || 'New Chat' }];
|
||||
}
|
||||
|
||||
function toMs(ts: number): number {
|
||||
return ts < 1e12 ? ts * 1000 : ts;
|
||||
}
|
||||
|
||||
function buildSessionSwitchPatch(currentState: ChatStoreState, nextSessionKey: string): Partial<ChatStoreState> {
|
||||
const leavingEmpty =
|
||||
!currentState.currentSessionKey.endsWith(':main') &&
|
||||
currentState.messages.length === 0 &&
|
||||
!currentState.sessionLastActivity[currentState.currentSessionKey] &&
|
||||
!currentState.sessionLabels[currentState.currentSessionKey];
|
||||
|
||||
const nextSessions = leavingEmpty
|
||||
? currentState.sessions.filter((session) => session.key !== currentState.currentSessionKey)
|
||||
: currentState.sessions;
|
||||
|
||||
return {
|
||||
currentSessionKey: nextSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||
sessions: ensureSessionEntry(nextSessions, nextSessionKey),
|
||||
sessionLabels: leavingEmpty
|
||||
? clearSessionEntryFromMap(currentState.sessionLabels, currentState.currentSessionKey)
|
||||
: currentState.sessionLabels,
|
||||
sessionLastActivity: leavingEmpty
|
||||
? clearSessionEntryFromMap(currentState.sessionLastActivity, currentState.currentSessionKey)
|
||||
: currentState.sessionLastActivity,
|
||||
messages: [],
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
activeRunId: null,
|
||||
error: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneChatEventDedupe(now: number): void {
|
||||
for (const [key, timestamp] of chatEventDedupe.entries()) {
|
||||
if (now - timestamp > CHAT_EVENT_DEDUPE_TTL_MS) {
|
||||
chatEventDedupe.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildChatEventDedupeKey(event: GatewayEvent): string | null {
|
||||
const runId = 'runId' in event && typeof event.runId === 'string' ? event.runId : '';
|
||||
const sessionKey = 'sessionKey' in event && typeof event.sessionKey === 'string' ? event.sessionKey : '';
|
||||
const type = event.type;
|
||||
if (!runId && !sessionKey && !type) return null;
|
||||
return `${runId}|${sessionKey}|${type}`;
|
||||
}
|
||||
|
||||
function isDuplicateChatEvent(event: GatewayEvent): boolean {
|
||||
const key = buildChatEventDedupeKey(event);
|
||||
if (!key) return false;
|
||||
|
||||
const now = Date.now();
|
||||
pruneChatEventDedupe(now);
|
||||
if (chatEventDedupe.has(key)) return true;
|
||||
chatEventDedupe.set(key, now);
|
||||
return false;
|
||||
}
|
||||
|
||||
async function resolveDefaultAccountId(): Promise<string | null> {
|
||||
try {
|
||||
const result = await gatewayRpc<{ accountId: string | null }>('provider.getDefault', {});
|
||||
if (result?.accountId) return result.accountId;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await hostApiFetch<{ accountId: string | null }>('/api/provider-accounts/default');
|
||||
return result.accountId ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function stageBuffer(base64: string, fileName: string, mimeType: string): Promise<StagedAttachment> {
|
||||
try {
|
||||
const result = await hostApiFetch<StagedAttachment>('/api/files/stage-buffer', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ base64, fileName, mimeType }),
|
||||
});
|
||||
|
||||
if (result?.stagedPath) {
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// fall through to local fallback
|
||||
}
|
||||
|
||||
const dataUrl = `data:${mimeType};base64,${base64}`;
|
||||
return {
|
||||
fileName,
|
||||
mimeType,
|
||||
fileSize: Math.ceil(base64.length * 0.75),
|
||||
stagedPath: dataUrl,
|
||||
preview: mimeType.startsWith('image/') ? dataUrl : null,
|
||||
};
|
||||
}
|
||||
|
||||
async function subscribeToGateway(): Promise<void> {
|
||||
if (gatewaySubscribed) return;
|
||||
|
||||
gatewaySubscribed = true;
|
||||
onGatewayEvent((event) => {
|
||||
if (event.type === 'gateway:status') {
|
||||
patchState({ gatewayStatus: event.status });
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof event.sessionKey === 'string' && event.sessionKey !== state.currentSessionKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleGatewayEvent(event);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSessions(): Promise<void> {
|
||||
const now = Date.now();
|
||||
if (loadSessionsInFlight) {
|
||||
await loadSessionsInFlight;
|
||||
return;
|
||||
}
|
||||
if (now - lastLoadSessionsAt < SESSION_LOAD_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadSessionsInFlight = (async () => {
|
||||
try {
|
||||
const localKeys = await gatewayRpc<string[]>('session.list', {});
|
||||
let sessions: ChatSession[] = localKeys.map((key) => ({
|
||||
key,
|
||||
displayName: state.sessionLabels[key] || 'New Chat',
|
||||
updatedAt: state.sessionLastActivity[key] || Date.now(),
|
||||
}));
|
||||
|
||||
const existingNonLocal = state.sessions.filter((session) => !session.key.startsWith('local:'));
|
||||
sessions = [...existingNonLocal, ...sessions];
|
||||
|
||||
let nextSessionKey = state.currentSessionKey || DEFAULT_SESSION_KEY;
|
||||
if (!sessions.find((session) => session.key === nextSessionKey) && sessions.length > 0) {
|
||||
nextSessionKey = sessions[0].key;
|
||||
}
|
||||
|
||||
const sessionsWithCurrent =
|
||||
!sessions.find((session) => session.key === nextSessionKey) && nextSessionKey
|
||||
? [...sessions, { key: nextSessionKey, displayName: nextSessionKey }]
|
||||
: sessions;
|
||||
|
||||
const discoveredActivity = Object.fromEntries(
|
||||
sessionsWithCurrent
|
||||
.filter((session) => typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt))
|
||||
.map((session) => [session.key, session.updatedAt!]),
|
||||
);
|
||||
|
||||
patchState({
|
||||
initialized: true,
|
||||
sessions: sessionsWithCurrent,
|
||||
currentSessionKey: nextSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSessionKey),
|
||||
sessionLastActivity: {
|
||||
...state.sessionLastActivity,
|
||||
...discoveredActivity,
|
||||
},
|
||||
});
|
||||
|
||||
if (nextSessionKey && nextSessionKey !== DEFAULT_SESSION_KEY) {
|
||||
await loadHistory(nextSessionKey, true);
|
||||
}
|
||||
|
||||
void Promise.all(
|
||||
sessionsWithCurrent
|
||||
.filter((session) => !state.sessionLabels[session.key])
|
||||
.map(async (session) => {
|
||||
try {
|
||||
const messages = await gatewayRpc<RawMessage[]>('chat.history', {
|
||||
sessionKey: session.key,
|
||||
limit: 50,
|
||||
});
|
||||
const firstUser = messages.find((message) => message.role === 'user');
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const nextPatch: Partial<ChatStoreState> = {};
|
||||
|
||||
if (firstUser) {
|
||||
const labelText = extractText(firstUser).trim();
|
||||
if (labelText) {
|
||||
nextPatch.sessionLabels = {
|
||||
...state.sessionLabels,
|
||||
[session.key]: labelText.length > 50 ? `${labelText.slice(0, 50)}...` : labelText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (lastMessage?.timestamp) {
|
||||
nextPatch.sessionLastActivity = {
|
||||
...state.sessionLastActivity,
|
||||
[session.key]: toMs(lastMessage.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
if (Object.keys(nextPatch).length > 0) {
|
||||
patchState(nextPatch);
|
||||
}
|
||||
} catch {
|
||||
// ignore background label loads
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
lastLoadSessionsAt = Date.now();
|
||||
}
|
||||
})();
|
||||
|
||||
try {
|
||||
await loadSessionsInFlight;
|
||||
} finally {
|
||||
loadSessionsInFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(sessionKey = state.currentSessionKey, quiet = false): Promise<void> {
|
||||
if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) {
|
||||
patchState({
|
||||
messages: [],
|
||||
loading: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const existingLoad = historyLoadInFlight.get(sessionKey);
|
||||
if (existingLoad) {
|
||||
await existingLoad;
|
||||
return;
|
||||
}
|
||||
|
||||
const lastLoadAt = lastHistoryLoadAtBySession.get(sessionKey) || 0;
|
||||
if (quiet && Date.now() - lastLoadAt < HISTORY_LOAD_MIN_INTERVAL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!quiet) {
|
||||
patchState({ loading: true });
|
||||
}
|
||||
|
||||
const loadPromise = (async () => {
|
||||
try {
|
||||
const messages = await gatewayRpc<RawMessage[]>('chat.history', {
|
||||
sessionKey,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
if (state.currentSessionKey !== sessionKey) return;
|
||||
|
||||
let nextMessages = messages.filter((message) => !message || !('role' in message) || message.role);
|
||||
|
||||
if (state.sending && state.lastUserMessageAt) {
|
||||
const hasRecentUser = nextMessages.some((message) => (
|
||||
message.role === 'user' &&
|
||||
message.timestamp &&
|
||||
Math.abs(toMs(message.timestamp) - state.lastUserMessageAt!) < 5000
|
||||
));
|
||||
|
||||
if (!hasRecentUser) {
|
||||
const optimistic = [...state.messages].reverse().find((message) => (
|
||||
message.role === 'user' &&
|
||||
message.timestamp &&
|
||||
Math.abs(toMs(message.timestamp) - state.lastUserMessageAt!) < 5000
|
||||
));
|
||||
|
||||
if (optimistic) {
|
||||
nextMessages = [...nextMessages, optimistic];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextPatch: Partial<ChatStoreState> = {
|
||||
messages: nextMessages,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
const firstUser = nextMessages.find((message) => message.role === 'user');
|
||||
if (firstUser && !sessionKey.endsWith(':main')) {
|
||||
const labelText = extractText(firstUser).trim();
|
||||
if (labelText) {
|
||||
nextPatch.sessionLabels = {
|
||||
...state.sessionLabels,
|
||||
[sessionKey]: labelText.length > 50 ? `${labelText.slice(0, 50)}...` : labelText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const lastMessage = nextMessages[nextMessages.length - 1];
|
||||
if (lastMessage?.timestamp) {
|
||||
nextPatch.sessionLastActivity = {
|
||||
...state.sessionLastActivity,
|
||||
[sessionKey]: toMs(lastMessage.timestamp),
|
||||
};
|
||||
}
|
||||
|
||||
patchState(nextPatch);
|
||||
} catch (error) {
|
||||
patchState({
|
||||
error: quiet ? state.error : String(error),
|
||||
loading: false,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
historyLoadInFlight.set(sessionKey, loadPromise);
|
||||
try {
|
||||
await loadPromise;
|
||||
} finally {
|
||||
lastHistoryLoadAtBySession.set(sessionKey, Date.now());
|
||||
if (historyLoadInFlight.get(sessionKey) === loadPromise) {
|
||||
historyLoadInFlight.delete(sessionKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function newSession(): Promise<void> {
|
||||
const defaultAccountId = await resolveDefaultAccountId();
|
||||
if (!defaultAccountId) {
|
||||
patchState({ error: '请先前往模型管理页面配置并设置一个默认模型' });
|
||||
return;
|
||||
}
|
||||
|
||||
const leavingEmpty =
|
||||
!state.currentSessionKey.endsWith(':main') &&
|
||||
state.messages.length === 0 &&
|
||||
!state.sessionLastActivity[state.currentSessionKey] &&
|
||||
!state.sessionLabels[state.currentSessionKey];
|
||||
|
||||
const newKey = `local:${defaultAccountId}:${crypto.randomUUID()}`;
|
||||
const nextSessions = leavingEmpty
|
||||
? state.sessions.filter((session) => session.key !== state.currentSessionKey)
|
||||
: state.sessions;
|
||||
|
||||
patchState({
|
||||
currentSessionKey: newKey,
|
||||
currentAgentId: 'local',
|
||||
sessions: [...nextSessions, { key: newKey, displayName: 'New Chat' }],
|
||||
sessionLabels: leavingEmpty
|
||||
? clearSessionEntryFromMap(state.sessionLabels, state.currentSessionKey)
|
||||
: state.sessionLabels,
|
||||
sessionLastActivity: leavingEmpty
|
||||
? clearSessionEntryFromMap(state.sessionLastActivity, state.currentSessionKey)
|
||||
: state.sessionLastActivity,
|
||||
messages: [],
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
activeRunId: null,
|
||||
error: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
function switchSession(sessionKey: string): void {
|
||||
if (sessionKey === state.currentSessionKey) return;
|
||||
patchState(buildSessionSwitchPatch(state, sessionKey));
|
||||
void loadHistory(sessionKey);
|
||||
}
|
||||
|
||||
async function deleteSession(sessionKey: string): Promise<void> {
|
||||
try {
|
||||
await gatewayRpc('session.delete', { sessionKey });
|
||||
} catch {
|
||||
// keep local cleanup even if gateway delete fails
|
||||
}
|
||||
|
||||
const remaining = state.sessions.filter((session) => session.key !== sessionKey);
|
||||
const basePatch: Partial<ChatStoreState> = {
|
||||
sessions: remaining,
|
||||
sessionLabels: clearSessionEntryFromMap(state.sessionLabels, sessionKey),
|
||||
sessionLastActivity: clearSessionEntryFromMap(state.sessionLastActivity, sessionKey),
|
||||
};
|
||||
|
||||
if (state.currentSessionKey === sessionKey) {
|
||||
const nextSession = remaining[0]?.key ?? DEFAULT_SESSION_KEY;
|
||||
patchState({
|
||||
...basePatch,
|
||||
currentSessionKey: nextSession,
|
||||
currentAgentId: getAgentIdFromSessionKey(nextSession),
|
||||
messages: [],
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
activeRunId: null,
|
||||
error: null,
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
|
||||
if (nextSession !== DEFAULT_SESSION_KEY) {
|
||||
await loadHistory(nextSession);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
patchState(basePatch);
|
||||
}
|
||||
|
||||
function renameSession(sessionKey: string, nextLabel: string): void {
|
||||
const trimmedLabel = nextLabel.trim();
|
||||
if (!trimmedLabel) return;
|
||||
|
||||
patchState({
|
||||
sessionLabels: {
|
||||
...state.sessionLabels,
|
||||
[sessionKey]: trimmedLabel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function sendMessage(text: string, attachments: StagedAttachment[] = []): Promise<boolean> {
|
||||
const trimmedText = text.trim();
|
||||
if (!trimmedText && attachments.length === 0) return false;
|
||||
|
||||
const defaultAccountId = await resolveDefaultAccountId();
|
||||
if (!defaultAccountId) {
|
||||
patchState({ error: '请先前往模型管理页面配置并设置一个默认模型' });
|
||||
return false;
|
||||
}
|
||||
|
||||
let targetSessionKey = state.currentSessionKey;
|
||||
if (!targetSessionKey || targetSessionKey === DEFAULT_SESSION_KEY) {
|
||||
targetSessionKey = `local:${defaultAccountId}:${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
const nowMs = Date.now();
|
||||
const userMessage: RawMessage = {
|
||||
role: 'user',
|
||||
content: trimmedText || (attachments.length > 0 ? '(file attached)' : ''),
|
||||
timestamp: nowMs,
|
||||
id: crypto.randomUUID(),
|
||||
_attachedFiles: attachments.map((attachment) => ({
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
fileSize: attachment.fileSize,
|
||||
preview: attachment.preview,
|
||||
filePath: attachment.stagedPath,
|
||||
})),
|
||||
};
|
||||
|
||||
const nextSessions = ensureSessionEntry(state.sessions, targetSessionKey, 'New Chat');
|
||||
const isFirstUserMessage = !state.messages.some((message) => message.role === 'user');
|
||||
const nextLabels =
|
||||
!targetSessionKey.endsWith(':main') && isFirstUserMessage && trimmedText
|
||||
? {
|
||||
...state.sessionLabels,
|
||||
[targetSessionKey]: trimmedText.length > 50 ? `${trimmedText.slice(0, 50)}...` : trimmedText,
|
||||
}
|
||||
: state.sessionLabels;
|
||||
|
||||
patchState({
|
||||
messages: [...state.messages, userMessage],
|
||||
sending: true,
|
||||
activeRunId: null,
|
||||
error: null,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: nowMs,
|
||||
sessions: nextSessions,
|
||||
currentSessionKey: targetSessionKey,
|
||||
currentAgentId: getAgentIdFromSessionKey(targetSessionKey),
|
||||
sessionLabels: nextLabels,
|
||||
sessionLastActivity: {
|
||||
...state.sessionLastActivity,
|
||||
[targetSessionKey]: nowMs,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
let messageContent = trimmedText;
|
||||
if (attachments.length > 0) {
|
||||
const refs = attachments
|
||||
.map((attachment) => `[media attached: ${attachment.fileName} (${attachment.mimeType}) | ${attachment.stagedPath}]`)
|
||||
.join('\n');
|
||||
messageContent = messageContent ? `${messageContent}\n\n${refs}` : refs;
|
||||
}
|
||||
|
||||
const result = await gatewayRpc<{ runId: string }>('chat.send', {
|
||||
sessionKey: targetSessionKey,
|
||||
message: {
|
||||
role: 'user',
|
||||
content: messageContent,
|
||||
},
|
||||
options: {
|
||||
providerAccountId: defaultAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
patchState({
|
||||
activeRunId: result.runId,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
patchState({
|
||||
error: String(error),
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
lastUserMessageAt: null,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function abortRun(): Promise<void> {
|
||||
const sessionKey = state.currentSessionKey;
|
||||
|
||||
patchState({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
|
||||
if (!sessionKey || sessionKey === DEFAULT_SESSION_KEY) return;
|
||||
|
||||
try {
|
||||
await gatewayRpc('chat.abort', { sessionKey });
|
||||
} catch (error) {
|
||||
patchState({ error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGatewayEvent(event: GatewayEvent): Promise<void> {
|
||||
if (isDuplicateChatEvent(event)) return;
|
||||
if (state.activeRunId && 'runId' in event && typeof event.runId === 'string' && event.runId !== state.activeRunId) return;
|
||||
|
||||
switch (event.type) {
|
||||
case 'chat:delta': {
|
||||
const previousContent = state.streamingMessage ? extractText(state.streamingMessage) : '';
|
||||
patchState({
|
||||
sending: true,
|
||||
error: null,
|
||||
activeRunId: typeof event.runId === 'string' ? event.runId : state.activeRunId,
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: previousContent + event.delta,
|
||||
timestamp: Date.now(),
|
||||
id: state.streamingMessage?.id || `stream-${event.runId || Date.now()}`,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'chat:final': {
|
||||
const composedMessage = state.streamingMessage && typeof event.message.content === 'string'
|
||||
? {
|
||||
...event.message,
|
||||
content: `${extractText(state.streamingMessage)}${event.message.content}`,
|
||||
}
|
||||
: event.message;
|
||||
|
||||
const messageId = composedMessage.id || `run-${event.runId || Date.now()}`;
|
||||
const hasOutput = Boolean(extractText(composedMessage).trim());
|
||||
const toolOnly = isToolOnlyMessage(composedMessage);
|
||||
|
||||
if (!state.messages.some((message) => message.id === messageId)) {
|
||||
patchState({
|
||||
messages: [...state.messages, { ...composedMessage, id: messageId }],
|
||||
sessionLastActivity: {
|
||||
...state.sessionLastActivity,
|
||||
[state.currentSessionKey]: composedMessage.timestamp ? toMs(composedMessage.timestamp) : Date.now(),
|
||||
},
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: !hasOutput || toolOnly,
|
||||
sending: !hasOutput || toolOnly,
|
||||
activeRunId: hasOutput && !toolOnly ? null : state.activeRunId,
|
||||
lastUserMessageAt: hasOutput && !toolOnly ? null : state.lastUserMessageAt,
|
||||
});
|
||||
} else {
|
||||
patchState({
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: !hasOutput || toolOnly,
|
||||
sending: !hasOutput || toolOnly,
|
||||
activeRunId: hasOutput && !toolOnly ? null : state.activeRunId,
|
||||
lastUserMessageAt: hasOutput && !toolOnly ? null : state.lastUserMessageAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasOutput && !toolOnly) {
|
||||
await loadHistory(state.currentSessionKey, true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'chat:error': {
|
||||
if (state.streamingMessage && !state.messages.some((message) => message.id === state.streamingMessage?.id)) {
|
||||
patchState({
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
...state.streamingMessage,
|
||||
id: state.streamingMessage.id || `error-snap-${Date.now()}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
patchState({
|
||||
error: event.error,
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'chat:aborted': {
|
||||
patchState({
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function clearError(): void {
|
||||
patchState({ error: null });
|
||||
}
|
||||
|
||||
async function initChatStore(): Promise<void> {
|
||||
await subscribeToGateway();
|
||||
await loadSessions();
|
||||
}
|
||||
|
||||
async function stageAttachmentFiles(files: File[]): Promise<StagedAttachment[]> {
|
||||
const stagedFiles: StagedAttachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'));
|
||||
reader.onloadend = () => {
|
||||
const dataUrl = String(reader.result || '');
|
||||
resolve(dataUrl.split(',')[1] || '');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
stagedFiles.push(await stageBuffer(base64, file.name, file.type || 'application/octet-stream'));
|
||||
}
|
||||
|
||||
return stagedFiles;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): ChatStoreState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export const chatStore = {
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getState: () => state,
|
||||
init: initChatStore,
|
||||
loadSessions,
|
||||
loadHistory,
|
||||
switchSession,
|
||||
newSession,
|
||||
deleteSession,
|
||||
renameSession,
|
||||
sendMessage,
|
||||
abortRun,
|
||||
clearError,
|
||||
stageAttachmentFiles,
|
||||
};
|
||||
|
||||
export function useChatStore(): ChatStoreState {
|
||||
return useSyncExternalStore(chatStore.subscribe, chatStore.getSnapshot, chatStore.getSnapshot);
|
||||
}
|
||||
15
src-react/stores/index.ts
Normal file
15
src-react/stores/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export * from './settings';
|
||||
export * from './chat';
|
||||
export * from './task';
|
||||
export * from './channel';
|
||||
export {
|
||||
settingsStore as appSettingsStore,
|
||||
settingsStore,
|
||||
useSettingsStore,
|
||||
initSettingsStore,
|
||||
updateThemeMode as setThemeMode,
|
||||
updateLanguage as setLanguage,
|
||||
updateFontSize as setFontSize,
|
||||
updateMinimizeToTray as setMinimizeToTray,
|
||||
updatePrimaryColor as setPrimaryColor,
|
||||
} from './settings';
|
||||
374
src-react/stores/settings.ts
Normal file
374
src-react/stores/settings.ts
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { CONFIG_KEYS, DEFAULT_LANGUAGE, DEFAULT_THEME_MODE, IPC_EVENTS } from '../lib/constants';
|
||||
import { hasHostApiBridge, hostApiFetch, invokeIpc, onIpc } from '../lib/host-api';
|
||||
import { applyThemeModeToDocument, detectSystemTheme, resolveAppliedTheme, watchSystemTheme } from '../lib/theme';
|
||||
import { detectRuntimePlatform, resolveWindowIdentity } from '../lib/runtime';
|
||||
import { i18n, setLocale as setI18nLocale } from '../i18n';
|
||||
import { detectSystemLanguage, resolveSupportedLanguage } from '../i18n/resolver';
|
||||
import type {
|
||||
ConfigValueMap,
|
||||
LanguageCode,
|
||||
ResolvedThemeMode,
|
||||
RuntimePlatform,
|
||||
ThemeMode,
|
||||
WindowIdentity,
|
||||
WindowName,
|
||||
} from '../types/runtime';
|
||||
|
||||
export interface SettingsState {
|
||||
initialized: boolean;
|
||||
platform: RuntimePlatform;
|
||||
windowId: string | number | null;
|
||||
windowName: WindowName;
|
||||
themeMode: ThemeMode;
|
||||
systemTheme: ResolvedThemeMode;
|
||||
appliedTheme: ResolvedThemeMode;
|
||||
language: LanguageCode;
|
||||
primaryColor: string;
|
||||
fontSize: number;
|
||||
minimizeToTray: boolean;
|
||||
providerId: string | null;
|
||||
defaultModel: string | null;
|
||||
}
|
||||
|
||||
const STORAGE_PREFIX = 'zn-ai-react:';
|
||||
const listeners = new Set<() => void>();
|
||||
let initPromise: Promise<SettingsState> | null = null;
|
||||
let unsubscribeThemeWatcher: (() => void) | null = null;
|
||||
let unsubscribeThemeEvent: (() => void) | null = null;
|
||||
|
||||
function getStorageValue<T>(key: string): T | undefined {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
|
||||
const raw = window.localStorage.getItem(`${STORAGE_PREFIX}${key}`);
|
||||
if (!raw) return undefined;
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return raw as unknown as T;
|
||||
}
|
||||
}
|
||||
|
||||
function setStorageValue(key: string, value: unknown): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.localStorage.setItem(`${STORAGE_PREFIX}${key}`, JSON.stringify(value));
|
||||
}
|
||||
|
||||
function createInitialState(): SettingsState {
|
||||
const systemTheme = detectSystemTheme();
|
||||
const systemLanguage = detectSystemLanguage();
|
||||
|
||||
return {
|
||||
initialized: false,
|
||||
platform: detectRuntimePlatform(),
|
||||
windowId: null,
|
||||
windowName: 'main',
|
||||
themeMode: DEFAULT_THEME_MODE,
|
||||
systemTheme,
|
||||
appliedTheme: resolveAppliedTheme(DEFAULT_THEME_MODE, systemTheme),
|
||||
language: systemLanguage ?? DEFAULT_LANGUAGE,
|
||||
primaryColor: '#1677ff',
|
||||
fontSize: 14,
|
||||
minimizeToTray: false,
|
||||
providerId: null,
|
||||
defaultModel: null,
|
||||
};
|
||||
}
|
||||
|
||||
let state: SettingsState = createInitialState();
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function patchState(patch: Partial<SettingsState>): SettingsState {
|
||||
state = { ...state, ...patch };
|
||||
emit();
|
||||
return state;
|
||||
}
|
||||
|
||||
async function readConfigValue<T>(key: keyof ConfigValueMap, fallback: T): Promise<T> {
|
||||
try {
|
||||
if (hasHostApiBridge()) {
|
||||
const value = await invokeIpc<T>(IPC_EVENTS.GET_CONFIG, key);
|
||||
return (typeof value === 'undefined' || value === null ? fallback : value) as T;
|
||||
}
|
||||
} catch {
|
||||
// fall back to local storage below
|
||||
}
|
||||
|
||||
const stored = getStorageValue<T>(String(key));
|
||||
return typeof stored === 'undefined' ? fallback : stored;
|
||||
}
|
||||
|
||||
async function writeConfigValue<T>(key: keyof ConfigValueMap, value: T): Promise<void> {
|
||||
try {
|
||||
if (hasHostApiBridge()) {
|
||||
await invokeIpc(IPC_EVENTS.SET_CONFIG, key, value);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall back to local storage below
|
||||
}
|
||||
|
||||
setStorageValue(String(key), value);
|
||||
}
|
||||
|
||||
async function readThemeMode(): Promise<ThemeMode> {
|
||||
try {
|
||||
if (hasHostApiBridge()) {
|
||||
const value = await invokeIpc<ThemeMode | boolean>(IPC_EVENTS.GET_THEME_MODE);
|
||||
if (value === true) return 'dark';
|
||||
if (value === false) return 'light';
|
||||
if (value === 'light' || value === 'dark' || value === 'system') return value;
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
return (getStorageValue<ThemeMode>('themeMode') ?? DEFAULT_THEME_MODE) as ThemeMode;
|
||||
}
|
||||
|
||||
async function writeThemeMode(themeMode: ThemeMode): Promise<void> {
|
||||
try {
|
||||
if (hasHostApiBridge()) {
|
||||
await invokeIpc(IPC_EVENTS.SET_THEME_MODE, themeMode);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fallback below
|
||||
}
|
||||
|
||||
setStorageValue('themeMode', themeMode);
|
||||
}
|
||||
|
||||
function applyLocale(locale: LanguageCode): void {
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = locale;
|
||||
}
|
||||
setI18nLocale(locale);
|
||||
}
|
||||
|
||||
function applyTheme(themeMode: ThemeMode, systemTheme: ResolvedThemeMode): ResolvedThemeMode {
|
||||
const appliedTheme = applyThemeModeToDocument(themeMode, systemTheme);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.dataset.themeMode = themeMode;
|
||||
}
|
||||
return appliedTheme;
|
||||
}
|
||||
|
||||
function syncSystemTheme(): void {
|
||||
if (unsubscribeThemeWatcher) return;
|
||||
|
||||
unsubscribeThemeWatcher = watchSystemTheme((systemTheme) => {
|
||||
const appliedTheme = state.themeMode === 'system' ? applyTheme(state.themeMode, systemTheme) : state.appliedTheme;
|
||||
patchState({
|
||||
systemTheme,
|
||||
appliedTheme,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function syncWindowIdentity(): Promise<WindowIdentity> {
|
||||
const identity = await resolveWindowIdentity();
|
||||
patchState({
|
||||
platform: identity.platform,
|
||||
windowId: identity.windowId,
|
||||
windowName: identity.windowName,
|
||||
});
|
||||
return identity;
|
||||
}
|
||||
|
||||
async function syncThemeEvent(): Promise<void> {
|
||||
if (unsubscribeThemeEvent || typeof window === 'undefined' || !window.api?.on) return;
|
||||
|
||||
unsubscribeThemeEvent = onIpc(IPC_EVENTS.THEME_MODE_UPDATED, (payload: boolean | ThemeMode) => {
|
||||
const themeMode: ThemeMode = typeof payload === 'boolean' ? (payload ? 'dark' : 'light') : payload;
|
||||
const appliedTheme = applyTheme(themeMode, state.systemTheme);
|
||||
patchState({
|
||||
themeMode,
|
||||
appliedTheme,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function hydrate(): Promise<SettingsState> {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
initPromise = (async () => {
|
||||
const identity = await syncWindowIdentity();
|
||||
const systemTheme = detectSystemTheme();
|
||||
const systemLanguage = detectSystemLanguage();
|
||||
|
||||
const [themeMode, language, fontSize, minimizeToTray, primaryColor, providerId, defaultModel] = await Promise.all([
|
||||
readThemeMode(),
|
||||
readConfigValue<LanguageCode>(CONFIG_KEYS.LANGUAGE, systemLanguage),
|
||||
readConfigValue<number>(CONFIG_KEYS.FONT_SIZE, 14),
|
||||
readConfigValue<boolean>(CONFIG_KEYS.MINIMIZE_TO_TRAY, false),
|
||||
readConfigValue<string>(CONFIG_KEYS.PRIMARY_COLOR, '#1677ff'),
|
||||
readConfigValue<string | null>(CONFIG_KEYS.PROVIDER, null),
|
||||
readConfigValue<string | null>(CONFIG_KEYS.DEFAULT_MODEL, null),
|
||||
]);
|
||||
|
||||
const resolvedLanguage = resolveSupportedLanguage(language ?? systemLanguage);
|
||||
const appliedTheme = applyTheme(themeMode, systemTheme);
|
||||
|
||||
patchState({
|
||||
initialized: true,
|
||||
platform: identity.platform,
|
||||
windowId: identity.windowId,
|
||||
windowName: identity.windowName,
|
||||
themeMode,
|
||||
systemTheme,
|
||||
appliedTheme,
|
||||
language: resolvedLanguage,
|
||||
primaryColor: primaryColor ?? '#1677ff',
|
||||
fontSize: fontSize ?? 14,
|
||||
minimizeToTray: Boolean(minimizeToTray),
|
||||
providerId: providerId ?? null,
|
||||
defaultModel: defaultModel ?? null,
|
||||
});
|
||||
|
||||
applyLocale(resolvedLanguage);
|
||||
syncSystemTheme();
|
||||
await syncThemeEvent();
|
||||
|
||||
return state;
|
||||
})();
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function setThemeMode(themeMode: ThemeMode): Promise<SettingsState> {
|
||||
const nextThemeMode = themeMode === 'light' || themeMode === 'dark' || themeMode === 'system' ? themeMode : DEFAULT_THEME_MODE;
|
||||
if (state.themeMode === nextThemeMode && state.initialized) return state;
|
||||
|
||||
const appliedTheme = applyTheme(nextThemeMode, state.systemTheme);
|
||||
patchState({
|
||||
themeMode: nextThemeMode,
|
||||
appliedTheme,
|
||||
});
|
||||
|
||||
await writeThemeMode(nextThemeMode);
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setLanguage(language: string | null | undefined, persist = true): Promise<SettingsState> {
|
||||
const resolved = resolveSupportedLanguage(language, state.language);
|
||||
if (state.language === resolved && state.initialized) return state;
|
||||
|
||||
applyLocale(resolved);
|
||||
patchState({
|
||||
language: resolved,
|
||||
});
|
||||
|
||||
if (persist) {
|
||||
await writeConfigValue(CONFIG_KEYS.LANGUAGE, resolved);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setFontSize(fontSize: number, persist = true): Promise<SettingsState> {
|
||||
const next = Number.isFinite(fontSize) ? fontSize : state.fontSize;
|
||||
if (state.fontSize === next && state.initialized) return state;
|
||||
|
||||
patchState({
|
||||
fontSize: next,
|
||||
});
|
||||
|
||||
if (persist) {
|
||||
await writeConfigValue(CONFIG_KEYS.FONT_SIZE, next);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setMinimizeToTray(minimizeToTray: boolean, persist = true): Promise<SettingsState> {
|
||||
const next = Boolean(minimizeToTray);
|
||||
if (state.minimizeToTray === next && state.initialized) return state;
|
||||
|
||||
patchState({
|
||||
minimizeToTray: next,
|
||||
});
|
||||
|
||||
if (persist) {
|
||||
await writeConfigValue(CONFIG_KEYS.MINIMIZE_TO_TRAY, next);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function setPrimaryColor(primaryColor: string, persist = true): Promise<SettingsState> {
|
||||
const next = primaryColor || '#1677ff';
|
||||
if (state.primaryColor === next && state.initialized) return state;
|
||||
|
||||
patchState({
|
||||
primaryColor: next,
|
||||
});
|
||||
|
||||
if (persist) {
|
||||
await writeConfigValue(CONFIG_KEYS.PRIMARY_COLOR, next);
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function getSnapshot(): SettingsState {
|
||||
return state;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export const settingsStore = {
|
||||
init: hydrate,
|
||||
getState: getSnapshot,
|
||||
subscribe,
|
||||
setThemeMode,
|
||||
setLanguage,
|
||||
setFontSize,
|
||||
setMinimizeToTray,
|
||||
setPrimaryColor,
|
||||
hostApiFetch,
|
||||
};
|
||||
|
||||
export function useSettingsStore<T = SettingsState>(selector?: (state: SettingsState) => T): T {
|
||||
const select = selector ?? ((current: SettingsState) => current as unknown as T);
|
||||
return useSyncExternalStore(subscribe, () => select(getSnapshot()), () => select(getSnapshot()));
|
||||
}
|
||||
|
||||
export function getSettingsState(): SettingsState {
|
||||
return getSnapshot();
|
||||
}
|
||||
|
||||
export async function initSettingsStore(): Promise<SettingsState> {
|
||||
return hydrate();
|
||||
}
|
||||
|
||||
export async function updateThemeMode(themeMode: ThemeMode): Promise<SettingsState> {
|
||||
return setThemeMode(themeMode);
|
||||
}
|
||||
|
||||
export async function updateLanguage(language: string | null | undefined, persist = true): Promise<SettingsState> {
|
||||
return setLanguage(language, persist);
|
||||
}
|
||||
|
||||
export async function updateFontSize(fontSize: number, persist = true): Promise<SettingsState> {
|
||||
return setFontSize(fontSize, persist);
|
||||
}
|
||||
|
||||
export async function updatePrimaryColor(primaryColor: string, persist = true): Promise<SettingsState> {
|
||||
return setPrimaryColor(primaryColor, persist);
|
||||
}
|
||||
|
||||
export async function updateMinimizeToTray(minimizeToTray: boolean, persist = true): Promise<SettingsState> {
|
||||
return setMinimizeToTray(minimizeToTray, persist);
|
||||
}
|
||||
|
||||
export { i18n };
|
||||
367
src-react/stores/task.ts
Normal file
367
src-react/stores/task.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import type { RoomTypeMapping } from '@api/types';
|
||||
import type { SubTask, Task, TaskProgressPayload } from '@lib/task-types';
|
||||
import { CONFIG_KEYS, IPC_EVENTS } from '../lib/constants';
|
||||
import { invokeIpc, onIpc } from '../lib/host-api';
|
||||
|
||||
export interface TaskStoreState {
|
||||
initialized: boolean;
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
type RoomTypeMappingLike = RoomTypeMapping & {
|
||||
dyHotSpringName?: string;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
export type TaskOperationInput = {
|
||||
taskId?: string;
|
||||
roomType: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
operation: 'open' | 'close';
|
||||
roomList: RoomTypeMappingLike[];
|
||||
};
|
||||
|
||||
type ExecuteTaskResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
result?: unknown;
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let eventSubscriptionsBound = false;
|
||||
let initPromise: Promise<void> | null = null;
|
||||
let state: TaskStoreState = {
|
||||
initialized: false,
|
||||
tasks: [],
|
||||
};
|
||||
|
||||
function emit(): void {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
function patchState(patch: Partial<TaskStoreState>): TaskStoreState {
|
||||
state = { ...state, ...patch };
|
||||
emit();
|
||||
return state;
|
||||
}
|
||||
|
||||
function deriveTaskStatus(task: Task): Task['status'] {
|
||||
if (task.subTasks.every((subTask) => subTask.status === 'success')) return 'success';
|
||||
if (task.subTasks.every((subTask) => subTask.status === 'failed')) return 'failed';
|
||||
if (task.subTasks.some((subTask) => subTask.status === 'failed') && task.subTasks.some((subTask) => subTask.status === 'success')) {
|
||||
return 'partial_failed';
|
||||
}
|
||||
if (task.subTasks.some((subTask) => subTask.status === 'running')) return 'running';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function normalizeRoomType(item: RoomTypeMappingLike): RoomTypeMappingLike {
|
||||
return {
|
||||
...item,
|
||||
dyHotSpringName: item.dyHotSpringName ?? item.dyHotSrpingName ?? '',
|
||||
dyHotSrpingName: item.dyHotSrpingName ?? item.dyHotSpringName ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRoomList(roomList: RoomTypeMappingLike[]): RoomTypeMappingLike[] {
|
||||
return Array.isArray(roomList) ? roomList.map((item) => normalizeRoomType(item ?? {})) : [];
|
||||
}
|
||||
|
||||
function buildTaskSubTasks(taskId: string, roomType: RoomTypeMappingLike | undefined): SubTask[] {
|
||||
const scriptMappings = [
|
||||
{ prop: 'fzName', scriptId: 'fg_trace.js', name: '飞猪房态追踪', hasValue: Boolean(roomType?.fzName) },
|
||||
{ prop: 'mtName', scriptId: 'mt_trace.js', name: '美团房态追踪', hasValue: Boolean(roomType?.mtName) },
|
||||
{ prop: 'dyHotelName', scriptId: 'dy_hotel_trace.js', name: '抖音酒店房态追踪', hasValue: Boolean(roomType?.dyHotelName) },
|
||||
{
|
||||
prop: 'dyHotSpringName',
|
||||
scriptId: 'dy_hot_spring_trace.js',
|
||||
name: '抖音温泉房态追踪',
|
||||
hasValue: Boolean(roomType?.dyHotSpringName || roomType?.dyHotSrpingName),
|
||||
},
|
||||
];
|
||||
|
||||
return scriptMappings
|
||||
.filter((mapping) => mapping.hasValue)
|
||||
.map((mapping) => ({
|
||||
id: `${taskId}_${mapping.prop}`,
|
||||
taskId,
|
||||
scriptId: mapping.scriptId,
|
||||
name: mapping.name,
|
||||
status: 'pending' as const,
|
||||
progress: 0,
|
||||
message: '等待执行',
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
startedAt: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
async function persistTasks(tasks: Task[]): Promise<void> {
|
||||
try {
|
||||
await invokeIpc(IPC_EVENTS.SET_CONFIG, CONFIG_KEYS.TASK_LIST, tasks);
|
||||
} catch {
|
||||
// ignore persistence failures in UI store
|
||||
}
|
||||
}
|
||||
|
||||
function updateTaskCollection(updater: (tasks: Task[]) => Task[]): void {
|
||||
const nextTasks = updater(state.tasks);
|
||||
patchState({ tasks: nextTasks });
|
||||
void persistTasks(nextTasks);
|
||||
}
|
||||
|
||||
function markTaskFailed(taskId: string, error: string): void {
|
||||
updateTaskCollection((tasks) => tasks.map((task) => {
|
||||
if (task.id !== taskId) return task;
|
||||
|
||||
const nextSubTasks = task.subTasks.map((subTask) => {
|
||||
if (subTask.status === 'success') return subTask;
|
||||
|
||||
return {
|
||||
...subTask,
|
||||
status: 'failed' as const,
|
||||
message: error,
|
||||
error,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...task,
|
||||
subTasks: nextSubTasks,
|
||||
status: deriveTaskStatus({ ...task, subTasks: nextSubTasks }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function handleTaskProgress(payload: TaskProgressPayload & { taskId: string; subTaskId: string }): void {
|
||||
updateTaskCollection((tasks) => tasks.map((task) => {
|
||||
if (task.id !== payload.taskId) return task;
|
||||
|
||||
const nextSubTasks: SubTask[] = task.subTasks.map((subTask) => {
|
||||
if (subTask.id !== payload.subTaskId) return subTask;
|
||||
|
||||
const nextStatus: SubTask['status'] = subTask.status === 'pending' ? 'running' : subTask.status;
|
||||
return {
|
||||
...subTask,
|
||||
status: nextStatus,
|
||||
progress: payload.progress ?? subTask.progress,
|
||||
message: payload.message ?? subTask.message,
|
||||
stdoutTail: payload.stdoutTail ?? subTask.stdoutTail,
|
||||
stderrTail: payload.stderrTail ?? subTask.stderrTail,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...task,
|
||||
subTasks: nextSubTasks,
|
||||
status: deriveTaskStatus({ ...task, subTasks: nextSubTasks }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
function handleTaskCompleted(payload: { taskId: string; subTaskId: string; success: boolean; exitCode: number | null; error?: string }): void {
|
||||
updateTaskCollection((tasks) => tasks.map((task) => {
|
||||
if (task.id !== payload.taskId) return task;
|
||||
|
||||
const nextSubTasks: SubTask[] = task.subTasks.map((subTask) => {
|
||||
if (subTask.id !== payload.subTaskId) return subTask;
|
||||
|
||||
const nextStatus: SubTask['status'] = payload.success ? 'success' : 'failed';
|
||||
return {
|
||||
...subTask,
|
||||
status: nextStatus,
|
||||
progress: payload.success ? 100 : subTask.progress,
|
||||
error: payload.error,
|
||||
completedAt: new Date().toISOString(),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...task,
|
||||
subTasks: nextSubTasks,
|
||||
status: deriveTaskStatus({ ...task, subTasks: nextSubTasks }),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTasks(): Promise<void> {
|
||||
try {
|
||||
const savedTasks = await invokeIpc<Task[]>(IPC_EVENTS.GET_CONFIG, CONFIG_KEYS.TASK_LIST);
|
||||
patchState({
|
||||
initialized: true,
|
||||
tasks: Array.isArray(savedTasks) ? savedTasks : [],
|
||||
});
|
||||
} catch {
|
||||
patchState({
|
||||
initialized: true,
|
||||
tasks: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function initTaskStore(): Promise<void> {
|
||||
if (!initPromise) {
|
||||
initPromise = loadTasks();
|
||||
}
|
||||
|
||||
if (!eventSubscriptionsBound) {
|
||||
eventSubscriptionsBound = true;
|
||||
onIpc(IPC_EVENTS.TASK_PROGRESS, handleTaskProgress as (...args: any[]) => void);
|
||||
onIpc(IPC_EVENTS.TASK_STARTED, ((payload: { taskId: string; subTaskId: string }) => {
|
||||
handleTaskProgress({
|
||||
...payload,
|
||||
progress: 0,
|
||||
message: '开始执行',
|
||||
});
|
||||
}) as (...args: any[]) => void);
|
||||
onIpc(IPC_EVENTS.TASK_COMPLETED, handleTaskCompleted as (...args: any[]) => void);
|
||||
}
|
||||
|
||||
await initPromise;
|
||||
}
|
||||
|
||||
function createTask(options: TaskOperationInput): Task {
|
||||
const taskId = options.taskId ?? crypto.randomUUID();
|
||||
const roomList = normalizeRoomList(options.roomList);
|
||||
const roomType = roomList.find((item) => item.id === options.roomType);
|
||||
const subTasks = buildTaskSubTasks(taskId, roomType);
|
||||
|
||||
const task: Task = {
|
||||
id: taskId,
|
||||
title: `${options.operation === 'open' ? '开启' : '关闭'}渠道房型 - ${roomType?.pmsName || ''}`,
|
||||
operation: options.operation,
|
||||
roomType: options.roomType,
|
||||
dateRange: [options.startTime, options.endTime],
|
||||
status: 'pending',
|
||||
subTasks,
|
||||
roomList,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
updateTaskCollection((tasks) => [task, ...tasks]);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function executeTask(taskId: string): Promise<ExecuteTaskResult> {
|
||||
const task = state.tasks.find((item) => item.id === taskId);
|
||||
if (!task) {
|
||||
return { success: false, error: '任务不存在,无法执行。' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invokeIpc<ExecuteTaskResult>(IPC_EVENTS.EXECUTE_SCRIPT, {
|
||||
taskId: task.id,
|
||||
roomType: task.roomType,
|
||||
startTime: task.dateRange[0],
|
||||
endTime: task.dateRange[1],
|
||||
operation: task.operation,
|
||||
roomList: normalizeRoomList(task.roomList as RoomTypeMappingLike[]),
|
||||
});
|
||||
|
||||
if (!result?.success) {
|
||||
markTaskFailed(task.id, result?.error || '任务执行失败');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
markTaskFailed(task.id, message);
|
||||
return {
|
||||
success: false,
|
||||
error: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndExecuteTask(options: TaskOperationInput): Promise<{ task: Task; result: ExecuteTaskResult }> {
|
||||
const task = createTask(options);
|
||||
const result = await executeTask(task.id);
|
||||
return { task, result };
|
||||
}
|
||||
|
||||
async function retryFailedSubTasks(taskId: string): Promise<ExecuteTaskResult> {
|
||||
const task = state.tasks.find((item) => item.id === taskId);
|
||||
if (!task) {
|
||||
return { success: false, error: '任务不存在,无法重试。' };
|
||||
}
|
||||
|
||||
const hasFailedSubTask = task.subTasks.some((subTask) => subTask.status === 'failed');
|
||||
if (!hasFailedSubTask) {
|
||||
return { success: false, error: '当前任务没有可重试的失败子任务。' };
|
||||
}
|
||||
|
||||
updateTaskCollection((tasks) => tasks.map((currentTask) => {
|
||||
if (currentTask.id !== taskId) return currentTask;
|
||||
|
||||
const nextSubTasks = currentTask.subTasks.map((subTask) => {
|
||||
if (subTask.status !== 'failed') return subTask;
|
||||
|
||||
return {
|
||||
...subTask,
|
||||
status: 'pending' as const,
|
||||
progress: 0,
|
||||
message: '等待执行',
|
||||
stdoutTail: '',
|
||||
stderrTail: '',
|
||||
error: undefined,
|
||||
completedAt: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...currentTask,
|
||||
subTasks: nextSubTasks,
|
||||
status: 'pending',
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}));
|
||||
|
||||
return executeTask(taskId);
|
||||
}
|
||||
|
||||
function removeTask(taskId: string): void {
|
||||
updateTaskCollection((tasks) => tasks.filter((task) => task.id !== taskId));
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void): () => void {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
function getSnapshot(): TaskStoreState {
|
||||
return state;
|
||||
}
|
||||
|
||||
export const taskStore = {
|
||||
subscribe,
|
||||
getSnapshot,
|
||||
getState: () => state,
|
||||
init: initTaskStore,
|
||||
load: loadTasks,
|
||||
createTask,
|
||||
createAndExecuteTask,
|
||||
retryFailedSubTasks,
|
||||
executeTask,
|
||||
removeTask,
|
||||
};
|
||||
|
||||
export function useTaskStore(): TaskStoreState {
|
||||
return useSyncExternalStore(taskStore.subscribe, taskStore.getSnapshot, taskStore.getSnapshot);
|
||||
}
|
||||
|
||||
export function getPendingTasks(tasks = state.tasks): Task[] {
|
||||
return tasks.filter((task) => task.status === 'pending' || task.status === 'running');
|
||||
}
|
||||
|
||||
export function getCompletedTasks(tasks = state.tasks): Task[] {
|
||||
return tasks.filter((task) => task.status === 'success' || task.status === 'failed' || task.status === 'partial_failed');
|
||||
}
|
||||
Reference in New Issue
Block a user