feat: 增强聊天功能和 markdown 渲染
- 优化聊天 UI 组件交互体验 - 扩展 markdown 渲染功能支持 - 更新类型定义 - 重构 LLM 聊天处理逻辑 - 更新模型提供商种子数据
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useState, useRef, useEffect, useCallback, memo } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { App, Conversation, Message } from "@/lib/types";
|
||||
import type { App, Conversation, Message, Chunk } from "@/lib/types";
|
||||
import api, { streamChat } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
@@ -43,9 +43,11 @@ import { useAuthStore } from "@/stores/auth";
|
||||
const ChatMessage = memo(function ChatMessage({
|
||||
msg,
|
||||
onCopy,
|
||||
chunks,
|
||||
}: {
|
||||
msg: Message;
|
||||
onCopy: (text: string) => void;
|
||||
chunks: Chunk[];
|
||||
}) {
|
||||
if (msg.role === "user") {
|
||||
return (
|
||||
@@ -70,7 +72,7 @@ const ChatMessage = memo(function ChatMessage({
|
||||
<div className="flex justify-start group/msg">
|
||||
<div className="max-w-[85%]">
|
||||
<div className="rounded-2xl px-5 py-3 bg-white dark:bg-card border border-border/50 rounded-bl-md shadow-sm">
|
||||
<GovMarkdown content={msg.content} />
|
||||
<GovMarkdown content={msg.content} chunks={chunks} />
|
||||
</div>
|
||||
{msg.content && (
|
||||
<div className="flex mt-1 opacity-0 group-hover/msg:opacity-100 transition-opacity">
|
||||
@@ -112,6 +114,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [chunks, setChunks] = useState<Chunk[]>([]);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
@@ -269,6 +272,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
setInput("");
|
||||
setFileContent(null);
|
||||
setFileName(null);
|
||||
setChunks([]);
|
||||
setIsStreaming(true);
|
||||
|
||||
const controller = new AbortController();
|
||||
@@ -302,6 +306,11 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
const event = JSON.parse(raw);
|
||||
if (event.conversation_id)
|
||||
setConversationId(event.conversation_id);
|
||||
// 解析首包的 chunks 映射表
|
||||
if (event.chunks) {
|
||||
console.log("[SSE] Received chunks:", event.chunks);
|
||||
setChunks(event.chunks as Chunk[]);
|
||||
}
|
||||
if (event.answer) {
|
||||
accumulated += event.answer;
|
||||
const snap = accumulated;
|
||||
@@ -687,7 +696,7 @@ export default function ChatbotUI({ app }: ChatbotUIProps) {
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
{messages.map((msg) => (
|
||||
<ChatMessage key={msg.id} msg={msg} onCopy={copyText} />
|
||||
<ChatMessage key={msg.id} msg={msg} onCopy={copyText} chunks={chunks} />
|
||||
))}
|
||||
|
||||
{messages.length <= 1 && suggestedPrompts.length > 0 && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm";
|
||||
import type { Components } from "react-markdown";
|
||||
import { BookOpen, BrainCircuit, ArrowRight } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Chunk } from "@/lib/types";
|
||||
|
||||
const mdComponents: Components = {
|
||||
h1: ({ children }) => (
|
||||
@@ -153,6 +154,7 @@ function AppLinkBadge({ slug, name }: { slug: string; name: string }) {
|
||||
interface GovMarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
chunks?: Chunk[];
|
||||
}
|
||||
|
||||
function stripOuterCodeFence(text: string): string {
|
||||
@@ -164,22 +166,50 @@ function stripOuterCodeFence(text: string): string {
|
||||
|
||||
/**
|
||||
* 预处理 markdown 内容:将来源标注转为特殊链接格式,由 ReactMarkdown 的 a 组件拦截渲染
|
||||
*
|
||||
* 支持的标注格式:
|
||||
* - [[chunk:N]] → 知识库引用(使用 chunks 映射表解析为文档名)
|
||||
* - [[知识库:文档名]] → 知识库引用徽章
|
||||
* - [[AI建议]] → AI建议徽章
|
||||
* - [[推荐应用:名称:slug]] → 可点击跳转链接
|
||||
*/
|
||||
function preprocessCitations(content: string): string {
|
||||
return content
|
||||
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
||||
.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`)
|
||||
// AI建议:标准格式 [[AI建议]]
|
||||
.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)")
|
||||
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
||||
.replace(/^(\s*>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)")
|
||||
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
||||
.replace(/^(\s*>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)")
|
||||
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
||||
.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`);
|
||||
function preprocessCitations(content: string, chunks?: Chunk[]): string {
|
||||
let result = content;
|
||||
console.log("[preprocessCitations] chunks:", chunks);
|
||||
|
||||
// 先处理 chunk 编号引用 [[chunk:N]]
|
||||
result = result.replace(/\[\[chunk:(\d+)\]\]/g, (_, idx) => {
|
||||
const i = parseInt(idx, 10);
|
||||
if (chunks && chunks[i]) {
|
||||
// 使用 chunks 映射表,转换为带文档名的知识库引用
|
||||
const docName = chunks[i].doc_name || chunks[i].content?.slice(0, 20) || "知识库片段";
|
||||
console.log(`[preprocessCitations] chunk[${i}]:`, docName);
|
||||
return `[知识库:${docName}](#cite-kb)`;
|
||||
}
|
||||
// 无映射时显示后备文本
|
||||
console.log(`[preprocessCitations] chunk[${i}] not found, chunks length:`, chunks?.length);
|
||||
return `[知识库片段](#cite-kb)`;
|
||||
});
|
||||
|
||||
// 知识库引用:[[知识库:文献名称]] 或 [[知识库:文献名称:条款]]
|
||||
result = result.replace(/\[\[(知识库:[^\]]+)\]\]/g, (_, label) => `[${label}](#cite-kb)`);
|
||||
|
||||
// AI建议:标准格式 [[AI建议]]
|
||||
result = result.replace(/\[\[AI建议\]\]/g, "[AI建议](#cite-ai)");
|
||||
|
||||
// AI建议:来源说明块中的 **AI建议:** 或 **AI建议** 标题(仅匹配行首或 > 后)
|
||||
result = result.replace(/^(\s*\>?\s*)\*\*AI建议[::]\*\*/gm, "$1[AI建议](#cite-ai)");
|
||||
|
||||
// AI建议:无加粗的 AI建议: 标题行(仅匹配行首或 > 后)
|
||||
result = result.replace(/^(\s*\>?\s*)AI建议[::]\s*$/gm, "$1[AI建议](#cite-ai)");
|
||||
|
||||
// 推荐应用:[[推荐应用:应用名称:slug]] → 可点击跳转链接
|
||||
result = result.replace(/\[\[推荐应用:([^:]+):([^\]]+)\]\]/g, (_, name, slug) => `[${name}](#cite-app:${slug})`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdownProps) {
|
||||
const GovMarkdown = memo(function GovMarkdown({ content, className, chunks }: GovMarkdownProps) {
|
||||
if (!content) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground py-1">
|
||||
@@ -188,7 +218,7 @@ const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdow
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const cleaned = preprocessCitations(stripOuterCodeFence(content));
|
||||
const cleaned = preprocessCitations(stripOuterCodeFence(content), chunks);
|
||||
return (
|
||||
<div className={`gov-markdown ${className || ""}`}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
@@ -198,4 +228,4 @@ const GovMarkdown = memo(function GovMarkdown({ content, className }: GovMarkdow
|
||||
);
|
||||
});
|
||||
|
||||
export default GovMarkdown;
|
||||
export default GovMarkdown;
|
||||
@@ -93,6 +93,14 @@ export interface Message {
|
||||
content: string;
|
||||
}
|
||||
|
||||
// 知识库片段映射,来自 SSE 首包
|
||||
export interface Chunk {
|
||||
id: string;
|
||||
doc_name: string;
|
||||
content: string;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
export interface DeleteTarget {
|
||||
type: "single" | "batch";
|
||||
id?: string;
|
||||
|
||||
Reference in New Issue
Block a user