feat: Introduce Markdown processing for the AI proofreading function, use Streamdown for rendering,add Alibaba Bold font

This commit is contained in:
JOYCEQL
2026-02-27 13:43:46 +08:00
parent 1884bd851e
commit 2bc2664593
7 changed files with 1103 additions and 30 deletions
+4
View File
@@ -59,6 +59,7 @@
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lucide-react": "^0.379.0", "lucide-react": "^0.379.0",
"mark.js": "^8.11.1", "mark.js": "^8.11.1",
"markdown-exit": "1.0.0-beta.8",
"next-themes": "^0.4.3", "next-themes": "^0.4.3",
"puppeteer": "^23.9.0", "puppeteer": "^23.9.0",
"puppeteer-core": "^23.9.0", "puppeteer-core": "^23.9.0",
@@ -69,8 +70,10 @@
"react-resizable-panels": "^2.0.20", "react-resizable-panels": "^2.0.20",
"sharp": "^0.33.5", "sharp": "^0.33.5",
"sonner": "^1.7.1", "sonner": "^1.7.1",
"streamdown": "^2.3.0",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.2",
"uuid": "^11.0.5", "uuid": "^11.0.5",
"vaul": "^1.1.1", "vaul": "^1.1.1",
"zustand": "^4.5.4" "zustand": "^4.5.4"
@@ -80,6 +83,7 @@
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^18", "@types/react": "^18",
"@types/react-dom": "^18", "@types/react-dom": "^18",
"@types/turndown": "^5.0.6",
"@vitejs/plugin-react": "^5.1.4", "@vitejs/plugin-react": "^5.1.4",
"eslint": "^8", "eslint": "^8",
"postcss": "^8", "postcss": "^8",
+1038 -4
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+8
View File
@@ -5,3 +5,11 @@
font-style: normal; font-style: normal;
font-display: swap; font-display: swap;
} }
@font-face {
font-family: "Alibaba PuHuiTi";
src: url("/fonts/AlibabaPuHuiTi-3-85-Bold.ttf") format("truetype");
font-weight: bold;
font-style: normal;
font-display: swap;
}
+48 -22
View File
@@ -2,6 +2,10 @@ import { useEffect, useState, useRef } from "react";
import { Loader2, Sparkles } from "lucide-react"; import { Loader2, Sparkles } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslations } from "@/i18n/compat/client"; import { useTranslations } from "@/i18n/compat/client";
import { Streamdown } from "streamdown";
import "streamdown/styles.css";
import { createMarkdownExit } from "markdown-exit";
import TurndownService from "turndown";
import { import {
Dialog, Dialog,
@@ -23,6 +27,19 @@ interface AIPolishDialogProps {
onApply: (content: string) => void; onApply: (content: string) => void;
} }
// markdown-exit 实例,用于将 AI 返回的 Markdown 转换为 Tiptap 兼容的 HTML
const md = createMarkdownExit({
html: true, // 允许 HTML 标签透传
breaks: true, // 将换行符转换为 <br>
linkify: false, // 简历内容不需要自动识别链接
});
// turndown 实例,用于将 Tiptap HTML 转换为 Markdown 发给 AI
const turndownService = new TurndownService({
headingStyle: "atx",
bulletListMarker: "-",
});
export default function AIPolishDialog({ export default function AIPolishDialog({
open, open,
onOpenChange, onOpenChange,
@@ -66,14 +83,14 @@ export default function AIPolishDialog({
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify({ body: JSON.stringify({
content, content: turndownService.turndown(content),
apiKey: selectedModel === "doubao" ? doubaoApiKey : selectedModel === "openai" ? openaiApiKey : deepseekApiKey, apiKey: selectedModel === "doubao" ? doubaoApiKey : selectedModel === "openai" ? openaiApiKey : deepseekApiKey,
apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined, apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined,
model: model:
selectedModel === "doubao" selectedModel === "doubao"
? doubaoModelId ? doubaoModelId
: selectedModel === "openai" ? openaiModelId : selectedModel === "openai" ? openaiModelId
: config.requiresModelId ? deepseekModelId : deepseekApiKey, : config.requiresModelId ? deepseekModelId : deepseekApiKey,
modelType: selectedModel modelType: selectedModel
}), }),
signal: abortControllerRef.current.signal signal: abortControllerRef.current.signal
@@ -95,16 +112,7 @@ export default function AIPolishDialog({
if (done) break; if (done) break;
const chunk = decoder.decode(value); const chunk = decoder.decode(value);
setPolishedContent((prev) => { setPolishedContent((prev) => prev + chunk);
const newContent = prev + chunk;
requestAnimationFrame(() => {
if (polishedContentRef.current) {
const container = polishedContentRef.current;
container.scrollTop = container.scrollHeight;
}
});
return newContent;
});
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
@@ -119,6 +127,16 @@ export default function AIPolishDialog({
} }
}; };
// 自动滚动到底部
useEffect(() => {
if (polishedContent && polishedContentRef.current) {
const container = polishedContentRef.current;
requestAnimationFrame(() => {
container.scrollTop = container.scrollHeight;
});
}
}, [polishedContent]);
useEffect(() => { useEffect(() => {
if (open) { if (open) {
handlePolish(); handlePolish();
@@ -141,7 +159,11 @@ export default function AIPolishDialog({
}; };
const handleApply = () => { const handleApply = () => {
onApply(polishedContent); // 将 Markdown 转为 HTML,并补回 Tiptap 所需的 ul/ol 类名
const htmlContent = md.render(polishedContent)
.replace(/<ul>/g, '<ul class="custom-list">')
.replace(/<ol>/g, '<ol class="custom-list-ordered">');
onApply(htmlContent);
handleClose(); handleClose();
toast.success(t("error.applied")); toast.success(t("error.applied"));
}; };
@@ -224,13 +246,14 @@ export default function AIPolishDialog({
"p-6 h-[400px] overflow-auto shadow-sm" "p-6 h-[400px] overflow-auto shadow-sm"
)} )}
> >
<div <Streamdown
className={cn( className={cn(
"prose dark:prose-invert max-w-none", "prose dark:prose-invert max-w-none",
"text-neutral-700 dark:text-neutral-300" "text-neutral-700 dark:text-neutral-300"
)} )}
dangerouslySetInnerHTML={{ __html: content }} >
/> {turndownService.turndown(content)}
</Streamdown>
</div> </div>
</div> </div>
@@ -260,13 +283,16 @@ export default function AIPolishDialog({
"p-6 h-[400px] overflow-auto shadow-sm scroll-smooth" "p-6 h-[400px] overflow-auto shadow-sm scroll-smooth"
)} )}
> >
<div <Streamdown
animated
isAnimating={isPolishing}
className={cn( className={cn(
"prose dark:prose-invert max-w-none", "prose dark:prose-invert max-w-none",
"text-neutral-800 dark:text-neutral-200" "text-neutral-800 dark:text-neutral-200"
)} )}
dangerouslySetInnerHTML={{ __html: polishedContent }} >
/> {polishedContent}
</Streamdown>
</div> </div>
</div> </div>
</div> </div>
+3 -3
View File
@@ -28,7 +28,7 @@ export const Route = createFileRoute("/api/polish")({
messages: [ messages: [
{ {
role: "system", role: "system",
content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。 content: `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。
优化原则: 优化原则:
1. 使用更专业的词汇和表达方式 1. 使用更专业的词汇和表达方式
@@ -36,9 +36,9 @@ export const Route = createFileRoute("/api/polish")({
3. 保持简洁清晰 3. 保持简洁清晰
4. 使用主动语气 4. 使用主动语气
5. 保持原有信息的完整性 5. 保持原有信息的完整性
6. 保留我输入的格式 6. 严格保留原有的 Markdown 格式结构(列表项保持为列表项,加粗保持加粗等)
请直接返回优化后的文本,不要包含任何解释或其他内容。` 请直接返回优化后的 Markdown 文本,不要包含任何解释或其他内容。`
}, },
{ {
role: "user", role: "user",
+2 -1
View File
@@ -9,7 +9,8 @@ const config = {
"./components/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}",
"./app/**/*.{ts,tsx}", "./app/**/*.{ts,tsx}",
"./src/**/*.{ts,tsx}", "./src/**/*.{ts,tsx}",
"./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}" "./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}",
"./node_modules/streamdown/dist/*.js"
], ],
prefix: "", prefix: "",
theme: { theme: {