994 lines
47 KiB
TypeScript
994 lines
47 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Bot,
|
|
ChevronRight,
|
|
CircleAlert,
|
|
FolderKanban,
|
|
Plus,
|
|
RefreshCw,
|
|
MessageSquare,
|
|
Play,
|
|
} 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,
|
|
CodingProjectSummary,
|
|
} from '@/types/coding-project';
|
|
import { CodingComposer } from './CodingComposer';
|
|
import { AgentBrowserPanel } from './AgentBrowserPanel';
|
|
import { CodingChangesSummary } from './CodingChangesSummary';
|
|
import { CodingConversationHeader } from './CodingConversationHeader';
|
|
import { CodingConversationTimeline } from './CodingConversationTimeline';
|
|
import { CodingInteractionPanel } from './CodingInteractionPanel';
|
|
import { CodingWelcomeHero } from './CodingWelcomeHero';
|
|
import { CodingStartHeading } from './CodingStartHeading';
|
|
import { CodingChatBody } from './CodingChatBody';
|
|
import { CommunityInvitation } from '@/components/community/CommunityInvitation';
|
|
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import { TeacherChatPanel } from './TeacherChatPanel';
|
|
import { TeacherCompanion } from './TeacherCompanion';
|
|
import { useTeacherCompanion } from './use-teacher-companion';
|
|
import type { TeacherReference } from '../../../shared/coding-teacher';
|
|
import { codingRecovery, CONTINUE_CODING_PROMPT, type CodingRecoveryAction } from '../../../shared/coding-recovery';
|
|
import { codingRecoveryAccount, type CodingLoginRecovery } from '@/lib/coding-login-recovery';
|
|
import { openWorksBillingAccount } from '@/lib/works-billing';
|
|
|
|
export interface CodingChatPanelProps {
|
|
navigationDraft?: string;
|
|
onCreateProject?(): void;
|
|
onOpenProject?(projectId: string): void;
|
|
onOpenProjectSettings?(): void;
|
|
onLoginRecovery?(projectId: string, conversationId: string | null, work: boolean): void;
|
|
loginRecovery?: CodingLoginRecovery;
|
|
onLoginRecoveryConsumed?(): 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[],
|
|
): CodingConversationMetadata | null {
|
|
return conversations
|
|
.filter((conversation) => !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 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;
|
|
}
|
|
|
|
const PROJECT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
});
|
|
|
|
function projectLastOpenedLabel(project: CodingProjectSummary): string {
|
|
const timestamp = Date.parse(project.lastOpenedAt);
|
|
if (!Number.isFinite(timestamp)) return '本地项目';
|
|
return `最近打开 ${PROJECT_DATE_FORMATTER.format(timestamp)}`;
|
|
}
|
|
|
|
export function CodingChatPanel({
|
|
navigationDraft,
|
|
onCreateProject,
|
|
onOpenProject,
|
|
onOpenProjectSettings,
|
|
onLoginRecovery,
|
|
loginRecovery,
|
|
onLoginRecoveryConsumed,
|
|
}: CodingChatPanelProps) {
|
|
const projects = useCodingWorkspaceStore((state) => state.projects);
|
|
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 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 [provisionalDrafts, setProvisionalDrafts] = useState<Record<string, string>>({});
|
|
const [submissionErrors, setSubmissionErrors] = useState<Record<string, LocalSubmissionError>>({});
|
|
const [modesByDraftKey, setModesByDraftKey] = useState<Record<string, PromptMode>>({});
|
|
const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState<
|
|
Record<string, LocalComposerAttachment[]>
|
|
>({});
|
|
const [agentBrowserOpen, setAgentBrowserOpen] = useState(false);
|
|
const [workRevision, setWorkRevision] = useState(0);
|
|
const [readyWorkProjectId, setReadyWorkProjectId] = useState<string | null>(null);
|
|
const recoveryFlight = useRef(false);
|
|
const appliedRecovery = useRef<string | null>(null);
|
|
const [consultationOpen, setConsultationOpen] = useState(false);
|
|
const [teacherComposing, setTeacherComposing] = useState(false);
|
|
const [consultationWidth, setConsultationWidth] = useState(508);
|
|
const account = useAuthStore((state) => state.user?.userId ?? state.user?.username ?? 'signed-out');
|
|
const [teacherQuote, setTeacherQuote] = useState<TeacherReference | undefined>();
|
|
const [teacherQuoteSource, setTeacherQuoteSource] = useState<string | null>(null);
|
|
const appliedNavigationDraftRef = useRef<string | null>(null);
|
|
const selectedConversationContextRef = useRef<string | null>(null);
|
|
const attachmentsRef = useRef(attachmentsByDraftKey);
|
|
const uploadedAttachmentsRef = useRef(new Map<string, CodingAttachmentRef>());
|
|
const uploadFlightsRef = useRef(new Map<string, Promise<CodingAttachmentRef>>());
|
|
const submissionFlightsRef = useRef(new Set<string>());
|
|
|
|
const agents = useMemo(() => (
|
|
config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? []
|
|
), [config]);
|
|
const selectedConversation = conversations.find((conversation) => (
|
|
conversation.id === selectedConversationId
|
|
)) ?? null;
|
|
const selectedAgent = config?.agents.find((agent) => agent.id === (selectedConversation?.agentId ?? config.defaultAgentId ?? selectedAgentId)) ?? 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 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 runStatus = snapshot?.run.status ?? 'idle';
|
|
const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus);
|
|
const teacherCompanion = useTeacherCompanion({ projectId: activeProject?.id ?? null, sourceId: targetConversationId, sourceBusy: running, sourceArchived: Boolean(selectedConversation?.archivedAt), teacherOpen: consultationOpen, teacherComposing });
|
|
const [teacherBubble, setTeacherBubble] = useState<HTMLDivElement | null>(null);
|
|
const [teacherIntroduction, setTeacherIntroduction] = useState<HTMLDivElement | null>(null);
|
|
const openTeacher = (agentId?: string) => {
|
|
if (agentId) teacherCompanion.selectAgent(agentId);
|
|
const selectedText = window.getSelection()?.toString().trim();
|
|
setTeacherQuote(selectedText ? { kind: 'code', text: selectedText.slice(0, 12000) } : undefined);
|
|
setTeacherQuoteSource(`${activeProject?.id}:${targetConversationId}`);
|
|
setConsultationOpen(true);
|
|
setAgentBrowserOpen(true);
|
|
};
|
|
const closeTeacher = () => {
|
|
setConsultationOpen(false);
|
|
const entry = document.querySelector<HTMLButtonElement>('.consultation-agent-tabs [aria-pressed="true"]')
|
|
?? document.querySelector<HTMLButtonElement>('.consultation-agent-tabs button');
|
|
entry?.focus();
|
|
};
|
|
const toggleTeacher = (agentId?: string) => {
|
|
if (consultationOpen && (!agentId || agentId === teacherCompanion.selectedAgent?.teacher_id)) {
|
|
closeTeacher();
|
|
return;
|
|
}
|
|
openTeacher(agentId);
|
|
};
|
|
const promptMode = running && draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt';
|
|
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(() => {
|
|
setAgentBrowserOpen(false);
|
|
}, [activeProject?.id]);
|
|
useEffect(() => { setTeacherQuote(undefined); }, [activeProject?.id, targetConversationId]);
|
|
|
|
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) {
|
|
const selectionContext = `${activeProject.id}:${selectedConversation.id}`;
|
|
if (selectedConversationContextRef.current !== selectionContext) {
|
|
void selectProjectConversation(activeProject.id, selectedConversation.id)
|
|
.catch(() => undefined);
|
|
}
|
|
return;
|
|
}
|
|
const next = newestConversation(conversations);
|
|
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;
|
|
}
|
|
clearConversationSelection();
|
|
}, [
|
|
activeProject,
|
|
clearConversationSelection,
|
|
conversations,
|
|
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]);
|
|
|
|
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<CodingDraftAttachment[]> => {
|
|
const prepared = new Array<CodingDraftAttachment>(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 && activeProject && selectedAgent && !submissionFlightsRef.current.has(draftKey)) {
|
|
const projectId = activeProject.id;
|
|
const initialText = provisionalDraft;
|
|
const attachments = [...localAttachments];
|
|
const selectedAtSend = codingConversationStore.getState().selectedConversationId;
|
|
let createdId: string | null = null;
|
|
submissionFlightsRef.current.add(draftKey);
|
|
void codingWorkspaceStore.getState().createConversation().then(async (conversation) => {
|
|
createdId = conversation.id;
|
|
primeConversation(createLocalConversationSnapshot(projectId, conversation));
|
|
setConversationDraft(conversation.id, initialText);
|
|
setProvisionalDrafts(current => {
|
|
if (current[draftKey] !== initialText) return current;
|
|
const next = { ...current }; delete next[draftKey]; return next;
|
|
});
|
|
setAttachmentsByDraftKey(current => {
|
|
const next = { ...current, [conversation.id]: attachments };
|
|
delete next[draftKey]; return next;
|
|
});
|
|
if (codingWorkspaceStore.getState().activeProjectId === projectId
|
|
&& codingConversationStore.getState().selectedConversationId === selectedAtSend) {
|
|
void selectProjectConversation(projectId, conversation.id).catch(() => undefined);
|
|
}
|
|
await submitPrompt({ conversationId: conversation.id, mode: 'prompt',
|
|
prepareAttachments: attachments.length ? () => prepareAttachments(attachments) : undefined });
|
|
setAttachmentsByDraftKey(current => {
|
|
const next = { ...current }; delete next[conversation.id]; return next;
|
|
});
|
|
for (const attachment of attachments) {
|
|
URL.revokeObjectURL(attachment.previewUrl);
|
|
uploadedAttachmentsRef.current.delete(attachment.id);
|
|
}
|
|
}).catch((error) => {
|
|
setSubmissionErrors(current => ({ ...current, [createdId ?? draftKey]: localSubmissionError(error) }));
|
|
}).finally(() => submissionFlightsRef.current.delete(draftKey));
|
|
return;
|
|
}
|
|
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);
|
|
});
|
|
}, [
|
|
activeProject,
|
|
selectedAgent,
|
|
provisionalDraft,
|
|
primeConversation,
|
|
setConversationDraft,
|
|
selectProjectConversation,
|
|
draftKey,
|
|
localAttachments,
|
|
prepareAttachments,
|
|
promptMode,
|
|
targetConversationId,
|
|
submitPrompt,
|
|
]);
|
|
|
|
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 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?.enabled && !selectedAgent.archivedAt);
|
|
const canSend = Boolean(
|
|
editable
|
|
&& (draft.trim() || localAttachments.length > 0)
|
|
&& modeMatchesRunState
|
|
&& submittingRequestCount === 0
|
|
&& !preparationError
|
|
&& (running || acceptedPromptRequestCount === 0)
|
|
&& blockingRequestCount - acceptedRequestCount === 0
|
|
&& uncertainRequestCount === 0
|
|
&& pendingInteractionCount === 0,
|
|
);
|
|
const handleRecovery = useCallback(async (action: CodingRecoveryAction, work: boolean) => {
|
|
if (!activeProject || recoveryFlight.current) return;
|
|
if (action === 'login') { onLoginRecovery?.(activeProject.id, targetConversationId, work); return; }
|
|
if (action === 'settings') { onOpenProjectSettings?.(); return; }
|
|
if (action === 'account') { await openWorksBillingAccount(); return; }
|
|
if (action === 'check-work') { setWorkRevision((value) => value + 1); setAgentBrowserOpen(true); return; }
|
|
if (!targetConversationId) return;
|
|
recoveryFlight.current = true;
|
|
try {
|
|
if (action === 'refresh') { await loadConversationSnapshot(targetConversationId, 'silent'); return; }
|
|
// Refresh before continuing: another window/run may already have made progress.
|
|
await loadConversationSnapshot(targetConversationId, 'silent');
|
|
const current = selectCodingConversationSnapshot(targetConversationId)(codingConversationStore.getState());
|
|
if (!current || !['idle', 'error'].includes(current.run.status)) return;
|
|
if (current.run.error?.code === 'CODING_REQUEST_UNCERTAIN') return;
|
|
const requests = codingConversationStore.getState().requestsByConversationId[targetConversationId];
|
|
if (Object.values(requests ?? {}).some((request) => ['pending', 'uncertain'].includes(request.status))) return;
|
|
if (current.run.terminalReason === 'completed') return;
|
|
const recoveryAccount = codingRecoveryAccount(useAuthStore.getState().user);
|
|
await recoverConversation(targetConversationId);
|
|
if (codingWorkspaceStore.getState().activeProjectId !== activeProject.id
|
|
|| recoveryAccount !== codingRecoveryAccount(useAuthStore.getState().user)) return;
|
|
const refreshed = selectCodingConversationSnapshot(targetConversationId)(codingConversationStore.getState());
|
|
if (!refreshed || !['idle', 'error'].includes(refreshed.run.status)) return;
|
|
await submitPrompt({ conversationId: targetConversationId, mode: 'prompt', text: CONTINUE_CODING_PROMPT, attachments: [], preserveDraft: true });
|
|
} finally { recoveryFlight.current = false; }
|
|
}, [activeProject, targetConversationId, onLoginRecovery, onOpenProjectSettings, loadConversationSnapshot, recoverConversation, submitPrompt]);
|
|
|
|
useEffect(() => {
|
|
if (!loginRecovery || appliedRecovery.current === loginRecovery.id || workspaceLoadState !== 'ready') return;
|
|
if (loginRecovery.account !== codingRecoveryAccount(useAuthStore.getState().user)
|
|
|| loginRecovery.projectId !== activeProject?.id) {
|
|
appliedRecovery.current = loginRecovery.id;
|
|
onLoginRecoveryConsumed?.();
|
|
return;
|
|
}
|
|
if (loginRecovery.conversationId !== targetConversationId
|
|
|| (targetConversationId ? entryLoadState !== 'live' : workspaceLoadState !== 'ready')) return;
|
|
appliedRecovery.current = loginRecovery.id;
|
|
onLoginRecoveryConsumed?.();
|
|
void handleRecovery(loginRecovery.work ? 'check-work' : 'continue', loginRecovery.work).catch((error) => {
|
|
if (draftKey) setSubmissionErrors((current) => ({ ...current, [draftKey]: localSubmissionError(error) }));
|
|
});
|
|
}, [loginRecovery, activeProject, targetConversationId, entryLoadState, workspaceLoadState, handleRecovery, draftKey, onLoginRecoveryConsumed]);
|
|
const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]);
|
|
const showWelcome = editable && !selectedConversation?.archivedAt && !running && runStatus === 'idle'
|
|
&& (!targetConversationId || (entryLoadState === 'live' && snapshot?.nodes.length === 0));
|
|
|
|
if (!activeProject && workspaceLoadState !== 'loading') {
|
|
return (
|
|
<section
|
|
className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background px-4 pb-3 pt-4 text-foreground sm:px-6 sm:pb-5"
|
|
data-testid="coding-chat-empty-project"
|
|
>
|
|
<CodingWelcomeHero className="min-h-[18rem] flex-1 pb-8 pt-6 sm:pb-12" />
|
|
|
|
<div className="mx-auto w-full max-w-[52rem] shrink-0 pb-1">
|
|
{workspaceError ? (
|
|
<div className="mb-2 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
|
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
|
|
<p className="min-w-0 flex-1 text-pretty" role="alert">{workspaceError}</p>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
className="min-h-8 shrink-0 rounded-lg px-2.5 text-destructive"
|
|
onClick={() => void loadWorkspace().catch(() => undefined)}
|
|
>
|
|
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
|
刷新
|
|
</Button>
|
|
</div>
|
|
) : null}
|
|
|
|
<Button
|
|
type="button"
|
|
data-testid="coding-start-project-button"
|
|
className="mx-auto flex h-12 min-w-44 rounded-xl border border-brand/20 bg-brand px-7 text-[15px] font-semibold text-primary-foreground shadow-[0_10px_24px_rgba(190,68,24,0.18)] transition-[transform,box-shadow,background-color] duration-200 hover:-translate-y-0.5 hover:bg-brand-hover hover:shadow-[0_14px_30px_rgba(190,68,24,0.22)] active:translate-y-0"
|
|
onClick={onCreateProject}
|
|
disabled={!onCreateProject}
|
|
>
|
|
<Plus className="mr-2 h-4.5 w-4.5" aria-hidden="true" />
|
|
新增项目
|
|
</Button>
|
|
|
|
{projects.length > 0 ? (
|
|
<section className="mt-7" aria-label="项目列表">
|
|
<div
|
|
className="flex gap-3 overflow-x-auto pb-2"
|
|
data-testid="coding-existing-projects"
|
|
>
|
|
{projects.map((project) => (
|
|
<button
|
|
type="button"
|
|
key={project.id}
|
|
data-testid={`coding-existing-project-${project.id}`}
|
|
aria-label={`进入项目 ${project.name}`}
|
|
className="motion-press group/project-card flex min-h-[6.75rem] w-64 shrink-0 flex-col justify-between rounded-2xl bg-surface-subtle p-4 text-left shadow-[0_8px_22px_rgba(35,43,56,0.055)] ring-1 ring-foreground/[0.055] transition-[transform,box-shadow,background-color] duration-200 hover:-translate-y-0.5 hover:bg-white hover:shadow-[0_14px_30px_rgba(35,43,56,0.09)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-2"
|
|
onClick={() => onOpenProject?.(project.id)}
|
|
disabled={!onOpenProject}
|
|
>
|
|
<span className="flex items-start justify-between gap-4">
|
|
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white text-brand shadow-[0_0_0_1px_rgba(35,43,56,0.06)] group-hover/project-card:bg-brand-soft">
|
|
<FolderKanban className="h-4 w-4" aria-hidden="true" />
|
|
</span>
|
|
<ChevronRight className="mt-1 h-4 w-4 text-muted-foreground/55 transition-[transform,color] duration-200 group-hover/project-card:translate-x-0.5 group-hover/project-card:text-foreground" aria-hidden="true" />
|
|
</span>
|
|
<span className="mt-4 min-w-0">
|
|
<span className="block truncate text-sm font-semibold text-foreground">{project.name}</span>
|
|
<time
|
|
dateTime={project.lastOpenedAt}
|
|
className="mt-1 block text-xs text-muted-foreground"
|
|
>
|
|
{projectLastOpenedLabel(project)}
|
|
</time>
|
|
</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</section>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden bg-background text-foreground" data-testid="coding-chat-panel" data-consultation-open={consultationOpen}>
|
|
<div className="classroom-main flex min-w-0 flex-1 flex-col">
|
|
<CodingConversationHeader
|
|
key={`header:${targetConversationId ?? 'none'}`}
|
|
conversation={selectedConversation}
|
|
snapshot={snapshot}
|
|
onRename={async (title) => {
|
|
if (targetConversationId) await patchConversation(targetConversationId, { title });
|
|
}}
|
|
onAbort={async () => {
|
|
if (targetConversationId) await abortConversation(targetConversationId);
|
|
}}
|
|
onRecover={async () => {
|
|
if (targetConversationId) await handleRecovery(codingRecovery(runtimeError?.code).action, false);
|
|
}}
|
|
browserOpen={agentBrowserOpen}
|
|
browserAvailable={Boolean(activeProject)}
|
|
onToggleBrowser={() => setAgentBrowserOpen((current) => !current)}
|
|
projectName={activeProject?.name}
|
|
consultationOpen={consultationOpen}
|
|
teacherEntry={activeProject ? <TeacherCompanion companion={teacherCompanion} open={consultationOpen} onOpen={openTeacher} onToggle={toggleTeacher} bubbleRef={setTeacherBubble} introductionRef={setTeacherIntroduction} /> : undefined}
|
|
/>
|
|
|
|
<div className="classroom-tabs" role="tablist" aria-label="当前工作">
|
|
<button role="tab" id="classroom-chat-tab" aria-controls="classroom-chat-view" aria-selected={!agentBrowserOpen} tabIndex={!agentBrowserOpen ? 0 : -1} onClick={() => setAgentBrowserOpen(false)} onKeyDown={(event) => { if (event.key === 'ArrowRight') { setAgentBrowserOpen(true); document.getElementById('classroom-work-tab')?.focus(); } }}><MessageSquare className="h-3.5 w-3.5" />操作对话</button>
|
|
<button role="tab" id="classroom-work-tab" aria-controls="classroom-work-view" aria-selected={agentBrowserOpen} tabIndex={agentBrowserOpen ? 0 : -1} onClick={() => setAgentBrowserOpen(true)} onKeyDown={(event) => { if (event.key === 'ArrowLeft') { setAgentBrowserOpen(false); document.getElementById('classroom-chat-tab')?.focus(); } }}><Play className="h-3.5 w-3.5" />作品</button>
|
|
</div>
|
|
<div id="classroom-work-view" role="tabpanel" aria-labelledby="classroom-work-tab" hidden={!agentBrowserOpen} className="classroom-work-view min-h-0 flex-1 flex-col p-5">
|
|
<AgentBrowserPanel projectId={activeProject?.id ?? null} open={agentBrowserOpen} onOpenChange={setAgentBrowserOpen} embedded
|
|
teacherBubble={teacherBubble}
|
|
teacherIntroduction={teacherIntroduction}
|
|
workRevision={workRevision}
|
|
onRecovery={handleRecovery}
|
|
onWorkResult={(result) => setReadyWorkProjectId(result.status === 'ready' ? activeProject?.id ?? null : null)}
|
|
conversationId={targetConversationId}
|
|
onWorkConversation={(conversation) => {
|
|
if (!activeProject || codingWorkspaceStore.getState().activeProjectId !== activeProject.id) return;
|
|
upsertConversation(activeProject.id, conversation);
|
|
if (!targetConversationId && !codingConversationStore.getState().selectedConversationId) {
|
|
primeConversation(createLocalConversationSnapshot(activeProject.id, conversation));
|
|
if (draftKey) {
|
|
setConversationDraft(conversation.id, provisionalDraft);
|
|
setProvisionalDrafts((current) => { const next = { ...current }; delete next[draftKey]; return next; });
|
|
setAttachmentsByDraftKey((current) => {
|
|
const next = { ...current, [conversation.id]: current[draftKey] ?? [] };
|
|
delete next[draftKey]; return next;
|
|
});
|
|
}
|
|
void selectProjectConversation(activeProject.id, conversation.id).catch(() => undefined);
|
|
}
|
|
}} />
|
|
</div>
|
|
<CodingChatBody welcome={showWelcome} hidden={agentBrowserOpen} bubble={teacherBubble}>
|
|
<div id="classroom-chat-view" role="tabpanel" aria-labelledby="classroom-chat-tab" hidden={agentBrowserOpen} className="classroom-chat-view min-h-0 flex-1 flex-col">
|
|
|
|
{(workspaceError || conversationMetadataError || 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 ?? conversationMetadataError ?? 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>
|
|
)}
|
|
|
|
{showWelcome ? <CodingStartHeading /> : targetConversationId
|
|
? (
|
|
<CodingConversationTimeline
|
|
key={targetConversationId}
|
|
conversationId={targetConversationId}
|
|
assistantName={selectedAgent?.name}
|
|
onRecovery={handleRecovery}
|
|
workReady={readyWorkProjectId === activeProject?.id}
|
|
onFork={selectedConversation?.archivedAt ? undefined : (sourceEntryId) => {
|
|
void handleForkConversation(sourceEntryId).catch((error) => {
|
|
if (!draftKey) return;
|
|
setSubmissionErrors((current) => ({
|
|
...current,
|
|
[draftKey]: localSubmissionError(error),
|
|
}));
|
|
});
|
|
}}
|
|
/>
|
|
)
|
|
: agents.length === 0
|
|
? (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center p-6" data-testid="coding-chat-empty-agent">
|
|
<div className="max-w-sm 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">
|
|
请打开项目设置检查默认编程配置。
|
|
</p>
|
|
{onOpenProjectSettings ? (
|
|
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
|
|
项目设置
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
)
|
|
: (
|
|
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground" data-testid="coding-conversation-pending">
|
|
<div className="pb-12 text-center"><h1 className="text-2xl font-medium tracking-tight text-foreground">今天,想做点什么?</h1><p className="mt-4 text-sm leading-7">从一个小想法开始,<br />我们一起把它变成作品。</p></div>
|
|
</div>
|
|
)}
|
|
|
|
{targetConversationId && (
|
|
<CodingInteractionPanel
|
|
key={`interaction:${targetConversationId}`}
|
|
conversationId={targetConversationId}
|
|
interactions={snapshot?.pendingInteractions ?? []}
|
|
onSettled={() => loadConversationSnapshot(targetConversationId, 'silent').then(() => undefined)}
|
|
/>
|
|
)}
|
|
|
|
{targetConversationId && (
|
|
<CodingChangesSummary
|
|
key={`changes:${targetConversationId}`}
|
|
conversationId={targetConversationId}
|
|
snapshot={snapshot}
|
|
/>
|
|
)}
|
|
|
|
</div>
|
|
<div hidden={agentBrowserOpen} className="classroom-composer-view shrink-0">
|
|
{selectedConversation?.archivedAt ? (
|
|
<div className="m-4 flex items-center justify-between gap-3 rounded-xl border bg-surface-subtle px-4 py-3 text-sm">
|
|
<span>{running ? '此对话已归档,任务仍在运行。' : '此对话已归档,恢复后可继续聊天。'}</span>
|
|
<Button onClick={() => void patchConversation(selectedConversation.id, { archived: false }).catch(() => undefined)}>恢复对话</Button>
|
|
</div>
|
|
) : selectedAgent && (!selectedAgent.enabled || selectedAgent.archivedAt) ? (
|
|
<div className="m-4 rounded-xl border bg-surface-subtle px-4 py-3 text-sm">
|
|
此历史会话的编程配置已停用,当前可查看历史与向智能体提问。
|
|
</div>
|
|
) : <CodingComposer
|
|
value={draft}
|
|
editable={editable}
|
|
canSend={canSend}
|
|
preparing={preparing || autoCreating}
|
|
recovering={recovering}
|
|
runStatus={runStatus}
|
|
mode={promptMode}
|
|
queue={snapshot?.queue ?? { items: [] }}
|
|
error={submissionError?.message ?? preparationError}
|
|
recoverableError={!submissionError && Boolean(preparationError)}
|
|
attachments={localAttachments.map(({ id, name, previewUrl }) => ({ 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 handleRecovery(codingRecovery(runtimeError?.code).action, false).catch(() => undefined);
|
|
}
|
|
}}
|
|
onAddFiles={handleAddFiles}
|
|
onRemoveAttachment={handleRemoveAttachment}
|
|
onRefreshRuntime={async () => {
|
|
if (targetConversationId) await loadConversationSnapshot(targetConversationId, 'silent');
|
|
}}
|
|
/>}
|
|
</div>
|
|
{showWelcome && <CommunityInvitation />}
|
|
</CodingChatBody>
|
|
</div>
|
|
|
|
{consultationOpen && activeProject && (
|
|
<div id="coding-consultation-dock" className="classroom-consultation-dock relative min-h-0 shrink-0 border-l" style={{ width: consultationWidth }}
|
|
onKeyDown={(event) => { if (event.key === 'Escape') closeTeacher(); }}>
|
|
<div role="separator" aria-label="调整咨询栏宽度" aria-orientation="vertical" aria-valuemin={320} aria-valuemax={640} aria-valuenow={consultationWidth} tabIndex={0} className="classroom-consultation-resizer"
|
|
onPointerDown={(event) => { if (event.button === 0) { event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); } }}
|
|
onPointerMove={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) setConsultationWidth(Math.max(320, Math.min(640, window.innerWidth - event.clientX))); }}
|
|
onPointerUp={(event) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }}
|
|
onKeyDown={(event) => { if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { event.preventDefault(); setConsultationWidth((width) => Math.max(320, Math.min(640, width + (event.key === 'ArrowLeft' ? 20 : -20)))); } }} />
|
|
<TeacherChatPanel key={`${account}:${teacherCompanion.selectedAgent?.teacher_id ?? activeProject.id}`}
|
|
projectId={activeProject.id} projectName={activeProject.name} sourceId={targetConversationId ?? ''}
|
|
selectedAgent={teacherCompanion.selectedAgent}
|
|
catalogState={teacherCompanion.catalogState}
|
|
reloadCatalog={teacherCompanion.reloadCatalog}
|
|
externalTopic={teacherCompanion.topic}
|
|
onTopicChange={teacherCompanion.noteTopic}
|
|
onComposingChange={setTeacherComposing}
|
|
quote={consultationOpen && teacherQuoteSource === `${activeProject.id}:${targetConversationId}` ? teacherQuote : undefined} />
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|