SearchImagesGrid (新增): - 瀑布流布局展示搜索图片 - 智能动态回填机制,自动替换加载失败的图片 - 支持图片灯箱预览 - 显示图片来源链接 MessageBubble: - 添加工具使用提示栏,显示本次对话使用的工具 - 集成 SearchImagesGrid 展示图片搜索结果 - 支持 Markdown 中图片链接在灯箱中打开 MarkdownRenderer: - 添加图片链接点击回调支持 - 识别并处理图片URL链接
263 lines
6.9 KiB
TypeScript
263 lines
6.9 KiB
TypeScript
'use client';
|
|
|
|
import { memo, useMemo } from 'react';
|
|
import ReactMarkdown from 'react-markdown';
|
|
import remarkGfm from 'remark-gfm';
|
|
import { CodeBlock } from './CodeBlock';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
interface MarkdownRendererProps {
|
|
content: string;
|
|
className?: string;
|
|
/** 图片链接点击回调,用于在灯箱中打开图片 */
|
|
onImageLinkClick?: (url: string) => void;
|
|
}
|
|
|
|
/**
|
|
* 判断 URL 是否为图片链接
|
|
*/
|
|
function isImageUrl(url: string): boolean {
|
|
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'];
|
|
const urlLower = url.toLowerCase();
|
|
// 检查扩展名(忽略查询参数)
|
|
const urlWithoutQuery = urlLower.split('?')[0];
|
|
return imageExtensions.some(ext => urlWithoutQuery.endsWith(ext));
|
|
}
|
|
|
|
/**
|
|
* 创建 Markdown 组件配置
|
|
* 使用工厂函数以支持传入回调
|
|
*/
|
|
function createMarkdownComponents(onImageLinkClick?: (url: string) => void) {
|
|
return {
|
|
// 代码块
|
|
code({ className, children, ...props }: { className?: string; children?: React.ReactNode }) {
|
|
const match = /language-(\w+)/.exec(className || '');
|
|
const isInline = !match && !className;
|
|
|
|
if (isInline) {
|
|
// 行内代码 - 无特殊样式,仅等宽字体
|
|
return (
|
|
<code
|
|
className="font-mono"
|
|
{...props}
|
|
>
|
|
{children}
|
|
</code>
|
|
);
|
|
}
|
|
|
|
// 代码块
|
|
return (
|
|
<CodeBlock
|
|
code={String(children).replace(/\n$/, '')}
|
|
language={match ? match[1] : 'text'}
|
|
/>
|
|
);
|
|
},
|
|
|
|
// 段落
|
|
p({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<p className="mb-3 last:mb-0 leading-[1.7]">
|
|
{children}
|
|
</p>
|
|
);
|
|
},
|
|
|
|
// 标题 - 使用相对单位保持与全局字体的比例
|
|
h1({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<h1 className="text-[1.25em] font-bold mt-5 mb-3 text-[var(--color-text-primary)]">
|
|
{children}
|
|
</h1>
|
|
);
|
|
},
|
|
h2({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<h2 className="text-[1.125em] font-bold mt-4 mb-2 text-[var(--color-text-primary)]">
|
|
{children}
|
|
</h2>
|
|
);
|
|
},
|
|
h3({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<h3 className="text-[1em] font-semibold mt-3 mb-2 text-[var(--color-text-primary)]">
|
|
{children}
|
|
</h3>
|
|
);
|
|
},
|
|
h4({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<h4 className="text-[1em] font-semibold mt-2 mb-1 text-[var(--color-text-primary)]">
|
|
{children}
|
|
</h4>
|
|
);
|
|
},
|
|
|
|
// 列表
|
|
ul({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<ul className="list-disc pl-5 my-2 space-y-1 marker:text-[#E06B3E]">
|
|
{children}
|
|
</ul>
|
|
);
|
|
},
|
|
ol({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<ol className="list-decimal pl-5 my-2 space-y-1 marker:text-[#E06B3E]">
|
|
{children}
|
|
</ol>
|
|
);
|
|
},
|
|
li({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<li className="leading-relaxed">
|
|
{children}
|
|
</li>
|
|
);
|
|
},
|
|
|
|
// 链接 - 支持图片链接在灯箱中打开
|
|
a({ href, children }: { href?: string; children?: React.ReactNode }) {
|
|
// 如果是图片链接且有回调,则拦截点击事件
|
|
if (href && onImageLinkClick && isImageUrl(href)) {
|
|
return (
|
|
<a
|
|
href={href}
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
onImageLinkClick(href);
|
|
}}
|
|
className="text-[var(--color-primary)] hover:underline cursor-pointer"
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
}
|
|
// 非图片链接保持原有行为
|
|
return (
|
|
<a
|
|
href={href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-[var(--color-primary)] hover:underline"
|
|
>
|
|
{children}
|
|
</a>
|
|
);
|
|
},
|
|
|
|
// 粗体
|
|
strong({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<strong className="font-semibold">
|
|
{children}
|
|
</strong>
|
|
);
|
|
},
|
|
|
|
// 斜体
|
|
em({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<em className="italic">
|
|
{children}
|
|
</em>
|
|
);
|
|
},
|
|
|
|
// 引用
|
|
blockquote({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<blockquote className="border-l-3 border-[var(--color-primary)] pl-3 my-3 italic text-[var(--color-text-secondary)]">
|
|
{children}
|
|
</blockquote>
|
|
);
|
|
},
|
|
|
|
// 分割线
|
|
hr() {
|
|
return (
|
|
<hr className="my-4 border-[var(--color-border)]" />
|
|
);
|
|
},
|
|
|
|
// 表格
|
|
table({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<div className="overflow-x-auto my-3">
|
|
<table className="min-w-full border border-[var(--color-border)] rounded-lg overflow-hidden text-[0.9em]">
|
|
{children}
|
|
</table>
|
|
</div>
|
|
);
|
|
},
|
|
thead({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<thead className="bg-[var(--color-bg-tertiary)]">
|
|
{children}
|
|
</thead>
|
|
);
|
|
},
|
|
tbody({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<tbody className="divide-y divide-[var(--color-border)]">
|
|
{children}
|
|
</tbody>
|
|
);
|
|
},
|
|
tr({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<tr className="hover:bg-[var(--color-bg-hover)] transition-colors">
|
|
{children}
|
|
</tr>
|
|
);
|
|
},
|
|
th({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<th className="px-3 py-1.5 text-left text-[0.85em] font-semibold text-[var(--color-text-primary)]">
|
|
{children}
|
|
</th>
|
|
);
|
|
},
|
|
td({ children }: { children?: React.ReactNode }) {
|
|
return (
|
|
<td className="px-3 py-1.5 text-[0.85em] text-[var(--color-text-secondary)]">
|
|
{children}
|
|
</td>
|
|
);
|
|
},
|
|
|
|
// 图片
|
|
img(props: React.ImgHTMLAttributes<HTMLImageElement>) {
|
|
return (
|
|
<img
|
|
{...props}
|
|
alt={props.alt || ''}
|
|
className="max-w-full h-auto rounded-lg my-3"
|
|
/>
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
// 使用 memo 包裹组件,避免不必要的重渲染
|
|
export const MarkdownRenderer = memo(function MarkdownRenderer({ content, className, onImageLinkClick }: MarkdownRendererProps) {
|
|
// 使用 useMemo 缓存 components 配置,仅在 onImageLinkClick 变化时重新创建
|
|
const components = useMemo(
|
|
() => createMarkdownComponents(onImageLinkClick),
|
|
[onImageLinkClick]
|
|
);
|
|
|
|
return (
|
|
<div className={cn('markdown-content', className)}>
|
|
<ReactMarkdown
|
|
remarkPlugins={[remarkGfm]}
|
|
components={components}
|
|
>
|
|
{content}
|
|
</ReactMarkdown>
|
|
</div>
|
|
);
|
|
});
|