From 8e1b7aba058d938b28233ddc3d67a7dfb4cbfc74 Mon Sep 17 00:00:00 2001 From: JOYCEQL <1449239013@qq.com> Date: Tue, 14 Jan 2025 13:18:04 +0800 Subject: [PATCH] feat: ai config deepseek --- apps/fronted/src/app/api/grammar/route.ts | 78 ++-- apps/fronted/src/app/api/polish/route.ts | 71 ++-- .../src/app/app/dashboard/settings/page.tsx | 345 +++++++++++------- .../src/components/editor/EditorHeader.tsx | 6 +- .../src/components/preview/PreviewDock.tsx | 29 +- .../components/shared/ai/AIPolishDialog.tsx | 33 +- apps/fronted/src/config/ai.ts | 28 ++ apps/fronted/src/i18n/locales/en.json | 20 + apps/fronted/src/i18n/locales/zh.json | 20 + apps/fronted/src/store/useAIConfigStore.ts | 15 + apps/fronted/src/store/useGrammarStore.ts | 74 ++-- 11 files changed, 476 insertions(+), 243 deletions(-) create mode 100644 apps/fronted/src/config/ai.ts diff --git a/apps/fronted/src/app/api/grammar/route.ts b/apps/fronted/src/app/api/grammar/route.ts index 2b84175..057417d 100644 --- a/apps/fronted/src/app/api/grammar/route.ts +++ b/apps/fronted/src/app/api/grammar/route.ts @@ -1,48 +1,54 @@ import { NextRequest, NextResponse } from "next/server"; +import { AIModelType } from "@/store/useAIConfigStore"; +import { AI_MODEL_CONFIGS } from "@/config/ai"; export async function POST(req: NextRequest) { try { const body = await req.json(); - const { apiKey, model, content } = body; + const { apiKey, model, content, modelType } = body; - const response = await fetch( - "https://ark.cn-beijing.volces.com/api/v3/chat/completions", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, + const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType]; + if (!modelConfig) { + throw new Error("Invalid model type"); + } + + const response = await fetch(modelConfig.url, { + method: "POST", + headers: modelConfig.headers(apiKey), + + body: JSON.stringify({ + model: modelConfig.requiresModelId ? model : modelConfig.defaultModel, + response_format: { + type: "json_object", }, - body: JSON.stringify({ - model, - messages: [ - { - role: "system", - content: `你是一个专业的中文简历语法检查助手。请完整检查以下文本中的语法语境错别字,包括: - 1. 只考虑语法组词中的错别字 + messages: [ + { + role: "system", + content: `你是一个专业的中文简历错别字检查助手。请帮助检查以下文本,以下是要求: + 1.仅需考虑组词中的错别字,不要考虑语法和格式错误。 + 2.对每个发现的问题,请按JSON格式返回 - 对每个发现的问题,请以JSON格式返回,格式如下: - { - "errors": [ - { - "text": "错误的文本", - "message": "详细的错误说明", - "type": "spelling"或"grammar", - "suggestions": ["建议修改1", "建议修改2"] - } - ] - } + 以下是示例格式: + { + "errors": [ + { + "text": "错误的文本", + "message": "详细的错误说明", + "type": "spelling"或"grammar", + "suggestions": ["建议修改1", "建议修改2"] + } + ] + } - 请确保返回的是可解析的JSON格式。`, - }, - { - role: "user", - content: content, - }, - ], - }), - } - ); + 请确保返回的格式可以正常解析`, + }, + { + role: "user", + content: content, + }, + ], + }), + }); const data = await response.json(); diff --git a/apps/fronted/src/app/api/polish/route.ts b/apps/fronted/src/app/api/polish/route.ts index c0f5d62..34c717a 100644 --- a/apps/fronted/src/app/api/polish/route.ts +++ b/apps/fronted/src/app/api/polish/route.ts @@ -1,44 +1,45 @@ import { NextResponse } from "next/server"; +import { AIModelType } from "@/store/useAIConfigStore"; +import { AI_MODEL_CONFIGS } from "@/config/ai"; export async function POST(req: Request) { try { const body = await req.json(); - const { apiKey, model, content } = body; + const { apiKey, model, content, modelType } = body; - const response = await fetch( - "https://ark.cn-beijing.volces.com/api/v3/chat/completions", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model, - messages: [ - { - role: "system", - content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。 - - 优化原则: - 1. 使用更专业的词汇和表达方式 - 2. 突出关键成就和技能 - 3. 保持简洁清晰 - 4. 使用主动语气 - 5. 保持原有信息的完整性 - 6. 保留我输入的格式 - - 请直接返回优化后的文本,不要包含任何解释或其他内容。`, - }, - { - role: "user", - content, - }, - ], - stream: true, - }), - } - ); + const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType]; + if (!modelConfig) { + throw new Error("Invalid model type"); + } + + const response = await fetch(modelConfig.url, { + method: "POST", + headers: modelConfig.headers(apiKey), + body: JSON.stringify({ + model: modelConfig.requiresModelId ? model : modelConfig.defaultModel, + messages: [ + { + role: "system", + content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。 + + 优化原则: + 1. 使用更专业的词汇和表达方式 + 2. 突出关键成就和技能 + 3. 保持简洁清晰 + 4. 使用主动语气 + 5. 保持原有信息的完整性 + 6. 保留我输入的格式 + + 请直接返回优化后的文本,不要包含任何解释或其他内容。`, + }, + { + role: "user", + content, + }, + ], + stream: true, + }), + }); const encoder = new TextEncoder(); const stream = new ReadableStream({ diff --git a/apps/fronted/src/app/app/dashboard/settings/page.tsx b/apps/fronted/src/app/app/dashboard/settings/page.tsx index 45c6ea6..f5da18c 100644 --- a/apps/fronted/src/app/app/dashboard/settings/page.tsx +++ b/apps/fronted/src/app/app/dashboard/settings/page.tsx @@ -15,6 +15,13 @@ import { useAIConfigStore } from "@/store/useAIConfigStore"; import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { cn } from "@/lib/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { getFileHandle, @@ -30,8 +37,18 @@ const SettingsPage = () => { useState(null); const [folderPath, setFolderPath] = useState(""); - const { doubaoApiKey, doubaoModelId, setDoubaoApiKey, setDoubaoModelId } = - useAIConfigStore(); + const { + selectedModel, + doubaoApiKey, + doubaoModelId, + deepseekApiKey, + deepseekModelId, + setSelectedModel, + setDoubaoApiKey, + setDoubaoModelId, + setDeepseekApiKey, + setDeepseekModelId, + } = useAIConfigStore(); const t = useTranslations(); @@ -79,160 +96,236 @@ const SettingsPage = () => { } }; - const handleApiKeyChange = async (e: React.ChangeEvent) => { + const handleApiKeyChange = async ( + e: React.ChangeEvent, + type: "doubao" | "deepseek" + ) => { const newApiKey = e.target.value; - setDoubaoApiKey(newApiKey); + if (type === "doubao") { + setDoubaoApiKey(newApiKey); + } else { + setDeepseekApiKey(newApiKey); + } }; const handleModelIdChange = async ( - e: React.ChangeEvent + e: React.ChangeEvent, + type: "doubao" | "deepseek" ) => { const newModelId = e.target.value; - setDoubaoModelId(newModelId); + if (type === "doubao") { + setDoubaoModelId(newModelId); + } else { + setDeepseekModelId(newModelId); + } }; return ( -
+
-

+

{t("dashboard.settings.title")}

-
- - - -
- +
+ {/* 同步目录卡片 */} + + + +
+
- {t("dashboard.settings.syncDirectory.title")} + + {t("dashboard.settings.sync.title")} +
- - {t("dashboard.settings.syncDirectory.description")} + + {t("dashboard.settings.sync.description")}
- -
-
-
- {folderPath ? ( -
- -
- - - {folderPath} - -
-
- ) : ( -
-

- {t( - "dashboard.settings.syncDirectory.noFolderConfigured" - )} -

-
- )} -
- -
+ +
+ +
+ {directoryHandle && ( + + + {folderPath} + + )}
- - - -
- + {/* AI配置卡片 */} + + + +
+
- {t("dashboard.settings.aiConfig.title")} + + {t("dashboard.settings.ai.title")} +
- - {t("dashboard.settings.aiConfig.description")} + + {t("dashboard.settings.ai.description")}
- -
-
-
- - -
- - {t("dashboard.settings.aiConfig.apiKey.description")} + +
+ + +
+ +
+
+

+ + {t("dashboard.settings.ai.doubao.title")} +

+
+
+ + handleApiKeyChange(e, "doubao")} + type="password" + className="h-11 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500" + placeholder={t( + "dashboard.settings.aiConfig.apiKey.placeholder" + )} + /> +
+
+ + handleModelIdChange(e, "doubao")} + className="h-11 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500" + placeholder={t( + "dashboard.settings.aiConfig.modelId.placeholder" + )} + />
+
-
- - -
- - {t("dashboard.settings.aiConfig.modelId.description")} - - +
+

+ + {t("dashboard.settings.ai.deepseek.title")} +

+
+
+ + handleApiKeyChange(e, "deepseek")} + type="password" + className="h-11 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500" + placeholder={t( + "dashboard.settings.aiConfig.apiKey.placeholder" + )} + /> +
+
+ + handleModelIdChange(e, "deepseek")} + className="h-11 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500" + placeholder={t( + "dashboard.settings.aiConfig.modelId.placeholder" + )} + />
diff --git a/apps/fronted/src/components/editor/EditorHeader.tsx b/apps/fronted/src/components/editor/EditorHeader.tsx index b36d9aa..21957ac 100644 --- a/apps/fronted/src/components/editor/EditorHeader.tsx +++ b/apps/fronted/src/components/editor/EditorHeader.tsx @@ -1,4 +1,6 @@ "use client"; +import { useTranslations } from "next-intl"; +import { AlertCircle } from "lucide-react"; import { motion } from "framer-motion"; import { useRouter } from "next/navigation"; import { Input } from "@/components/ui/input"; @@ -7,16 +9,12 @@ import ThemeToggle from "../shared/ThemeToggle"; import { useResumeStore } from "@/store/useResumeStore"; import { getThemeConfig } from "@/theme/themeConfig"; import { useGrammarCheck } from "@/hooks/useGrammarCheck"; -import { useEffect } from "react"; -import { toast } from "sonner"; import { HoverCard, HoverCardTrigger, HoverCardContent, } from "@/components/ui/hover-card"; -import { AlertCircle } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { useTranslations } from "next-intl"; interface EditorHeaderProps { isMobile?: boolean; diff --git a/apps/fronted/src/components/preview/PreviewDock.tsx b/apps/fronted/src/components/preview/PreviewDock.tsx index c9a265c..6467cf3 100644 --- a/apps/fronted/src/components/preview/PreviewDock.tsx +++ b/apps/fronted/src/components/preview/PreviewDock.tsx @@ -22,6 +22,7 @@ import { cn } from "@/lib/utils"; import { useGrammarCheck } from "@/hooks/useGrammarCheck"; import { useAIConfigStore } from "@/store/useAIConfigStore"; import { useTranslations } from "next-intl"; +import { AI_MODEL_CONFIGS } from "@/config/ai"; export type IconProps = React.HTMLAttributes; @@ -54,11 +55,25 @@ export const PreviewDock = ({ const router = useRouter(); const t = useTranslations("previewDock"); const { checkGrammar, isChecking } = useGrammarCheck(); - const { doubaoApiKey, doubaoModelId } = useAIConfigStore(); + const { + selectedModel, + doubaoApiKey, + doubaoModelId, + deepseekApiKey, + deepseekModelId, + } = useAIConfigStore(); + const handleGrammarCheck = useCallback(async () => { if (!resumeContentRef.current) return; + const config = AI_MODEL_CONFIGS[selectedModel]; + const isConfigured = + selectedModel === "doubao" + ? doubaoApiKey && doubaoModelId + : config.requiresModelId + ? deepseekApiKey && deepseekModelId + : deepseekApiKey; - if (!doubaoApiKey || !doubaoModelId) { + if (!isConfigured) { toast.error( <> {t("grammarCheck.configurePrompt")} @@ -79,7 +94,15 @@ export const PreviewDock = ({ } catch (error) { toast.error(t("grammarCheck.errorToast")); } - }, [resumeContentRef, doubaoApiKey, doubaoModelId, t]); + }, [ + resumeContentRef, + selectedModel, + doubaoApiKey, + doubaoModelId, + deepseekApiKey, + deepseekModelId, + t, + ]); const handleGoGitHub = () => { window.open(GITHUB_REPO_URL, "_blank"); diff --git a/apps/fronted/src/components/shared/ai/AIPolishDialog.tsx b/apps/fronted/src/components/shared/ai/AIPolishDialog.tsx index e6abdb3..784356a 100644 --- a/apps/fronted/src/components/shared/ai/AIPolishDialog.tsx +++ b/apps/fronted/src/components/shared/ai/AIPolishDialog.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { useAIConfigStore } from "@/store/useAIConfigStore"; +import { AI_MODEL_CONFIGS } from "@/config/ai"; import { cn } from "@/lib/utils"; interface AIPolishDialogProps { @@ -29,12 +30,32 @@ export default function AIPolishDialog({ }: AIPolishDialogProps) { const [isPolishing, setIsPolishing] = useState(false); const [polishedContent, setPolishedContent] = useState(""); - const { doubaoApiKey, doubaoModelId } = useAIConfigStore(); + const { + selectedModel, + doubaoApiKey, + doubaoModelId, + deepseekApiKey, + deepseekModelId, + } = useAIConfigStore(); const abortControllerRef = useRef(null); const polishedContentRef = useRef(null); const handlePolish = async () => { try { + const config = AI_MODEL_CONFIGS[selectedModel]; + const isConfigured = + selectedModel === "doubao" + ? doubaoApiKey && doubaoModelId + : config.requiresModelId + ? deepseekApiKey && deepseekModelId + : deepseekApiKey; + + if (!isConfigured) { + toast.error("请先配置 AI 模型"); + onOpenChange(false); + return; + } + setIsPolishing(true); setPolishedContent(""); @@ -47,8 +68,14 @@ export default function AIPolishDialog({ }, body: JSON.stringify({ content, - apiKey: doubaoApiKey, - model: doubaoModelId, + apiKey: selectedModel === "doubao" ? doubaoApiKey : deepseekApiKey, + model: + selectedModel === "doubao" + ? doubaoModelId + : config.requiresModelId + ? deepseekModelId + : config.defaultModel, + modelType: selectedModel, }), signal: abortControllerRef.current.signal, }); diff --git a/apps/fronted/src/config/ai.ts b/apps/fronted/src/config/ai.ts new file mode 100644 index 0000000..1a0cddc --- /dev/null +++ b/apps/fronted/src/config/ai.ts @@ -0,0 +1,28 @@ +import { AIModelType } from "@/store/useAIConfigStore"; + +export interface AIModelConfig { + url: string; + requiresModelId: boolean; + defaultModel?: string; + headers: (apiKey: string) => Record; +} + +export const AI_MODEL_CONFIGS: Record = { + doubao: { + url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions", + requiresModelId: true, + headers: (apiKey: string) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }), + }, + deepseek: { + url: "https://api.deepseek.com/v1/chat/completions", + requiresModelId: false, + defaultModel: "deepseek-chat", + headers: (apiKey: string) => ({ + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }), + }, +}; diff --git a/apps/fronted/src/i18n/locales/en.json b/apps/fronted/src/i18n/locales/en.json index 19641c5..bbbd1ec 100644 --- a/apps/fronted/src/i18n/locales/en.json +++ b/apps/fronted/src/i18n/locales/en.json @@ -79,6 +79,26 @@ "placeholder": "Please enter Doubao Model ID", "description": "Obtain Model ID from Doubao Open Platform" } + }, + "sync": { + "title": "Sync Directory", + "description": "Choose a folder to sync and backup your resumes.", + "select": "Select Folder" + }, + "ai": { + "title": "AI Model Configuration", + "description": "Configure AI models to provide intelligent optimization and suggestions for your resume.", + "selectedModel": "Select Model", + "doubao": { + "title": "Doubao Settings", + "apiKey": "API Key", + "modelId": "Model ID" + }, + "deepseek": { + "title": "Deepseek Settings", + "apiKey": "API Key", + "modelId": "Model ID" + } } } }, diff --git a/apps/fronted/src/i18n/locales/zh.json b/apps/fronted/src/i18n/locales/zh.json index 89ac560..38a46e9 100644 --- a/apps/fronted/src/i18n/locales/zh.json +++ b/apps/fronted/src/i18n/locales/zh.json @@ -79,6 +79,26 @@ "placeholder": "请输入豆包模型 ID", "description": "在豆包开放平台获取模型 ID" } + }, + "sync": { + "title": "同步目录", + "description": "选择一个文件夹来同步和备份您的简历。", + "select": "选择文件夹" + }, + "ai": { + "title": "AI 模型配置", + "description": "配置 AI 模型,让 AI 助手为你的简历提供智能优化和建议。", + "selectedModel": "选择模型", + "doubao": { + "title": "豆包", + "apiKey": "API 密钥", + "modelId": "模型 ID" + }, + "deepseek": { + "title": "Deepseek", + "apiKey": "API 密钥", + "modelId": "模型 ID" + } } } }, diff --git a/apps/fronted/src/store/useAIConfigStore.ts b/apps/fronted/src/store/useAIConfigStore.ts index 2975d17..9423b84 100644 --- a/apps/fronted/src/store/useAIConfigStore.ts +++ b/apps/fronted/src/store/useAIConfigStore.ts @@ -1,20 +1,35 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; +export type AIModelType = "doubao" | "deepseek"; + interface AIConfigState { + selectedModel: AIModelType; doubaoApiKey: string; doubaoModelId: string; + deepseekApiKey: string; + deepseekModelId: string; + setSelectedModel: (model: AIModelType) => void; setDoubaoApiKey: (apiKey: string) => void; setDoubaoModelId: (modelId: string) => void; + setDeepseekApiKey: (apiKey: string) => void; + setDeepseekModelId: (modelId: string) => void; } export const useAIConfigStore = create()( persist( (set) => ({ + selectedModel: "doubao", doubaoApiKey: "", doubaoModelId: "", + deepseekApiKey: "", + deepseekModelId: "", + setSelectedModel: (model: AIModelType) => set({ selectedModel: model }), setDoubaoApiKey: (apiKey: string) => set({ doubaoApiKey: apiKey }), setDoubaoModelId: (modelId: string) => set({ doubaoModelId: modelId }), + setDeepseekApiKey: (apiKey: string) => set({ deepseekApiKey: apiKey }), + setDeepseekModelId: (modelId: string) => + set({ deepseekModelId: modelId }), }), { name: "ai-config-storage", diff --git a/apps/fronted/src/store/useGrammarStore.ts b/apps/fronted/src/store/useGrammarStore.ts index 1683439..ee774a1 100644 --- a/apps/fronted/src/store/useGrammarStore.ts +++ b/apps/fronted/src/store/useGrammarStore.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { toast } from "sonner"; import Mark from "mark.js"; import { useAIConfigStore } from "@/store/useAIConfigStore"; +import { AI_MODEL_CONFIGS } from "@/config/ai"; +import { cn } from "@/lib/utils"; export interface GrammarError { error: string; @@ -38,7 +40,19 @@ export const useGrammarStore = create((set, get) => ({ set((state) => ({ highlightKey: state.highlightKey + 1 })), checkGrammar: async (text: string) => { - const { doubaoApiKey, doubaoModelId } = useAIConfigStore.getState(); + const { + selectedModel, + doubaoApiKey, + doubaoModelId, + deepseekApiKey, + deepseekModelId, + } = useAIConfigStore.getState(); + + const config = AI_MODEL_CONFIGS[selectedModel]; + const apiKey = selectedModel === "doubao" ? doubaoApiKey : deepseekApiKey; + const modelId = + selectedModel === "doubao" ? doubaoModelId : deepseekModelId; + set({ isChecking: true }); try { @@ -49,8 +63,9 @@ export const useGrammarStore = create((set, get) => ({ }, body: JSON.stringify({ content: text, - apiKey: doubaoApiKey, - model: doubaoModelId, + apiKey, + model: config.requiresModelId ? modelId : config.defaultModel, + modelType: selectedModel, }), }); @@ -70,6 +85,7 @@ export const useGrammarStore = create((set, get) => ({ try { const grammarErrors = JSON.parse(aiResponse); if (grammarErrors.errors.length === 0) { + set({ errors: [] }); toast.success("无语法错误"); return; } @@ -106,47 +122,33 @@ export const useGrammarStore = create((set, get) => ({ }, selectError: (index: number) => { - const state = get(); - const error = state.errors[index]; + const { errors } = get(); + const error = errors[index]; if (!error) return; set({ selectedErrorIndex: index }); const preview = document.getElementById("resume-preview"); - if (preview) { - const marker = new Mark(preview); - marker.unmark(); + if (!preview) return; - state.errors.forEach((error) => { - marker.mark(error.context || error.text || "", { - className: `grammar-error ${error.type || ""}`, - acrossElements: true, - separateWordSearch: false, - caseSensitive: true, - }); + const marker = new Mark(preview); + marker.unmark(); + + errors.forEach((err, i) => { + marker.mark(err.context || err.text || "", { + className: cn( + "bg-yellow-200 dark:bg-yellow-900", + i === index && "bg-green-200 dark:bg-green-900" + ), }); + }); - const errorElements = preview.querySelectorAll( - `.grammar-error[data-markjs=true]` - ); - - errorElements.forEach((el) => { - el.classList.remove("active"); - }); - - errorElements.forEach((el) => { - if (el.textContent === (error.context || error.text)) { - el.classList.add(`active-${state.highlightKey}`); - - el.scrollIntoView({ - behavior: "smooth", - block: "center", - }); - - setTimeout(() => { - el.classList.remove(`active-${state.highlightKey}`); - }, 2000); - } + const marks = preview.querySelectorAll("mark"); + const selectedMark = marks[index]; + if (selectedMark) { + selectedMark.scrollIntoView({ + behavior: "smooth", + block: "center", }); } },