mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-05 08:58:34 +00:00
* feat(desktop): add native host scaffold * feat(webui): track turns and usage in gateway * feat(webui): polish desktop chat experience * feat(apps): add ArcGIS and Joplin logos * feat(desktop): polish shell and shared surfaces * fix(webui): avoid preview chips for glob references * test: align CI expectations for token fallback * feat(webui): preview prompt rail entries * feat(webui): add prompt navigator drawer * style(webui): refine prompt navigator placement * style(webui): align prompt navigator with header actions * style(webui): simplify prompt navigator header * refactor(webui): clean thread resource refresh * feat(desktop): add native reply notifications * fix(webui): preserve desktop restart and replay state * fix(desktop): harden gateway proxy startup * fix(web): fall back when readability is unavailable * fix(desktop): hide window instead of closing on macos * fix(webui): unify desktop header actions * fix(webui): simplify prompt history rows * fix(desktop): log notification delivery failures * chore(desktop): clean source package artifacts * fix(cron): support one-time relative reminders * fix(webui): reveal scroll button in place * Revert "fix(cron): support one-time relative reminders" This reverts commit 4c4661da120a3c7283e0768412bae48604e7390b. * refactor(webui): extract token usage heatmap * docs(desktop): clarify contributor guides --------- Co-authored-by: chengyongru <2755839590@qq.com>
156 lines
3.9 KiB
TypeScript
156 lines
3.9 KiB
TypeScript
import {
|
|
Suspense,
|
|
lazy,
|
|
memo,
|
|
startTransition,
|
|
useCallback,
|
|
useEffect,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from "react";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
interface MarkdownTextProps {
|
|
children: string;
|
|
className?: string;
|
|
streaming?: boolean;
|
|
onOpenFilePreview?: (path: string) => void;
|
|
}
|
|
|
|
const loadMarkdownRenderer = () => import("@/components/MarkdownTextRenderer");
|
|
const LazyMarkdownRenderer = lazy(loadMarkdownRenderer);
|
|
|
|
const MemoizedMarkdownRenderer = memo(function MemoizedMarkdownRenderer({
|
|
source,
|
|
className,
|
|
highlightCode,
|
|
onOpenFilePreview,
|
|
}: {
|
|
source: string;
|
|
className?: string;
|
|
highlightCode: boolean;
|
|
onOpenFilePreview?: (path: string) => void;
|
|
}) {
|
|
return (
|
|
<LazyMarkdownRenderer
|
|
className={className}
|
|
highlightCode={highlightCode}
|
|
onOpenFilePreview={onOpenFilePreview}
|
|
>
|
|
{source}
|
|
</LazyMarkdownRenderer>
|
|
);
|
|
});
|
|
|
|
const SHORT_STREAM_COMMIT_MS = 80;
|
|
const MEDIUM_STREAM_COMMIT_MS = 140;
|
|
const LONG_STREAM_COMMIT_MS = 220;
|
|
const STREAMING_HIGHLIGHT_CHAR_LIMIT = 16_000;
|
|
|
|
export function preloadMarkdownText(): void {
|
|
void loadMarkdownRenderer();
|
|
}
|
|
|
|
/**
|
|
* Lightweight markdown renderer mirroring agent-chat-ui: GFM + math via
|
|
* ``remark-math`` / ``rehype-katex``, and fenced code blocks delegated to
|
|
* ``CodeBlock`` for copy-to-clipboard and syntax highlighting.
|
|
*/
|
|
export function MarkdownText({
|
|
children,
|
|
className,
|
|
streaming = false,
|
|
onOpenFilePreview,
|
|
}: MarkdownTextProps) {
|
|
const renderedSource = useStreamingMarkdownSource(children, streaming);
|
|
const highlightCode = streaming
|
|
? renderedSource.length <= STREAMING_HIGHLIGHT_CHAR_LIMIT
|
|
: renderedSource === children;
|
|
|
|
useEffect(() => {
|
|
if (streaming) preloadMarkdownText();
|
|
}, [streaming]);
|
|
|
|
return (
|
|
<Suspense
|
|
fallback={
|
|
<div
|
|
className={cn(
|
|
"whitespace-pre-wrap break-words leading-relaxed text-foreground/92",
|
|
className,
|
|
)}
|
|
>
|
|
{renderedSource}
|
|
</div>
|
|
}
|
|
>
|
|
<MemoizedMarkdownRenderer
|
|
source={renderedSource}
|
|
className={className}
|
|
highlightCode={highlightCode}
|
|
onOpenFilePreview={onOpenFilePreview}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function useStreamingMarkdownSource(source: string, streaming: boolean): string {
|
|
const [renderedSource, setRenderedSource] = useState(source);
|
|
const latestSourceRef = useRef(source);
|
|
const renderedSourceRef = useRef(source);
|
|
const timerRef = useRef<number | null>(null);
|
|
|
|
const clearPendingCommit = useCallback(() => {
|
|
if (timerRef.current !== null) {
|
|
window.clearTimeout(timerRef.current);
|
|
timerRef.current = null;
|
|
}
|
|
}, []);
|
|
|
|
const commitSource = useCallback((next: string, urgent: boolean) => {
|
|
if (renderedSourceRef.current === next) return;
|
|
renderedSourceRef.current = next;
|
|
if (urgent) {
|
|
setRenderedSource(next);
|
|
return;
|
|
}
|
|
startTransition(() => setRenderedSource(next));
|
|
}, []);
|
|
|
|
const scheduleCommit = useCallback(() => {
|
|
if (timerRef.current !== null) return;
|
|
timerRef.current = window.setTimeout(() => {
|
|
timerRef.current = null;
|
|
commitSource(latestSourceRef.current, false);
|
|
}, streamingCommitDelay(latestSourceRef.current.length));
|
|
}, [commitSource]);
|
|
|
|
latestSourceRef.current = source;
|
|
|
|
useLayoutEffect(() => {
|
|
latestSourceRef.current = source;
|
|
if (!streaming) {
|
|
clearPendingCommit();
|
|
commitSource(source, true);
|
|
}
|
|
}, [clearPendingCommit, commitSource, source, streaming]);
|
|
|
|
useEffect(() => {
|
|
latestSourceRef.current = source;
|
|
if (!streaming) return;
|
|
scheduleCommit();
|
|
}, [scheduleCommit, source, streaming]);
|
|
|
|
useEffect(() => clearPendingCommit, [clearPendingCommit]);
|
|
|
|
return renderedSource;
|
|
}
|
|
|
|
function streamingCommitDelay(length: number): number {
|
|
if (length > 24_000) return LONG_STREAM_COMMIT_MS;
|
|
if (length > 8_000) return MEDIUM_STREAM_COMMIT_MS;
|
|
return SHORT_STREAM_COMMIT_MS;
|
|
}
|