feat: expand intelligent investigation workspace
This commit is contained in:
@@ -1,559 +0,0 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken,
|
||||
} from "shiki";
|
||||
import { createHighlighter } from "shiki";
|
||||
|
||||
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
||||
const isUnderline = (fontStyle: number | undefined) =>
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4;
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken;
|
||||
key: string;
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
||||
lines.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
key: `line-${lineIdx}-${tokenIdx}`,
|
||||
token,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Token rendering component
|
||||
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
||||
<span
|
||||
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
|
||||
style={
|
||||
{
|
||||
backgroundColor: token.bgColor,
|
||||
color: token.color,
|
||||
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
||||
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
||||
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
||||
...token.htmlStyle,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Line number styles using CSS counters
|
||||
const LINE_NUMBER_CLASSES = cn(
|
||||
"block",
|
||||
"before:content-[counter(line)]",
|
||||
"before:inline-block",
|
||||
"before:[counter-increment:line]",
|
||||
"before:w-8",
|
||||
"before:mr-4",
|
||||
"before:text-right",
|
||||
"before:text-muted-foreground/50",
|
||||
"before:font-mono",
|
||||
"before:select-none"
|
||||
);
|
||||
|
||||
// Line rendering component
|
||||
const LineSpan = ({
|
||||
keyedLine,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
keyedLine: KeyedLine;
|
||||
showLineNumbers: boolean;
|
||||
}) => (
|
||||
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
||||
{keyedLine.tokens.length === 0
|
||||
? "\n"
|
||||
: keyedLine.tokens.map(({ token, key }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
interface TokenizedCode {
|
||||
tokens: ThemedToken[][];
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
interface CodeBlockContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Context
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
const createRawTokens = (code: string): TokenizedCode => ({
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line === ""
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "inherit",
|
||||
content: line,
|
||||
} as ThemedToken,
|
||||
]
|
||||
),
|
||||
});
|
||||
|
||||
// Synchronous highlight with callback for async results
|
||||
const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Subscribe callback if provided
|
||||
if (callback) {
|
||||
if (!subscribers.has(tokensCacheKey)) {
|
||||
subscribers.set(tokensCacheKey, new Set());
|
||||
}
|
||||
subscribers.get(tokensCacheKey)?.add(callback);
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
});
|
||||
|
||||
const tokenized: TokenizedCode = {
|
||||
bg: result.bg ?? "transparent",
|
||||
fg: result.fg ?? "inherit",
|
||||
tokens: result.tokens,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
tokensCache.set(tokensCacheKey, tokenized);
|
||||
|
||||
// Notify all subscribers
|
||||
const subs = subscribers.get(tokensCacheKey);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
sub(tokenized);
|
||||
}
|
||||
subscribers.delete(tokensCacheKey);
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
.catch((error) => {
|
||||
console.error("Failed to highlight code:", error);
|
||||
subscribers.delete(tokensCacheKey);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CodeBlockBody = memo(
|
||||
({
|
||||
tokenized,
|
||||
showLineNumbers,
|
||||
className,
|
||||
}: {
|
||||
tokenized: TokenizedCode;
|
||||
showLineNumbers: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const preStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: tokenized.bg,
|
||||
color: tokenized.fg,
|
||||
}),
|
||||
[tokenized.bg, tokenized.fg]
|
||||
);
|
||||
|
||||
const keyedLines = useMemo(
|
||||
() => addKeysToTokens(tokenized.tokens),
|
||||
[tokenized.tokens]
|
||||
);
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
|
||||
className
|
||||
)}
|
||||
style={preStyle}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"font-mono text-sm",
|
||||
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
|
||||
)}
|
||||
>
|
||||
{keyedLines.map((keyedLine) => (
|
||||
<LineSpan
|
||||
key={keyedLine.key}
|
||||
keyedLine={keyedLine}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.tokenized === nextProps.tokenized &&
|
||||
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
|
||||
CodeBlockBody.displayName = "CodeBlockBody";
|
||||
|
||||
export const CodeBlockContainer = ({
|
||||
className,
|
||||
language,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
data-language={language}
|
||||
style={{
|
||||
containIntrinsicSize: "auto 200px",
|
||||
contentVisibility: "auto",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CodeBlockHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockTitle = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockFilename = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const CodeBlockActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
||||
|
||||
// Synchronous cache lookup — avoids setState in effect for cached results
|
||||
const syncTokens = useMemo(
|
||||
() => highlightCode(code, language) ?? rawTokens,
|
||||
[code, language, rawTokens]
|
||||
);
|
||||
|
||||
// Async highlighting result (populated after shiki loads)
|
||||
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
|
||||
const asyncKeyRef = useRef({ code, language });
|
||||
|
||||
// Invalidate stale async tokens synchronously during render
|
||||
if (
|
||||
asyncKeyRef.current.code !== code ||
|
||||
asyncKeyRef.current.language !== language
|
||||
) {
|
||||
asyncKeyRef.current = { code, language };
|
||||
setAsyncTokens(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
highlightCode(code, language, (result) => {
|
||||
if (!cancelled) {
|
||||
setAsyncTokens(result);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
const tokenized = asyncTokens ?? syncTokens;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-auto">
|
||||
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={contextValue}>
|
||||
<CodeBlockContainer className={className} language={language} {...props}>
|
||||
{children}
|
||||
<CodeBlockContent
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const CodeBlockLanguageSelector = (
|
||||
props: CodeBlockLanguageSelectorProps
|
||||
) => <Select {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"form-field-inset h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorValue = (
|
||||
props: CodeBlockLanguageSelectorValueProps
|
||||
) => <SelectValue {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorContent = ({
|
||||
align = "end",
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorContentProps) => (
|
||||
<SelectContent align={align} {...props} />
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
||||
typeof SelectItem
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorItem = (
|
||||
props: CodeBlockLanguageSelectorItemProps
|
||||
) => <SelectItem {...props} />;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReadableMarkdownDiagram, ReadableMarkdownTable } from "./markdown-reading";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("readable Markdown tables", () => {
|
||||
const rows = <><thead><tr><th>对象</th><th>压力(MPa)</th></tr></thead><tbody><tr><td>北区入口</td><td id="pressure-value">0.2130</td></tr></tbody></>;
|
||||
it("keeps the original table visible while expanded, avoids duplicate IDs and returns keyboard focus", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<ReadableMarkdownTable>{rows}</ReadableMarkdownTable>);
|
||||
const original = within(container).getByRole("table");
|
||||
const trigger = screen.getByRole("button", { name: "放大查看表格" });
|
||||
expect(trigger).toHaveTextContent("");
|
||||
await user.click(trigger);
|
||||
const dialog = screen.getByRole("dialog", { name: "表格" });
|
||||
expect(within(dialog).getByRole("table")).toHaveTextContent("0.2130");
|
||||
expect(original).toBeVisible();
|
||||
expect(original).toHaveTextContent("0.2130");
|
||||
expect(document.querySelectorAll("#pressure-value")).toHaveLength(1);
|
||||
await user.click(within(dialog).getByRole("button", { name: "复制表格" }));
|
||||
expect(await navigator.clipboard.readText()).toBe("对象\t压力(MPa)\n北区入口\t0.2130");
|
||||
await user.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||
expect(trigger).toHaveFocus();
|
||||
expect(original).toBeVisible();
|
||||
});
|
||||
it("downloads the displayed values without converting their precision", async () => {
|
||||
const user = userEvent.setup();
|
||||
const urls = { createObjectURL: URL.createObjectURL, revokeObjectURL: URL.revokeObjectURL };
|
||||
let saved: Blob | undefined;
|
||||
URL.createObjectURL = vi.fn((blob: Blob) => { saved = blob; return "blob:table-test"; });
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
const click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {});
|
||||
try {
|
||||
render(<ReadableMarkdownTable>{rows}</ReadableMarkdownTable>);
|
||||
await user.click(screen.getByRole("button", { name: "下载表格" }));
|
||||
expect(click).toHaveBeenCalledTimes(1);
|
||||
expect(saved).toBeDefined();
|
||||
// Blob keeps the original textual readout, including trailing zeroes.
|
||||
const reader = new FileReader();
|
||||
const content = await new Promise<string>((resolve) => { reader.onload = () => resolve(String(reader.result)); reader.readAsText(saved!); });
|
||||
expect(content).toContain("北区入口,0.2130");
|
||||
} finally { URL.createObjectURL = urls.createObjectURL; URL.revokeObjectURL = urls.revokeObjectURL; }
|
||||
});
|
||||
});
|
||||
|
||||
it("defers incomplete Mermaid source and provides an accessible icon-only expansion entry", () => {
|
||||
render(<ReadableMarkdownDiagram code="flowchart TD\nA[未完成" isIncomplete language="mermaid" />);
|
||||
expect(screen.getByText("图示仍在生成…")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "放大查看图示" })).toHaveTextContent("");
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { Children, cloneElement, isValidElement, useCallback, useContext, useRef, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { Streamdown, StreamdownContext, extractTableDataFromElement, tableDataToCSV, tableDataToTSV, type CustomRendererProps } from "streamdown";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { Check, Copy, Download, Maximize2, Table2, Workflow, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogTitle, DialogTrigger } from "@/shared/ui/dialog";
|
||||
import { createReadingPreview } from "@/shared/ui/reading-preview";
|
||||
import { copySelectedText } from "./paragraph-copy";
|
||||
|
||||
/** Move the existing renderer into a bounded reader. Pan, selection and DOM state survive. */
|
||||
function MarkdownReading({ title, kind, children, expandedChildren, toolbar }: {
|
||||
title: string;
|
||||
kind: "table" | "diagram";
|
||||
children: ReactNode;
|
||||
expandedChildren?: ReactNode;
|
||||
toolbar?: (host: HTMLDivElement) => ReactNode;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [host] = useState(() => document.createElement("div"));
|
||||
const inline = useRef<HTMLDivElement | null>(null);
|
||||
const snapshot = useRef<HTMLElement | null>(null);
|
||||
const { isAnimating } = useContext(StreamdownContext);
|
||||
const attachInline = useCallback((slot: HTMLDivElement | null) => {
|
||||
inline.current = slot;
|
||||
if (slot && !host.isConnected) slot.append(host);
|
||||
}, [host]);
|
||||
const attachExpanded = useCallback((slot: HTMLDivElement | null) => {
|
||||
if (!inline.current) return;
|
||||
if (slot) {
|
||||
if (kind === "diagram") {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = createReadingPreview(host);
|
||||
inline.current.append(snapshot.current);
|
||||
}
|
||||
slot.append(host);
|
||||
} else {
|
||||
snapshot.current?.remove();
|
||||
snapshot.current = null;
|
||||
inline.current.append(host);
|
||||
}
|
||||
}, [host, kind]);
|
||||
const Icon = kind === "table" ? Table2 : Workflow;
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<section className="markdown-reading-block" data-streamdown={kind === "table" ? "table-wrapper" : undefined} data-markdown-diagram={kind === "diagram" || undefined}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<span className="flex items-center gap-2 text-xs font-medium text-muted-foreground"><Icon size={16} aria-hidden />{title}</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{toolbar?.(host)}
|
||||
<DialogTrigger asChild><Button variant="ghost" size="icon-sm" disabled={isAnimating} aria-label={`放大查看${title}`} title={`放大查看${title}`}><Maximize2 size={15} aria-hidden /></Button></DialogTrigger>
|
||||
</div>
|
||||
</header>
|
||||
{kind === "table" ? <div className="min-w-0">{children}</div> : <div ref={attachInline} className="min-w-0" />}
|
||||
</section>
|
||||
{createPortal(expandedChildren ?? children, host)}
|
||||
<DialogContent onCopy={copySelectedText} className="markdown-reading-window motion-reduce:animate-none" data-reading-kind={kind} overlayClassName="markdown-reading-backdrop motion-reduce:animate-none" data-agent-theme="chat" showCloseButton={false}
|
||||
{...(kind === "table" ? { "aria-describedby": undefined } : {})}
|
||||
onEscapeKeyDown={(event) => { event.preventDefault(); event.stopPropagation(); setOpen(false); }}>
|
||||
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
|
||||
<div className="min-w-0"><DialogTitle className="text-base leading-6 tracking-normal">{title}</DialogTitle>
|
||||
{kind === "diagram" ? <DialogDescription className="mt-0.5 text-xs">可缩放和平移查看图中关系。</DialogDescription> : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">{toolbar?.(host)}<DialogClose asChild><Button variant="ghost" size="icon-sm" aria-label="关闭放大查看"><X size={17} aria-hidden /></Button></DialogClose></div>
|
||||
</header>
|
||||
<div className="markdown-reading-scroll min-h-0 flex-1 overflow-auto overscroll-contain p-3 md:p-5" data-markdown-reading-scroll>
|
||||
<div ref={kind === "table" ? (slot) => { if (slot) slot.append(host); } : attachExpanded} className="min-w-0" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>;
|
||||
}
|
||||
|
||||
function MarkdownTableActions({ host }: { host: HTMLDivElement }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const getData = () => {
|
||||
const table = host.querySelector("table");
|
||||
return table ? extractTableDataFromElement(table) : null;
|
||||
};
|
||||
return <><Button type="button" variant="ghost" size="icon-sm" aria-label={copied ? "表格已复制" : "复制表格"} onClick={async () => {
|
||||
const data = getData();
|
||||
if (!data) return;
|
||||
try { await navigator.clipboard.writeText(tableDataToTSV(data)); setCopied(true); setError(false); }
|
||||
catch { setError(true); }
|
||||
}}>{copied ? <Check size={15} aria-hidden /> : <Copy size={15} aria-hidden />}</Button>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="下载表格" onClick={() => {
|
||||
const data = getData();
|
||||
if (!data) return;
|
||||
const url = URL.createObjectURL(new Blob(["\uFEFF", tableDataToCSV(data)], { type: "text/csv;charset=utf-8" }));
|
||||
const anchor = document.createElement("a"); anchor.href = url; anchor.download = "table.csv"; anchor.click();
|
||||
const revoke = URL.revokeObjectURL.bind(URL);
|
||||
setTimeout(() => revoke(url), 1000);
|
||||
}}><Download size={15} aria-hidden /></Button>
|
||||
{error ? <span role="status" className="text-xs text-destructive">复制失败,请重试</span> : null}
|
||||
</>;
|
||||
}
|
||||
|
||||
export function ReadableMarkdownTable({ children, className, node: _node, ...props }: ComponentProps<"table"> & { node?: unknown }) {
|
||||
const table = <div className="markdown-table-scroll" role="region" aria-label="表格数据" tabIndex={0}>
|
||||
<table {...props} className={className} data-streamdown="table">{children}</table>
|
||||
</div>;
|
||||
return <MarkdownReading title="表格" kind="table" expandedChildren={withoutExplicitIds(table)} toolbar={(host) => <MarkdownTableActions host={host} />}>
|
||||
{table}
|
||||
</MarkdownReading>;
|
||||
}
|
||||
|
||||
function withoutExplicitIds(children: ReactNode): ReactNode {
|
||||
return Children.map(children, (child) => {
|
||||
if (!isValidElement<{ id?: string; children?: ReactNode }>(child)) return child;
|
||||
return cloneElement(child, { id: undefined, ...(child.props.children !== undefined ? { children: withoutExplicitIds(child.props.children) } : {}) });
|
||||
});
|
||||
}
|
||||
|
||||
export function ReadableMarkdownDiagram({ code, isIncomplete }: CustomRendererProps) {
|
||||
return <MarkdownReading title="图示" kind="diagram">
|
||||
{isIncomplete ? <p className="p-4 text-sm text-muted-foreground">图示仍在生成…</p> : <Streamdown
|
||||
className="markdown-diagram-renderer" plugins={{ mermaid }}
|
||||
mermaid={{ config: { securityLevel: "strict", theme: "neutral", fontFamily: '"Noto Sans SC Variable", "PingFang SC", sans-serif', themeVariables: { fontSize: "18px" }, flowchart: { useMaxWidth: false, padding: 20 } } }}
|
||||
controls={{ mermaid: { fullscreen: false, panZoom: true, copy: true, download: true } }}
|
||||
>{`\`\`\`mermaid\n${code}\n\`\`\``}</Streamdown>}
|
||||
</MarkdownReading>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { copySelectedParagraph, copySelectedText } from "./paragraph-copy";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
@@ -16,7 +17,7 @@ import { createMathPlugin } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import type { ClipboardEvent, ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
@@ -326,17 +327,32 @@ const streamdownPlugins = {
|
||||
mermaid,
|
||||
};
|
||||
|
||||
function copyResponseParagraph(event: ClipboardEvent<HTMLElement>) {
|
||||
const selection = event.currentTarget.ownerDocument.defaultView?.getSelection();
|
||||
const anchor = selection?.rangeCount ? selection.getRangeAt(0).startContainer : null;
|
||||
const element = anchor instanceof Element ? anchor : anchor?.parentElement;
|
||||
const paragraph = element?.closest("p");
|
||||
// Keep native serialization for multi-block content, hard breaks, tables,
|
||||
// diagrams and code, whose visible text differs from DOM Range text.
|
||||
if (!paragraph || !event.currentTarget.contains(paragraph) ||
|
||||
paragraph.closest("pre, table, [data-streamdown=mermaid]") ||
|
||||
paragraph.querySelector("br, img, math, svg")) return;
|
||||
copySelectedParagraph(event, paragraph, true);
|
||||
}
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
animated
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
({ className, plugins, ...props }: MessageResponseProps) => (
|
||||
<div className="contents" onCopy={(event) => { copyResponseParagraph(event); copySelectedText(event); }}>
|
||||
<Streamdown
|
||||
animated
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
plugins={{ ...streamdownPlugins, ...plugins }}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/shared/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/shared/ui/collapsible";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface PlanContextValue {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => {
|
||||
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
|
||||
|
||||
return (
|
||||
<PlanContext.Provider value={contextValue}>
|
||||
<Collapsible asChild data-slot="plan" {...props}>
|
||||
<Card className={cn("shadow-none", className)}>{children}</Card>
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent asChild>
|
||||
<CardContent data-slot="plan-content" {...props} />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={cn("size-8", className)}
|
||||
data-slot="plan-trigger"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PromptInput, PromptInputBody, PromptInputSubmit, PromptInputTextarea } from "./prompt-input";
|
||||
|
||||
function composer(onSubmit = vi.fn(), disabled = false) {
|
||||
render(<PromptInput onSubmit={onSubmit}>
|
||||
<PromptInputBody><PromptInputTextarea aria-label="输入问题" /></PromptInputBody>
|
||||
<PromptInputSubmit disabled={disabled} />
|
||||
</PromptInput>);
|
||||
return { input: screen.getByRole("textbox", { name: "输入问题" }), onSubmit };
|
||||
}
|
||||
|
||||
describe("text prompt composition", () => {
|
||||
it("waits for IME composition to finish before Enter can submit the complete text", async () => {
|
||||
const { input, onSubmit } = composer();
|
||||
fireEvent.compositionStart(input);
|
||||
fireEvent.change(input, { target: { value: "核查节点压力" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(input).toHaveValue("核查节点压力");
|
||||
fireEvent.compositionEnd(input);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({ text: "核查节点压力" }, expect.anything()));
|
||||
});
|
||||
|
||||
it("keeps Shift+Enter and disabled submissions from sending a message", () => {
|
||||
const { input, onSubmit } = composer(vi.fn(), true);
|
||||
fireEvent.change(input, { target: { value: "保留草稿" } });
|
||||
expect(fireEvent.keyDown(input, { key: "Enter", shiftKey: true })).toBe(true);
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(input).toHaveValue("保留草稿");
|
||||
});
|
||||
|
||||
it("lets the owning panel intercept keyboard submission", () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(<PromptInput onSubmit={onSubmit}>
|
||||
<PromptInputTextarea aria-label="输入问题" onKeyDown={(event) => event.preventDefault()} />
|
||||
<PromptInputSubmit />
|
||||
</PromptInput>);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "保留给宿主处理" } });
|
||||
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" });
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops a running generation without submitting a second prompt", () => {
|
||||
const onSubmit = vi.fn(), onStop = vi.fn();
|
||||
render(<PromptInput onSubmit={onSubmit}>
|
||||
<PromptInputTextarea /><PromptInputSubmit status="streaming" onStop={onStop} />
|
||||
</PromptInput>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||
expect(onStop).toHaveBeenCalledOnce();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,75 +0,0 @@
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import type { CSSProperties, ElementType, JSX } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||
|
||||
// Cache motion components at module level to avoid creating during render
|
||||
const motionComponentCache = new Map<
|
||||
keyof JSX.IntrinsicElements,
|
||||
React.ComponentType<MotionHTMLProps>
|
||||
>();
|
||||
|
||||
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||
let component = motionComponentCache.get(element);
|
||||
if (!component) {
|
||||
component = motion.create(element);
|
||||
motionComponentCache.set(element, component);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = getMotionComponent(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
duration,
|
||||
ease: "linear",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
Reference in New Issue
Block a user