feat: implement PI core chat timeline
This commit is contained in:
370
src/pages/Chat/CodingConversationTimeline.tsx
Normal file
370
src/pages/Chat/CodingConversationTimeline.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import {
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronDown,
|
||||
CircleAlert,
|
||||
FileImage,
|
||||
Hammer,
|
||||
Layers3,
|
||||
UserRound,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { loadCodingAttachmentPreview } from '@/lib/coding-attachments';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
selectCodingConversationSnapshot,
|
||||
useCodingConversationStore,
|
||||
type CodingConversationStoreState,
|
||||
} from '@/stores/coding-conversations';
|
||||
import type {
|
||||
ConversationBoundaryNode,
|
||||
ConversationCompactionNode,
|
||||
ConversationContentBlock,
|
||||
ConversationMessageNode,
|
||||
ConversationNode,
|
||||
ConversationNoticeNode,
|
||||
ConversationToolNode,
|
||||
} from '../../../shared/coding-conversation-contracts';
|
||||
|
||||
const EMPTY_NODES: ConversationNode[] = [];
|
||||
const INITIAL_WINDOW = 120;
|
||||
const WINDOW_STEP = 100;
|
||||
const LONG_OUTPUT_CHARS = 4_000;
|
||||
|
||||
const MarkdownText = memo(function MarkdownText({ text }: { text: string }) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
a: ({ children, ...props }) => (
|
||||
<a {...props} className="font-medium text-brand underline underline-offset-2">
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children, className, ...props }) => (
|
||||
<code
|
||||
{...props}
|
||||
className={cn(
|
||||
'rounded bg-foreground/[0.06] px-1 py-0.5 font-mono text-[0.92em]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="my-3 max-w-full overflow-x-auto rounded-xl bg-foreground/[0.04] p-3 font-mono text-xs leading-5">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
p: ({ children }) => <p className="text-pretty leading-7">{children}</p>,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
const AttachmentImage = memo(function AttachmentImage({
|
||||
attachmentId,
|
||||
mime,
|
||||
}: {
|
||||
attachmentId: string;
|
||||
mime: string;
|
||||
}) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let objectUrl: string | null = null;
|
||||
void loadCodingAttachmentPreview(attachmentId)
|
||||
.then((preview) => {
|
||||
if (!active) return;
|
||||
const bytes = Uint8Array.from(preview.bytes);
|
||||
objectUrl = URL.createObjectURL(new Blob([bytes], { type: preview.mime }));
|
||||
setPreviewUrl(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setFailed(true);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [attachmentId]);
|
||||
|
||||
return (
|
||||
<figure
|
||||
className="max-w-sm overflow-hidden rounded-2xl bg-foreground/[0.04] shadow-[0_0_0_1px_rgba(0,0,0,0.06)]"
|
||||
data-attachment-id={attachmentId}
|
||||
>
|
||||
{previewUrl
|
||||
? (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="对话图片附件"
|
||||
className="max-h-80 w-full object-contain outline outline-1 -outline-offset-1 outline-black/10"
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div className="flex min-h-28 items-center justify-center gap-2 px-4 text-sm text-muted-foreground">
|
||||
<FileImage className="h-5 w-5" aria-hidden="true" />
|
||||
{failed ? '图片预览不可用' : '正在加载图片…'}
|
||||
</div>
|
||||
)}
|
||||
<figcaption className="truncate px-3 py-2 font-mono text-[11px] text-muted-foreground">
|
||||
{mime} · {attachmentId}
|
||||
</figcaption>
|
||||
</figure>
|
||||
);
|
||||
});
|
||||
|
||||
const ContentBlock = memo(function ContentBlock({
|
||||
block,
|
||||
markdown,
|
||||
}: {
|
||||
block: ConversationContentBlock;
|
||||
markdown: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(
|
||||
block.kind === 'thinking' && block.status === 'streaming',
|
||||
);
|
||||
if (block.kind === 'image') {
|
||||
return <AttachmentImage attachmentId={block.attachmentId} mime={block.mime} />;
|
||||
}
|
||||
if (block.kind === 'thinking') {
|
||||
return (
|
||||
<details
|
||||
className="group rounded-xl bg-foreground/[0.035] px-3 py-2.5"
|
||||
open={expanded}
|
||||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 text-xs font-semibold text-muted-foreground">
|
||||
<Brain className="h-4 w-4" aria-hidden="true" />
|
||||
思考过程
|
||||
<ChevronDown className="ml-auto h-4 w-4 transition-transform duration-150 group-open:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<p className="whitespace-pre-wrap break-words pb-1 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
{block.text}
|
||||
</p>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
if (!markdown) {
|
||||
return <p className="whitespace-pre-wrap break-words text-pretty leading-7">{block.text}</p>;
|
||||
}
|
||||
return <MarkdownText text={block.text} />;
|
||||
});
|
||||
|
||||
const MessageNode = memo(function MessageNode({ node }: { node: ConversationMessageNode }) {
|
||||
const user = node.role === 'user';
|
||||
return (
|
||||
<article
|
||||
data-node-id={node.id}
|
||||
data-node-kind="message"
|
||||
className={cn('flex gap-3', user && 'justify-end')}
|
||||
>
|
||||
{!user && (
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-foreground text-background shadow-soft">
|
||||
<Bot className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-0 max-w-[min(46rem,88%)] space-y-2.5 text-sm text-foreground',
|
||||
user
|
||||
? 'rounded-2xl rounded-br-md bg-foreground px-4 py-2.5 text-background shadow-soft'
|
||||
: 'flex-1 py-1.5',
|
||||
node.status === 'error' && 'shadow-[0_0_0_1px_hsl(var(--destructive)/0.25)]',
|
||||
)}
|
||||
>
|
||||
{node.blocks.map((block) => (
|
||||
<ContentBlock key={block.id} block={block} markdown={!user} />
|
||||
))}
|
||||
{node.status === 'optimistic' && (
|
||||
<p className={cn('text-xs', user ? 'text-background/65' : 'text-muted-foreground')}>
|
||||
正在提交…
|
||||
</p>
|
||||
)}
|
||||
{node.status === 'error' && (
|
||||
<p className={cn('text-xs font-medium', user ? 'text-background/80' : 'text-destructive')}>
|
||||
本次提交未被接受,内容已保留在输入框。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{user && (
|
||||
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-surface-subtle text-foreground shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
|
||||
<UserRound className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
});
|
||||
|
||||
const ToolNode = memo(function ToolNode({ node }: { node: ConversationToolNode }) {
|
||||
const outputChars = node.output.reduce((total, block) => (
|
||||
total + (block.kind === 'image' ? 0 : block.text.length)
|
||||
), 0);
|
||||
const open = node.status === 'running' || node.status === 'waiting';
|
||||
const [expanded, setExpanded] = useState(open);
|
||||
return (
|
||||
<details
|
||||
data-node-id={node.id}
|
||||
data-node-kind="tool"
|
||||
className="group ml-12 rounded-2xl bg-card p-2 shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_2px_5px_rgba(0,0,0,0.04)]"
|
||||
open={expanded}
|
||||
onToggle={(event) => setExpanded(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 rounded-xl px-2 text-sm font-medium hover:bg-surface-subtle">
|
||||
<Hammer className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate">{node.title || node.toolName}</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">{node.status}</span>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-150 group-open:rotate-180" aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="space-y-3 px-2 pb-2 pt-1">
|
||||
{node.inputText && (
|
||||
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded-xl bg-foreground/[0.035] p-3 font-mono text-xs leading-5 text-muted-foreground">
|
||||
{node.inputText}
|
||||
</pre>
|
||||
)}
|
||||
{node.output.map((block) => (
|
||||
<ContentBlock key={block.id} block={block} markdown={false} />
|
||||
))}
|
||||
{outputChars > LONG_OUTPUT_CHARS && (
|
||||
<p className="text-xs text-muted-foreground">长输出已收纳在工具卡片中。</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
});
|
||||
|
||||
const CompactionNode = memo(function CompactionNodeView({ node }: { node: ConversationCompactionNode }) {
|
||||
return (
|
||||
<div
|
||||
data-node-id={node.id}
|
||||
data-node-kind="compaction"
|
||||
className="ml-12 flex items-start gap-3 rounded-2xl bg-brand-soft/70 px-4 py-3 text-sm shadow-[0_0_0_1px_rgba(0,0,0,0.05)]"
|
||||
>
|
||||
<Layers3 className="mt-0.5 h-4 w-4 shrink-0 text-brand" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium">上下文整理 · {node.status}</p>
|
||||
{node.summary && <p className="mt-1 text-pretty text-muted-foreground">{node.summary}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const BoundaryNode = memo(function BoundaryNodeView({ node }: { node: ConversationBoundaryNode }) {
|
||||
const label = node.boundary === 'turn-start'
|
||||
? '开始新一轮'
|
||||
: node.boundary === 'turn-end'
|
||||
? '本轮完成'
|
||||
: `正在重试${node.attempt ? ` · 第 ${node.attempt} 次` : ''}`;
|
||||
return (
|
||||
<div
|
||||
data-node-id={node.id}
|
||||
data-node-kind="boundary"
|
||||
className="ml-12 flex items-center gap-3 py-1 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
<span className="h-px flex-1 bg-foreground/10" />
|
||||
<span className="tabular-nums">{label}</span>
|
||||
<span className="h-px flex-1 bg-foreground/10" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const NoticeNode = memo(function NoticeNodeView({ node }: { node: ConversationNoticeNode }) {
|
||||
return (
|
||||
<div
|
||||
data-node-id={node.id}
|
||||
data-node-kind="notice"
|
||||
className="ml-12 flex items-start gap-2 rounded-xl bg-surface-subtle px-3 py-2.5 text-sm"
|
||||
>
|
||||
<CircleAlert className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<p className="text-pretty">{node.message}</p>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const TimelineNode = memo(function TimelineNode({ node }: { node: ConversationNode }) {
|
||||
if (node.kind === 'message') return <MessageNode node={node} />;
|
||||
if (node.kind === 'tool') return <ToolNode node={node} />;
|
||||
if (node.kind === 'compaction') return <CompactionNode node={node} />;
|
||||
if (node.kind === 'boundary') return <BoundaryNode node={node} />;
|
||||
if (node.kind === 'notice') return <NoticeNode node={node} />;
|
||||
return (
|
||||
<div data-node-id={node.id} data-node-kind={node.kind} className="ml-12 rounded-xl bg-surface-subtle px-3 py-2 text-sm text-muted-foreground">
|
||||
子任务执行状态将在后续功能面板中展开。
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const CodingConversationTimeline = memo(function CodingConversationTimeline({
|
||||
conversationId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
}) {
|
||||
const selectNodes = useCallback((state: CodingConversationStoreState) => (
|
||||
selectCodingConversationSnapshot(conversationId)(state)?.nodes ?? EMPTY_NODES
|
||||
), [conversationId]);
|
||||
const nodes = useCodingConversationStore(selectNodes);
|
||||
const [visibleLimit, setVisibleLimit] = useState(INITIAL_WINDOW);
|
||||
const windowStart = Math.max(0, nodes.length - visibleLimit);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const stickToBottomRef = useRef(true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
const element = scrollRef.current;
|
||||
if (element) element.scrollTop = element.scrollHeight;
|
||||
}, [nodes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
data-testid="coding-conversation-timeline"
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6"
|
||||
onScroll={(event) => {
|
||||
const element = event.currentTarget;
|
||||
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 64;
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto flex w-full max-w-3xl flex-col gap-5">
|
||||
{windowStart > 0 && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="mx-auto min-h-10 rounded-xl px-4 text-xs transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
onClick={() => setVisibleLimit((current) => current + WINDOW_STEP)}
|
||||
>
|
||||
加载更早内容
|
||||
</Button>
|
||||
)}
|
||||
{nodes.slice(windowStart).map((node) => <TimelineNode key={node.id} node={node} />)}
|
||||
{nodes.length === 0 && (
|
||||
<div className="mx-auto flex max-w-md flex-col items-center px-6 py-16 text-center">
|
||||
<div className="flex h-11 w-11 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>
|
||||
<h2 className="mt-4 text-balance text-base font-semibold">从一个清晰的问题开始</h2>
|
||||
<p className="mt-1.5 text-pretty text-sm leading-6 text-muted-foreground">
|
||||
输入框会先于本地 Agent 就绪,你可以立即整理需求。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user