Files
platform/src/workspaces/apps/Chat/usePi.ts
T
2026-02-21 15:05:56 +00:00

288 lines
8.7 KiB
TypeScript

import { useState, useEffect, useRef } from 'react';
import { useChatWebSocket } from 'hooks/useChatWebSocket';
import { useChatSessions } from '@/state/useChatSessions';
import type { ChatMessage, ServerMessage, TaskInfo, Message } from './types';
const SAVE_DEBOUNCE_MS = 1000;
type ResourceChatStorage = {
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
save: (sessionId: string, messages: ChatMessage[]) => Promise<void>;
};
type UsePiOptions = {
replaceUrl?: boolean;
storage?: ResourceChatStorage;
resourceChatDir?: string;
taskInfo?: TaskInfo;
};
export function usePi(initialSessionId?: string, initialModel?: string | null, options?: UsePiOptions) {
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [streamingText, setStreamingText] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
const [model, setModel] = useState<string | null>(null);
const [selectedModel, setSelectedModel] = useState<string | null>(initialModel ?? null);
const [cwd, setCwd] = useState<string | null>(null);
const streamingRef = useRef('');
const rafRef = useRef<number | null>(null);
const sessionIdRef = useRef<string | null>(initialSessionId ?? null);
const saveTimerRef = useRef<number | null>(null);
const { getSession, saveMessages } = useChatSessions();
const token = localStorage.getItem('BEARER_TOKEN');
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/api/pi/chat/ws?token=${token}`;
function flushStreaming() {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
setStreamingText(streamingRef.current);
rafRef.current = null;
});
}
function commitStreaming() {
if (!streamingRef.current) return;
setMessages((prev) => [...prev, { role: 'assistant', text: streamingRef.current }]);
streamingRef.current = '';
setStreamingText('');
}
function handleMessage(data: unknown) {
const msg = data as ServerMessage;
switch (msg.type) {
case 'session:init':
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
setModel(msg.model);
setCwd(msg.cwd);
if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`);
break;
case 'assistant:delta':
streamingRef.current += msg.text;
flushStreaming();
break;
case 'assistant:text':
if (streamingRef.current) {
commitStreaming();
} else {
setMessages((prev) => [...prev, { role: 'assistant', text: msg.text }]);
}
break;
case 'tool:start':
setMessages((prev) => [
...prev,
{
role: 'tool',
toolName: msg.toolName,
toolInput: msg.toolInput,
toolCallId: msg.toolCallId,
},
]);
break;
case 'tool:result':
setMessages((prev) =>
prev.map((m) =>
m.role === 'tool' && m.toolCallId === msg.toolCallId
? { ...m, output: msg.output, isError: msg.isError }
: m,
),
);
break;
case 'result':
commitStreaming();
setMessages((prev) => [
...prev,
{
role: 'result',
cost: msg.cost,
},
]);
setIsGenerating(false);
break;
case 'sync:messages':
sessionIdRef.current = msg.sessionId;
setSessionId(msg.sessionId);
// Convert Message[] to ChatMessage[]
const chatMessages = msg.messages.map((m): ChatMessage => {
if (m.role === 'user') {
return { role: 'user', text: m.text || '' };
} else if (m.role === 'assistant') {
return { role: 'assistant', text: m.text || '' };
} else if (m.role === 'tool') {
return {
role: 'tool',
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant', text: '' }; // Fallback
});
setMessages(chatMessages);
setIsGenerating(msg.isGenerating);
if (msg.streamingText) {
streamingRef.current = msg.streamingText;
flushStreaming();
}
break;
case 'error':
commitStreaming();
setMessages((prev) => [...prev, { role: 'error', text: msg.message }]);
setIsGenerating(false);
break;
case 'stopped':
commitStreaming();
setIsGenerating(false);
break;
}
}
const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage });
// Load messages from server on mount when resuming a session
useEffect(() => {
if (storage) {
storage
.load()
.then(({ sessionId: sid, messages: msgs }) => {
if (sid) {
sessionIdRef.current = sid;
setSessionId(sid);
}
if (msgs.length > 0) setMessages(msgs);
})
.catch(() => {});
return;
}
if (!initialSessionId) return;
getSession(initialSessionId)
.then((data) => {
if (data.session?.messages && data.session.messages.length > 0) {
// Convert backend Message[] to ChatMessage[]
const chatMessages = data.session.messages.map((m: Message): ChatMessage => {
if (m.role === 'user') {
return { role: 'user', text: m.text || '' };
} else if (m.role === 'assistant') {
return { role: 'assistant', text: m.text || '' };
} else if (m.role === 'tool') {
return {
role: 'tool',
toolName: m.toolName || '',
toolInput: m.toolInput || {},
toolCallId: m.toolCallId || '',
output: m.output,
isError: m.isError,
};
}
return { role: 'assistant', text: '' }; // Fallback
});
setMessages(chatMessages);
}
})
.catch(() => {});
}, [initialSessionId]);
// Debounced save messages to server
useEffect(() => {
if (!sessionIdRef.current || messages.length === 0) return;
if (saveTimerRef.current !== null) clearTimeout(saveTimerRef.current);
const sid = sessionIdRef.current;
const snapshot = messages;
saveTimerRef.current = window.setTimeout(() => {
if (storage) {
storage.save(sid, snapshot).catch(() => {});
} else {
saveMessages(sid, snapshot).catch(() => {});
}
saveTimerRef.current = null;
}, SAVE_DEBOUNCE_MS);
return () => {
if (saveTimerRef.current !== null) {
clearTimeout(saveTimerRef.current);
saveTimerRef.current = null;
}
};
}, [messages]);
// Clean up RAF on unmount
useEffect(() => {
return () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
};
}, []);
function sendPrompt(
text: string,
attachmentIds?: string[],
images?: { filename: string; dataUrl: string }[],
cwdParam?: { root?: string; path: string },
groupSlug?: string | null,
) {
setMessages((prev) => [...prev, { role: 'user', text, ...(images?.length ? { images } : {}) }]);
setIsGenerating(true);
streamingRef.current = '';
setStreamingText('');
// Parse dataUrls into { mediaType, data } for the server
const imageData = images
?.map((img) => {
const match = img.dataUrl.match(/^data:([^;]+);base64,(.+)$/);
return match ? { mediaType: match[1], data: match[2] } : null;
})
.filter((x): x is { mediaType: string; data: string } => x !== null);
send({
type: 'chat',
prompt: text,
sessionId: sessionIdRef.current,
...(selectedModel ? { model: selectedModel } : {}),
...(cwdParam?.path ? { cwd: cwdParam.path } : {}),
...(groupSlug !== undefined ? { groupSlug } : {}),
...(attachmentIds?.length ? { attachmentIds } : {}),
...(imageData?.length ? { images: imageData } : {}),
...(resourceChatDir ? { resourceChatDir } : {}),
...(taskInfo ? { taskInfo } : {}),
});
}
function stopGeneration() {
send({ type: 'stop' });
}
return {
messages,
streamingText,
isConnected,
isGenerating,
sessionId,
model,
selectedModel,
cwd,
setSelectedModel,
sendPrompt,
stopGeneration,
};
}
export type UsePiType = ReturnType<typeof usePi>;