feat: Complete Pi Harness Rebuild - Phases 1 till 7.2
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { ChatMessage } from 'apps/Chat';
|
||||
import type { LegacyChatMessage } from 'apps/Chat';
|
||||
import type { Attachment } from './EmbeddableChat';
|
||||
import { Settings } from './Settings';
|
||||
|
||||
@@ -61,7 +61,7 @@ type InputAreaProps = {
|
||||
isConnected: boolean;
|
||||
commandFeedback: string | null;
|
||||
textareaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
messages: ChatMessage[];
|
||||
messages: LegacyChatMessage[];
|
||||
availableModels: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onModelChange: (modelId: string) => void;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import type { ModelOption } from '@/state/useModels';
|
||||
import type { ChatMessage } from 'apps/Chat';
|
||||
import type { LegacyChatMessage } from 'apps/Chat';
|
||||
|
||||
const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
anthropic: 'Anthropic',
|
||||
@@ -21,7 +21,7 @@ const PROVIDER_DISPLAY: Record<string, string> = {
|
||||
};
|
||||
|
||||
type SettingsProps = {
|
||||
messages: ChatMessage[];
|
||||
messages: LegacyChatMessage[];
|
||||
availableModels: ModelOption[];
|
||||
selectedModel: string | null;
|
||||
onModelChange: (modelId: string) => void;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat';
|
||||
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 1000;
|
||||
|
||||
type ResourceChatStorage = {
|
||||
load: () => Promise<{ sessionId: string | null; messages: ChatMessage[] }>;
|
||||
save: (sessionId: string, messages: ChatMessage[]) => Promise<void>;
|
||||
load: () => Promise<{ sessionId: string | null; messages: LegacyChatMessage[] }>;
|
||||
save: (sessionId: string, messages: LegacyChatMessage[]) => Promise<void>;
|
||||
};
|
||||
|
||||
type UseClaudeOptions = {
|
||||
@@ -19,7 +19,7 @@ type UseClaudeOptions = {
|
||||
|
||||
export const useClaude = (initialSessionId?: string, initialModel?: string | null, options?: UseClaudeOptions) => {
|
||||
const { replaceUrl = true, storage, resourceChatDir, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
||||
@@ -53,7 +53,7 @@ export const useClaude = (initialSessionId?: string, initialModel?: string | nul
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown) => {
|
||||
const msg = data as ServerMessage;
|
||||
const msg = data as LegacyServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
|
||||
@@ -3,12 +3,12 @@ import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import { useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat';
|
||||
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
|
||||
|
||||
const SYSTEM_RE = /^<system>([\s\S]*?)<\/system>\s*/;
|
||||
|
||||
const splitSystemBlocks = (messages: ChatMessage[]): ChatMessage[] => {
|
||||
const result: ChatMessage[] = [];
|
||||
const splitSystemBlocks = (messages: LegacyChatMessage[]): LegacyChatMessage[] => {
|
||||
const result: LegacyChatMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'user') {
|
||||
result.push(msg);
|
||||
@@ -32,7 +32,7 @@ type UseOpenCodeOptions = {
|
||||
|
||||
export const useOpenCode = (initialSessionId?: string, initialModel?: string | null, options?: UseOpenCodeOptions) => {
|
||||
const { replaceUrl = true, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
||||
@@ -78,7 +78,7 @@ export const useOpenCode = (initialSessionId?: string, initialModel?: string | n
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown) => {
|
||||
const msg = data as ServerMessage;
|
||||
const msg = data as LegacyServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import { useChatSessions } from '@/state/useChatSessions';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo, Message } from 'apps/Chat';
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useChatWebSocket } from 'hooks/useChatWebSocket';
|
||||
import type { ChatMessage, ServerMessage, TaskInfo } from 'apps/Chat';
|
||||
import type { LegacyChatMessage, LegacyServerMessage, TaskInfo } from 'apps/Chat';
|
||||
|
||||
type UsePiMonoOptions = {
|
||||
replaceUrl?: boolean;
|
||||
@@ -9,7 +9,7 @@ type UsePiMonoOptions = {
|
||||
|
||||
export const usePiMono = (initialSessionId?: string, initialModel?: string | null, options?: UsePiMonoOptions) => {
|
||||
const { replaceUrl = true, taskInfo } = options ?? {};
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [messages, setMessages] = useState<LegacyChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(initialSessionId ?? null);
|
||||
@@ -40,7 +40,7 @@ export const usePiMono = (initialSessionId?: string, initialModel?: string | nul
|
||||
};
|
||||
|
||||
const handleMessage = (data: unknown) => {
|
||||
const msg = data as ServerMessage;
|
||||
const msg = data as LegacyServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'session:init':
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Search, AlertCircle, CheckCircle2, Clock, ArrowLeft } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Card } from '@/components/Card';
|
||||
import { MessageBubble, type ChatMessage } from 'apps/Chat';
|
||||
import { MessageBubble, type LegacyChatMessage } from 'apps/Chat';
|
||||
|
||||
type LogMetadata = {
|
||||
filename: string;
|
||||
@@ -18,7 +18,7 @@ type LogMetadata = {
|
||||
};
|
||||
|
||||
type FullLog = LogMetadata & {
|
||||
messages: ChatMessage[];
|
||||
messages: LegacyChatMessage[];
|
||||
};
|
||||
|
||||
const formatDate = (iso: string) => {
|
||||
|
||||
Reference in New Issue
Block a user