项目文件夹

文件
wehub-resource-sync e4dcfc49aa
Tests / Import Check (Python 3.13) (push) Has been cancelled
Tests / Import Check (Python 3.14) (push) Has been cancelled
Tests / Python Tests (Python 3.11) (push) Has been cancelled
Tests / Python Tests (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.14) (push) Has been cancelled
Tests / Test Summary (push) Has been cancelled
Tests / Lint and Format (push) Has been cancelled
Tests / Web Node Tests (push) Has been cancelled
Tests / Import Check (Python 3.11) (push) Has been cancelled
Tests / Import Check (Python 3.12) (push) Has been cancelled
Tests / Python Tests (Python 3.13) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:00:43 +08:00

741 行
25 KiB
TypeScript

"use client";
/**
* FollowupChatComposer — renders the SAME ``ChatComposer`` used on the
* main chat page, but with its own local state pool and a send handler
* that routes through ``QuizFollowupController`` instead of the unified
* chat context. The follow-up tab uses this so the composer surface
* (look, controls, @space popup, KB picker, attachments, LLM selector,
* picker dialogs) matches the main chat composer exactly.
*
* Self-contained: loads its own KB / LLM lists, owns drag-and-drop +
* attachment state, mounts the @space pickers internally.
*/
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { MessageSquare } from "lucide-react";
import { useTranslation } from "react-i18next";
import ChatComposer from "@/components/chat/home/ChatComposer";
import {
type QuizFollowupTabContext,
useFollowupThread,
useQuizFollowupController,
} from "@/context/QuizFollowupContext";
import { buildQuizFollowupConfig } from "@/lib/quiz-types";
import {
classifyFile,
isSvgFilename,
MAX_ATTACHMENT_BYTES,
MAX_TOTAL_ATTACHMENT_BYTES,
} from "@/lib/doc-attachments";
import {
extractBase64FromDataUrl,
readFileAsDataUrl,
} from "@/lib/file-attachments";
import { listKnowledgeBases } from "@/lib/knowledge-api";
import { listLLMOptions, type LLMOption } from "@/lib/llm-options";
import { selectedBooksToPayload } from "@/lib/book-references";
import type { SelectedBookReference } from "@/lib/book-references";
import type { SelectedHistorySession } from "@/components/chat/HistorySessionPicker";
import type { SelectedQuestionEntry } from "@/components/chat/QuestionBankPicker";
import type { SelectedRecord } from "@/lib/notebook-selection-types";
import type { SpaceMemoryFile } from "@/lib/space-items";
import type { LLMSelection } from "@/lib/unified-ws";
const NotebookRecordPicker = dynamic(
() => import("@/components/notebook/NotebookRecordPicker"),
{ ssr: false },
);
const HistorySessionPicker = dynamic(
() => import("@/components/chat/HistorySessionPicker"),
{ ssr: false },
);
const QuestionBankPicker = dynamic(
() => import("@/components/chat/QuestionBankPicker"),
{ ssr: false },
);
const PersonaPicker = dynamic(() => import("@/components/chat/PersonaPicker"), {
ssr: false,
});
const MemoryPicker = dynamic(() => import("@/components/chat/MemoryPicker"), {
ssr: false,
});
const BookReferencePicker = dynamic(
() => import("@/components/chat/BookReferencePicker"),
{ ssr: false },
);
interface KnowledgeBase {
name: string;
is_default?: boolean;
}
interface PendingAttachment {
type: string;
filename: string;
base64?: string;
previewUrl?: string;
size?: number;
mimeType?: string;
}
// Single-capability list — the follow-up tab is locked to "chat".
// label/description are i18n keys; resolved via t() inside the component.
const FOLLOWUP_CAPABILITIES_RAW = [
{
value: "",
label: "Chat",
description: "Flexible conversation with any tool",
icon: MessageSquare,
allowedTools: [],
},
];
interface FollowupChatComposerProps {
context: QuizFollowupTabContext;
}
function FollowupChatComposerImpl({ context }: FollowupChatComposerProps) {
const { t } = useTranslation();
const controller = useQuizFollowupController();
const thread = useFollowupThread(context.questionKey);
// ── Composer DOM refs ─────────────────────────────────────────
const composerRef = useRef<HTMLDivElement>(null);
const capMenuRef = useRef<HTMLDivElement>(null);
const capBtnRef = useRef<HTMLButtonElement>(null);
const spaceMenuRef = useRef<HTMLDivElement>(null);
const spaceBtnRef = useRef<HTMLButtonElement>(null);
const dragCounter = useRef(0);
// ── Composer local state ──────────────────────────────────────
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const attachmentErrorTimer = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const [dragging, setDragging] = useState(false);
const [capMenuOpen, setCapMenuOpen] = useState(false);
const [spaceMenuOpen, setSpaceMenuOpen] = useState(false);
const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState<
string[]
>([]);
const [selectedBookReferences, setSelectedBookReferences] = useState<
SelectedBookReference[]
>([]);
const [selectedNotebookRecords, setSelectedNotebookRecords] = useState<
SelectedRecord[]
>([]);
const [selectedHistorySessions, setSelectedHistorySessions] = useState<
SelectedHistorySession[]
>([]);
const [selectedQuestionEntries, setSelectedQuestionEntries] = useState<
SelectedQuestionEntry[]
>([]);
const [selectedPersona, setSelectedPersona] = useState<string | null>(null);
const [selectedMemoryFiles, setSelectedMemoryFiles] = useState<
SpaceMemoryFile[]
>([]);
// ── Picker dialog visibility ──────────────────────────────────
const [showNotebookPicker, setShowNotebookPicker] = useState(false);
const [showBookPicker, setShowBookPicker] = useState(false);
const [showHistoryPicker, setShowHistoryPicker] = useState(false);
const [showQuestionBankPicker, setShowQuestionBankPicker] = useState(false);
const [showPersonaPicker, setShowPersonaPicker] = useState(false);
const [showMemoryPicker, setShowMemoryPicker] = useState(false);
// ── Shared data (KBs + LLMs) ──────────────────────────────────
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [llmOptions, setLLMOptions] = useState<LLMOption[]>([]);
const [activeLLMDefault, setActiveLLMDefault] = useState<LLMSelection | null>(
null,
);
const [llmSelection, setLLMSelection] = useState<LLMSelection | null>(null);
const [llmOptionsLoading, setLLMOptionsLoading] = useState(true);
const [llmOptionsError, setLLMOptionsError] = useState(false);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
const list = await listKnowledgeBases({ force: false });
if (!cancelled) setKnowledgeBases(list);
} catch {
if (!cancelled) setKnowledgeBases([]);
}
};
void run();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
let cancelled = false;
const run = async () => {
setLLMOptionsLoading(true);
try {
const payload = await listLLMOptions();
if (cancelled) return;
setLLMOptions(payload.options);
setActiveLLMDefault(payload.active);
setLLMOptionsError(false);
} catch {
if (cancelled) return;
setLLMOptionsError(true);
setLLMOptions([]);
setActiveLLMDefault(null);
} finally {
if (!cancelled) setLLMOptionsLoading(false);
}
};
void run();
return () => {
cancelled = true;
};
}, []);
// Default to the server-side active LLM until the user picks one.
useEffect(() => {
if (llmSelection || !activeLLMDefault) return;
setLLMSelection(activeLLMDefault);
}, [activeLLMDefault, llmSelection]);
// Click-outside handlers for menu chrome (cap / space).
useEffect(() => {
if (!capMenuOpen && !spaceMenuOpen) return;
const handler = (event: MouseEvent) => {
const target = event.target as Node | null;
if (!target) return;
if (
capMenuOpen &&
capMenuRef.current &&
!capMenuRef.current.contains(target) &&
capBtnRef.current &&
!capBtnRef.current.contains(target)
) {
setCapMenuOpen(false);
}
if (
spaceMenuOpen &&
spaceMenuRef.current &&
!spaceMenuRef.current.contains(target) &&
spaceBtnRef.current &&
!spaceBtnRef.current.contains(target)
) {
setSpaceMenuOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [capMenuOpen, spaceMenuOpen]);
// ── Attachment helpers ────────────────────────────────────────
const showAttachmentError = useCallback((message: string) => {
setAttachmentError(message);
if (attachmentErrorTimer.current) {
clearTimeout(attachmentErrorTimer.current);
}
attachmentErrorTimer.current = setTimeout(() => {
setAttachmentError(null);
attachmentErrorTimer.current = null;
}, 4000);
}, []);
const fileToAttachment = useCallback(
(f: File): Promise<PendingAttachment> =>
new Promise((resolve, reject) => {
readFileAsDataUrl(f)
.then((raw) => {
const svg = isSvgFilename(f.name) || f.type === "image/svg+xml";
const isImage = !svg && f.type.startsWith("image/");
const b64 = extractBase64FromDataUrl(raw);
resolve({
type: isImage ? "image" : "file",
filename: f.name,
base64: b64,
previewUrl: isImage || svg ? raw : undefined,
size: f.size,
mimeType: f.type || undefined,
});
})
.catch(reject);
}),
[],
);
const filterAndReportFiles = useCallback(
(files: File[]): File[] => {
let runningTotal = attachments.reduce((s, a) => s + (a.size ?? 0), 0);
const accepted: File[] = [];
const rejected: {
name: string;
reason: "unsupported" | "too_large" | "quota";
}[] = [];
for (const f of files) {
const kind = classifyFile(f);
if (!kind) {
rejected.push({ name: f.name, reason: "unsupported" });
continue;
}
if (f.size > MAX_ATTACHMENT_BYTES) {
rejected.push({ name: f.name, reason: "too_large" });
continue;
}
if (runningTotal + f.size > MAX_TOTAL_ATTACHMENT_BYTES) {
rejected.push({ name: f.name, reason: "quota" });
break;
}
runningTotal += f.size;
accepted.push(f);
}
if (rejected.length) {
const first = rejected[0];
let msg: string;
if (first.reason === "too_large") {
msg = t("File too large: {{name}}", { name: first.name });
} else if (first.reason === "quota") {
msg = t("Too many files, skipped some");
} else {
msg = t("Unsupported file type: {{name}}", { name: first.name });
}
showAttachmentError(msg);
}
return accepted;
},
[attachments, showAttachmentError, t],
);
const handleAddFiles = useCallback(
async (files: File[]) => {
const accepted = filterAndReportFiles(files);
if (!accepted.length) return;
const next = await Promise.all(accepted.map(fileToAttachment));
setAttachments((prev) => [...prev, ...next]);
},
[fileToAttachment, filterAndReportFiles],
);
const removeAttachment = useCallback((index: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
const handlePaste = useCallback(
async (event: React.ClipboardEvent) => {
const items = Array.from(event.clipboardData.items);
const files = items
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile())
.filter((f): f is File => f !== null);
const accepted = filterAndReportFiles(files);
if (!accepted.length) return;
event.preventDefault();
const next = await Promise.all(accepted.map(fileToAttachment));
setAttachments((prev) => [...prev, ...next]);
},
[fileToAttachment, filterAndReportFiles],
);
// ── Drag-and-drop on the composer surface ─────────────────────
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current += 1;
if (e.dataTransfer.types.includes("Files")) setDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current -= 1;
if (dragCounter.current <= 0) {
dragCounter.current = 0;
setDragging(false);
}
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback(
async (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
dragCounter.current = 0;
setDragging(false);
const files = Array.from(e.dataTransfer.files);
await handleAddFiles(files);
},
[handleAddFiles],
);
// ── Picker handlers ───────────────────────────────────────────
const handleToggleKB = useCallback((name: string) => {
setSelectedKnowledgeBases((prev) =>
prev.includes(name) ? prev.filter((kb) => kb !== name) : [...prev, name],
);
}, []);
const handleSelectNotebookPicker = useCallback(() => {
setShowNotebookPicker(true);
}, []);
const handleSelectBookPicker = useCallback(() => {
setShowBookPicker(true);
}, []);
const handleSelectHistoryPicker = useCallback(() => {
setShowHistoryPicker(true);
}, []);
const handleSelectQuestionBankPicker = useCallback(() => {
setShowQuestionBankPicker(true);
}, []);
const handleSelectPersonaPicker = useCallback(() => {
setShowPersonaPicker(true);
}, []);
const handleSelectMemoryPicker = useCallback(() => {
setShowMemoryPicker(true);
}, []);
const handleClearPersona = useCallback(() => {
setSelectedPersona(null);
}, []);
const handleToggleMemoryFile = useCallback((file: SpaceMemoryFile) => {
setSelectedMemoryFiles((prev) =>
prev.includes(file) ? prev.filter((f) => f !== file) : [...prev, file],
);
}, []);
const handleRemoveHistory = useCallback((sessionId: string) => {
setSelectedHistorySessions((prev) =>
prev.filter((s) => s.sessionId !== sessionId),
);
}, []);
const handleRemoveBookReference = useCallback((bookId: string) => {
setSelectedBookReferences((prev) =>
prev.filter((b) => b.bookId !== bookId),
);
}, []);
const handleRemoveNotebook = useCallback((notebookId: string) => {
setSelectedNotebookRecords((prev) =>
prev.filter((r) => r.notebookId !== notebookId),
);
}, []);
const handleRemoveQuestion = useCallback((entryId: number) => {
setSelectedQuestionEntries((prev) => prev.filter((e) => e.id !== entryId));
}, []);
// ── References payloads ───────────────────────────────────────
const notebookReferencesPayload = useMemo(() => {
const grouped = new Map<string, string[]>();
selectedNotebookRecords.forEach((record) => {
const current = grouped.get(record.notebookId) || [];
current.push(record.id);
grouped.set(record.notebookId, current);
});
return Array.from(grouped.entries()).map(([notebook_id, record_ids]) => ({
notebook_id,
record_ids,
}));
}, [selectedNotebookRecords]);
const bookReferencesPayload = useMemo(
() => selectedBooksToPayload(selectedBookReferences),
[selectedBookReferences],
);
const historyReferencesPayload = useMemo(
() => selectedHistorySessions.map((s) => s.sessionId),
[selectedHistorySessions],
);
const questionNotebookReferencesPayload = useMemo(
() => selectedQuestionEntries.map((entry) => entry.id),
[selectedQuestionEntries],
);
const memoryReferencesPayload = useMemo(
() => [...selectedMemoryFiles],
[selectedMemoryFiles],
);
const notebookReferenceGroups = useMemo(() => {
return notebookReferencesPayload.map((ref) => {
const sample = selectedNotebookRecords.find(
(r) => r.notebookId === ref.notebook_id,
);
return {
notebookId: ref.notebook_id,
notebookName: sample?.notebookName ?? ref.notebook_id,
count: ref.record_ids.length,
};
});
}, [notebookReferencesPayload, selectedNotebookRecords]);
// Once the user has clicked Send we wipe transient selections — the
// follow-up chat session has captured them and later turns ride on
// server-side memory, mirroring the main chat behavior.
const handleSend = useCallback(
(content: string) => {
const hasContent = content.trim().length > 0;
const hasReferences =
attachments.length > 0 ||
selectedKnowledgeBases.length > 0 ||
selectedBookReferences.length > 0 ||
selectedNotebookRecords.length > 0 ||
selectedHistorySessions.length > 0 ||
selectedQuestionEntries.length > 0 ||
!!selectedPersona ||
selectedMemoryFiles.length > 0;
if (!hasContent && !hasReferences) return;
if (thread.isStreaming) return;
const isFirstSend = !thread.sessionId && thread.messages.length === 0;
const answerImageAttachments = isFirstSend
? context.answerImages
.map((image) => {
if (image.base64) {
return {
type: "image",
base64: image.base64,
filename: image.filename,
mime_type: image.mime,
} as const;
}
if (image.url) {
return {
type: "image",
url: image.url,
filename: image.filename,
mime_type: image.mime,
} as const;
}
return null;
})
.filter(
(entry): entry is NonNullable<typeof entry> => entry !== null,
)
: [];
const composerAttachments = attachments.map((a) => ({
type: a.type,
filename: a.filename,
base64: a.base64,
mime_type: a.mimeType,
}));
const personaPayload = selectedPersona ?? undefined;
const baseConfig = buildQuizFollowupConfig(
context.question,
context.userAnswer,
context.isCorrect,
context.parentQuizSessionId,
{
userAnswerImageFilenames: context.answerImages.map(
(image) => image.filename,
),
aiJudgment: context.aiJudgment,
},
);
// Memory references ride on ``config`` — same convention as the
// main chat sendMessage path.
const config: Record<string, unknown> = { ...baseConfig };
if (memoryReferencesPayload.length > 0) {
config.memory_references = memoryReferencesPayload;
}
controller.sendMessage({
questionKey: context.questionKey,
content,
attachments: [...answerImageAttachments, ...composerAttachments],
config,
language: context.language,
knowledgeBases: selectedKnowledgeBases,
notebookReferences: notebookReferencesPayload,
historyReferences: historyReferencesPayload,
bookReferences: bookReferencesPayload,
questionNotebookReferences: questionNotebookReferencesPayload,
persona: personaPayload,
llmSelection,
});
// Wipe transient selections — the chat session now owns them.
setAttachments([]);
setSelectedBookReferences([]);
setSelectedNotebookRecords([]);
setSelectedHistorySessions([]);
setSelectedQuestionEntries([]);
setSelectedPersona(null);
setSelectedMemoryFiles([]);
},
[
attachments,
bookReferencesPayload,
context,
controller,
historyReferencesPayload,
llmSelection,
memoryReferencesPayload,
notebookReferencesPayload,
questionNotebookReferencesPayload,
selectedBookReferences.length,
selectedHistorySessions.length,
selectedKnowledgeBases,
selectedMemoryFiles,
selectedNotebookRecords.length,
selectedQuestionEntries.length,
selectedPersona,
thread.isStreaming,
thread.messages.length,
thread.sessionId,
],
);
const handleCancelStreaming = useCallback(() => {
// The follow-up runner is owned by the controller; we don't expose
// a hard cancel from the public surface. Treat this as a no-op for
// now — the user can refresh or close the tab to recover.
}, []);
// ── Active capability is always "chat" for follow-up ──────────
const FOLLOWUP_CAPABILITIES = useMemo(
() =>
FOLLOWUP_CAPABILITIES_RAW.map((cap) => ({
...cap,
label: t(cap.label),
description: t(cap.description),
})),
[t],
);
const activeCap = FOLLOWUP_CAPABILITIES[0];
return (
<>
<ChatComposer
composerRef={composerRef}
capMenuRef={capMenuRef}
capBtnRef={capBtnRef}
spaceMenuRef={spaceMenuRef}
spaceBtnRef={spaceBtnRef}
dragCounter={dragCounter}
dragging={dragging}
capMenuOpen={capMenuOpen}
spaceMenuOpen={spaceMenuOpen}
hasMessages={
thread.messages.filter((m) => m.role !== "system").length > 0
}
attachments={attachments}
attachmentError={attachmentError}
activeCap={activeCap}
knowledgeBases={knowledgeBases}
llmOptions={llmOptions}
activeLLMDefault={activeLLMDefault}
llmSelection={llmSelection}
llmOptionsLoading={llmOptionsLoading}
llmOptionsError={llmOptionsError}
selectedBookReferences={selectedBookReferences}
selectedNotebookRecords={selectedNotebookRecords}
selectedHistorySessions={selectedHistorySessions}
selectedAgentSessions={[]}
selectedQuestionEntries={selectedQuestionEntries}
notebookReferenceGroups={notebookReferenceGroups}
selectedPersona={selectedPersona}
selectedMemoryFiles={selectedMemoryFiles}
selectedKnowledgeBases={selectedKnowledgeBases}
isStreaming={thread.isStreaming}
isVisualizeMode={false}
capabilityNeedsConfig={false}
capabilityConfigConfirmed={true}
onRequestConfigConfirm={() => {}}
capabilities={FOLLOWUP_CAPABILITIES}
onSetCapMenuOpen={setCapMenuOpen}
onSetSpaceMenuOpen={setSpaceMenuOpen}
onToggleKB={handleToggleKB}
onSelectLLM={setLLMSelection}
onSelectNotebookPicker={handleSelectNotebookPicker}
onSelectBookPicker={handleSelectBookPicker}
onSelectHistoryPicker={handleSelectHistoryPicker}
agentsAvailable={false}
onSelectAgentsPicker={() => {}}
onSelectQuestionBankPicker={handleSelectQuestionBankPicker}
onSelectPersonaPicker={handleSelectPersonaPicker}
onSelectMemoryPicker={handleSelectMemoryPicker}
onClearPersona={handleClearPersona}
onToggleMemoryFile={handleToggleMemoryFile}
onSend={handleSend}
onRemoveAttachment={removeAttachment}
onRemoveHistory={handleRemoveHistory}
onRemoveAgent={() => {}}
onRemoveBookReference={handleRemoveBookReference}
onRemoveNotebook={handleRemoveNotebook}
onRemoveQuestion={handleRemoveQuestion}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPaste={handlePaste}
onAddFiles={handleAddFiles}
onSelectCapability={() => {}}
onCancelStreaming={handleCancelStreaming}
inputPlaceholder={t(
"Ask anything about this question, your answer, or the AI judgment.",
)}
/>
<NotebookRecordPicker
open={showNotebookPicker}
onClose={() => setShowNotebookPicker(false)}
onApply={(records: SelectedRecord[]) => {
setSelectedNotebookRecords(records);
setShowNotebookPicker(false);
}}
/>
<BookReferencePicker
open={showBookPicker}
initialReferences={selectedBookReferences}
onClose={() => setShowBookPicker(false)}
onApply={(refs: SelectedBookReference[]) => {
setSelectedBookReferences(refs);
setShowBookPicker(false);
}}
/>
<HistorySessionPicker
open={showHistoryPicker}
onClose={() => setShowHistoryPicker(false)}
onApply={(sessions: SelectedHistorySession[]) => {
setSelectedHistorySessions(sessions);
setShowHistoryPicker(false);
}}
/>
<QuestionBankPicker
open={showQuestionBankPicker}
onClose={() => setShowQuestionBankPicker(false)}
onApply={(entries: SelectedQuestionEntry[]) => {
setSelectedQuestionEntries(entries);
setShowQuestionBankPicker(false);
}}
/>
<PersonaPicker
open={showPersonaPicker}
initialPersona={selectedPersona}
onClose={() => setShowPersonaPicker(false)}
onApply={(persona: string | null) => {
setSelectedPersona(persona);
setShowPersonaPicker(false);
}}
/>
<MemoryPicker
open={showMemoryPicker}
initialFiles={selectedMemoryFiles}
onClose={() => setShowMemoryPicker(false)}
onApply={(files: SpaceMemoryFile[]) => {
setSelectedMemoryFiles(files);
setShowMemoryPicker(false);
}}
/>
</>
);
}
const FollowupChatComposer = memo(FollowupChatComposerImpl);
export default FollowupChatComposer;
export type { FollowupChatComposerProps };