22
web/src/components/ai/generation-tool-card.tsx
Normal file
22
web/src/components/ai/generation-tool-card.tsx
Normal 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}
|
||||
</>;
|
||||
}
|
||||
41
web/src/components/ai/message-reasoning.tsx
Normal file
41
web/src/components/ai/message-reasoning.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user