mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-06-01 23:38:48 +02:00
feat: Introduce Markdown processing for the AI proofreading function, use Streamdown for rendering,add Alibaba Bold font
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
Generated
+1038
-4
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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: {
|
||||||
|
|||||||
Reference in New Issue
Block a user