feat: implement PI core chat timeline

This commit is contained in:
2026-08-24 00:34:03 +08:00
parent 54d8bb2e4a
commit 612135f911
40 changed files with 3881 additions and 119 deletions

View File

@@ -0,0 +1,574 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
CircleAlert,
LoaderCircle,
MessageSquarePlus,
RefreshCw,
Settings2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
CODING_ATTACHMENT_MAX_BYTES,
CODING_ATTACHMENT_MIMES,
uploadCodingAttachment,
type CodingAttachmentRef,
} from '@/lib/coding-attachments';
import { cn } from '@/lib/utils';
import {
codingConversationStore,
selectCodingConversationDraft,
selectCodingConversationRequests,
selectCodingConversationSnapshot,
useCodingConversationStore,
type CodingConversationStoreState,
} from '@/stores/coding-conversations';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import type {
CodingDraftAttachment,
} from '@/types/coding-conversation';
import type {
CodingConversationMetadata,
} from '@/types/coding-project';
import { CodingComposer } from './CodingComposer';
import { CodingConversationTimeline } from './CodingConversationTimeline';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
export interface CodingChatPanelProps {
navigationDraft?: string;
onOpenProjectSettings?(): void;
}
interface LocalComposerAttachment {
id: string;
name: string;
file: File;
previewUrl: string;
}
function newestConversation(
conversations: CodingConversationMetadata[],
agentId: string,
): CodingConversationMetadata | null {
return conversations
.filter((conversation) => conversation.agentId === agentId && !conversation.archivedAt)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0] ?? null;
}
function requestCount(
state: CodingConversationStoreState,
conversationId: string | null,
statuses: ReadonlySet<string>,
): number {
if (!conversationId) return 0;
const requests = selectCodingConversationRequests(conversationId)(state);
return Object.values(requests).filter((request) => statuses.has(request.status)).length;
}
const BLOCKING_REQUEST_STATUSES = new Set(['pending', 'accepted']);
const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']);
const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']);
export function CodingChatPanel({
navigationDraft,
onOpenProjectSettings,
}: CodingChatPanelProps) {
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const config = useCodingWorkspaceStore((state) => state.config);
const conversations = useCodingWorkspaceStore((state) => state.conversations);
const selectedAgentId = useCodingWorkspaceStore((state) => state.selectedAgentId);
const workspaceLoadState = useCodingWorkspaceStore((state) => state.loadState);
const workspaceError = useCodingWorkspaceStore((state) => state.error);
const creatingAgentIds = useCodingWorkspaceStore((state) => state.creatingAgentIds);
const loadWorkspace = useCodingWorkspaceStore((state) => state.load);
const selectAgent = useCodingWorkspaceStore((state) => state.selectAgent);
const ensureConversation = useCodingWorkspaceStore((state) => state.ensureConversation);
const createConversation = useCodingWorkspaceStore((state) => state.createConversation);
const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId);
const connectionState = useCodingConversationStore((state) => state.connectionState);
const connectionError = useCodingConversationStore((state) => state.globalError);
const primeConversation = useCodingConversationStore((state) => state.primeConversation);
const selectConversation = useCodingConversationStore((state) => state.selectConversation);
const clearConversationSelection = useCodingConversationStore((state) => state.clearConversationSelection);
const disconnectEvents = useCodingConversationStore((state) => state.disconnectEvents);
const setConversationDraft = useCodingConversationStore((state) => state.setDraft);
const submitPrompt = useCodingConversationStore((state) => state.submitPrompt);
const recoverConversation = useCodingConversationStore((state) => state.recoverConversation);
const [provisionalDraft, setProvisionalDraft] = useState('');
const [creatingNewConversation, setCreatingNewConversation] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [submissionError, setSubmissionError] = useState<string | null>(null);
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
Record<string, LocalComposerAttachment[]>
>({});
const appliedNavigationDraftRef = useRef<string | null>(null);
const automaticCreationKeyRef = useRef<string | null>(null);
const attachmentsRef = useRef(attachmentsByDraftKey);
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
const agents = useMemo(() => (
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
), [config]);
const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null;
const agentConversations = useMemo(() => (
selectedAgent
? conversations
.filter((conversation) => conversation.agentId === selectedAgent.id && !conversation.archivedAt)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
: []
), [conversations, selectedAgent]);
const selectedConversation = conversations.find((conversation) => (
conversation.id === selectedConversationId
)) ?? null;
const provisionalDraftKey = activeProject && selectedAgent
? `new:${activeProject.id}:${selectedAgent.id}`
: null;
const draftKey = selectedConversationId ?? provisionalDraftKey;
const localAttachments = useMemo(() => (
draftKey ? attachmentsByDraftKey[draftKey] ?? [] : []
), [attachmentsByDraftKey, draftKey]);
attachmentsRef.current = attachmentsByDraftKey;
const selectDraft = useCallback((state: CodingConversationStoreState) => (
selectedConversationId
? selectCodingConversationDraft(selectedConversationId)(state)
: null
), [selectedConversationId]);
const selectedDraft = useCodingConversationStore(selectDraft);
const selectSnapshot = useCallback((state: CodingConversationStoreState) => (
selectedConversationId
? selectCodingConversationSnapshot(selectedConversationId)(state)
: null
), [selectedConversationId]);
const snapshot = useCodingConversationStore(selectSnapshot);
const entryLoadState = useCodingConversationStore((state) => (
selectedConversationId
? state.entriesByConversationId[selectedConversationId]?.loadState ?? 'empty'
: 'empty'
));
const entryError = useCodingConversationStore((state) => (
selectedConversationId
? state.entriesByConversationId[selectedConversationId]?.error ?? null
: null
));
const blockingRequestCount = useCodingConversationStore((state) => requestCount(
state,
selectedConversationId,
BLOCKING_REQUEST_STATUSES,
));
const acceptedRequestCount = useCodingConversationStore((state) => requestCount(
state,
selectedConversationId,
ACCEPTED_REQUEST_STATUSES,
));
const uncertainRequestCount = useCodingConversationStore((state) => requestCount(
state,
selectedConversationId,
UNCERTAIN_REQUEST_STATUSES,
));
useEffect(() => {
void loadWorkspace().catch(() => undefined);
}, [loadWorkspace]);
useEffect(() => () => disconnectEvents(), [disconnectEvents]);
useEffect(() => () => {
for (const attachments of Object.values(attachmentsRef.current)) {
for (const attachment of attachments) URL.revokeObjectURL(attachment.previewUrl);
}
}, []);
useEffect(() => {
if (!activeProject) return;
for (const conversation of conversations) {
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
}
}, [activeProject, conversations, primeConversation]);
useEffect(() => {
if (!activeProject || !selectedAgent) {
clearConversationSelection();
return;
}
if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) return;
const next = newestConversation(conversations, selectedAgent.id);
if (next) {
void selectConversation(next.id).catch(() => undefined);
return;
}
const creationKey = `${activeProject.id}:${selectedAgent.id}`;
if (automaticCreationKeyRef.current === creationKey) return;
automaticCreationKeyRef.current = creationKey;
void ensureConversation(selectedAgent.id)
.then((conversation) => {
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
void selectConversation(conversation.id).catch(() => undefined);
})
.catch(() => undefined);
}, [
activeProject,
clearConversationSelection,
conversations,
ensureConversation,
primeConversation,
selectConversation,
selectedAgent,
selectedConversation,
]);
useEffect(() => {
const nextDraft = navigationDraft?.trim();
if (!nextDraft || appliedNavigationDraftRef.current === nextDraft) return;
appliedNavigationDraftRef.current = nextDraft;
if (selectedConversationId) {
setConversationDraft(selectedConversationId, nextDraft);
} else {
setProvisionalDraft(nextDraft);
}
}, [navigationDraft, selectedConversationId, setConversationDraft]);
useEffect(() => {
if (!selectedConversationId) return;
if (provisionalDraft.trim()) {
const current = codingConversationStore.getState().draftsByConversationId[selectedConversationId];
if (!current?.text) setConversationDraft(selectedConversationId, provisionalDraft);
setProvisionalDraft('');
}
if (provisionalDraftKey) {
setAttachmentsByDraftKey((currentAttachments) => {
const pending = currentAttachments[provisionalDraftKey];
if (!pending?.length || currentAttachments[selectedConversationId]?.length) return currentAttachments;
const next = { ...currentAttachments, [selectedConversationId]: pending };
delete next[provisionalDraftKey];
return next;
});
}
}, [provisionalDraft, provisionalDraftKey, selectedConversationId, setConversationDraft]);
const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => {
if (!activeProject) return;
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
void selectConversation(conversation.id).catch(() => undefined);
}, [activeProject, primeConversation, selectConversation]);
const handleCreateConversation = useCallback(async () => {
if (!activeProject || !selectedAgent || creatingNewConversation) return;
setCreatingNewConversation(true);
try {
const conversation = await createConversation(selectedAgent.id);
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
await selectConversation(conversation.id).catch(() => undefined);
} finally {
setCreatingNewConversation(false);
}
}, [
activeProject,
createConversation,
creatingNewConversation,
primeConversation,
selectConversation,
selectedAgent,
]);
const handleAddFiles = useCallback((files: File[]) => {
if (!draftKey) return;
const accepted: LocalComposerAttachment[] = [];
for (const file of files) {
const mime = file.type.trim().toLowerCase();
if (!CODING_ATTACHMENT_MIMES.has(mime)) {
setSubmissionError('仅支持 PNG、JPEG、WebP 或 GIF 图片。');
continue;
}
if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) {
setSubmissionError('图片不能超过 16 MB。');
continue;
}
accepted.push({
id: crypto.randomUUID(),
name: file.name || '图片',
file,
previewUrl: URL.createObjectURL(file),
});
}
if (accepted.length === 0) return;
setSubmissionError(null);
setAttachmentsByDraftKey((current) => ({
...current,
[draftKey]: [...(current[draftKey] ?? []), ...accepted],
}));
}, [draftKey]);
const handleRemoveAttachment = useCallback((id: string) => {
if (!draftKey) return;
setAttachmentsByDraftKey((current) => {
const attachment = current[draftKey]?.find((item) => item.id === id);
if (attachment) URL.revokeObjectURL(attachment.previewUrl);
const nextItems = (current[draftKey] ?? []).filter((item) => item.id !== id);
const next = { ...current, [draftKey]: nextItems };
if (nextItems.length === 0) delete next[draftKey];
return next;
});
uploadedAttachmentsRef.current.delete(id);
uploadFlightsRef.current.delete(id);
}, [draftKey]);
const prepareAttachments = useCallback(async (
attachments: LocalComposerAttachment[],
): Promise<CodingDraftAttachment[]> => await Promise.all(attachments.map(async (attachment) => {
let uploaded = uploadedAttachmentsRef.current.get(attachment.id);
if (!uploaded) {
let flight = uploadFlightsRef.current.get(attachment.id);
if (!flight) {
flight = uploadCodingAttachment(attachment.file);
uploadFlightsRef.current.set(attachment.id, flight);
}
try {
uploaded = await flight;
uploadedAttachmentsRef.current.set(attachment.id, uploaded);
} finally {
if (uploadFlightsRef.current.get(attachment.id) === flight) {
uploadFlightsRef.current.delete(attachment.id);
}
}
}
return {
attachmentId: uploaded.attachmentId,
mime: uploaded.mime,
previewUrl: attachment.previewUrl,
};
})), []);
const handleSubmit = useCallback(() => {
if (!selectedConversationId || !draftKey || submitting) return;
const attachments = [...localAttachments];
setSubmitting(true);
setSubmissionError(null);
void submitPrompt({
conversationId: selectedConversationId,
mode: 'prompt',
prepareAttachments: attachments.length > 0
? () => prepareAttachments(attachments)
: undefined,
}).then(() => {
setAttachmentsByDraftKey((current) => {
const next = { ...current };
delete next[draftKey];
return next;
});
for (const attachment of attachments) {
URL.revokeObjectURL(attachment.previewUrl);
uploadedAttachmentsRef.current.delete(attachment.id);
}
}).catch((error) => {
setSubmissionError(error instanceof Error ? error.message : String(error));
}).finally(() => {
setSubmitting(false);
});
}, [
draftKey,
localAttachments,
prepareAttachments,
selectedConversationId,
submitPrompt,
submitting,
]);
const runStatus = snapshot?.run.status ?? 'idle';
const workerStatus = snapshot?.worker.status ?? 'stopped';
const runtimeError = snapshot?.run.error ?? snapshot?.worker.error ?? null;
const preparationError = entryError ?? runtimeError?.message ?? null;
const preparing = entryLoadState === 'loading'
|| workerStatus === 'starting'
|| workerStatus === 'recovering'
|| runStatus === 'preparing';
const recovering = entryLoadState === 'recovering';
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
const draft = selectedDraft?.text ?? provisionalDraft;
const editable = Boolean(activeProject && selectedAgent);
const canSend = Boolean(
editable
&& selectedConversationId
&& (draft.trim() || localAttachments.length > 0)
&& !running
&& !submitting
&& !preparationError
&& blockingRequestCount === 0
&& uncertainRequestCount === 0,
);
const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]);
if (!activeProject && workspaceLoadState !== 'loading') {
return (
<section className="flex min-h-0 flex-1 items-center justify-center bg-background p-6" data-testid="coding-chat-empty-project">
<div className="max-w-md text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
<Bot className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
</div>
<h1 className="mt-4 text-balance text-lg font-semibold">先选择一个编程项目</h1>
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
项目和伙伴信息只在本地读取,不会为了打开输入框启动 Agent。
</p>
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
打开项目设置
</Button>
</div>
</section>
);
}
return (
<section className="flex min-h-0 flex-1 overflow-hidden bg-background text-foreground" data-testid="coding-chat-panel">
<aside className="hidden w-60 shrink-0 flex-col border-r border-foreground/10 bg-surface-subtle/45 md:flex">
<div className="border-b border-foreground/10 px-4 py-4">
<p className="truncate text-sm font-semibold">{activeProject?.name ?? 'Makelore Code'}</p>
<p className="mt-0.5 text-xs text-muted-foreground">本地 Conversation</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">伙伴</p>
<div className="space-y-1">
{agents.map((agent) => (
<button
key={agent.id}
type="button"
className={cn(
'flex min-h-10 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
selectedAgent?.id === agent.id ? 'bg-background font-medium shadow-soft' : 'hover:bg-background/70',
)}
onClick={() => selectAgent(agent.id)}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background">
{agent.name.trim().slice(0, 1) || 'A'}
</span>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
</button>
))}
</div>
<div className="mt-4 flex min-h-10 items-center justify-between px-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">对话</p>
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]"
aria-label="新建对话"
disabled={!selectedAgent || creatingNewConversation}
onClick={() => void handleCreateConversation()}
>
{creatingNewConversation
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
: <MessageSquarePlus className="h-4 w-4" aria-hidden="true" />}
</Button>
</div>
<div className="space-y-1">
{agentConversations.map((conversation) => (
<button
key={conversation.id}
type="button"
className={cn(
'min-h-10 w-full truncate rounded-xl px-3 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
selectedConversationId === conversation.id
? 'bg-background font-medium shadow-soft'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => handleSelectConversation(conversation)}
>
{conversation.title}
</button>
))}
{autoCreating && (
<div className="flex min-h-10 items-center gap-2 px-3 text-xs text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
正在创建首个对话…
</div>
)}
</div>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex min-h-14 shrink-0 items-center gap-3 border-b border-foreground/10 px-4 sm:px-5">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">{selectedConversation?.title ?? '新对话'}</p>
<p className="truncate text-xs text-muted-foreground">
{selectedAgent?.name ?? '尚未选择伙伴'}
{connectionState === 'reconnecting' ? ' · 正在重连' : ''}
</p>
</div>
{workspaceLoadState === 'loading' && <LoaderCircle className="h-4 w-4 animate-spin text-muted-foreground" aria-label="正在刷新项目信息" />}
<Button
type="button"
variant="ghost"
size="icon"
className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]"
aria-label="伙伴设置"
onClick={onOpenProjectSettings}
>
<Settings2 className="h-4 w-4" aria-hidden="true" />
</Button>
</header>
{(workspaceError || connectionError) && (
<div className="mx-4 mt-3 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive sm:mx-5">
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1 text-pretty">{workspaceError ?? connectionError}</p>
{workspaceError && (
<Button
type="button"
variant="ghost"
className="min-h-10 rounded-xl px-3 text-destructive transition-transform duration-150 ease-out active:scale-[0.96]"
onClick={() => void loadWorkspace().catch(() => undefined)}
>
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden="true" />
刷新
</Button>
)}
</div>
)}
{selectedConversationId
? (
<CodingConversationTimeline
key={selectedConversationId}
conversationId={selectedConversationId}
/>
)
: (
<div className="min-h-0 flex-1" data-testid="coding-conversation-pending" />
)}
<CodingComposer
value={draft}
editable={editable}
canSend={canSend}
preparing={preparing || autoCreating}
recovering={recovering}
runStatus={runStatus}
error={submissionError ?? preparationError}
recoverableError={!submissionError && Boolean(preparationError)}
acceptedCount={acceptedRequestCount}
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ id, name, previewUrl }))}
submitting={submitting}
placeholder={selectedAgent ? '给当前对话发送消息' : '请选择一个伙伴后再发送'}
onChange={(value) => {
setSubmissionError(null);
if (selectedConversationId) setConversationDraft(selectedConversationId, value);
else setProvisionalDraft(value);
}}
onSubmit={handleSubmit}
onRecover={() => {
if (selectedConversationId) {
void recoverConversation(selectedConversationId).catch(() => undefined);
}
}}
onAddFiles={handleAddFiles}
onRemoveAttachment={handleRemoveAttachment}
/>
</div>
</section>
);
}