feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
460
dexto/packages/webui/components/ui/markdown-text.tsx
Normal file
460
dexto/packages/webui/components/ui/markdown-text.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import { memo, useState } from 'react';
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
||||
|
||||
import { TooltipIconButton } from '@/components/ui/tooltip-icon-button';
|
||||
|
||||
// Helper functions for media validation (copied from MessageList to avoid circular imports)
|
||||
function isValidDataUri(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean {
|
||||
const typePattern = expectedType ? `${expectedType}/` : '[a-z0-9.+-]+/';
|
||||
const dataUriRegex = new RegExp(
|
||||
`^data:${typePattern}[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}$`,
|
||||
'i'
|
||||
);
|
||||
return dataUriRegex.test(src);
|
||||
}
|
||||
|
||||
function isSafeHttpUrl(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
|
||||
// Check protocol
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block localhost and common local names
|
||||
if (hostname === 'localhost' || hostname === '::1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for IPv4 addresses
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const ipv4Match = hostname.match(ipv4Regex);
|
||||
if (ipv4Match) {
|
||||
const [, a, b, c, d] = ipv4Match.map(Number);
|
||||
|
||||
// Validate IP range (0-255)
|
||||
if (a > 255 || b > 255 || c > 255 || d > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block loopback (127.0.0.0/8)
|
||||
if (a === 127) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block private networks (RFC 1918)
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block link-local (169.254.0.0/16)
|
||||
if (a === 169 && b === 254) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block 0.0.0.0
|
||||
if (a === 0 && b === 0 && c === 0 && d === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for IPv6 addresses
|
||||
if (hostname.includes(':')) {
|
||||
// Block IPv6 loopback
|
||||
if (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block IPv6 unique-local (fc00::/7)
|
||||
if (hostname.startsWith('fc') || hostname.startsWith('fd')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block IPv6 link-local (fe80::/10)
|
||||
if (
|
||||
hostname.startsWith('fe8') ||
|
||||
hostname.startsWith('fe9') ||
|
||||
hostname.startsWith('fea') ||
|
||||
hostname.startsWith('feb')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeMediaUrl(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean {
|
||||
if (src.startsWith('blob:') || isSafeHttpUrl(src)) return true;
|
||||
if (src.startsWith('data:')) {
|
||||
return expectedType ? isValidDataUri(src, expectedType) : isValidDataUri(src);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVideoUrl(url: string): boolean {
|
||||
// Check for video file extensions
|
||||
if (url.match(/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i)) {
|
||||
return true;
|
||||
}
|
||||
// Check for video MIME types in URL or common video hosting patterns
|
||||
if (url.includes('/video/') || url.includes('video_')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAudioUrl(url: string): boolean {
|
||||
// Check for audio file extensions
|
||||
if (url.match(/\.(mp3|wav|ogg|m4a|aac|flac|wma)(\?.*)?$/i)) {
|
||||
return true;
|
||||
}
|
||||
// Check for audio patterns in URL
|
||||
if (url.includes('/audio/') || url.includes('audio_')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-linkify plain text URLs
|
||||
function linkifyText(text: string): React.ReactNode {
|
||||
// URL regex that matches http(s) URLs
|
||||
const urlRegex = /(https?:\/\/[^\s<]+)/g;
|
||||
const parts = text.split(urlRegex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (part.match(urlRegex)) {
|
||||
// Validate URL safety before rendering as link
|
||||
if (!isSafeHttpUrl(part)) {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="text-muted-foreground underline decoration-dotted"
|
||||
title="Unsafe URL"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline-offset-2 hover:underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium break-all overflow-wrap-anywhere max-w-full inline"
|
||||
title={part}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
// Code block component with copy functionality
|
||||
const CodeBlock = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const text = String(children ?? '').replace(/\n$/, '');
|
||||
const isInline = !className;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono break-all overflow-wrap-anywhere"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group my-4 min-w-0 max-w-full">
|
||||
<TooltipIconButton
|
||||
tooltip={copied ? 'Copied!' : 'Copy code'}
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
className="absolute right-2 top-2 z-10 opacity-70 hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
|
||||
>
|
||||
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
|
||||
</TooltipIconButton>
|
||||
<pre className="overflow-auto bg-muted p-3 rounded-lg text-sm max-w-full">
|
||||
<code className={className}>{text}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Enhanced markdown component with proper emoji support and spacing
|
||||
const MarkdownTextImpl = ({ children }: { children: string }) => {
|
||||
const blobUrlsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
// Capture the current ref value for cleanup
|
||||
const blobUrls = blobUrlsRef.current;
|
||||
return () => {
|
||||
// Clean up any created blob URLs on unmount
|
||||
blobUrls.forEach((url) => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Silently fail if revoke fails
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="prose max-w-none dark:prose-invert min-w-0 overflow-hidden break-words overflow-wrap-anywhere [&>p]:my-5 [&>p]:leading-7 [&>p]:first:mt-0 [&>p]:last:mb-0 [&>p]:break-words [&>p]:overflow-wrap-anywhere [&>h1]:mb-8 [&>h1]:text-4xl [&>h1]:font-extrabold [&>h1]:tracking-tight [&>h1]:last:mb-0 [&>h1]:break-words [&>h2]:mb-4 [&>h2]:mt-8 [&>h2]:text-3xl [&>h2]:font-semibold [&>h2]:tracking-tight [&>h2]:first:mt-0 [&>h2]:last:mb-0 [&>h2]:break-words [&>h3]:mb-4 [&>h3]:mt-6 [&>h3]:text-2xl [&>h3]:font-semibold [&>h3]:tracking-tight [&>h3]:first:mt-0 [&>h3]:last:mb-0 [&>h3]:break-words [&>h4]:mb-4 [&>h4]:mt-6 [&>h4]:text-xl [&>h4]:font-semibold [&>h4]:tracking-tight [&>h4]:first:mt-0 [&>h4]:last:mb-0 [&>h4]:break-words [&>ul]:my-5 [&>ul]:ml-6 [&>ul]:list-disc [&>ul>li]:mt-2 [&>ol]:my-5 [&>ol]:ml-6 [&>ol]:list-decimal [&>ol>li]:mt-2 [&_ul]:my-5 [&_ul]:ml-6 [&_ul]:list-disc [&_ul>li]:mt-2 [&_ol]:my-5 [&_ol]:ml-6 [&_ol]:list-decimal [&_ol>li]:mt-2 [&>blockquote]:border-l-2 [&>blockquote]:pl-6 [&>blockquote]:italic [&>blockquote]:break-words [&>hr]:my-5 [&>hr]:border-b">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
skipHtml={true}
|
||||
components={{
|
||||
a: ({ href, children, ...props }) => {
|
||||
const url = (href as string | undefined) ?? '';
|
||||
const isHttp = /^https?:\/\//i.test(url);
|
||||
const isAllowed = isHttp; // extend if you want: || url.startsWith('mailto:') || url.startsWith('tel:')
|
||||
if (!isAllowed || !isSafeHttpUrl(url)) {
|
||||
return (
|
||||
<span
|
||||
className="text-muted-foreground underline decoration-dotted"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Regular link rendering with better overflow handling (no truncation)
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline-offset-2 hover:underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium break-all overflow-wrap-anywhere max-w-full inline"
|
||||
title={url}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt, ...props }) => {
|
||||
if (!src) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
No media source provided
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Blob sources - validate and convert to string URL
|
||||
let srcString: string | null = null;
|
||||
|
||||
if (typeof src === 'string') {
|
||||
srcString = src;
|
||||
} else if ((src as any) instanceof Blob || (src as any) instanceof File) {
|
||||
// Safe to convert Blob or File to object URL
|
||||
try {
|
||||
const objectUrl = URL.createObjectURL(src as Blob | File);
|
||||
srcString = objectUrl;
|
||||
// Track the URL for cleanup
|
||||
blobUrlsRef.current.add(objectUrl);
|
||||
} catch {
|
||||
// URL.createObjectURL failed, treat as invalid
|
||||
srcString = null;
|
||||
}
|
||||
} else if (
|
||||
typeof src === 'object' &&
|
||||
src !== null &&
|
||||
(src as any) instanceof MediaSource
|
||||
) {
|
||||
// MediaSource objects can also be used with createObjectURL
|
||||
try {
|
||||
const objectUrl = URL.createObjectURL(
|
||||
src as unknown as MediaSource
|
||||
);
|
||||
srcString = objectUrl;
|
||||
// Track the URL for cleanup
|
||||
blobUrlsRef.current.add(objectUrl);
|
||||
} catch {
|
||||
// URL.createObjectURL failed, treat as invalid
|
||||
srcString = null;
|
||||
}
|
||||
} else {
|
||||
// Invalid or unsafe type - not a string, Blob, File, or MediaSource
|
||||
srcString = null;
|
||||
}
|
||||
|
||||
// If we couldn't get a valid source string, show error
|
||||
if (!srcString) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Invalid or unsafe media source
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a video URL - render video player
|
||||
if (isVideoUrl(srcString) && isSafeMediaUrl(srcString, 'video')) {
|
||||
return (
|
||||
<div className="my-4 max-w-full overflow-hidden">
|
||||
<video
|
||||
controls
|
||||
src={srcString}
|
||||
className="w-full max-h-[360px] rounded-lg bg-black"
|
||||
preload="metadata"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{alt && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{alt}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is an audio URL - render audio player
|
||||
if (isAudioUrl(srcString) && isSafeMediaUrl(srcString, 'audio')) {
|
||||
return (
|
||||
<div className="my-4 max-w-full overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border bg-muted/30">
|
||||
<audio
|
||||
controls
|
||||
src={srcString}
|
||||
className="flex-1 min-w-0 h-10"
|
||||
>
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
</div>
|
||||
{alt && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{alt}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to image rendering
|
||||
if (!isSafeMediaUrl(srcString, 'image')) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Invalid or unsafe media source
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={srcString}
|
||||
alt={alt || 'Image'}
|
||||
className="max-w-full max-h-[500px] object-contain rounded-lg border border-border my-4"
|
||||
loading="lazy"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
p: ({ children, ...props }) => {
|
||||
// Auto-linkify plain text URLs in paragraphs
|
||||
if (typeof children === 'string') {
|
||||
return <p {...props}>{linkifyText(children)}</p>;
|
||||
}
|
||||
return <p {...props}>{children}</p>;
|
||||
},
|
||||
table: ({ className, children, ...props }) => (
|
||||
<div className="my-4 overflow-x-auto -mx-1 px-1">
|
||||
<table
|
||||
className={['w-full border-separate border-spacing-0', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ className, ...props }) => <thead className={className} {...props} />,
|
||||
tr: ({ className, ...props }) => (
|
||||
<tr
|
||||
className={[
|
||||
'm-0 border-b first:border-t',
|
||||
'[&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ className, ...props }) => (
|
||||
<th
|
||||
className={[
|
||||
'bg-muted text-left font-bold align-top',
|
||||
'px-4 py-2 first:rounded-tl-lg last:rounded-tr-lg',
|
||||
'[&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ className, ...props }) => (
|
||||
<td
|
||||
className={[
|
||||
'border-b border-l last:border-r text-left align-top',
|
||||
'px-4 py-2 whitespace-normal break-words',
|
||||
'[&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: CodeBlock,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownText = memo(MarkdownTextImpl);
|
||||
Reference in New Issue
Block a user