import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Bot, CircleAlert, RefreshCw, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { CODING_ATTACHMENT_MAX_BYTES, CODING_ATTACHMENT_MAX_COUNT, CODING_ATTACHMENT_MIMES, CODING_ATTACHMENT_UPLOAD_CONCURRENCY, uploadCodingAttachment, type CodingAttachmentRef, } from '@/lib/coding-attachments'; import { abortCodingConversation, forkCodingConversation, } from '@/lib/coding-conversations'; import { subscribeHostEvent } from '@/lib/host-events'; import { codingConversationStore, selectCodingConversationDraft, selectCodingConversationRequests, selectCodingConversationSnapshot, useCodingConversationStore, type CodingConversationStoreState, } from '@/stores/coding-conversations'; import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-workspace'; import type { CodingDraftAttachment, PromptMode, } from '@/types/coding-conversation'; import type { CodingConversationMetadata, } from '@/types/coding-project'; import { CodingComposer } from './CodingComposer'; import { CodingChangesSummary } from './CodingChangesSummary'; import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; import { CodingConversationTimeline } from './CodingConversationTimeline'; import { CodingInteractionPanel } from './CodingInteractionPanel'; import { createLocalConversationSnapshot } from './coding-chat-snapshot'; export interface CodingChatPanelProps { navigationDraft?: string; onOpenProjectSettings?(): void; } interface LocalComposerAttachment { id: string; name: string; file: File; previewUrl: string; } interface LocalSubmissionError { message: string; backendCode?: string; } function localSubmissionError(error: unknown): LocalSubmissionError { const message = error instanceof Error ? error.message : String(error); const details = error && typeof error === 'object' && 'details' in error && error.details && typeof error.details === 'object' ? error.details as { backendCode?: unknown } : null; return { message, ...(typeof details?.backendCode === 'string' ? { backendCode: details.backendCode } : {}), }; } 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, ): 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 SUBMITTING_REQUEST_STATUSES = new Set(['pending']); const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']); const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']); function acceptedPromptCount( state: CodingConversationStoreState, conversationId: string | null, ): number { if (!conversationId) return 0; return Object.values(selectCodingConversationRequests(conversationId)(state)).filter((request) => ( request.status === 'accepted' && request.mode === 'prompt' )).length; } 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 conversationErrorsByProjectId = useCodingWorkspaceStore( (state) => state.conversationErrorsByProjectId, ); 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 patchConversation = useCodingWorkspaceStore((state) => state.patchConversation); const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation); const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId); 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 loadConversationSnapshot = useCodingConversationStore((state) => state.loadSnapshot); const markConversationUnread = useCodingConversationStore((state) => state.markUnread); const conversationSummaries = useCodingConversationStore((state) => state.summariesByConversationId); const [provisionalDrafts, setProvisionalDrafts] = useState>({}); const [submissionErrors, setSubmissionErrors] = useState>({}); const [modesByDraftKey, setModesByDraftKey] = useState>({}); const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< Record >({}); const appliedNavigationDraftRef = useRef(null); const automaticCreationKeyRef = useRef(null); const selectedConversationContextRef = useRef(null); const attachmentsRef = useRef(attachmentsByDraftKey); const uploadedAttachmentsRef = useRef(new Map()); const uploadFlightsRef = useRef(new Map>()); const submissionFlightsRef = useRef(new Set()); const agents = useMemo(() => ( config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? [] ), [config]); const selectedAgent = agents.find((agent) => agent.id === selectedAgentId) ?? null; const selectedConversation = conversations.find((conversation) => ( conversation.id === selectedConversationId && conversation.agentId === selectedAgent?.id && !conversation.archivedAt )) ?? null; const targetConversationId = selectedConversation?.id ?? null; const conversationMetadataError = activeProject && targetConversationId ? conversationErrorsByProjectId[activeProject.id]?.[targetConversationId] ?? null : null; const provisionalDraftKey = activeProject && selectedAgent ? `new:${activeProject.id}:${selectedAgent.id}` : null; const draftKey = targetConversationId ?? provisionalDraftKey; const abortConversation = useCallback(async (conversationId: string) => { await abortCodingConversation(conversationId); await loadConversationSnapshot(conversationId, 'silent'); }, [loadConversationSnapshot]); const promptMode = draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt'; const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : ''; const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null; const localAttachments = useMemo(() => ( draftKey ? attachmentsByDraftKey[draftKey] ?? [] : [] ), [attachmentsByDraftKey, draftKey]); attachmentsRef.current = attachmentsByDraftKey; const selectProjectConversation = useCallback((projectId: string, conversationId: string) => { selectedConversationContextRef.current = `${projectId}:${conversationId}`; return selectConversation(conversationId); }, [selectConversation]); const selectDraft = useCallback((state: CodingConversationStoreState) => ( targetConversationId ? selectCodingConversationDraft(targetConversationId)(state) : null ), [targetConversationId]); const selectedDraft = useCodingConversationStore(selectDraft); const selectSnapshot = useCallback((state: CodingConversationStoreState) => ( targetConversationId ? selectCodingConversationSnapshot(targetConversationId)(state) : null ), [targetConversationId]); const snapshot = useCodingConversationStore(selectSnapshot); const entryLoadState = useCodingConversationStore((state) => ( targetConversationId ? state.entriesByConversationId[targetConversationId]?.loadState ?? 'empty' : 'empty' )); const entryError = useCodingConversationStore((state) => ( targetConversationId ? state.entriesByConversationId[targetConversationId]?.error ?? null : null )); const blockingRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, BLOCKING_REQUEST_STATUSES, )); const acceptedRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, ACCEPTED_REQUEST_STATUSES, )); const acceptedPromptRequestCount = useCodingConversationStore((state) => acceptedPromptCount( state, targetConversationId, )); const uncertainRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, UNCERTAIN_REQUEST_STATUSES, )); const submittingRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, SUBMITTING_REQUEST_STATUSES, )); useEffect(() => { void loadWorkspace().catch(() => undefined); }, [loadWorkspace]); useEffect(() => () => disconnectEvents(), [disconnectEvents]); useEffect(() => subscribeHostEvent('lifecycle:sleep', () => { disconnectEvents(); }), [disconnectEvents]); useEffect(() => { if (!targetConversationId) return undefined; const refreshVisibleConversation = () => { if (document.visibilityState !== 'visible') return; void selectConversation(targetConversationId).catch(() => undefined); }; window.addEventListener('focus', refreshVisibleConversation); document.addEventListener('visibilitychange', refreshVisibleConversation); return () => { window.removeEventListener('focus', refreshVisibleConversation); document.removeEventListener('visibilitychange', refreshVisibleConversation); }; }, [selectConversation, targetConversationId]); 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) { selectedConversationContextRef.current = null; clearConversationSelection(); return; } if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) { const selectionContext = `${activeProject.id}:${selectedConversation.id}`; if (selectedConversationContextRef.current !== selectionContext) { void selectProjectConversation(activeProject.id, selectedConversation.id) .catch(() => undefined); } return; } const next = newestConversation(conversations, selectedAgent.id); if (next) { markConversationUnread(next.id, false); if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined); void selectProjectConversation(activeProject.id, 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)); const current = codingWorkspaceStore.getState(); if (current.activeProjectId === activeProject.id && current.selectedAgentId === selectedAgent.id) { void selectProjectConversation(activeProject.id, conversation.id) .catch(() => undefined); } }) .catch(() => undefined); }, [ activeProject, clearConversationSelection, conversations, ensureConversation, markConversationUnread, patchConversation, primeConversation, selectProjectConversation, selectedAgent, selectedConversation, ]); useEffect(() => { const nextDraft = navigationDraft?.trim(); if (!nextDraft || appliedNavigationDraftRef.current === nextDraft) return; appliedNavigationDraftRef.current = nextDraft; if (targetConversationId) { setConversationDraft(targetConversationId, nextDraft); } else if (provisionalDraftKey) { setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: nextDraft })); } else { return; } }, [navigationDraft, provisionalDraftKey, setConversationDraft, targetConversationId]); useEffect(() => { if (!targetConversationId) return; if (provisionalDraft.trim()) { const current = codingConversationStore.getState().draftsByConversationId[targetConversationId]; if (!current?.text) setConversationDraft(targetConversationId, provisionalDraft); if (provisionalDraftKey) { setProvisionalDrafts((drafts) => { const next = { ...drafts }; delete next[provisionalDraftKey]; return next; }); } } if (provisionalDraftKey) { setAttachmentsByDraftKey((currentAttachments) => { const pending = currentAttachments[provisionalDraftKey]; if (!pending?.length || currentAttachments[targetConversationId]?.length) return currentAttachments; const next = { ...currentAttachments, [targetConversationId]: pending }; delete next[provisionalDraftKey]; return next; }); } }, [provisionalDraft, provisionalDraftKey, setConversationDraft, targetConversationId]); const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => { if (!activeProject) return; primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); markConversationUnread(conversation.id, false); if (conversation.unread) { void patchConversation(conversation.id, { unread: false }).catch(() => undefined); } void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined); }, [activeProject, markConversationUnread, patchConversation, primeConversation, selectProjectConversation]); const handleCreateConversation = useCallback(async () => { if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return; const projectId = activeProject.id; const agentId = selectedAgent.id; const conversation = await createConversation(agentId).catch(() => null); if (!conversation) return; primeConversation(createLocalConversationSnapshot(projectId, conversation)); const current = codingWorkspaceStore.getState(); if (current.activeProjectId === projectId && current.selectedAgentId === agentId) { await selectProjectConversation(projectId, conversation.id).catch(() => undefined); } }, [ activeProject, createConversation, creatingAgentIds, primeConversation, selectProjectConversation, selectedAgent, ]); const handleForkConversation = useCallback(async (sourceEntryId?: string) => { if (!activeProject || !selectedAgent || !targetConversationId) return; const sourceProjectId = activeProject.id; const sourceAgentId = selectedAgent.id; const sourceConversationId = targetConversationId; const forked = await forkCodingConversation(sourceConversationId, sourceEntryId); const workspace = codingWorkspaceStore.getState(); if (workspace.activeProjectId !== sourceProjectId) return; upsertConversation(sourceProjectId, forked); primeConversation(createLocalConversationSnapshot(sourceProjectId, forked)); const selectedId = codingConversationStore.getState().selectedConversationId; if (workspace.activeProjectId === sourceProjectId && workspace.selectedAgentId === sourceAgentId && selectedId === sourceConversationId) { await selectProjectConversation(sourceProjectId, forked.id); } }, [activeProject, primeConversation, selectProjectConversation, selectedAgent, targetConversationId, upsertConversation]); const handleAddFiles = useCallback((files: File[]) => { if (!draftKey || (targetConversationId && submissionFlightsRef.current.has(targetConversationId))) return; const accepted: LocalComposerAttachment[] = []; const remaining = Math.max(0, CODING_ATTACHMENT_MAX_COUNT - localAttachments.length); let validationError = files.length > remaining ? `每条消息最多添加 ${CODING_ATTACHMENT_MAX_COUNT} 张图片。` : null; for (const file of files.slice(0, remaining)) { const mime = file.type.trim().toLowerCase(); if (!CODING_ATTACHMENT_MIMES.has(mime)) { validationError = '仅支持 PNG、JPEG、WebP 或 GIF 图片。'; continue; } if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) { validationError = '图片不能超过 16 MB。'; continue; } accepted.push({ id: crypto.randomUUID(), name: file.name || '图片', file, previewUrl: URL.createObjectURL(file), }); } setSubmissionErrors((current) => { const next = { ...current }; if (validationError) next[draftKey] = { message: validationError }; else delete next[draftKey]; return next; }); if (accepted.length === 0) return; setAttachmentsByDraftKey((current) => ({ ...current, [draftKey]: [...(current[draftKey] ?? []), ...accepted], })); }, [draftKey, localAttachments.length, targetConversationId]); const handleRemoveAttachment = useCallback((id: string) => { if (!draftKey || (targetConversationId && submissionFlightsRef.current.has(targetConversationId))) 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); setSubmissionErrors((current) => { const next = { ...current }; delete next[draftKey]; return next; }); }, [draftKey, targetConversationId]); const prepareAttachments = useCallback(async ( attachments: LocalComposerAttachment[], ): Promise => { const prepared = new Array(attachments.length); let nextIndex = 0; const workers = Array.from({ length: Math.min(CODING_ATTACHMENT_UPLOAD_CONCURRENCY, attachments.length), }, async () => { while (nextIndex < attachments.length) { const index = nextIndex++; const attachment = attachments[index]; 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); } } } prepared[index] = { attachmentId: uploaded.attachmentId, mime: uploaded.mime, previewUrl: attachment.previewUrl, }; } }); const results = await Promise.allSettled(workers); for (const result of results) { if (result.status === 'rejected') throw result.reason; } return prepared; }, []); const handleSubmit = useCallback(() => { if (!targetConversationId || !draftKey || submissionFlightsRef.current.has(targetConversationId)) return; const conversationId = targetConversationId; const attachments = [...localAttachments]; submissionFlightsRef.current.add(conversationId); setSubmissionErrors((current) => { const next = { ...current }; delete next[draftKey]; return next; }); void submitPrompt({ conversationId, mode: promptMode, 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) => { setSubmissionErrors((current) => ({ ...current, [draftKey]: localSubmissionError(error), })); }).finally(() => { submissionFlightsRef.current.delete(conversationId); }); }, [ draftKey, localAttachments, prepareAttachments, promptMode, targetConversationId, submitPrompt, ]); 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; useEffect(() => { if (!draftKey || uncertainRequestCount > 0 || runtimeError?.code === 'CODING_REQUEST_UNCERTAIN') return; setSubmissionErrors((current) => { if (current[draftKey]?.backendCode !== 'CODING_REQUEST_UNCERTAIN') return current; const next = { ...current }; delete next[draftKey]; return next; }); }, [draftKey, runtimeError?.code, uncertainRequestCount]); const preparing = entryLoadState === 'loading' || workerStatus === 'starting' || workerStatus === 'recovering' || runStatus === 'preparing'; const recovering = entryLoadState === 'recovering'; const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); const modeMatchesRunState = running ? promptMode !== 'prompt' : promptMode === 'prompt'; const pendingInteractionCount = snapshot?.pendingInteractions.filter((interaction) => ( interaction.status === 'pending' )).length ?? 0; const draft = selectedDraft?.text ?? provisionalDraft; const editable = Boolean(activeProject && selectedAgent); const canSend = Boolean( editable && targetConversationId && (draft.trim() || localAttachments.length > 0) && modeMatchesRunState && submittingRequestCount === 0 && !preparationError && (running || acceptedPromptRequestCount === 0) && blockingRequestCount - acceptedRequestCount === 0 && uncertainRequestCount === 0 && pendingInteractionCount === 0, ); const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]); if (!activeProject && workspaceLoadState !== 'loading') { return (

先选择一个编程项目

项目与智能体配置从本地读取,打开输入框不会提前启动 Pi 运行实例。

); } return (
{ clearConversationSelection(); selectAgent(agentId); }} onSelectConversation={handleSelectConversation} onCreateConversation={() => void handleCreateConversation()} onOpenProjectSettings={onOpenProjectSettings} />
{ if (targetConversationId) await patchConversation(targetConversationId, { title }); }} onAbort={async () => { if (targetConversationId) await abortConversation(targetConversationId); }} onRecover={async () => { if (targetConversationId) await recoverConversation(targetConversationId); }} /> {(workspaceError || conversationMetadataError || connectionError) && (
)} {targetConversationId ? ( { void handleForkConversation(sourceEntryId).catch((error) => { if (!draftKey) return; setSubmissionErrors((current) => ({ ...current, [draftKey]: localSubmissionError(error), })); }); }} /> ) : (
)} {targetConversationId && ( loadConversationSnapshot(targetConversationId, 'silent').then(() => undefined)} /> )} {targetConversationId && ( )} ({ id, name, previewUrl }))} submitting={submittingRequestCount > 0} placeholder={selectedAgent ? '随心输入' : '请选择一个智能体后再发送'} conversation={selectedConversation} snapshot={snapshot} voiceScopeKey={draftKey} onModeChange={(mode) => { if (draftKey) setModesByDraftKey((current) => ({ ...current, [draftKey]: mode })); }} onChange={(value) => { if (draftKey) { setSubmissionErrors((current) => { const next = { ...current }; delete next[draftKey]; return next; }); } if (targetConversationId) setConversationDraft(targetConversationId, value); else if (provisionalDraftKey) { setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: value })); } }} onSubmit={handleSubmit} onAbort={() => { if (!targetConversationId) return; const conversationId = targetConversationId; void abortConversation(conversationId) .catch((error) => { if (!draftKey) return; setSubmissionErrors((current) => ({ ...current, [draftKey]: localSubmissionError(error), })); }); }} onRecover={() => { if (targetConversationId) { void recoverConversation(targetConversationId).catch(() => undefined); } }} onAddFiles={handleAddFiles} onRemoveAttachment={handleRemoveAttachment} onRefreshRuntime={async () => { if (targetConversationId) await loadConversationSnapshot(targetConversationId, 'silent'); }} />
); }