feat: 增强聊天功能和 markdown 渲染

- 优化聊天 UI 组件交互体验
- 扩展 markdown 渲染功能支持
- 更新类型定义
- 重构 LLM 聊天处理逻辑
- 更新模型提供商种子数据
This commit is contained in:
freedakgmail
2026-06-23 00:46:08 +08:00
parent 95dee5a70e
commit d31bdd1261
5 changed files with 424 additions and 160 deletions
+12 -3
View File
@@ -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 && (
+45 -15
View File
@@ -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;
+8
View File
@@ -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;