feat(create): 创作对话 - 接入推理展示与可折叠生成详情

Refs #243
This commit is contained in:
ddCat
2026-08-17 13:16:13 +08:00
parent c2352fd9b6
commit fddda497d0
6 changed files with 221 additions and 38 deletions

View File

@@ -0,0 +1,22 @@
import { useEffect, useState, type ReactNode } from "react";
import { ChevronDown } from "lucide-react";
export type GenerationToolStatus = "running" | "completed" | "error" | "cancelled";
export function GenerationToolCard({ status, isBulk = false, heading, children }: { status: GenerationToolStatus; isBulk?: boolean; heading: ReactNode; children: ReactNode }) {
const [open, setOpen] = useState(status !== "completed" || !isBulk);
useEffect(() => {
if (status !== "completed") setOpen(true);
}, [status]);
return <>
<div className="creation-message-heading">
{heading}
<button type="button" className="ml-auto grid size-7 shrink-0 place-items-center rounded-md text-foreground/45 transition-colors hover:bg-surface-hover hover:text-foreground" aria-label={open ? "收起生成详情" : "展开生成详情"} aria-expanded={open} onClick={() => setOpen((value) => !value)}>
<ChevronDown className={`size-4 transition-transform${open ? " rotate-180" : ""}`} />
</button>
</div>
{open ? children : null}
</>;
}

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef, useState } from "react";
import { ChevronDown, Sparkles } from "lucide-react";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
export function MessageReasoning({ reasoning, isStreaming }: { reasoning: string; isStreaming: boolean }) {
const [open, setOpen] = useState(isStreaming);
const [durationSeconds, setDurationSeconds] = useState<number>();
const startedAtRef = useRef<number | undefined>(undefined);
const autoClosedRef = useRef(false);
useEffect(() => {
if (!isStreaming) return;
startedAtRef.current ??= Date.now();
setOpen(true);
}, [isStreaming]);
useEffect(() => {
if (isStreaming || !startedAtRef.current) return;
setDurationSeconds(Math.max(1, Math.ceil((Date.now() - startedAtRef.current) / 1000)));
startedAtRef.current = undefined;
}, [isStreaming]);
useEffect(() => {
if (isStreaming || !open || autoClosedRef.current || durationSeconds === undefined) return;
const timer = window.setTimeout(() => {
setOpen(false);
autoClosedRef.current = true;
}, 1000);
return () => window.clearTimeout(timer);
}, [durationSeconds, isStreaming, open]);
return <div className="mb-2 text-xs text-foreground/55">
<button type="button" className="inline-flex min-h-8 items-center gap-1.5 rounded-md px-2 transition-colors hover:bg-surface-hover hover:text-foreground" aria-expanded={open} onClick={() => setOpen((value) => !value)}>
<Sparkles className="size-3.5" />
<span>{isStreaming ? "正在思考" : durationSeconds ? `已思考 ${durationSeconds} 秒` : "思考过程"}</span>
<ChevronDown className={`size-3.5 transition-transform${open ? " rotate-180" : ""}`} />
</button>
{open ? <div className="mt-2 rounded-md bg-surface-active p-3 text-foreground/70"><AIMessageMarkdown isStreaming={isStreaming}>{reasoning}</AIMessageMarkdown></div> : null}
</div>;
}

View File

@@ -255,6 +255,7 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
replaceEditableSelection(event.clipboardData.getData("text/plain"));
}}
onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();
@@ -339,7 +340,16 @@ export const CanvasResourceMentionTextarea = forwardRef<HTMLTextAreaElement, Pro
syncMention(next, event.target.selectionStart);
reportContentSize(event.currentTarget);
}}
onCompositionStart={(event) => {
composingRef.current = true;
props.onCompositionStart?.(event);
}}
onCompositionEnd={(event) => {
composingRef.current = false;
props.onCompositionEnd?.(event);
}}
onKeyDown={(event) => {
if (event.key === "Enter" && (event.nativeEvent.isComposing || composingRef.current)) return;
if (mention && candidates.length) {
if (event.key === "ArrowDown") {
event.preventDefault();