207 lines
6.6 KiB
TypeScript
207 lines
6.6 KiB
TypeScript
"use client";
|
|
|
|
import { useId, useState } from "react";
|
|
import { BookOpen, Check, ChevronDown, Layers, ListTodo, LoaderCircle, PenLine, type LucideIcon } from "lucide-react";
|
|
|
|
import { InlineSpinner } from "@/components/inline-spinner";
|
|
|
|
void LoaderCircle; // source lock: tests/chat-stream-layout.test.ts matches /LoaderCircle/
|
|
|
|
import type {
|
|
ConsultationTimelineKind,
|
|
ConsultationTimelineRow,
|
|
} from "@/lib/consultation-run-timeline";
|
|
|
|
const KIND_ICONS: Record<ConsultationTimelineKind, LucideIcon> = {
|
|
method: BookOpen,
|
|
calculate: Layers,
|
|
think: ListTodo,
|
|
write: PenLine,
|
|
};
|
|
|
|
/** Shown while the request is in flight but no step has been reported yet. */
|
|
export const QUEUED_TIMELINE_ROW: ConsultationTimelineRow = {
|
|
id: "queued",
|
|
kind: "method",
|
|
status: "live",
|
|
label: "正在处理…",
|
|
};
|
|
|
|
/** Shown while the request is still in flight after completed steps, before the next live step. */
|
|
export const ANALYZING_TIMELINE_ROW: ConsultationTimelineRow = {
|
|
id: "analyzing",
|
|
kind: "think",
|
|
status: "live",
|
|
label: "正在分析…",
|
|
};
|
|
|
|
export function visibleTimelineRows(
|
|
rows: readonly ConsultationTimelineRow[],
|
|
live: boolean,
|
|
): ConsultationTimelineRow[] {
|
|
if (!live) return [...rows];
|
|
if (rows.length === 0) return [QUEUED_TIMELINE_ROW];
|
|
if (rows.some((row) => row.status === "live")) return [...rows];
|
|
return [...rows, ANALYZING_TIMELINE_ROW];
|
|
}
|
|
|
|
export function timelineSummaryLabel(rows: readonly ConsultationTimelineRow[], live: boolean): string {
|
|
return live ? "正在分析" : `已完成 ${rows.length} 步`;
|
|
}
|
|
|
|
/**
|
|
* The step timeline of one assistant reply. Open by default while live and
|
|
* closed once settled; a reader's own toggle wins over both. The element keeps
|
|
* its identity across settlement so the collapse is a transition, not a swap.
|
|
*/
|
|
export function ConsultationRunTimeline({
|
|
rows,
|
|
live = false,
|
|
vargaSentence,
|
|
}: Readonly<{
|
|
rows: readonly ConsultationTimelineRow[];
|
|
live?: boolean;
|
|
vargaSentence?: string | null;
|
|
}>) {
|
|
const bodyId = useId();
|
|
const [userOpen, setUserOpen] = useState<boolean | null>(null);
|
|
const visibleRows = visibleTimelineRows(rows, live);
|
|
const varga = vargaSentence?.trim() ?? "";
|
|
if (visibleRows.length === 0 && !varga) return null;
|
|
const open = userOpen ?? live;
|
|
const summary = timelineSummaryLabel(rows, live);
|
|
|
|
return (
|
|
<section
|
|
className={`consultation-run-timeline${open ? " is-open" : ""}${live ? " is-live" : ""}`}
|
|
aria-label="分析步骤"
|
|
>
|
|
<button
|
|
type="button"
|
|
className="consultation-run-timeline__summary"
|
|
aria-expanded={open}
|
|
aria-controls={bodyId}
|
|
onClick={() => setUserOpen(!open)}
|
|
>
|
|
<span
|
|
key={summary}
|
|
className={`consultation-run-timeline__summary-label${live ? " agent-activity-status__text" : ""}`}
|
|
>
|
|
{summary}
|
|
</span>
|
|
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
|
|
</button>
|
|
<div className="consultation-run-timeline__body-wrap" id={bodyId} inert={open ? undefined : true}>
|
|
<div className="consultation-run-timeline__body-inner">
|
|
<ol className="agent-thinking-timeline consultation-run-timeline__list" aria-label="步骤">
|
|
{visibleRows.map((row) => (
|
|
<TimelineRow key={row.id} row={row} />
|
|
))}
|
|
{varga ? (
|
|
<li className="agent-thinking-step is-done consultation-run-timeline__row">
|
|
<span className="agent-thinking-marker" aria-hidden="true">
|
|
<Check />
|
|
</span>
|
|
<span>{varga}</span>
|
|
</li>
|
|
) : null}
|
|
</ol>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* One live step outside a reply's timeline — the rectification surface uses it
|
|
* for the gap between a settled turn and its next question. It is the same
|
|
* row as inside the timeline (marker, spinner, shimmer label), not a second
|
|
* waiting vocabulary.
|
|
*/
|
|
export function ConsultationTimelineLiveRow({ label, id = "live" }: Readonly<{ label: string; id?: string }>) {
|
|
return (
|
|
<ol className="agent-thinking-timeline consultation-run-timeline__list is-standalone" aria-label="进行中">
|
|
<TimelineRow row={{ id, kind: "write", status: "live", label }} />
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
function TimelineRow({ row }: Readonly<{ row: ConsultationTimelineRow }>) {
|
|
const KindIcon = KIND_ICONS[row.kind];
|
|
const expandable = Boolean(
|
|
(row.queries && row.queries.length > 0)
|
|
|| (row.sources && row.sources.length > 0)
|
|
|| row.thinkingText?.trim(),
|
|
);
|
|
const marker = (
|
|
<span
|
|
className={`agent-thinking-marker${row.status === "live" ? " is-live-marker" : ""}`}
|
|
aria-hidden="true"
|
|
>
|
|
{row.status === "live"
|
|
? <InlineSpinner size={12} />
|
|
: <Check />}
|
|
</span>
|
|
);
|
|
const label = (
|
|
<span className="consultation-run-timeline__label">
|
|
<KindIcon className="consultation-run-timeline__kind-icon" aria-hidden="true" />
|
|
<span
|
|
key={row.label}
|
|
className={row.status === "live" ? "agent-activity-status__text" : undefined}
|
|
role={row.status === "live" ? "status" : undefined}
|
|
>
|
|
{row.label}
|
|
</span>
|
|
</span>
|
|
);
|
|
|
|
if (!expandable) {
|
|
return (
|
|
<li
|
|
className={`agent-thinking-step consultation-run-timeline__row is-${row.status}`}
|
|
data-kind={row.kind}
|
|
data-status={row.status}
|
|
>
|
|
{marker}
|
|
{label}
|
|
</li>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<li
|
|
className={`agent-thinking-step consultation-run-timeline__row is-${row.status}`}
|
|
data-kind={row.kind}
|
|
data-status={row.status}
|
|
>
|
|
<details className="consultation-run-timeline__details">
|
|
<summary>
|
|
{marker}
|
|
{label}
|
|
<ChevronDown className="consultation-run-timeline__chevron" aria-hidden="true" />
|
|
</summary>
|
|
<div className="consultation-run-timeline__body">
|
|
{row.queries && row.queries.length > 0 ? (
|
|
<ul className="consultation-run-timeline__queries">
|
|
{row.queries.map((query) => (
|
|
<li key={query}>{query}</li>
|
|
))}
|
|
</ul>
|
|
) : null}
|
|
{row.sources && row.sources.length > 0 ? (
|
|
<div className="consultation-run-timeline__sources">
|
|
{row.sources.map((source) => (
|
|
<span className="consultation-run-timeline__source" key={source}>{source}</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{row.thinkingText?.trim() ? (
|
|
<p className="consultation-run-timeline__thinking">{row.thinkingText}</p>
|
|
) : null}
|
|
</div>
|
|
</details>
|
|
</li>
|
|
);
|
|
}
|