feat: expand intelligent investigation workspace
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s

This commit is contained in:
2026-10-30 17:30:19 +08:00
parent ddd03dbff6
commit c0e4231b83
223 changed files with 13155 additions and 5304 deletions
-559
View File
@@ -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();
});
+121
View File
@@ -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>;
}
+27 -11
View File
@@ -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 &&
-145
View File
@@ -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
-75
View File
@@ -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);
-13
View File
@@ -22,19 +22,6 @@ const browserHttpUrl = z
}
});
const optionalBrowserHttpUrl = z
.string()
.trim()
.default("")
.superRefine((value, context) => {
if (!value) return;
const parsed = browserHttpUrl.safeParse(value);
if (!parsed.success) {
context.addIssue({ code: z.ZodIssueCode.custom, message: "必须是安全的 HTTP(S) 地址" });
}
})
.transform((value) => value.replace(/\/+$/, ""));
const optionalBrowserHttpUrlOrSameOriginPath = z
.string()
.trim()
+15
View File
@@ -0,0 +1,15 @@
import { z } from "zod";
export const speechVoiceIdSchema = z.string().max(120).regex(/^zh-CN-[a-zA-Z0-9]+Neural$/);
export const speechVoiceSchema = z.object({
id: speechVoiceIdSchema,
name: z.string().min(1).max(200),
locale: z.literal("zh-CN"),
gender: z.enum(["Female", "Male"])
});
export const speechVoiceCatalogSchema = z.object({
voices: z.array(speechVoiceSchema).min(1).max(2_000),
defaultVoice: speechVoiceIdSchema
});
export type SpeechVoice = z.infer<typeof speechVoiceSchema>;
export type SpeechVoiceCatalog = z.infer<typeof speechVoiceCatalogSchema>;
+6 -2
View File
@@ -13,6 +13,7 @@ export interface DateTimeFieldProps {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
contentClassName?: string;
}
interface DateTimeParts {
@@ -121,7 +122,7 @@ function moveDay(parts: DateTimeParts, amount: number): DateTimeParts {
return { ...parts, year, month: date.getMonth() + 1, day: date.getDate() };
}
export function DateTimeField({ label, value, onChange, disabled = false }: DateTimeFieldProps) {
export function DateTimeField({ label, value, onChange, disabled = false, contentClassName }: DateTimeFieldProps) {
const id = useId();
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<DateTimeParts>(
@@ -214,7 +215,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
<DialogPrimitive.Overlay className="fixed inset-0 z-[80] bg-slate-950/20 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
aria-describedby={undefined}
className="fixed left-1/2 top-1/2 z-[85] max-h-[calc(100dvh-1.5rem)] w-[352px] max-w-[calc(100vw-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-4 shadow-[0_20px_60px_rgba(15,23,42,0.22)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
className={cn("fixed left-1/2 top-1/2 z-[85] max-h-[calc(100dvh-1.5rem)] w-[352px] max-w-[calc(100vw-1.5rem)] -translate-x-1/2 -translate-y-1/2 overflow-y-auto rounded-2xl border border-[var(--surface-divider)] bg-[var(--surface-reading)] p-4 shadow-[0_20px_60px_rgba(15,23,42,0.22)] outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", contentClassName)}
onOpenAutoFocus={(event) => {
if (sourceInvalid) return;
event.preventDefault();
@@ -224,6 +225,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
<DialogTitle className="pr-8 text-base text-slate-900">选择{label}</DialogTitle>
<div className="mt-3 flex min-w-0 items-end gap-2">
<FieldSelect
contentClassName={contentClassName}
label="月份"
value={String(draft.month)}
options={MONTH_OPTIONS}
@@ -349,6 +351,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
</div>
<div className="mt-3 grid grid-cols-2 gap-2 border-t border-[var(--surface-divider)] pt-3">
<FieldSelect
contentClassName={contentClassName}
label="小时"
value={pad(draft.hour)}
options={HOUR_OPTIONS}
@@ -358,6 +361,7 @@ export function DateTimeField({ label, value, onChange, disabled = false }: Date
}}
/>
<FieldSelect
contentClassName={contentClassName}
label="分钟"
value={pad(draft.minute)}
options={MINUTE_OPTIONS}
-27
View File
@@ -1,27 +0,0 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/shared/ui/cn"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-(--radix-hover-card-content-transform-origin)",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }
+1 -1
View File
@@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"form-field flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"form-field flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base text-foreground shadow-xs transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "./cn";
export const Popover = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-80 max-w-[calc(100vw-1.5rem)] rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-hidden",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 motion-reduce:animate-none origin-(--radix-popover-content-transform-origin)",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = "PopoverContent";
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { createReadingPreview } from "./reading-preview";
describe("reading previews", () => {
it("retains complete content without active controls or duplicate SVG IDs", () => {
const host = document.createElement("div");
host.innerHTML = '<table id="values"><tr><td>0.2130 MPa</td></tr></table><svg><defs><marker id="arrow" /></defs><path marker-end="url(#arrow)" /><use href="#arrow" /></svg><input value="现场草稿" />';
const preview = createReadingPreview(host);
expect(preview.textContent).toContain("0.2130 MPa");
expect(preview).toHaveAttribute("inert");
expect(preview).toHaveAttribute("aria-hidden", "true");
const marker = preview.querySelector("marker")!;
expect(marker.id).not.toBe("arrow");
expect(preview.querySelector("path")).toHaveAttribute("marker-end", `url(#${marker.id})`);
expect(preview.querySelector("use")).toHaveAttribute("href", `#${marker.id}`);
expect(preview.querySelector("input")).toHaveValue("现场草稿");
expect(host.querySelector("marker")).toHaveAttribute("id", "arrow");
const second = createReadingPreview(host);
expect(second.querySelector("marker")!.id).not.toBe(marker.id);
});
});
+35
View File
@@ -0,0 +1,35 @@
let previewSequence = 0;
/** A visible, inert snapshot keeps the original reading context in place. */
export function createReadingPreview(host: HTMLElement) {
const preview = host.cloneNode(true) as HTMLElement;
const prefix = `reading-preview-${++previewSequence}-`;
const ids = new Map<string, string>();
for (const element of [preview, ...preview.querySelectorAll<HTMLElement>("[id]")]) {
if (!element.id) continue;
const original = element.id;
const next = `${prefix}${ids.size}`;
ids.set(original, next);
element.id = next;
}
// Preserve SVG markers and clipping while keeping every cloned ID distinct.
for (const element of preview.querySelectorAll("*")) {
for (const attribute of [...element.attributes]) {
let value = attribute.value;
for (const [original, next] of ids) {
value = value.replaceAll(`url(#${original})`, `url(#${next})`);
if (["href", "xlink:href"].includes(attribute.name) && value === `#${original}`) value = `#${next}`;
}
if (value !== attribute.value) element.setAttribute(attribute.name, value);
}
if (element.localName === "style") {
let css = element.textContent ?? "";
for (const [original, next] of [...ids].sort((a, b) => b[0].length - a[0].length)) css = css.replaceAll(`#${original}`, `#${next}`);
element.textContent = css;
}
}
preview.setAttribute("aria-hidden", "true");
preview.setAttribute("inert", "");
preview.setAttribute("data-reading-preview", "");
return preview;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { createContext, useContext } from "react";
export type ReadingDocument = { id: string; title: string; host: HTMLDivElement };
export type ReadingDocument = { id: string; title: string; host: HTMLDivElement; restoreFocus?: () => void };
export type ReadingWorkspace = {
active: ReadingDocument | null;
view: "focus" | "map_split" | "map_only";
+18 -9
View File
@@ -5,6 +5,7 @@ import {
import { createPortal } from "react-dom";
import { Maximize2 } from "lucide-react";
import { Button } from "./button";
import { createReadingPreview } from "./reading-preview";
import { ReadingWorkspaceContext as Context, useReadingWorkspace, type ReadingDocument, type ReadingWorkspace } from "./reading-workspace-context";
@@ -29,18 +30,23 @@ export function ReadingWorkspaceProvider({ children }: { children: ReactNode })
/** One live subtree moves between the conversation/report and the workspace. */
export function ExpandableReading({
title, children, summary, enabled = true
title, children, enabled = true
}: { title: string; children: ReactNode; summary?: ReactNode; enabled?: boolean }) {
const workspace = useReadingWorkspace();
const id = useId();
const [host] = useState(() => document.createElement("div"));
const inline = useRef<HTMLDivElement>(null);
const trigger = useRef<HTMLButtonElement>(null);
const snapshot = useRef<HTMLElement | null>(null);
const wasActive = useRef(false);
const expanded = workspace?.active?.id === id;
const close = workspace?.close;
useLayoutEffect(() => {
if (!expanded && inline.current && host.parentElement !== inline.current) inline.current.append(host);
if (!expanded && inline.current) {
snapshot.current?.remove();
snapshot.current = null;
if (host.parentElement !== inline.current) inline.current.append(host);
}
if (wasActive.current && !expanded) trigger.current?.focus({ preventScroll: true });
wasActive.current = expanded;
}, [expanded, host, enabled]);
@@ -48,15 +54,18 @@ export function ExpandableReading({
if (!workspace || !enabled) return <>{children}</>;
return <section className="min-w-0" data-expandable-reading>
<div className="mb-2 flex items-center justify-end">
<Button ref={trigger} size="sm" variant="ghost" className="h-9 gap-1.5 text-xs text-muted-foreground"
onClick={() => workspace.open({ id, title, host })}>
<Maximize2 size={14} aria-hidden />{expanded ? "返回展开内容" : "展开查看"}
<Button ref={trigger} size="icon-sm" variant="ghost" className="text-muted-foreground"
aria-label={expanded ? "返回展开内容" : "展开查看"} title={expanded ? "返回展开内容" : "展开查看"}
onClick={() => {
snapshot.current?.remove();
snapshot.current = createReadingPreview(host);
inline.current?.append(snapshot.current);
workspace.open({ id, title, host, restoreFocus: () => trigger.current?.focus({ preventScroll: true }) });
}}>
<Maximize2 size={15} aria-hidden />
</Button>
</div>
{expanded ? <div className="rounded-lg border bg-muted/30 p-3 text-xs leading-6 text-muted-foreground">
{summary ?? `${title}已在工作区展开。`}
</div> : null}
<div ref={inline} className={expanded ? "hidden" : "min-w-0"} />
<div ref={inline} className="min-w-0" />
{createPortal(children, host)}
</section>;
}
+1 -1
View File
@@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"form-field flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-xs transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"form-field flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base text-foreground shadow-xs transition-colors placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
+15
View File
@@ -27,3 +27,18 @@ export function formatTimeRange(value: string, durationMinutes: number | undefin
}
return `${startTime} - ${formatTime(timestamp + durationMinutes * 60_000)}`;
}
/** Calendar keys follow the caller's local clock, independently of display time zone. */
export function toLocalDateKey(value: Date) {
const year = value.getFullYear();
const month = String(value.getMonth() + 1).padStart(2, "0");
const day = String(value.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/** Preserve the local-clock presentation used by history and peripheral records. */
export function formatLocalMonthDayTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false
}).format(new Date(value));
}