项目文件夹

文件
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

1488 行
52 KiB
TypeScript

"use client";
import dynamic from "next/dynamic";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
BookOpen,
Bot,
Brain,
Check,
ChevronLeft,
ChevronRight,
ClipboardList,
Coins,
Copy,
AlertCircle,
Database,
Loader2,
MessageSquare,
Pencil,
RefreshCcw,
Square,
UserRound,
Volume2,
X,
Trash2,
type LucideIcon,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SelectedHistorySession } from "@/components/chat/HistorySessionPicker";
import type { SelectedQuestionEntry } from "@/components/chat/QuestionBankPicker";
import AssistantResponse from "@/components/common/AssistantResponse";
import {
InlineFileCardProvider,
mergeGeneratedFiles,
} from "@/components/common/InlineFileCard";
import Tooltip from "@/components/common/Tooltip";
import type {
MessageAttachment,
MessageRequestSnapshot,
} from "@/context/UnifiedChatContext";
import { apiFetch, apiUrl } from "@/lib/api";
import { docIconFor } from "@/lib/doc-attachments";
import { useVoiceAutoplay } from "@/hooks/useVoiceAutoplay";
import { extractMathAnimatorResult } from "@/lib/math-animator-types";
import {
extractQuizQuestions,
extractStreamingQuizQuestions,
} from "@/lib/quiz-types";
import { extractVisualizeResult } from "@/lib/visualize-types";
import type { StreamEvent } from "@/lib/unified-ws";
import { hasVisibleMarkdownContent } from "@/lib/markdown-display";
import type { SelectedBookReference } from "@/lib/book-references";
import { buildVisiblePath, type SiblingInfo } from "@/lib/message-branches";
import type { SpaceMemoryFile } from "@/lib/space-items";
import {
AskUserOptions,
extractAskUserPayload,
extractMessageSegments,
} from "./AskUserOptions";
import ContextReferenceTree, {
type ContextTreeItem,
} from "./ContextReferenceTree";
import { AssistantActivity } from "./TracePanels";
import { agentGlyph } from "@/components/agents/agent-icons";
import { useConnectedAgentKinds } from "@/hooks/useConnectedAgentKinds";
const MathAnimatorViewer = dynamic(
() => import("@/components/math-animator/MathAnimatorViewer"),
{ ssr: false },
);
const QuizViewer = dynamic(() => import("@/components/quiz/QuizViewer"), {
ssr: false,
});
const ResearchOutlineEditor = dynamic(
() => import("@/components/research/ResearchOutlineEditor"),
{ ssr: false },
);
const VisualizationViewer = dynamic(
() => import("@/components/visualize/VisualizationViewer"),
{ ssr: false },
);
interface ChatMessageItem {
id?: number;
role: "user" | "assistant" | "system";
content: string;
capability?: string;
events?: StreamEvent[];
attachments?: MessageAttachment[];
requestSnapshot?: MessageRequestSnapshot;
parentMessageId?: number | null;
}
interface NotebookReferenceGroup {
notebookId: string;
notebookName: string;
count: number;
}
// Returns the i18n key (and a sensible fallback) for the capability badge
// shown above the user's message. Callers must run `t(...)` on the result.
function getModeBadgeLabel(capability?: string | null): string {
if (!capability || capability === "chat") return "Chat";
if (capability === "deep_solve") return "Deep Solve";
if (capability === "deep_question") return "Quiz Generation";
if (capability === "deep_research") return "Deep Research";
if (capability === "math_animator") return "Math Animator";
if (capability === "visualize") return "Visualize";
if (capability === "mastery_path") return "Mastery Path";
return capability;
}
function imageSrcForAttachment(attachment: MessageAttachment): string | null {
if (attachment.url) {
if (
attachment.url.startsWith("http") ||
attachment.url.startsWith("blob:") ||
attachment.url.startsWith("data:")
) {
return attachment.url;
}
return apiUrl(attachment.url);
}
const base64 = attachment.base64?.trim();
if (!base64) return null;
if (base64.startsWith("data:")) return base64;
return `data:${attachment.mime_type || "image/png"};base64,${base64}`;
}
/** Format a byte count for a file card subtitle (e.g. "14 KB"). */
function formatFileSize(bytes?: number): string {
if (!bytes || bytes <= 0) return "";
const units = ["B", "KB", "MB", "GB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit += 1;
}
return `${unit === 0 ? value : value.toFixed(1)} ${units[unit]}`;
}
/** "DeepTutor_Introduction.pdf" → "DeepTutor Introduction" — the card title
* reads like a document name; the extension already shows in the subtitle. */
function humanizeFilename(filename: string): string {
const stem = filename.replace(/\.[A-Za-z0-9]{1,8}$/, "");
return (
stem
.replace(/[_-]+/g, " ")
.replace(/\s{2,}/g, " ")
.trim() || filename
);
}
/**
* Files the assistant produced this turn (exec/code/media artifacts),
* rendered as openable cards under the message — click to open in the Viewer
* side panel, same path as user uploads. Sources: persisted ``generated``
* attachments on the message (durable) merged with artifacts from streamed
* tool_result events (live, while the turn is still running), deduped by URL.
*/
function GeneratedFileCards({
attachments,
events,
onOpen,
}: {
attachments: MessageAttachment[];
events?: StreamEvent[];
onOpen?: (attachment: MessageAttachment) => void;
}) {
const { t } = useTranslation();
const files = useMemo(
() => mergeGeneratedFiles(attachments, events),
[attachments, events],
);
if (!files.length) return null;
return (
<div className="mt-3 flex flex-col gap-2">
{files.map((a, i) => {
const filename = a.filename || t("File");
const key = a.id || a.url || `gen-${i}`;
const mime = a.mime_type || "";
const mediaSrc = imageSrcForAttachment(a);
// Generated images / videos render inline (preview the moment they
// arrive); everything else stays a compact openable file card.
if (mime.startsWith("image/") && mediaSrc) {
return (
<button
key={key}
type="button"
onClick={onOpen ? () => onOpen(a) : undefined}
className="group block w-full max-w-[min(520px,90%)] overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] text-left shadow-sm transition hover:border-[var(--border)]"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={mediaSrc}
alt={filename}
loading="lazy"
className="block max-h-[360px] w-full bg-[var(--background)] object-contain"
/>
<span className="flex items-center justify-between gap-2 px-3 py-2">
<span className="min-w-0 truncate text-[12.5px] font-medium text-[var(--foreground)]">
{humanizeFilename(filename)}
</span>
<span className="shrink-0 text-[11px] text-[var(--muted-foreground)] transition group-hover:text-[var(--foreground)]">
{t("Open")}
</span>
</span>
</button>
);
}
if (mime.startsWith("video/") && mediaSrc) {
return (
<div
key={key}
className="w-full max-w-[min(520px,90%)] overflow-hidden rounded-xl border border-[var(--border)] bg-[var(--card)] shadow-sm"
>
<video
src={mediaSrc}
controls
preload="metadata"
className="block max-h-[360px] w-full bg-black"
/>
<button
type="button"
onClick={onOpen ? () => onOpen(a) : undefined}
className="flex w-full items-center justify-between gap-2 px-3 py-2 text-left transition hover:bg-[var(--muted)]/30"
>
<span className="min-w-0 truncate text-[12.5px] font-medium text-[var(--foreground)]">
{humanizeFilename(filename)}
</span>
<span className="shrink-0 text-[11px] text-[var(--muted-foreground)]">
{t("Open")}
</span>
</button>
</div>
);
}
const spec = docIconFor(filename);
const Icon = spec.Icon;
const size = formatFileSize(a.size_bytes);
return (
<button
key={key}
type="button"
onClick={onOpen ? () => onOpen(a) : undefined}
className="group flex w-full max-w-[min(520px,90%)] items-center gap-3 rounded-xl border border-[var(--border)] bg-[var(--card)] px-3 py-2.5 text-left shadow-sm transition hover:border-[var(--border)] hover:bg-[var(--muted)]/30"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[var(--border)] bg-[var(--background)]">
<Icon className={`h-[18px] w-[18px] ${spec.tint}`} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-[13px] font-medium text-[var(--foreground)]">
{humanizeFilename(filename)}
</span>
<span className="block text-[11px] text-[var(--muted-foreground)]">
{spec.label}
{size ? ` · ${size}` : ""}
</span>
</span>
<span className="shrink-0 rounded-lg border border-[var(--border)] bg-[var(--background)] px-2.5 py-1 text-[11.5px] font-medium text-[var(--foreground)] transition group-hover:bg-[var(--muted)]/40">
{t("Open")}
</span>
</button>
);
})}
</div>
);
}
const AssistantMessage = memo(function AssistantMessage({
msg,
isStreaming,
outlineStatus,
sessionId,
language,
onConfirmOutline,
onSubmitUserReply,
researchRequestSnapshot,
}: {
msg: { content: string; capability?: string; events?: StreamEvent[] };
isStreaming?: boolean;
outlineStatus?: "editing" | "researching" | "done";
sessionId?: string | null;
language?: string;
researchRequestSnapshot?: MessageRequestSnapshot | null;
onConfirmOutline?: (
outline: Array<{ title: string; overview: string }>,
topic: string,
researchConfig?: Record<string, unknown> | null,
requestSnapshot?: MessageRequestSnapshot | null,
) => void;
/**
* Submit a reply for a turn that is paused on ``ask_user``. Wired
* through from the page so the card's option-buttons / free-text
* input can deliver the user's selection back to the backend over
* the unified WebSocket. Triggers a same-turn resume (no new user
* bubble). Accepts either a flat string (legacy single-question) or
* a structured object with per-question ``answers`` (v2 path).
*/
onSubmitUserReply?: (
reply:
| string
| {
text?: string;
answers?: Array<{ questionId: string; text: string }>;
},
) => void;
}) {
const events = useMemo(() => msg.events ?? [], [msg.events]);
const resultEvent = useMemo(
() => msg.events?.find((event) => event.type === "result") ?? null,
[msg.events],
);
const outlinePreview = useMemo(() => {
if (msg.capability !== "deep_research" || !resultEvent) return null;
const meta = resultEvent.metadata as Record<string, unknown> | undefined;
if (!meta?.outline_preview) return null;
return {
sub_topics: (meta.sub_topics ?? []) as Array<{
title: string;
overview: string;
}>,
topic: String(meta.topic ?? ""),
research_config: (meta.research_config ?? null) as Record<
string,
unknown
> | null,
};
}, [msg.capability, resultEvent]);
const quizQuestions = useMemo(() => {
if (msg.capability !== "deep_question") return null;
// Once the final result event lands, it's authoritative — it carries
// the canonical summary.results[]. Until then, accumulate questions
// from the live ``quiz_question_emitted`` content events so the
// QuizViewer can render each card the moment it's generated.
if (resultEvent) return extractQuizQuestions(resultEvent.metadata);
return extractStreamingQuizQuestions(msg.events ?? []);
}, [msg.capability, msg.events, resultEvent]);
const mathAnimatorResult = useMemo(() => {
if (msg.capability !== "math_animator" || !resultEvent) return null;
return extractMathAnimatorResult(resultEvent.metadata);
}, [msg.capability, resultEvent]);
const visualizeResult = useMemo(() => {
if (msg.capability !== "visualize" || !resultEvent) return null;
return extractVisualizeResult(resultEvent.metadata);
}, [msg.capability, resultEvent]);
// Detect the ``ask_user`` terminator payload: when the assistant turn
// ended via the ``ask_user`` tool, this is the question the user is
// expected to answer next. Render option chips below the message.
const askUserPayload = useMemo(
() => extractAskUserPayload(msg.events),
[msg.events],
);
// Interleaved segments for the default chat surface — text emitted
// before the ask_user call renders above the card; text emitted by
// the resumed iteration renders below it. Only walked when this
// message will actually render through the default branch (the
// research / quiz / animator / visualize branches have their own
// layout and pin the card elsewhere).
const useInlineAskUserSegments =
!outlinePreview &&
!mathAnimatorResult &&
!visualizeResult &&
!(quizQuestions && quizQuestions.length > 0);
const messageSegments = useMemo(
() => (useInlineAskUserSegments ? extractMessageSegments(msg.events) : []),
[useInlineAskUserSegments, msg.events],
);
const hasInlineAskUser =
useInlineAskUserSegments &&
messageSegments.some((seg) => seg.kind === "ask_user");
const researchInProgress =
outlineStatus === "researching" || outlineStatus === "done";
const showResearchBody =
Boolean(outlinePreview) && researchInProgress && Boolean(msg.content);
return (
<>
{/* Activity block pinned to the TOP: the status header
("DeepTutor Exploring… · 8s" → "DeepTutor responded. · 10s") with
the exploring trace nested beneath it — expanded while DeepTutor is
still working, collapsed once it settles into the final answer. */}
<AssistantActivity
events={events}
isStreaming={isStreaming}
content={msg.content}
className="mb-3"
/>
{outlinePreview && outlinePreview.sub_topics.length > 0 ? (
<>
{/* Layout for the merged research bubble:
1. trace rows (above, via TraceFlow)
2. ask_user Q&A summary (collapsible once research starts)
3. Outline editor (auto-collapses once locked)
4. Final report body (only after research is underway)
The Q&A intentionally sits ABOVE the outline so the user
sees the path that produced the outline before the outline
itself. */}
{askUserPayload ? (
<AskUserOptions
data={askUserPayload}
onSubmit={(reply) => {
if (!onSubmitUserReply) return;
onSubmitUserReply(reply);
}}
collapsible={researchInProgress}
defaultCollapsed={researchInProgress}
/>
) : null}
<ResearchOutlineEditor
outline={outlinePreview.sub_topics}
topic={outlinePreview.topic}
onConfirm={(items) =>
onConfirmOutline?.(
items,
outlinePreview.topic,
outlinePreview.research_config,
researchRequestSnapshot,
)
}
status={outlineStatus}
/>
{showResearchBody ? (
<AssistantResponse
content={msg.content}
isStreaming={isStreaming}
/>
) : null}
</>
) : mathAnimatorResult ? (
<MathAnimatorViewer result={mathAnimatorResult} />
) : visualizeResult ? (
<VisualizationViewer result={visualizeResult} />
) : quizQuestions && quizQuestions.length > 0 ? (
<>
{/* The quiz preface (the "I researched X, now let me quiz you on Y"
sentence the user watched stream in) rides along ABOVE the quiz
card. Without this, the streamed text
vanishes from the bubble the moment the first card appears
because the branch above is mutually exclusive with
<AssistantResponse>. The body is already free of the
per-question markdown — the pipeline trims that out of
``msg.content`` since the QuizViewer renders the cards
themselves. */}
{msg.content ? (
<AssistantResponse
content={msg.content}
isStreaming={isStreaming}
/>
) : null}
<QuizViewer
questions={quizQuestions}
sessionId={sessionId}
turnId={resultEvent?.turn_id ?? null}
language={language}
/>
</>
) : hasInlineAskUser ? (
// Default chat surface with one or more ask_user calls: render
// text and cards in the exact order they were streamed, so the
// pre-ask_user narration sits above the card and the resumed
// iteration's text sits below.
messageSegments.map((seg) =>
seg.kind === "text" ? (
<AssistantResponse
key={seg.key}
content={seg.text}
isStreaming={isStreaming}
/>
) : (
<AskUserOptions
key={seg.key}
data={seg.data}
onSubmit={(reply) => {
if (!onSubmitUserReply) return;
onSubmitUserReply(reply);
}}
/>
),
)
) : (
<AssistantResponse content={msg.content} isStreaming={isStreaming} />
)}
{/* Non-default branches (quiz, math animator, visualize) keep
ask_user below the body. The default branch inlines the card
via ``messageSegments``; the research branch renders its own
card above the outline editor — both skip this fallback. */}
{!outlinePreview && !hasInlineAskUser && askUserPayload ? (
<AskUserOptions
data={askUserPayload}
onSubmit={(reply) => {
if (!onSubmitUserReply) return;
onSubmitUserReply(reply);
}}
/>
) : null}
</>
);
});
AssistantMessage.displayName = "AssistantMessage";
function CostFooter({
cost,
tokens,
calls,
}: {
cost: number;
tokens: number;
calls: number;
}) {
const { t } = useTranslation();
const formatCost = (usd: number) => {
if (usd < 0.01) return `$${usd.toFixed(4)}`;
return `$${usd.toFixed(2)}`;
};
const formatTokens = (n: number) => {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
};
return (
<div className="flex items-center gap-1.5 text-[11px] text-[var(--muted-foreground)]/70">
<Coins size={11} strokeWidth={1.5} className="shrink-0" />
<span>{formatCost(cost)}</span>
<span className="opacity-50">·</span>
<span>
{formatTokens(tokens)} {t("tokens")}
</span>
<span className="opacity-50">·</span>
<span>
{calls} {t("calls")}
</span>
</div>
);
}
// Claude-style icon-only message action: a quiet 15px glyph with the label
// in an instant tooltip, brightening on hover.
function RoughActionButton({
icon: Icon,
label,
onClick,
disabled,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
disabled?: boolean;
}) {
return (
<Tooltip label={label} side="top">
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className="inline-flex items-center justify-center rounded-md p-1 text-[var(--muted-foreground)] transition-colors hover:bg-[var(--muted)]/50 hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-35"
>
<Icon size={15} strokeWidth={1.5} />
</button>
</Tooltip>
);
}
function CopyActionButton({
content,
onCopy,
}: {
content: string;
onCopy: (content: string) => void | Promise<void>;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, []);
const handleClick = useCallback(() => {
void Promise.resolve(onCopy(content)).then(() => {
setCopied(true);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => setCopied(false), 1600);
});
}, [content, onCopy]);
return (
<Tooltip label={copied ? t("Copied") : t("Copy")} side="top">
<button
type="button"
onClick={handleClick}
aria-live="polite"
aria-label={copied ? t("Copied") : t("Copy")}
className={`inline-flex items-center justify-center rounded-md p-1 transition-colors ${
copied
? "text-[var(--primary)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]/50 hover:text-[var(--foreground)]"
}`}
>
{copied ? (
<Check size={15} strokeWidth={2} />
) : (
<Copy size={15} strokeWidth={1.5} />
)}
</button>
</Tooltip>
);
}
// Speaker button: synthesizes the reply via the configured TTS provider and
// plays it. On the first manual play of a session it offers to auto-play the
// rest; `autoPlayFresh` triggers playback automatically for a reply that just
// finished generating when auto-play is on.
function PlayAudioButton({
content,
conversationKey,
autoPlayFresh,
}: {
content: string;
conversationKey?: string;
autoPlayFresh: boolean;
}) {
const { t } = useTranslation();
const {
autoplayEnabled,
enableForSession,
markPrompted,
shouldPromptOnFirstPlay,
} = useVoiceAutoplay(conversationKey);
const [state, setState] = useState<"idle" | "loading" | "playing">("idle");
const [showPrompt, setShowPrompt] = useState(false);
const audioRef = useRef<HTMLAudioElement | null>(null);
const urlRef = useRef<string | null>(null);
const autoPlayedRef = useRef(false);
const cleanup = useCallback(() => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
if (urlRef.current) {
URL.revokeObjectURL(urlRef.current);
urlRef.current = null;
}
}, []);
const play = useCallback(async () => {
setState("loading");
try {
const resp = await apiFetch(apiUrl("/api/v1/voice/tts"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: content }),
});
if (!resp.ok) {
cleanup();
setState("idle");
return;
}
const blob = await resp.blob();
cleanup();
const url = URL.createObjectURL(blob);
urlRef.current = url;
const audio = new Audio(url);
audioRef.current = audio;
audio.onended = () => {
setState("idle");
cleanup();
};
audio.onerror = () => {
setState("idle");
cleanup();
};
await audio.play();
setState("playing");
} catch {
cleanup();
setState("idle");
}
}, [cleanup, content]);
const handleClick = useCallback(() => {
if (state === "playing" || state === "loading") {
cleanup();
setState("idle");
return;
}
const willPrompt = shouldPromptOnFirstPlay();
void play();
if (willPrompt) {
markPrompted();
setShowPrompt(true);
}
}, [cleanup, markPrompted, play, shouldPromptOnFirstPlay, state]);
// Auto-play a freshly-generated reply when enabled, exactly once. Deferred
// to a timer so synthesis (which sets state) starts off the effect body.
useEffect(() => {
if (!autoPlayFresh || !autoplayEnabled) return;
if (autoPlayedRef.current) return;
if (!content.trim()) return;
autoPlayedRef.current = true;
const id = window.setTimeout(() => void play(), 0);
return () => window.clearTimeout(id);
}, [autoPlayFresh, autoplayEnabled, content, play]);
useEffect(() => cleanup, [cleanup]);
return (
<div className="relative inline-flex">
<Tooltip
label={state === "playing" ? t("Stop") : t("Play aloud")}
side="top"
>
<button
type="button"
onClick={handleClick}
aria-label={state === "playing" ? t("Stop") : t("Play aloud")}
className={`inline-flex items-center justify-center rounded-md p-1 transition-colors ${
state === "playing"
? "text-[var(--primary)]"
: "text-[var(--muted-foreground)] hover:bg-[var(--muted)]/50 hover:text-[var(--foreground)]"
}`}
>
{state === "loading" ? (
<Loader2 size={15} strokeWidth={1.8} className="animate-spin" />
) : state === "playing" ? (
<Square size={13} strokeWidth={1.8} className="fill-current" />
) : (
<Volume2 size={15} strokeWidth={1.5} />
)}
</button>
</Tooltip>
{showPrompt && (
<div className="absolute bottom-full left-0 z-30 mb-2 w-60 rounded-lg border border-[var(--border)] bg-[var(--card)] p-3 shadow-lg">
<p className="text-[12px] leading-relaxed text-[var(--foreground)]">
{t("Auto-play replies in this conversation?")}
</p>
<div className="mt-2.5 flex justify-end gap-2">
<button
type="button"
onClick={() => setShowPrompt(false)}
className="rounded-md px-2.5 py-1 text-[11.5px] text-[var(--muted-foreground)] hover:bg-[var(--muted)]/50 hover:text-[var(--foreground)]"
>
{t("Not now")}
</button>
<button
type="button"
onClick={() => {
enableForSession();
setShowPrompt(false);
}}
className="rounded-md bg-[var(--primary)] px-2.5 py-1 text-[11.5px] font-medium text-[var(--primary-foreground)] hover:bg-[var(--primary)]/90"
>
{t("Turn on")}
</button>
</div>
</div>
)}
</div>
);
}
function BranchNavigator({
info,
onSwitch,
}: {
info: SiblingInfo;
onSwitch: (childId: number) => void;
}) {
const { t } = useTranslation();
const prevIdx = info.index - 2; // 0-based prev index
const nextIdx = info.index; // 0-based next index
const prevId = prevIdx >= 0 ? info.siblingIds[prevIdx] : null;
const nextId =
nextIdx < info.siblingIds.length ? info.siblingIds[nextIdx] : null;
return (
<div className="inline-flex items-center gap-0.5 text-[10.5px] text-[var(--muted-foreground)]">
<button
type="button"
onClick={() => prevId !== null && onSwitch(prevId)}
disabled={prevId === null}
aria-label={t("Previous branch")}
className="rounded p-0.5 transition-colors hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronLeft size={12} strokeWidth={1.8} />
</button>
<span className="select-none tabular-nums">
{info.index} / {info.total}
</span>
<button
type="button"
onClick={() => nextId !== null && onSwitch(nextId)}
disabled={nextId === null}
aria-label={t("Next branch")}
className="rounded p-0.5 transition-colors hover:text-[var(--foreground)] disabled:cursor-not-allowed disabled:opacity-30"
>
<ChevronRight size={12} strokeWidth={1.8} />
</button>
</div>
);
}
function DeleteTurnButton({ onDelete }: { onDelete: () => void }) {
const { t } = useTranslation();
const [confirm, setConfirm] = useState(false);
if (!confirm) {
return (
<RoughActionButton
icon={Trash2}
label={t("Delete")}
onClick={() => setConfirm(true)}
/>
);
}
return (
<div className="inline-flex items-center gap-1.5 text-[11px]">
<span className="text-[var(--muted-foreground)]">
{t("Delete this turn?")}
</span>
<button
type="button"
onClick={() => {
onDelete();
setConfirm(false);
}}
className="rounded-md px-1.5 py-0.5 font-medium text-[var(--destructive)] hover:bg-[var(--destructive)]/10"
>
{t("Delete")}
</button>
<button
type="button"
onClick={() => setConfirm(false)}
className="rounded-md px-1.5 py-0.5 font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)]/40"
>
{t("Cancel")}
</button>
</div>
);
}
const UserMessage = memo(function UserMessage({
msg,
index,
onPreviewAttachment,
onCopy,
onEdit,
editDisabled,
siblingInfo,
onSwitchBranch,
}: {
msg: ChatMessageItem;
index: number;
onPreviewAttachment?: (attachment: MessageAttachment) => void;
onCopy?: (content: string) => void | Promise<void>;
onEdit?: (messageId: number, newContent: string) => void;
editDisabled?: boolean;
siblingInfo?: SiblingInfo;
onSwitchBranch?: (parentMessageId: number | null, childId: number) => void;
}) {
const { t } = useTranslation();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(msg.content);
// Connected subagents ride in knowledge_bases (same selection path) but are
// agents, not KBs — this maps a selected name to its backend kind so the
// reference chip can badge it with the agent's brand icon.
const agentKinds = useConnectedAgentKinds();
if (msg.content.startsWith("[Quiz Performance]")) return null;
// ``msg.id`` can be a negative client-side sentinel for optimistic
// (just-sent, not yet reconciled with the server) rows. We still allow
// the Edit button to surface — ``editMessage`` in the context handles
// the optimistic case by triggering a session reload to resolve the
// real id before submitting the branch.
const canEdit =
Boolean(onEdit) && typeof msg.id === "number" && !editDisabled;
const startEdit = () => {
if (!canEdit) return;
setDraft(msg.content);
setEditing(true);
};
const cancelEdit = () => {
setEditing(false);
setDraft(msg.content);
};
const submitEdit = () => {
const trimmed = draft.trim();
if (!trimmed || trimmed === msg.content) {
cancelEdit();
return;
}
if (typeof msg.id !== "number") return;
onEdit?.(msg.id, trimmed);
setEditing(false);
};
// Everything this turn carried — file attachments plus the request
// snapshot's Space references — rendered as one collapsed tree under
// the bubble (the sent-message mirror of the composer's tree).
const snap = msg.requestSnapshot;
const refTreeItems: ContextTreeItem[] = [
...(msg.attachments ?? []).map((a, ai): ContextTreeItem => {
const filename = a.filename || t("Attachment");
const spec = docIconFor(filename);
const src = a.type === "image" ? imageSrcForAttachment(a) : null;
return {
key: `att-${ai}`,
icon: spec.Icon,
kind: spec.label,
label: filename,
thumbnailUrl: src ?? undefined,
onClick: onPreviewAttachment ? () => onPreviewAttachment(a) : undefined,
};
}),
...(snap?.knowledgeBases ?? []).map((name): ContextTreeItem => {
const agentKind = agentKinds[name];
if (agentKind) {
return {
key: `agent-${name}`,
// Brand SVG marks share the lucide call signature (size/strokeWidth/
// className); cast bridges the structural-variance gap.
icon: (agentGlyph(agentKind) ?? Bot) as unknown as LucideIcon,
kind: t("Agent"),
label: name,
};
}
return {
key: `kb-${name}`,
icon: Database,
kind: t("Knowledge"),
label: name,
};
}),
...(snap?.bookReferences ?? []).map(
(ref): ContextTreeItem => ({
key: `book-${ref.book_id}`,
icon: BookOpen,
kind: t("Book"),
label: `${ref.page_ids.length} ${t("chapters")}`,
}),
),
...(snap?.notebookReferences ?? []).map(
(ref): ContextTreeItem => ({
key: `nb-${ref.notebook_id}`,
icon: BookOpen,
kind: t("Notebook"),
label: `${ref.record_ids.length} ${t("records")}`,
}),
),
// Imported agent conversations are folded into the same history_references
// payload but carry the `imported_` id prefix — split them back out so they
// read as "My Agents" rather than "Chat History" (mirrors the composer).
...(snap?.historyReferences ?? [])
.filter((sid) => !sid.startsWith("imported_"))
.map(
(sid): ContextTreeItem => ({
key: `hist-${sid}`,
icon: MessageSquare,
kind: t("Chat History"),
label: "",
}),
),
...(snap?.historyReferences ?? [])
.filter((sid) => sid.startsWith("imported_"))
.map(
(sid): ContextTreeItem => ({
key: `agent-${sid}`,
icon: Bot,
kind: t("My Agents"),
label: "",
}),
),
...(snap?.questionNotebookReferences?.length
? [
{
key: "qb",
icon: ClipboardList,
kind: t("Question Bank"),
label: `${snap.questionNotebookReferences.length} ${t("items")}`,
} satisfies ContextTreeItem,
]
: []),
...(snap?.persona
? [
{
key: "persona",
icon: UserRound,
kind: t("Persona"),
label: snap.persona,
} satisfies ContextTreeItem,
]
: []),
...(snap?.memoryReferences ?? []).map(
(file): ContextTreeItem => ({
key: `mem-${file}`,
icon: Brain,
kind: t("Memory"),
label: file === "summary" ? t("Summary") : t("Profile"),
}),
),
];
return (
<div key={`${msg.role}-${index}`} className="group flex justify-end">
<div className="flex max-w-[75%] flex-col items-end gap-1.5">
<div className="flex justify-end pr-1">
<span className="text-[10px] tracking-wide text-[var(--muted-foreground)]">
{t(getModeBadgeLabel(msg.capability))}
</span>
</div>
{editing ? (
<div className="w-[min(620px,75vw)] rounded-2xl border border-[var(--primary)]/40 bg-[var(--secondary)] px-3 py-2.5 text-[14px] leading-relaxed text-[var(--foreground)] shadow-sm">
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancelEdit();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submitEdit();
}
}}
rows={Math.min(8, Math.max(2, draft.split("\n").length))}
className="w-full resize-none border-0 bg-transparent text-[14px] leading-relaxed text-[var(--foreground)] outline-none focus:outline-none"
/>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10.5px] text-[var(--muted-foreground)]/80">
{t("Use the arrows below to switch between branches.")}
</span>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={cancelEdit}
className="rounded-md px-2 py-0.5 text-[11px] font-medium text-[var(--muted-foreground)] hover:bg-[var(--muted)]/40"
>
{t("Cancel")}
</button>
<button
type="button"
onClick={submitEdit}
disabled={!draft.trim() || draft.trim() === msg.content}
className="rounded-md bg-[var(--primary)] px-2.5 py-0.5 text-[11px] font-medium text-[var(--primary-foreground)] hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
>
{t("Send")}
</button>
</div>
</div>
</div>
) : (
<div className="rounded-2xl bg-[var(--secondary)] px-4 py-2.5 text-[14px] leading-relaxed text-[var(--foreground)] shadow-sm">
<div className="whitespace-pre-wrap">{msg.content}</div>
</div>
)}
{!editing && refTreeItems.length > 0 && (
<div className="pr-1">
<ContextReferenceTree
items={refTreeItems}
direction="down"
align="right"
summaryNoun={t("attachments")}
/>
</div>
)}
{!editing && (onCopy || canEdit || siblingInfo) && msg.content && (
<div className="flex h-7 items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
{siblingInfo && siblingInfo.total > 1 && (
<BranchNavigator
info={siblingInfo}
onSwitch={(childId) =>
onSwitchBranch?.(siblingInfo.parentId, childId)
}
/>
)}
{onCopy && (
<CopyActionButton content={msg.content} onCopy={onCopy} />
)}
{canEdit && (
<RoughActionButton
icon={Pencil}
label={t("Edit")}
onClick={startEdit}
/>
)}
</div>
)}
</div>
</div>
);
});
UserMessage.displayName = "UserMessage";
export const ChatMessageList = memo(function ChatMessageList({
messages,
isStreaming,
sessionId,
language,
onCopyAssistantMessage,
onRegenerateMessage,
onConfirmOutline,
onPreviewAttachment,
onDeleteTurn,
selectedBranches,
onEditMessage,
onSwitchBranch,
onSubmitUserReply,
}: {
messages: ChatMessageItem[];
isStreaming: boolean;
sessionId?: string | null;
language?: string;
onCopyAssistantMessage: (content: string) => void | Promise<void>;
onRegenerateMessage: () => void;
onConfirmOutline?: (
outline: Array<{ title: string; overview: string }>,
topic: string,
researchConfig?: Record<string, unknown> | null,
requestSnapshot?: MessageRequestSnapshot | null,
) => void;
onPreviewAttachment?: (attachment: MessageAttachment) => void;
onDeleteTurn?: (messageId: number) => void;
/** Edit-branching: selected sibling at each branch point. */
selectedBranches?: Record<string, number>;
onEditMessage?: (messageId: number, newContent: string) => void;
onSwitchBranch?: (parentMessageId: number | null, childId: number) => void;
/**
* Deliver an ``ask_user`` reply back to the backend so the agentic
* loop resumes on the same turn. Forwarded into each
* ``AssistantMessage`` so the card UI rendered alongside the paused
* assistant bubble can submit selections / free-form text. Accepts
* either a string (legacy) or a structured object with per-question
* ``answers`` (v2).
*/
onSubmitUserReply?: (
reply:
| string
| {
text?: string;
answers?: Array<{ questionId: string; text: string }>;
},
) => void;
}) {
const { t } = useTranslation();
// Visible path: when no branching has happened the result is identical
// to the input. After an edit, sibling branches are filtered out so the
// UI shows exactly one continuous thread, with arrow nav exposed on the
// user message where branching diverges.
const { messages: visibleMessages, siblingsByMessageId } = useMemo(
() => buildVisiblePath(messages, selectedBranches),
[messages, selectedBranches],
);
// Deep-research two-turn merge.
//
// The capability runs in two BE turns: turn-1 emits rephrase +
// decompose + an outline-preview result; turn-2 (after the user
// confirms the outline) emits the research blocks + the final
// report. The user wants both turns to live in ONE assistant
// bubble so the rephrase trace, the Q&A summary, the (collapsed)
// outline editor, and the research / reporting traces are all
// visually contiguous instead of split across two bubbles.
//
// For each parent (outline-preview) msg with a followup
// deep_research msg, we synthesise a merged msg with:
//
// * events — parent.events ++ followup.events (preserving order
// so TraceFlow's call_id grouping keeps working).
// * content — followup.content (the report). The parent's
// rephrase preface is already represented inside the trace card,
// so concatenating again would duplicate it above the report.
//
// The followup is dropped from the visible row list so only the
// merged bubble renders.
const deepResearchMergeMap = useMemo(() => {
const map = new Map<
number,
{ mergedEvents: StreamEvent[]; mergedContent: string }
>();
const followupIndices = new Set<number>();
for (let i = 0; i < visibleMessages.length; i++) {
const msg = visibleMessages[i];
if (msg.role !== "assistant" || msg.capability !== "deep_research")
continue;
const resultEv = msg.events?.find((e) => e.type === "result");
const meta = resultEv?.metadata as Record<string, unknown> | undefined;
if (!meta?.outline_preview) continue;
const followupIdx = visibleMessages
.slice(i + 1)
.findIndex(
(m) => m.role === "assistant" && m.capability === "deep_research",
);
if (followupIdx === -1) continue;
const absoluteFollowupIdx = i + 1 + followupIdx;
const followup = visibleMessages[absoluteFollowupIdx];
const mergedEvents = [...(msg.events ?? []), ...(followup.events ?? [])];
const mergedContent = followup.content || msg.content;
map.set(i, { mergedEvents, mergedContent });
followupIndices.add(absoluteFollowupIdx);
}
return { mergedByParent: map, followupIndices };
}, [visibleMessages]);
const outlineStatusByIndex = useMemo(() => {
const map = new Map<number, "editing" | "researching" | "done">();
for (let i = 0; i < visibleMessages.length; i++) {
const msg = visibleMessages[i];
if (msg.role !== "assistant" || msg.capability !== "deep_research")
continue;
const resultEv = msg.events?.find((e) => e.type === "result");
const meta = resultEv?.metadata as Record<string, unknown> | undefined;
if (!meta?.outline_preview) continue;
const followup = visibleMessages
.slice(i + 1)
.find(
(m) => m.role === "assistant" && m.capability === "deep_research",
);
if (followup) {
const followupResult = followup.events?.find(
(e) => e.type === "result",
);
map.set(i, followupResult ? "done" : "researching");
} else {
// The first deep_research turn only plans/rephrases/decomposes and
// returns an outline preview. While that turn is still flushing
// post-result events, the outline must already be editable; only the
// hidden follow-up turn created by "Start Research" means research is
// actually underway.
map.set(i, "editing");
}
}
return map;
}, [visibleMessages]);
const messageRows = useMemo(() => {
// System messages are backend grounding (e.g. quiz follow-up context) and
// must never be rendered as a chat bubble. Filter them out defensively in
// addition to the hydration-time filter in UnifiedChatContext.
return visibleMessages
.map((msg, index) => ({ msg, originalIndex: index }))
.filter(({ msg, originalIndex }) => {
if (msg.role === "system") return false;
// Drop deep_research followup msgs — their events were merged
// into the parent (outline-preview) bubble.
if (deepResearchMergeMap.followupIndices.has(originalIndex))
return false;
return true;
})
.map(({ msg, originalIndex }) => {
// Splice in the merged event stream when this row owns a
// deep_research two-turn pair.
const merged = deepResearchMergeMap.mergedByParent.get(originalIndex);
const effectiveMsg: ChatMessageItem = merged
? {
...msg,
events: merged.mergedEvents,
content: merged.mergedContent,
}
: msg;
if (effectiveMsg.role === "user") {
return {
msg: effectiveMsg,
originalIndex,
pairedUserMessage: null as ChatMessageItem | null,
};
}
const pairedUserMessage =
[...visibleMessages.slice(0, originalIndex)]
.reverse()
.find((previous) => previous.role === "user") ?? null;
return { msg: effectiveMsg, originalIndex, pairedUserMessage };
});
}, [visibleMessages, deepResearchMergeMap]);
const lastRenderedAssistantIndex = useMemo(() => {
for (let idx = messageRows.length - 1; idx >= 0; idx -= 1) {
if (messageRows[idx].msg.role === "assistant")
return messageRows[idx].originalIndex;
}
return -1;
}, [messageRows]);
// Auto-play (when enabled) must fire only for a reply that JUST finished
// generating — never when loading history. We capture the last-assistant
// index at the moment streaming flips off; the matching speaker button
// plays once. Switching sessions clears the marker. Uses the "adjust state
// during render" pattern (state-vs-prop comparison, like the API-key reset
// in ServiceConfigEditor) — both branches are conditional and bounded.
const [prevStreaming, setPrevStreaming] = useState(isStreaming);
const [prevSession, setPrevSession] = useState(sessionId);
const [freshlyCompletedIndex, setFreshlyCompletedIndex] = useState<
number | null
>(null);
if (prevSession !== sessionId) {
setPrevSession(sessionId);
setPrevStreaming(false);
setFreshlyCompletedIndex(null);
} else if (prevStreaming !== isStreaming) {
setPrevStreaming(isStreaming);
if (!isStreaming && lastRenderedAssistantIndex >= 0) {
setFreshlyCompletedIndex(lastRenderedAssistantIndex);
}
}
return (
<>
{messageRows.map(({ msg, originalIndex, pairedUserMessage }) => {
const i = originalIndex;
if (msg.role === "user") {
const sib =
msg.id !== undefined ? siblingsByMessageId.get(msg.id) : undefined;
return (
<UserMessage
key={`${msg.role}-${i}`}
msg={msg}
index={i}
onPreviewAttachment={onPreviewAttachment}
onCopy={onCopyAssistantMessage}
onEdit={onEditMessage}
editDisabled={isStreaming}
siblingInfo={sib}
onSwitchBranch={onSwitchBranch}
/>
);
}
const isActiveAssistant =
isStreaming && i === lastRenderedAssistantIndex;
const msgDone = !isActiveAssistant;
const showActions = msgDone && hasVisibleMarkdownContent(msg.content);
const isLastAssistant = i === lastRenderedAssistantIndex;
const showRegenerate =
showActions &&
!isStreaming &&
isLastAssistant &&
Boolean(pairedUserMessage) &&
(!pairedUserMessage?.capability ||
pairedUserMessage?.capability === "chat");
const deletableTurnUserId =
msgDone && pairedUserMessage?.id != null && onDeleteTurn
? pairedUserMessage.id
: null;
const showDelete = deletableTurnUserId != null;
const costSummary = (() => {
if (!msgDone) return null;
const resultEv = msg.events?.find((e) => e.type === "result");
if (!resultEv) return null;
const meta = resultEv.metadata?.metadata as
| Record<string, unknown>
| undefined;
const cs = meta?.cost_summary as
| {
total_cost_usd?: number;
total_tokens?: number;
total_calls?: number;
}
| undefined;
if (!cs || !cs.total_calls) return null;
return cs;
})();
return (
<div key={`${msg.role}-${i}`} className="w-full">
<InlineFileCardProvider
attachments={msg.attachments ?? []}
events={msg.events}
onOpen={onPreviewAttachment}
>
<AssistantMessage
msg={msg}
isStreaming={isActiveAssistant}
outlineStatus={outlineStatusByIndex.get(i)}
sessionId={sessionId}
language={language}
onConfirmOutline={onConfirmOutline}
onSubmitUserReply={onSubmitUserReply}
researchRequestSnapshot={
pairedUserMessage?.requestSnapshot ?? null
}
/>
</InlineFileCardProvider>
<GeneratedFileCards
attachments={msg.attachments ?? []}
events={msg.events}
onOpen={onPreviewAttachment}
/>
{(() => {
// A turn that died (LLM/provider failure, interruption) ends
// with a turn_terminal error event. Surface it as an error
// card with an inline retry instead of leaving a bare trace.
if (isActiveAssistant) return null;
const terminalError = (msg.events ?? []).find(
(e) =>
e.type === "error" &&
Boolean(
(e.metadata as { turn_terminal?: boolean } | undefined)
?.turn_terminal,
),
);
if (!terminalError) return null;
return (
<div className="mt-3 flex w-full max-w-[min(520px,90%)] items-center gap-2 rounded-xl border border-[var(--destructive)]/30 bg-[var(--destructive)]/5 px-3 py-2">
<AlertCircle className="h-4 w-4 shrink-0 text-[var(--destructive)]" />
<span className="min-w-0 flex-1 text-[12px] leading-[1.5] text-[var(--foreground)]">
{terminalError.content || t("The turn was interrupted.")}
</span>
{showRegenerate ? (
<button
type="button"
onClick={() => onRegenerateMessage()}
className="shrink-0 rounded-md px-2 py-1 text-[11.5px] font-medium text-[var(--destructive)] hover:bg-[var(--destructive)]/10"
>
{t("Retry")}
</button>
) : null}
</div>
);
})()}
{(showActions || costSummary || showDelete) && (
<div className="mt-3 flex items-center">
{(showActions || showDelete) && (
<div className="flex items-center gap-1">
{showActions && (
<CopyActionButton
content={msg.content}
onCopy={onCopyAssistantMessage}
/>
)}
{showActions && (
<PlayAudioButton
content={msg.content}
conversationKey={sessionId ?? undefined}
autoPlayFresh={
isLastAssistant && freshlyCompletedIndex === i
}
/>
)}
{showActions && showRegenerate && (
<RoughActionButton
icon={RefreshCcw}
label={t("Regenerate")}
onClick={() => onRegenerateMessage()}
/>
)}
{showDelete && (
<DeleteTurnButton
onDelete={() => onDeleteTurn?.(deletableTurnUserId)}
/>
)}
</div>
)}
{costSummary && (
<div className="ml-auto">
<CostFooter
cost={costSummary.total_cost_usd ?? 0}
tokens={costSummary.total_tokens ?? 0}
calls={costSummary.total_calls ?? 0}
/>
</div>
)}
</div>
)}
</div>
);
})}
</>
);
});
ChatMessageList.displayName = "ChatMessageList";