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

202
src-react/stores/channel.ts Normal file
View 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
View 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
View 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';

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