- 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.
789 lines
24 KiB
TypeScript
789 lines
24 KiB
TypeScript
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);
|
|
}
|