feat: make chat file references clickable and responsive (#587)
* docs: clarify named and quick Cloudflare tunnel usage * feat: make chat file paths openable from rendered responses * perf: speed up chat file-path links and open behavior * fix: open chat file references at mentioned lines * fix: prevent context panel flicker on blocked file opens
This commit is contained in:
@@ -17,6 +17,8 @@ import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import type { ToolPopupContent } from './message/types';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
|
||||
const withStableStringId = <T extends object>(value: T, id: string): T => {
|
||||
const existingPrimitive = (value as Record<symbol, unknown>)[Symbol.toPrimitive];
|
||||
@@ -682,6 +684,530 @@ interface MarkdownRendererProps {
|
||||
}
|
||||
|
||||
const MERMAID_BLOCK_SELECTOR = '[data-streamdown="mermaid-block"]';
|
||||
const FILE_LINK_SELECTOR = '[data-openchamber-file-link="true"]';
|
||||
|
||||
type ParsedFileReference = {
|
||||
path: string;
|
||||
line?: number;
|
||||
column?: number;
|
||||
};
|
||||
|
||||
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:[\\/]/;
|
||||
const WINDOWS_UNC_PATH_PATTERN = /^\\\\[^\\]+\\[^\\]+/;
|
||||
const KNOWN_FILE_BASENAMES = new Set([
|
||||
'dockerfile',
|
||||
'makefile',
|
||||
'readme',
|
||||
'license',
|
||||
'.env',
|
||||
'.gitignore',
|
||||
'.npmrc',
|
||||
]);
|
||||
const KNOWN_BASENAME_PATTERN = Array.from(KNOWN_FILE_BASENAMES)
|
||||
.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
||||
.join('|');
|
||||
|
||||
const normalizePath = (value: string): string => {
|
||||
const source = (value || '').trim();
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const withSlashes = source.replace(/\\/g, '/');
|
||||
const hadUncPrefix = withSlashes.startsWith('//');
|
||||
|
||||
let normalized = withSlashes.replace(/\/+/g, '/');
|
||||
if (hadUncPrefix && !normalized.startsWith('//')) {
|
||||
normalized = `/${normalized}`;
|
||||
}
|
||||
|
||||
const isUnixRoot = normalized === '/';
|
||||
const isWindowsDriveRoot = /^[A-Za-z]:\/$/.test(normalized);
|
||||
if (!isUnixRoot && !isWindowsDriveRoot) {
|
||||
normalized = normalized.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const isAbsolutePath = (value: string): boolean => {
|
||||
return value.startsWith('/')
|
||||
|| WINDOWS_DRIVE_PATH_PATTERN.test(value)
|
||||
|| WINDOWS_UNC_PATH_PATTERN.test(value)
|
||||
|| value.startsWith('//');
|
||||
};
|
||||
|
||||
const toAbsolutePath = (basePath: string, targetPath: string): string => {
|
||||
const normalizedTarget = normalizePath(targetPath);
|
||||
if (!normalizedTarget) {
|
||||
return normalizePath(basePath);
|
||||
}
|
||||
|
||||
if (isAbsolutePath(normalizedTarget)) {
|
||||
return normalizedTarget;
|
||||
}
|
||||
|
||||
const normalizedBase = normalizePath(basePath);
|
||||
if (!normalizedBase) {
|
||||
return normalizedTarget;
|
||||
}
|
||||
|
||||
const isWindowsDriveBase = /^[A-Za-z]:/.test(normalizedBase);
|
||||
const prefix = isWindowsDriveBase ? normalizedBase.slice(0, 2) : '';
|
||||
const baseRemainder = isWindowsDriveBase ? normalizedBase.slice(2) : normalizedBase;
|
||||
|
||||
const stack = baseRemainder.split('/').filter(Boolean);
|
||||
const parts = normalizedTarget.split('/').filter(Boolean);
|
||||
for (const part of parts) {
|
||||
if (part === '.') {
|
||||
continue;
|
||||
}
|
||||
if (part === '..') {
|
||||
if (stack.length > 0) {
|
||||
stack.pop();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
stack.push(part);
|
||||
}
|
||||
|
||||
if (isWindowsDriveBase) {
|
||||
return `${prefix}/${stack.join('/')}`;
|
||||
}
|
||||
|
||||
return `/${stack.join('/')}`;
|
||||
};
|
||||
|
||||
const trimPathCandidate = (value: string): string => {
|
||||
let next = (value || '').trim();
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ((next.startsWith('`') && next.endsWith('`')) || (next.startsWith('"') && next.endsWith('"')) || (next.startsWith("'") && next.endsWith("'"))) {
|
||||
next = next.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
next = next.replace(/[.,;!?]+$/g, '');
|
||||
|
||||
if (next.endsWith(')') && !next.includes('(')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
if (next.endsWith(']') && !next.includes('[')) {
|
||||
next = next.slice(0, -1);
|
||||
}
|
||||
|
||||
return next;
|
||||
};
|
||||
|
||||
const stripTrailingReference = (value: string): string => {
|
||||
let next = trimPathCandidate(value);
|
||||
if (!next) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const semicolonIndex = next.indexOf(';');
|
||||
if (semicolonIndex >= 0) {
|
||||
next = next.slice(0, semicolonIndex);
|
||||
}
|
||||
|
||||
next = next.replace(/#.*$/, '');
|
||||
|
||||
const extensionSuffixMatch = next.match(/^(.*\.[A-Za-z0-9_-]{1,16}):.*$/);
|
||||
if (extensionSuffixMatch) {
|
||||
next = extensionSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
const basenameSuffixMatch = KNOWN_BASENAME_PATTERN.length > 0
|
||||
? next.match(new RegExp(`^(.*(?:/|^)(${KNOWN_BASENAME_PATTERN})):.*$`, 'i'))
|
||||
: null;
|
||||
if (basenameSuffixMatch) {
|
||||
next = basenameSuffixMatch[1] ?? next;
|
||||
}
|
||||
|
||||
return trimPathCandidate(next);
|
||||
};
|
||||
|
||||
const parseFileReference = (value: string): ParsedFileReference | null => {
|
||||
const trimmed = trimPathCandidate(value);
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const semicolonIndex = trimmed.indexOf(';');
|
||||
const withoutSemicolonSuffix = semicolonIndex >= 0
|
||||
? trimPathCandidate(trimmed.slice(0, semicolonIndex))
|
||||
: trimmed;
|
||||
if (!withoutSemicolonSuffix) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashMatch = withoutSemicolonSuffix.match(/^(.*)#L(\d+)(?:C(\d+))?$/i);
|
||||
if (hashMatch) {
|
||||
const path = stripTrailingReference(hashMatch[1] ?? '');
|
||||
const line = Number.parseInt(hashMatch[2] ?? '', 10);
|
||||
const column = hashMatch[3] ? Number.parseInt(hashMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const colonMatch = withoutSemicolonSuffix.match(/^(.*):(\d+)(?::(\d+))?$/);
|
||||
if (colonMatch) {
|
||||
const path = stripTrailingReference(colonMatch[1] ?? '');
|
||||
const line = Number.parseInt(colonMatch[2] ?? '', 10);
|
||||
const column = colonMatch[3] ? Number.parseInt(colonMatch[3], 10) : undefined;
|
||||
if (!path || !Number.isFinite(line)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
line,
|
||||
column: Number.isFinite(column ?? Number.NaN) ? column : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const pathOnly = stripTrailingReference(withoutSemicolonSuffix);
|
||||
if (!pathOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: pathOnly };
|
||||
};
|
||||
|
||||
const hasFileExtension = (path: string): boolean => {
|
||||
const base = path.split('/').filter(Boolean).pop() ?? '';
|
||||
if (!base || base.endsWith('.')) {
|
||||
return false;
|
||||
}
|
||||
return /\.[A-Za-z0-9_-]{1,16}$/.test(base);
|
||||
};
|
||||
|
||||
const isLikelyFilePathValue = (path: string): boolean => {
|
||||
if (!path || path.startsWith('--') || path.includes('://')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/[<>]/.test(path) || /\s{2,}/.test(path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = normalizePath(path);
|
||||
const baseName = normalized.split('/').filter(Boolean).pop() ?? normalized;
|
||||
if (!baseName || baseName === '.' || baseName === '..') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const base = baseName.toLowerCase();
|
||||
if (KNOWN_FILE_BASENAMES.has(base) || (base.startsWith('.') && base.length > 1)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return hasFileExtension(normalized);
|
||||
};
|
||||
|
||||
const isLikelyFilePath = (value: string): boolean => {
|
||||
const parsed = parseFileReference(value);
|
||||
if (!parsed) {
|
||||
return false;
|
||||
}
|
||||
return isLikelyFilePathValue(parsed.path);
|
||||
};
|
||||
|
||||
const extractPathCandidateFromElement = (element: HTMLElement): string => {
|
||||
if (element.tagName.toLowerCase() === 'a') {
|
||||
const href = element.getAttribute('href')?.trim();
|
||||
if (href && isLikelyFilePath(href)) {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
return (element.textContent || '').trim();
|
||||
};
|
||||
|
||||
const getResolvedReference = (rawValue: string, effectiveDirectory: string): (ParsedFileReference & { resolvedPath: string }) | null => {
|
||||
const parsed = parseFileReference(rawValue);
|
||||
if (!parsed || !isLikelyFilePathValue(parsed.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPath = isAbsolutePath(parsed.path)
|
||||
? normalizePath(parsed.path)
|
||||
: toAbsolutePath(effectiveDirectory, parsed.path);
|
||||
if (!resolvedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...parsed,
|
||||
resolvedPath,
|
||||
};
|
||||
};
|
||||
|
||||
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
|
||||
const normalizedDirectory = normalizePath(effectiveDirectory);
|
||||
if (normalizedDirectory) {
|
||||
return normalizedDirectory;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePath(resolvedPath);
|
||||
const parent = normalizedPath.replace(/\/[^/]*$/, '');
|
||||
return parent || normalizedPath;
|
||||
};
|
||||
|
||||
const useFileReferenceInteractions = ({
|
||||
containerRef,
|
||||
effectiveDirectory,
|
||||
readFile,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
effectiveDirectory: string;
|
||||
readFile?: (path: string) => Promise<{ content: string; path: string }>;
|
||||
}) => {
|
||||
const validationCacheRef = React.useRef<Map<string, boolean>>(new Map());
|
||||
const inFlightValidationsRef = React.useRef<Map<string, Promise<boolean>>>(new Map());
|
||||
const annotationPassRef = React.useRef(0);
|
||||
const annotationDebounceRef = React.useRef<number | null>(null);
|
||||
const isValidationSweepRunningRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let disposed = false;
|
||||
|
||||
const isPathResolvable = async (resolvedPath: string): Promise<boolean> => {
|
||||
const cache = validationCacheRef.current;
|
||||
if (cache.has(resolvedPath)) {
|
||||
return cache.get(resolvedPath) === true;
|
||||
}
|
||||
|
||||
const inFlight = inFlightValidationsRef.current.get(resolvedPath);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
const checkPromise = (async () => {
|
||||
try {
|
||||
if (!readFile) {
|
||||
return false;
|
||||
}
|
||||
await readFile(resolvedPath);
|
||||
cache.set(resolvedPath, true);
|
||||
return true;
|
||||
} catch {
|
||||
cache.set(resolvedPath, false);
|
||||
return false;
|
||||
} finally {
|
||||
inFlightValidationsRef.current.delete(resolvedPath);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightValidationsRef.current.set(resolvedPath, checkPromise);
|
||||
return checkPromise;
|
||||
};
|
||||
|
||||
const clearCandidateLinkAttrs = (candidate: HTMLElement) => {
|
||||
candidate.removeAttribute('data-openchamber-file-link');
|
||||
candidate.removeAttribute('data-openchamber-file-ref');
|
||||
candidate.removeAttribute('data-openchamber-file-path');
|
||||
if (candidate.getAttribute('title') === 'Open file') {
|
||||
candidate.removeAttribute('title');
|
||||
}
|
||||
if (candidate.tagName.toLowerCase() !== 'a') {
|
||||
candidate.removeAttribute('role');
|
||||
candidate.removeAttribute('tabindex');
|
||||
}
|
||||
};
|
||||
|
||||
const applyCandidateLinkAttrs = (candidate: HTMLElement, rawCandidate: string, resolvedPath: string) => {
|
||||
candidate.setAttribute('data-openchamber-file-link', 'true');
|
||||
candidate.setAttribute('data-openchamber-file-ref', rawCandidate);
|
||||
candidate.setAttribute('data-openchamber-file-path', resolvedPath);
|
||||
candidate.setAttribute('title', 'Open file');
|
||||
if (candidate.tagName.toLowerCase() !== 'a') {
|
||||
candidate.setAttribute('role', 'button');
|
||||
candidate.setAttribute('tabindex', '0');
|
||||
}
|
||||
};
|
||||
|
||||
const runValidationSweep = async (paths: string[], expectedPassID: number) => {
|
||||
if (isValidationSweepRunningRef.current || paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
isValidationSweepRunningRef.current = true;
|
||||
const maxConcurrent = 3;
|
||||
let cursor = 0;
|
||||
|
||||
const worker = async () => {
|
||||
while (!disposed && cursor < paths.length) {
|
||||
const index = cursor;
|
||||
cursor += 1;
|
||||
const pathToCheck = paths[index];
|
||||
if (!pathToCheck) {
|
||||
continue;
|
||||
}
|
||||
await isPathResolvable(pathToCheck);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await Promise.all(Array.from({ length: Math.min(maxConcurrent, paths.length) }, () => worker()));
|
||||
} finally {
|
||||
isValidationSweepRunningRef.current = false;
|
||||
}
|
||||
|
||||
if (!disposed && annotationPassRef.current === expectedPassID) {
|
||||
void annotateFileLinks();
|
||||
}
|
||||
};
|
||||
|
||||
const annotateFileLinks = async () => {
|
||||
const passID = annotationPassRef.current + 1;
|
||||
annotationPassRef.current = passID;
|
||||
const candidates = container.querySelectorAll<HTMLElement>('[data-streamdown="inline-code"], a');
|
||||
const unresolvedPaths = new Set<string>();
|
||||
|
||||
for (const candidate of Array.from(candidates)) {
|
||||
const rawCandidate = extractPathCandidateFromElement(candidate);
|
||||
const resolved = getResolvedReference(rawCandidate, effectiveDirectory);
|
||||
if (!resolved) {
|
||||
clearCandidateLinkAttrs(candidate);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (annotationPassRef.current !== passID) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedResult = validationCacheRef.current.get(resolved.resolvedPath);
|
||||
if (cachedResult === true) {
|
||||
applyCandidateLinkAttrs(candidate, rawCandidate, resolved.resolvedPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
clearCandidateLinkAttrs(candidate);
|
||||
if (cachedResult !== false) {
|
||||
unresolvedPaths.add(resolved.resolvedPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (unresolvedPaths.size > 0) {
|
||||
void runValidationSweep(Array.from(unresolvedPaths), passID);
|
||||
}
|
||||
};
|
||||
|
||||
const openFileReference = async (sourceElement: HTMLElement): Promise<boolean> => {
|
||||
const raw = sourceElement.getAttribute('data-openchamber-file-ref') || extractPathCandidateFromElement(sourceElement);
|
||||
const resolved = getResolvedReference(raw, effectiveDirectory);
|
||||
if (!resolved) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isResolvable = await isPathResolvable(resolved.resolvedPath);
|
||||
if (!isResolvable) {
|
||||
sourceElement.removeAttribute('data-openchamber-file-link');
|
||||
sourceElement.removeAttribute('data-openchamber-file-ref');
|
||||
sourceElement.removeAttribute('data-openchamber-file-path');
|
||||
if (sourceElement.getAttribute('title') === 'Open file') {
|
||||
sourceElement.removeAttribute('title');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const contextDirectory = getContextDirectory(effectiveDirectory, resolved.resolvedPath);
|
||||
const uiStore = useUIStore.getState();
|
||||
if (Number.isFinite(resolved.line ?? Number.NaN)) {
|
||||
uiStore.openContextFileAtLine(
|
||||
contextDirectory,
|
||||
resolved.resolvedPath,
|
||||
Math.max(1, Math.trunc(resolved.line as number)),
|
||||
Number.isFinite(resolved.column ?? Number.NaN)
|
||||
? Math.max(1, Math.trunc(resolved.column as number))
|
||||
: 1,
|
||||
);
|
||||
} else {
|
||||
uiStore.openContextFile(contextDirectory, resolved.resolvedPath);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fileRefElement = target.closest(FILE_LINK_SELECTOR);
|
||||
if (!(fileRefElement instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
void openFileReference(fileRefElement);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.target;
|
||||
if (!(target instanceof HTMLElement) || target.getAttribute('data-openchamber-file-link') !== 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
void openFileReference(target);
|
||||
};
|
||||
|
||||
void annotateFileLinks();
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(annotationDebounceRef.current);
|
||||
}
|
||||
if (typeof window === 'undefined') {
|
||||
void annotateFileLinks();
|
||||
return;
|
||||
}
|
||||
annotationDebounceRef.current = window.setTimeout(() => {
|
||||
annotationDebounceRef.current = null;
|
||||
void annotateFileLinks();
|
||||
}, 120);
|
||||
});
|
||||
observer.observe(container, { childList: true, subtree: true, characterData: true });
|
||||
|
||||
container.addEventListener('click', handleClick);
|
||||
container.addEventListener('keydown', handleKeyDown);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
annotationPassRef.current += 1;
|
||||
if (annotationDebounceRef.current !== null && typeof window !== 'undefined') {
|
||||
window.clearTimeout(annotationDebounceRef.current);
|
||||
}
|
||||
annotationDebounceRef.current = null;
|
||||
observer.disconnect();
|
||||
container.removeEventListener('click', handleClick);
|
||||
container.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [containerRef, effectiveDirectory, readFile]);
|
||||
};
|
||||
|
||||
const useMermaidInlineInteractions = ({
|
||||
containerRef,
|
||||
@@ -786,9 +1312,12 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
|
||||
variant = 'assistant',
|
||||
onShowPopup,
|
||||
}) => {
|
||||
const { files } = useRuntimeAPIs();
|
||||
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(content), [content]);
|
||||
useMermaidInlineInteractions({ containerRef: streamdownContainerRef, mermaidBlocks, onShowPopup });
|
||||
useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile });
|
||||
|
||||
const shikiThemes = useMarkdownShikiThemes();
|
||||
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
|
||||
@@ -844,11 +1373,13 @@ export const SimpleMarkdownRenderer: React.FC<{
|
||||
onShowPopup,
|
||||
allowMermaidWheelZoom = false,
|
||||
}) => {
|
||||
const { files } = useRuntimeAPIs();
|
||||
const renderedContent = React.useMemo(
|
||||
() => (stripFrontmatter ? stripLeadingFrontmatter(content) : content),
|
||||
[content, stripFrontmatter],
|
||||
);
|
||||
const streamdownContainerRef = React.useRef<HTMLDivElement>(null);
|
||||
const effectiveDirectory = useEffectiveDirectory() ?? '';
|
||||
const mermaidBlocks = React.useMemo(() => extractMermaidBlocks(renderedContent), [renderedContent]);
|
||||
useMermaidInlineInteractions({
|
||||
containerRef: streamdownContainerRef,
|
||||
@@ -856,6 +1387,7 @@ export const SimpleMarkdownRenderer: React.FC<{
|
||||
onShowPopup,
|
||||
allowWheelZoom: allowMermaidWheelZoom,
|
||||
});
|
||||
useFileReferenceInteractions({ containerRef: streamdownContainerRef, effectiveDirectory, readFile: files.readFile });
|
||||
|
||||
const shikiThemes = useMarkdownShikiThemes();
|
||||
const streamdownPlugins = useStreamdownPlugins(shikiThemes);
|
||||
|
||||
Reference in New Issue
Block a user