mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-06-01 23:38:48 +02:00
feat: Integrate Gemini API for resume import && AI polishing && AI grammar
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"release": "bumpp"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"@heroui/checkbox": "^2.3.30",
|
||||
"@heroui/date-input": "^2.3.30",
|
||||
"@heroui/react": "^2.8.8",
|
||||
@@ -62,6 +63,7 @@
|
||||
"mark.js": "^8.11.1",
|
||||
"markdown-exit": "1.0.0-beta.8",
|
||||
"next-themes": "^0.4.3",
|
||||
"pdfjs-dist": "^5.4.624",
|
||||
"puppeteer": "^23.9.0",
|
||||
"puppeteer-core": "^23.9.0",
|
||||
"react": "^18",
|
||||
@@ -75,6 +77,7 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"turndown": "^7.2.2",
|
||||
"undici": "^7.22.0",
|
||||
"uuid": "^11.0.5",
|
||||
"vaul": "^1.1.1",
|
||||
"zustand": "^4.5.4"
|
||||
|
||||
Generated
+153
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@google/generative-ai':
|
||||
specifier: ^0.24.1
|
||||
version: 0.24.1
|
||||
'@heroui/checkbox':
|
||||
specifier: ^2.3.30
|
||||
version: 2.3.30(@heroui/system@2.4.26(@heroui/theme@2.4.26(tailwindcss@3.4.17(ts-node@10.9.1(@types/node@20.17.17)(typescript@5.7.3))))(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@heroui/theme@2.4.26(tailwindcss@3.4.17(ts-node@10.9.1(@types/node@20.17.17)(typescript@5.7.3))))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -158,6 +161,9 @@ importers:
|
||||
next-themes:
|
||||
specifier: ^0.4.3
|
||||
version: 0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
pdfjs-dist:
|
||||
specifier: ^5.4.624
|
||||
version: 5.4.624
|
||||
puppeteer:
|
||||
specifier: ^23.9.0
|
||||
version: 23.11.1(typescript@5.7.3)
|
||||
@@ -197,6 +203,9 @@ importers:
|
||||
turndown:
|
||||
specifier: ^7.2.2
|
||||
version: 7.2.2
|
||||
undici:
|
||||
specifier: ^7.22.0
|
||||
version: 7.22.0
|
||||
uuid:
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5
|
||||
@@ -787,6 +796,10 @@ packages:
|
||||
'@formatjs/intl-localematcher@0.5.10':
|
||||
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
|
||||
|
||||
'@google/generative-ai@0.24.1':
|
||||
resolution: {integrity: sha512-MqO+MLfM6kjxcKoy0p1wRzG3b4ZZXtPI+z2IE26UogS2Cm/XHO+7gGRBh6gcJsOiIVoH93UwKvW4HdgiOZCy9Q==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
'@heroui/accordion@2.2.27':
|
||||
resolution: {integrity: sha512-ay/pIMUo8+ZUQKPvBZ5qjqvW6luqBuxObACCk1zZZCDtCUIRW6agVzN5oQD+gmoDadDMGeAE8mRr1pfG93K39A==}
|
||||
peerDependencies:
|
||||
@@ -1522,6 +1535,81 @@ packages:
|
||||
'@mixmark-io/domino@2.2.0':
|
||||
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.95':
|
||||
resolution: {integrity: sha512-SqTh0wsYbetckMXEvHqmR7HKRJujVf1sYv1xdlhkifg6TlCSysz1opa49LlS3+xWuazcQcfRfmhA07HxxxGsAA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.95':
|
||||
resolution: {integrity: sha512-F7jT0Syu+B9DGBUBcMk3qCRIxAWiDXmvEjamwbYfbZl7asI1pmXZUnCOoIu49Wt0RNooToYfRDxU9omD6t5Xuw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.95':
|
||||
resolution: {integrity: sha512-54eb2Ho15RDjYGXO/harjRznBrAvu+j5nQ85Z4Qd6Qg3slR8/Ja+Yvvy9G4yo7rdX6NR9GPkZeSTf2UcKXwaXw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
|
||||
resolution: {integrity: sha512-hYaLCSLx5bmbnclzQc3ado3PgZ66blJWzjXp0wJmdwpr/kH+Mwhj6vuytJIomgksyJoCdIqIa4N6aiqBGJtJ5Q==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-J7VipONahKsmScPZsipHVQBqpbZx4favaD8/enWzzlGcjiwycOoymL7f4tNeqdjK0su19bDOUt6mjp9gsPWYlw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.95':
|
||||
resolution: {integrity: sha512-PXy0UT1J/8MPG8UAkWp6Fd51ZtIZINFzIjGH909JjQrtCuJf3X6nanHYdz1A+Wq9o4aoPAw1YEUpFS1lelsVlg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-2IzCkW2RHRdcgF9W5/plHvYFpc6uikyjMb5SxjqmNxfyDFz9/HB89yhi8YQo0SNqrGRI7yBVDec7Pt+uMyRWsg==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.95':
|
||||
resolution: {integrity: sha512-OV/ol/OtcUr4qDhQg8G7SdViZX8XyQeKpPsVv/j3+7U178FGoU4M+yIocdVo1ih/A8GQ63+LjF4jDoEjaVU8Pw==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.95':
|
||||
resolution: {integrity: sha512-Z5KzqBK/XzPz5+SFHKz7yKqClEQ8pOiEDdgk5SlphBLVNb8JFIJkxhtJKSvnJyHh2rjVgiFmvtJzMF0gNwwKyQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.95':
|
||||
resolution: {integrity: sha512-aj0YbRpe8qVJ4OzMsK7NfNQePgcf9zkGFzNZ9mSuaxXzhpLHmlF2GivNdCdNOg8WzA/NxV6IU4c5XkXadUMLeA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.95':
|
||||
resolution: {integrity: sha512-GA8leTTCfdjuHi8reICTIxU0081PhXvl3lzIniLUjeLACx9GubUiyzkwFb+oyeKLS5IAGZFLKnzAf4wm2epRlA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@napi-rs/canvas@0.1.95':
|
||||
resolution: {integrity: sha512-lkg23ge+rgyhgUwXmlbkPEhuhHq/hUi/gXKH+4I7vO+lJrbNfEYcQdJLIGjKyXLQzgFiiyDAwh5vAe/tITAE+w==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@next/env@14.2.3':
|
||||
resolution: {integrity: sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==}
|
||||
|
||||
@@ -4814,6 +4902,9 @@ packages:
|
||||
node-fetch-native@1.6.7:
|
||||
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==}
|
||||
|
||||
node-releases@2.0.19:
|
||||
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
|
||||
|
||||
@@ -4924,6 +5015,10 @@ packages:
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pdfjs-dist@5.4.624:
|
||||
resolution: {integrity: sha512-sm6TxKTtWv1Oh6n3C6J6a8odejb5uO4A4zo/2dgkHuC0iu8ZMAXOezEODkVaoVp8nX1Xzr+0WxFJJmUr45hQzg==}
|
||||
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
@@ -6357,6 +6452,8 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@google/generative-ai@0.24.1': {}
|
||||
|
||||
'@heroui/accordion@2.2.27(@heroui/system@2.4.26(@heroui/theme@2.4.26(tailwindcss@3.4.17(ts-node@10.9.1(@types/node@20.17.17)(typescript@5.7.3))))(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@heroui/theme@2.4.26(tailwindcss@3.4.17(ts-node@10.9.1(@types/node@20.17.17)(typescript@5.7.3))))(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@heroui/aria-utils': 2.2.27(@heroui/theme@2.4.26(tailwindcss@3.4.17(ts-node@10.9.1(@types/node@20.17.17)(typescript@5.7.3))))(framer-motion@11.18.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -7529,6 +7626,54 @@ snapshots:
|
||||
|
||||
'@mixmark-io/domino@2.2.0': {}
|
||||
|
||||
'@napi-rs/canvas-android-arm64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-arm64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-darwin-x64@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-arm64-musl@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-riscv64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-gnu@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-linux-x64-musl@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-arm64-msvc@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas-win32-x64-msvc@0.1.95':
|
||||
optional: true
|
||||
|
||||
'@napi-rs/canvas@0.1.95':
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas-android-arm64': 0.1.95
|
||||
'@napi-rs/canvas-darwin-arm64': 0.1.95
|
||||
'@napi-rs/canvas-darwin-x64': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-arm64-musl': 0.1.95
|
||||
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-x64-gnu': 0.1.95
|
||||
'@napi-rs/canvas-linux-x64-musl': 0.1.95
|
||||
'@napi-rs/canvas-win32-arm64-msvc': 0.1.95
|
||||
'@napi-rs/canvas-win32-x64-msvc': 0.1.95
|
||||
optional: true
|
||||
|
||||
'@next/env@14.2.3':
|
||||
optional: true
|
||||
|
||||
@@ -11551,6 +11696,9 @@ snapshots:
|
||||
|
||||
node-fetch-native@1.6.7: {}
|
||||
|
||||
node-readable-to-web-readable-stream@0.4.2:
|
||||
optional: true
|
||||
|
||||
node-releases@2.0.19: {}
|
||||
|
||||
normalize-path@3.0.0: {}
|
||||
@@ -11680,6 +11828,11 @@ snapshots:
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pdfjs-dist@5.4.624:
|
||||
optionalDependencies:
|
||||
'@napi-rs/canvas': 0.1.95
|
||||
node-readable-to-web-readable-stream: 0.4.2
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
perfect-debounce@2.1.0: {}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ExternalLink, Sparkles } from "lucide-react";
|
||||
import { useTranslations } from "@/i18n/compat/client";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import DeepSeekLogo from "@/components/ai/icon/IconDeepseek";
|
||||
import IconDoubao from "@/components/ai/icon/IconDoubao";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useAIConfigStore } from "@/store/useAIConfigStore";
|
||||
import { cn } from "@/lib/utils";
|
||||
import IconOpenAi from "@/components/ai/icon/IconOpenAi";
|
||||
@@ -24,33 +17,38 @@ const AISettingsPage = () => {
|
||||
openaiApiKey,
|
||||
openaiModelId,
|
||||
openaiApiEndpoint,
|
||||
geminiApiKey,
|
||||
geminiModelId,
|
||||
setDoubaoApiKey,
|
||||
setDoubaoModelId,
|
||||
setDeepseekApiKey,
|
||||
setOpenaiApiKey,
|
||||
setOpenaiModelId,
|
||||
setOpenaiApiEndpoint,
|
||||
setGeminiApiKey,
|
||||
setGeminiModelId,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
} = useAIConfigStore();
|
||||
const [currentModel, setCurrentModel] = useState(selectedModel);
|
||||
|
||||
const [currentModel, setCurrentModel] = useState("");
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentModel(selectedModel);
|
||||
}, [selectedModel]);
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
const handleApiKeyChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
type: "doubao" | "deepseek" | "openai"
|
||||
type: "doubao" | "deepseek" | "openai" | "gemini"
|
||||
) => {
|
||||
const newApiKey = e.target.value;
|
||||
if (type === "doubao") {
|
||||
setDoubaoApiKey(newApiKey);
|
||||
} else if (type === "deepseek") {
|
||||
setDeepseekApiKey(newApiKey);
|
||||
} else if (type === "gemini") {
|
||||
setGeminiApiKey(newApiKey);
|
||||
} else {
|
||||
setOpenaiApiKey(newApiKey);
|
||||
}
|
||||
@@ -58,13 +56,15 @@ const AISettingsPage = () => {
|
||||
|
||||
const handleModelIdChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
type: "doubao" | "deepseek" | "openai"
|
||||
type: "doubao" | "deepseek" | "openai" | "gemini"
|
||||
) => {
|
||||
const newModelId = e.target.value;
|
||||
if (type === "doubao") {
|
||||
setDoubaoModelId(newModelId);
|
||||
} else if (type === "openai") {
|
||||
setOpenaiModelId(newModelId);
|
||||
} else if (type === "gemini") {
|
||||
setGeminiModelId(newModelId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -109,76 +109,87 @@ const AISettingsPage = () => {
|
||||
bgColor: "bg-blue-50 dark:bg-blue-950/50",
|
||||
isConfigured: !!(openaiApiKey && openaiModelId && openaiApiEndpoint),
|
||||
},
|
||||
{
|
||||
id: "gemini",
|
||||
name: t("dashboard.settings.ai.gemini.title"),
|
||||
description: t("dashboard.settings.ai.gemini.description"),
|
||||
icon: Sparkles,
|
||||
link: "https://aistudio.google.com/app/apikey",
|
||||
color: "text-amber-500",
|
||||
bgColor: "bg-amber-50 dark:bg-amber-950/50",
|
||||
isConfigured: !!(geminiApiKey && geminiModelId),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto py-4 px-4">
|
||||
<div className="flex gap-8">
|
||||
<div className="w-64 space-y-6">
|
||||
<div>
|
||||
<Label className="text-sm mb-2 block text-muted-foreground">
|
||||
{t("dashboard.settings.ai.currentModel")}
|
||||
</Label>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue
|
||||
placeholder={t("dashboard.settings.ai.selectModel")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((model) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<model.icon className={cn("h-4 w-4", model.color)} />
|
||||
<span>{model.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="h-[1px] bg-gray-200 dark:bg-gray-800" />
|
||||
|
||||
{/* 配置模型列表 */}
|
||||
<div className="flex flex-col space-y-1">
|
||||
{models.map((model) => {
|
||||
const Icon = model.icon;
|
||||
const isActive = currentModel === model.id;
|
||||
const isChecked = selectedModel === model.id;
|
||||
const isViewing = currentModel === model.id;
|
||||
return (
|
||||
<button
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => setCurrentModel(model.id)}
|
||||
onClick={() => {
|
||||
setCurrentModel(model.id as typeof currentModel);
|
||||
}}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-lg text-left relative",
|
||||
"transition-all duration-200",
|
||||
"hover:bg-primary/10",
|
||||
isActive && "bg-primary/10"
|
||||
"w-full flex items-center gap-3 px-3 py-3 rounded-lg text-left border",
|
||||
"transition-all duration-200 cursor-pointer",
|
||||
"hover:bg-primary/10 hover:border-primary/30",
|
||||
isViewing
|
||||
? "bg-primary/10 border-primary/40"
|
||||
: "border-transparent"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
isActive ? "text-primary" : "text-muted-foreground"
|
||||
isViewing ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 flex flex-col items-start">
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-sm",
|
||||
isActive && "text-primary"
|
||||
isViewing && "text-primary"
|
||||
)}
|
||||
>
|
||||
{model.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate w-full">
|
||||
{model.isConfigured
|
||||
? t("common.configured")
|
||||
: t("common.notConfigured")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Select ${model.name}`}
|
||||
onClick={() => {
|
||||
setSelectedModel(
|
||||
model.id as "doubao" | "deepseek" | "openai" | "gemini"
|
||||
);
|
||||
setCurrentModel(
|
||||
model.id as "doubao" | "deepseek" | "openai" | "gemini"
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
"h-6 w-6 rounded-md flex items-center justify-center border transition-all",
|
||||
"shrink-0",
|
||||
isChecked
|
||||
? "bg-primary border-primary text-primary-foreground"
|
||||
: "bg-transparent border-muted-foreground/40 text-transparent hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -223,12 +234,14 @@ const AISettingsPage = () => {
|
||||
? doubaoApiKey
|
||||
: model.id === "openai"
|
||||
? openaiApiKey
|
||||
: model.id === "gemini"
|
||||
? geminiApiKey
|
||||
: deepseekApiKey
|
||||
}
|
||||
onChange={(e) =>
|
||||
handleApiKeyChange(
|
||||
e,
|
||||
model.id as "doubao" | "deepseek" | "openai"
|
||||
model.id as "doubao" | "deepseek" | "openai" | "gemini"
|
||||
)
|
||||
}
|
||||
type="password"
|
||||
@@ -244,7 +257,7 @@ const AISettingsPage = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentModel === "doubao" && (
|
||||
{model.id === "doubao" && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{t("dashboard.settings.ai.doubao.modelId")}
|
||||
@@ -265,7 +278,7 @@ const AISettingsPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentModel === "openai" && (
|
||||
{model.id === "openai" && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{t("dashboard.settings.ai.openai.modelId")}
|
||||
@@ -286,7 +299,26 @@ const AISettingsPage = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentModel === "openai" && (
|
||||
{model.id === "gemini" && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{t("dashboard.settings.ai.gemini.modelId")}
|
||||
</Label>
|
||||
<Input
|
||||
value={geminiModelId}
|
||||
onChange={(e) => handleModelIdChange(e, "gemini")}
|
||||
placeholder={t("dashboard.settings.ai.gemini.modelId")}
|
||||
className={cn(
|
||||
"h-11",
|
||||
"bg-white dark:bg-gray-900",
|
||||
"border-gray-200 dark:border-gray-800",
|
||||
"focus:ring-2 focus:ring-primary/20"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{model.id === "openai" && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{t("dashboard.settings.ai.openai.apiEndpoint")}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { useTranslations } from "@/i18n/compat/client";
|
||||
import { Braces, Loader2 } from "lucide-react";
|
||||
import { PdfIcon } from "@/components/shared/icons/PdfIcon";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ImportResumeDialogProps {
|
||||
open: boolean;
|
||||
isImporting: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
jsonFileInputRef: React.RefObject<HTMLInputElement>;
|
||||
pdfFileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onJsonFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onPdfFileChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export const ImportResumeDialog = ({
|
||||
open,
|
||||
isImporting,
|
||||
onOpenChange,
|
||||
jsonFileInputRef,
|
||||
pdfFileInputRef,
|
||||
onJsonFileChange,
|
||||
onPdfFileChange,
|
||||
}: ImportResumeDialogProps) => {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={jsonFileInputRef}
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
className="hidden"
|
||||
onChange={onJsonFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={pdfFileInputRef}
|
||||
type="file"
|
||||
accept=".pdf,application/pdf"
|
||||
className="hidden"
|
||||
onChange={onPdfFileChange}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (isImporting) return;
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[480px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("dashboard.resumes.importDialog.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isImporting}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-4 rounded-xl border border-border/50 bg-card p-4 text-left transition-all duration-200",
|
||||
"hover:border-primary/50 hover:bg-accent/50 hover:shadow-md",
|
||||
"active:scale-[0.98]",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
)}
|
||||
onClick={() => jsonFileInputRef.current?.click()}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-blue-500/10 text-blue-600 transition-colors group-hover:bg-blue-500/20 dark:bg-blue-500/20 dark:text-blue-400">
|
||||
<Braces className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-semibold text-foreground leading-none">
|
||||
{t("dashboard.resumes.importDialog.jsonTitle")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t("dashboard.resumes.importDialog.jsonDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isImporting}
|
||||
className={cn(
|
||||
"group relative flex w-full items-start gap-4 rounded-xl border border-border/50 bg-card p-4 text-left transition-all duration-200",
|
||||
"hover:border-primary/50 hover:bg-accent/50 hover:shadow-md",
|
||||
"active:scale-[0.98]",
|
||||
"disabled:opacity-60 disabled:cursor-not-allowed disabled:active:scale-100"
|
||||
)}
|
||||
onClick={() => pdfFileInputRef.current?.click()}
|
||||
>
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-red-500/10 text-red-600 transition-colors group-hover:bg-red-500/20 dark:bg-red-500/20 dark:text-red-400">
|
||||
<PdfIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="font-semibold text-foreground leading-none">
|
||||
{t("dashboard.resumes.importDialog.pdfTitle")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t("dashboard.resumes.importDialog.pdfDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isImporting && (
|
||||
<DialogFooter className="sm:justify-start">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
{t("dashboard.resumes.importDialog.importing")}
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect } from "react";
|
||||
import { useTranslations, useLocale } from "@/i18n/compat/client";
|
||||
import { useRouter } from "@/lib/navigation";
|
||||
import { Plus, FileText, Settings, AlertCircle, Upload } from "lucide-react";
|
||||
import { Plus, Settings, AlertCircle, Upload, Braces } from "lucide-react";
|
||||
import { PdfIcon } from "@/components/shared/icons/PdfIcon";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -34,6 +35,142 @@ import { DEFAULT_TEMPLATES } from "@/config";
|
||||
|
||||
import { generateUUID } from "@/utils/uuid";
|
||||
import { CreateResumeModal } from "./CreateResumeModal";
|
||||
import { ImportResumeDialog } from "./ImportResumeDialog";
|
||||
import { useAIConfigStore } from "@/store/useAIConfigStore";
|
||||
import pdfWorkerUrl from "pdfjs-dist/legacy/build/pdf.worker.min.mjs?url";
|
||||
|
||||
const MAX_PDF_IMPORT_PAGES = 3;
|
||||
const PDF_IMAGE_QUALITY = 0.82;
|
||||
const PDF_MAX_IMAGE_WIDTH = 1600;
|
||||
|
||||
const escapeHtml = (value: string) =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
const toString = (value: unknown) =>
|
||||
typeof value === "string" ? value.trim() : "";
|
||||
|
||||
const toStringArray = (value: unknown) => {
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((item) => toString(item))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.replace(/^[-*•\d.)\s]+/, "").trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return [] as string[];
|
||||
};
|
||||
|
||||
const toListHtml = (value: unknown) => {
|
||||
const items = toStringArray(value);
|
||||
if (items.length === 0) return "";
|
||||
return `<ul class="custom-list">${items
|
||||
.map((item) => `<li>${escapeHtml(item)}</li>`)
|
||||
.join("")}</ul>`;
|
||||
};
|
||||
|
||||
const extractJsonContent = (content: string) => {
|
||||
const direct = content.trim();
|
||||
try {
|
||||
return JSON.parse(direct);
|
||||
} catch (error) { }
|
||||
|
||||
const fencedMatch = direct.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fencedMatch?.[1]) {
|
||||
try {
|
||||
return JSON.parse(fencedMatch[1].trim());
|
||||
} catch (error) { }
|
||||
}
|
||||
|
||||
const objectMatch = direct.match(/\{[\s\S]*\}/);
|
||||
if (objectMatch?.[0]) {
|
||||
try {
|
||||
return JSON.parse(objectMatch[0]);
|
||||
} catch (error) { }
|
||||
}
|
||||
|
||||
throw new Error("Invalid AI JSON content");
|
||||
};
|
||||
|
||||
const createResumeFromAIResult = (result: any, fileName: string) => {
|
||||
const now = new Date().toISOString();
|
||||
const id = generateUUID();
|
||||
|
||||
const education = Array.isArray(result?.education) ? result.education : [];
|
||||
const experience = Array.isArray(result?.experience) ? result.experience : [];
|
||||
const projects = Array.isArray(result?.projects) ? result.projects : [];
|
||||
|
||||
const skillSource = result?.skillContent ?? result?.skills;
|
||||
const skillContent = toListHtml(skillSource);
|
||||
|
||||
return {
|
||||
...initialResumeState,
|
||||
id,
|
||||
title: toString(result?.title) || fileName || `Imported Resume ${id.slice(0, 6)}`,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
templateId: DEFAULT_TEMPLATES[0]?.id,
|
||||
basic: {
|
||||
...initialResumeState.basic,
|
||||
name: toString(result?.basic?.name),
|
||||
title: toString(result?.basic?.title),
|
||||
email: toString(result?.basic?.email),
|
||||
phone: toString(result?.basic?.phone),
|
||||
location: toString(result?.basic?.location),
|
||||
employementStatus: toString(result?.basic?.employementStatus),
|
||||
birthDate: toString(result?.basic?.birthDate),
|
||||
customFields: [],
|
||||
photo: "",
|
||||
githubKey: "",
|
||||
githubUseName: "",
|
||||
githubContributionsVisible: false,
|
||||
},
|
||||
education: education
|
||||
.map((item: any) => ({
|
||||
id: generateUUID(),
|
||||
school: toString(item?.school),
|
||||
major: toString(item?.major),
|
||||
degree: toString(item?.degree),
|
||||
startDate: toString(item?.startDate),
|
||||
endDate: toString(item?.endDate),
|
||||
gpa: toString(item?.gpa),
|
||||
description: toListHtml(item?.description),
|
||||
visible: true,
|
||||
}))
|
||||
.filter((item: any) => item.school || item.major || item.degree),
|
||||
experience: experience
|
||||
.map((item: any) => ({
|
||||
id: generateUUID(),
|
||||
company: toString(item?.company),
|
||||
position: toString(item?.position),
|
||||
date: toString(item?.date),
|
||||
details: toListHtml(item?.details || item?.description),
|
||||
visible: true,
|
||||
}))
|
||||
.filter((item: any) => item.company || item.position || item.date || item.details),
|
||||
projects: projects
|
||||
.map((item: any) => ({
|
||||
id: generateUUID(),
|
||||
name: toString(item?.name),
|
||||
role: toString(item?.role),
|
||||
date: toString(item?.date),
|
||||
description: toListHtml(item?.description || item?.details),
|
||||
link: toString(item?.link),
|
||||
visible: true,
|
||||
}))
|
||||
.filter((item: any) => item.name || item.role || item.date || item.description),
|
||||
skillContent,
|
||||
customData: {},
|
||||
};
|
||||
};
|
||||
|
||||
const ResumesList = () => {
|
||||
return <ResumeWorkbench />;
|
||||
@@ -187,21 +324,76 @@ const ResumeCardItem = ({ id, resume, t, locale, setActiveResume, router, delete
|
||||
);
|
||||
};
|
||||
|
||||
const AnimatedImportButton = ({ onClick, t }: { onClick: () => void; t: any }) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
onHoverStart={() => setIsHovered(true)}
|
||||
onHoverEnd={() => setIsHovered(false)}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"relative h-10 overflow-hidden px-4 font-medium transition-all duration-300",
|
||||
"border-border/60 bg-background hover:border-primary/50 hover:bg-accent/50 hover:shadow-sm",
|
||||
"dark:border-border/40 dark:hover:border-primary/40"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative h-5 w-5 overflow-hidden">
|
||||
<motion.div
|
||||
animate={{
|
||||
y: isHovered ? -20 : 0,
|
||||
}}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 20,
|
||||
}}
|
||||
className="flex flex-col"
|
||||
>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<Braces className="h-4 w-4 text-blue-500" />
|
||||
</div>
|
||||
<div className="flex h-5 w-5 items-center justify-center">
|
||||
<PdfIcon className="h-4 w-4 text-red-500" />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<span className="relative z-10">{t("dashboard.resumes.import")}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const ResumeWorkbench = () => {
|
||||
const t = useTranslations();
|
||||
const locale = useLocale();
|
||||
const {
|
||||
resumes,
|
||||
setActiveResume,
|
||||
updateResume,
|
||||
updateResumeFromFile,
|
||||
addResume,
|
||||
deleteResume,
|
||||
createResume,
|
||||
} = useResumeStore();
|
||||
const {
|
||||
geminiApiKey,
|
||||
geminiModelId,
|
||||
} = useAIConfigStore();
|
||||
const router = useRouter();
|
||||
const [hasConfiguredFolder, setHasConfiguredFolder] = React.useState(false);
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = React.useState(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = React.useState(false);
|
||||
const [isImporting, setIsImporting] = React.useState(false);
|
||||
const jsonFileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const pdfFileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const syncResumesFromFiles = async () => {
|
||||
@@ -214,7 +406,7 @@ const ResumeWorkbench = () => {
|
||||
|
||||
const dirHandle = handle as FileSystemDirectoryHandle;
|
||||
|
||||
for await (const entry of dirHandle.values()) {
|
||||
for await (const entry of (dirHandle as any).values()) {
|
||||
if (entry.kind === "file" && entry.name.endsWith(".json")) {
|
||||
try {
|
||||
const file = await entry.getFile();
|
||||
@@ -234,7 +426,7 @@ const ResumeWorkbench = () => {
|
||||
if (Object.keys(resumes).length === 0) {
|
||||
syncResumesFromFiles();
|
||||
}
|
||||
}, [resumes, updateResume]);
|
||||
}, [resumes, updateResumeFromFile]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSavedConfig = async () => {
|
||||
@@ -283,39 +475,158 @@ const ResumeWorkbench = () => {
|
||||
setActiveResume(newId);
|
||||
router.push(`/app/workbench/${newId}`);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleImportJson = () => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const content = await file.text();
|
||||
const config = JSON.parse(content);
|
||||
|
||||
const newResume = {
|
||||
...initialResumeState,
|
||||
...config,
|
||||
id: generateUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
addResume(newResume);
|
||||
toast.success(t("dashboard.resumes.importSuccess"));
|
||||
} catch (error) {
|
||||
console.error("Import error:", error);
|
||||
toast.error(t("dashboard.resumes.importError"));
|
||||
}
|
||||
const importResumeFromJson = async (file: File) => {
|
||||
const content = await file.text();
|
||||
const config = JSON.parse(content);
|
||||
const now = new Date().toISOString();
|
||||
const newResume = {
|
||||
...initialResumeState,
|
||||
...config,
|
||||
id: generateUUID(),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
const resumeId = addResume(newResume);
|
||||
setActiveResume(resumeId);
|
||||
setIsImportDialogOpen(false);
|
||||
toast.success(t("dashboard.resumes.importSuccess"));
|
||||
router.push(`/app/workbench/${resumeId}`);
|
||||
};
|
||||
|
||||
input.click();
|
||||
const extractImagesFromPdf = async (file: File) => {
|
||||
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
||||
const buffer = await file.arrayBuffer();
|
||||
const typedPdfjs = pdfjs as any;
|
||||
|
||||
typedPdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl;
|
||||
|
||||
const loadingTask = typedPdfjs.getDocument({
|
||||
data: new Uint8Array(buffer),
|
||||
});
|
||||
const pdf = await loadingTask.promise;
|
||||
const pageImages: string[] = [];
|
||||
const totalPages = Math.min(pdf.numPages, MAX_PDF_IMPORT_PAGES);
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
const page = await pdf.getPage(pageNumber);
|
||||
const baseViewport = page.getViewport({ scale: 2 });
|
||||
const widthScale = Math.min(1, PDF_MAX_IMAGE_WIDTH / baseViewport.width);
|
||||
const viewport = page.getViewport({ scale: 2 * widthScale });
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d", { alpha: false });
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Unable to create canvas context");
|
||||
}
|
||||
|
||||
canvas.width = Math.max(1, Math.floor(viewport.width));
|
||||
canvas.height = Math.max(1, Math.floor(viewport.height));
|
||||
|
||||
await page.render({
|
||||
canvasContext: context,
|
||||
viewport,
|
||||
}).promise;
|
||||
|
||||
const imageDataUrl = canvas.toDataURL("image/jpeg", PDF_IMAGE_QUALITY);
|
||||
pageImages.push(imageDataUrl);
|
||||
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
|
||||
return pageImages;
|
||||
};
|
||||
|
||||
const importResumeFromPdf = async (file: File) => {
|
||||
if (!geminiApiKey || !geminiModelId) {
|
||||
toast.error(t("dashboard.resumes.importDialog.geminiConfigRequired"));
|
||||
router.push("/app/dashboard/ai");
|
||||
return;
|
||||
}
|
||||
|
||||
const pdfImages = await extractImagesFromPdf(file);
|
||||
if (pdfImages.length === 0) {
|
||||
throw new Error("No extractable PDF pages");
|
||||
}
|
||||
|
||||
const response = await fetch("/api/resume-import", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
images: pdfImages,
|
||||
apiKey: geminiApiKey,
|
||||
model: geminiModelId,
|
||||
locale,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
const message = data?.details
|
||||
? `${data?.error || "Resume import failed"}\n${data.details}`
|
||||
: data?.error || "Resume import failed";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const aiResume = data?.resume
|
||||
? data.resume
|
||||
: data?.choices?.[0]?.message?.content
|
||||
? extractJsonContent(data.choices[0].message.content)
|
||||
: null;
|
||||
|
||||
if (!aiResume) {
|
||||
throw new Error("Invalid AI response");
|
||||
}
|
||||
|
||||
const nameWithoutExt = file.name.replace(/\.[^.]+$/, "").trim();
|
||||
const resume = createResumeFromAIResult(aiResume, nameWithoutExt);
|
||||
const resumeId = addResume(resume);
|
||||
setActiveResume(resumeId);
|
||||
setIsImportDialogOpen(false);
|
||||
toast.success(t("dashboard.resumes.importDialog.pdfSuccess"));
|
||||
router.push(`/app/workbench/${resumeId}`);
|
||||
};
|
||||
|
||||
const handleJsonFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file || isImporting) return;
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
await importResumeFromJson(file);
|
||||
} catch (error) {
|
||||
console.error("Import JSON error:", error);
|
||||
toast.error(t("dashboard.resumes.importError"));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePdfFileChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file || isImporting) return;
|
||||
|
||||
try {
|
||||
setIsImporting(true);
|
||||
await importResumeFromPdf(file);
|
||||
} catch (error) {
|
||||
console.error("Import PDF error:", error);
|
||||
const message =
|
||||
error instanceof Error && error.message
|
||||
? error.message
|
||||
: t("dashboard.resumes.importDialog.pdfError");
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -389,20 +700,7 @@ const ResumeWorkbench = () => {
|
||||
{t("dashboard.resumes.myResume")}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 17 }}
|
||||
>
|
||||
<Button
|
||||
onClick={handleImportJson}
|
||||
variant="outline"
|
||||
className="hover:bg-gray-100 dark:border-primary/50 dark:hover:bg-primary/10"
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{t("dashboard.resumes.import")}
|
||||
</Button>
|
||||
</motion.div>
|
||||
<AnimatedImportButton onClick={() => setIsImportDialogOpen(true)} t={t} />
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
@@ -481,6 +779,16 @@ const ResumeWorkbench = () => {
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreate={handleCreateFromModal}
|
||||
/>
|
||||
|
||||
<ImportResumeDialog
|
||||
open={isImportDialogOpen}
|
||||
isImporting={isImporting}
|
||||
onOpenChange={setIsImportDialogOpen}
|
||||
jsonFileInputRef={jsonFileInputRef}
|
||||
pdfFileInputRef={pdfFileInputRef}
|
||||
onJsonFileChange={handleJsonFileChange}
|
||||
onPdfFileChange={handlePdfFileChange}
|
||||
/>
|
||||
</motion.div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
@@ -58,6 +58,8 @@ export default function AIPolishDialog({
|
||||
openaiApiKey,
|
||||
openaiModelId,
|
||||
openaiApiEndpoint,
|
||||
geminiApiKey,
|
||||
geminiModelId,
|
||||
isConfigured
|
||||
} = useAIConfigStore();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
@@ -77,6 +79,23 @@ export default function AIPolishDialog({
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
const config = AI_MODEL_CONFIGS[selectedModel];
|
||||
const apiKey =
|
||||
selectedModel === "doubao"
|
||||
? doubaoApiKey
|
||||
: selectedModel === "openai"
|
||||
? openaiApiKey
|
||||
: selectedModel === "gemini"
|
||||
? geminiApiKey
|
||||
: deepseekApiKey;
|
||||
const modelId =
|
||||
selectedModel === "doubao"
|
||||
? doubaoModelId
|
||||
: selectedModel === "openai"
|
||||
? openaiModelId
|
||||
: selectedModel === "gemini"
|
||||
? geminiModelId
|
||||
: deepseekModelId;
|
||||
|
||||
const response = await fetch("/api/polish", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -84,13 +103,9 @@ export default function AIPolishDialog({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: turndownService.turndown(content),
|
||||
apiKey: selectedModel === "doubao" ? doubaoApiKey : selectedModel === "openai" ? openaiApiKey : deepseekApiKey,
|
||||
apiKey,
|
||||
apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined,
|
||||
model:
|
||||
selectedModel === "doubao"
|
||||
? doubaoModelId
|
||||
: selectedModel === "openai" ? openaiModelId
|
||||
: config.requiresModelId ? deepseekModelId : deepseekApiKey,
|
||||
model: config.requiresModelId ? modelId : config.defaultModel,
|
||||
modelType: selectedModel
|
||||
}),
|
||||
signal: abortControllerRef.current.signal
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PdfIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const PdfIcon = ({ className }: PdfIconProps) => {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn("h-full w-full", className)}
|
||||
>
|
||||
{/* Main red body with rounded corners */}
|
||||
<path
|
||||
d="M4 4C4 2.89543 4.89543 2 6 2H14L20 8V20C20 21.1046 19.1046 22 18 22H6C4.89543 22 4 21.1046 4 20V4Z"
|
||||
fill="#EF4444"
|
||||
/>
|
||||
{/* Lighter fold corner */}
|
||||
<path
|
||||
d="M14 2V8H20L14 2Z"
|
||||
fill="#FECACA"
|
||||
fillOpacity="0.9"
|
||||
/>
|
||||
{/* White 'PDF' text - using simplified paths for better scaling than <text> */}
|
||||
<g fill="white">
|
||||
<path d="M7 11.5H8.8C9.35228 11.5 9.8 11.9477 9.8 12.5C9.8 13.0523 9.35228 13.5 8.8 13.5H8V15H7V11.5ZM8.8 12.5H8V11.5H8.8V12.5Z" />
|
||||
<path d="M11 11.5H12.5C13.3284 11.5 14 12.1716 14 13C14 13.8284 13.3284 14.5 12.5 14.5H12V15H11V11.5ZM12.5 13.5C12.7761 13.5 13 13.2761 13 13C13 12.7239 12.7761 12.5 12.5 12.5H12V13.5H12.5Z" />
|
||||
<path d="M15 11.5H17.5V12.5H16V13H17.5V14H16V15H15V11.5Z" />
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
+18
-21
@@ -1,4 +1,4 @@
|
||||
export type AIModelType = "doubao" | "deepseek" | "openai";
|
||||
export type AIModelType = "doubao" | "deepseek" | "openai" | "gemini";
|
||||
|
||||
export interface AIValidationContext {
|
||||
doubaoApiKey?: string;
|
||||
@@ -8,10 +8,12 @@ export interface AIValidationContext {
|
||||
openaiApiKey?: string;
|
||||
openaiModelId?: string;
|
||||
openaiApiEndpoint?: string;
|
||||
geminiApiKey?: string;
|
||||
geminiModelId?: string;
|
||||
}
|
||||
|
||||
export interface AIModelConfig {
|
||||
url: (endpoint: string) => string;
|
||||
url: (endpoint?: string) => string;
|
||||
requiresModelId: boolean;
|
||||
defaultModel?: string;
|
||||
headers: (apiKey: string) => Record<string, string>;
|
||||
@@ -20,7 +22,7 @@ export interface AIModelConfig {
|
||||
|
||||
export const AI_MODEL_CONFIGS: Record<AIModelType, AIModelConfig> = {
|
||||
doubao: {
|
||||
url: (endpoint: string) => "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
url: () => "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
requiresModelId: true,
|
||||
headers: (apiKey: string) => ({
|
||||
"Content-Type": "application/json",
|
||||
@@ -29,36 +31,31 @@ export const AI_MODEL_CONFIGS: Record<AIModelType, AIModelConfig> = {
|
||||
validate: (context: AIValidationContext) => !!(context.doubaoApiKey && context.doubaoModelId),
|
||||
},
|
||||
deepseek: {
|
||||
url: (endpoint: string) => "https://api.deepseek.com/v1/chat/completions",
|
||||
url: () => "https://api.deepseek.com/v1/chat/completions",
|
||||
requiresModelId: false,
|
||||
defaultModel: "deepseek-chat",
|
||||
headers: (apiKey: string) => ({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
}),
|
||||
validate: (context: AIValidationContext) =>
|
||||
context.deepseekModelId
|
||||
// If requiresModelId is false (for deepseek it is), we usually just check apiKey?
|
||||
// But looking at previous store logic:
|
||||
// requiredModelId ? (apiKey && modelId) : apiKey
|
||||
// For deepseek config above, requiresModelId is false.
|
||||
// So logic should be !!context.deepseekApiKey
|
||||
// BUT, in your previous store code:
|
||||
// config.requiresModelId ? !!(state.deepseekApiKey && state.deepseekModelId) : !!state.deepseekApiKey
|
||||
// Deepseek config has requiresModelId: false.
|
||||
// So it returns !!state.deepseekApiKey.
|
||||
// Wait, let's make it generic based on its own config if possible, or just hardcode specific logic.
|
||||
// Since we are inside the specific config, we know logic.
|
||||
? !!(context.deepseekApiKey && context.deepseekModelId)
|
||||
: !!context.deepseekApiKey,
|
||||
validate: (context: AIValidationContext) => !!context.deepseekApiKey,
|
||||
},
|
||||
openai: {
|
||||
url: (endpoint: string) => `${endpoint}/chat/completions`,
|
||||
url: (endpoint?: string) => `${endpoint}/chat/completions`,
|
||||
requiresModelId: true,
|
||||
headers: (apiKey: string) => ({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
}),
|
||||
validate: (context: AIValidationContext) => !!(context.openaiApiKey && context.openaiModelId && context.openaiApiEndpoint),
|
||||
}
|
||||
},
|
||||
gemini: {
|
||||
url: () => "https://generativelanguage.googleapis.com/v1beta",
|
||||
requiresModelId: true,
|
||||
headers: (apiKey: string) => ({
|
||||
"Content-Type": "application/json",
|
||||
"x-goog-api-key": apiKey,
|
||||
}),
|
||||
validate: (context: AIValidationContext) => !!(context.geminiApiKey && context.geminiModelId),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"deleteSuccess": "Deleted successfully",
|
||||
"deleteModuleConfirm": "Are you sure you want to delete this module? This action cannot be undone."
|
||||
"deleteModuleConfirm": "Are you sure you want to delete this module? This action cannot be undone.",
|
||||
"configured": "Configured",
|
||||
"notConfigured": "Not configured"
|
||||
},
|
||||
"home": {
|
||||
"header": {
|
||||
@@ -115,10 +117,21 @@
|
||||
"create": "Create Resume",
|
||||
"newResume": "New Resume",
|
||||
"newResumeDescription": "Create a new resume to get started.",
|
||||
"import": "Import JSON Config",
|
||||
"import": "Import Resume",
|
||||
"untitled": "Untitled Resume",
|
||||
"importSuccess": "Configuration imported successfully",
|
||||
"importError": "Import failed, please check the file format",
|
||||
"importDialog": {
|
||||
"title": "Import Resume",
|
||||
"jsonTitle": "Import JSON",
|
||||
"jsonDescription": "Import an exported resume config file (.json)",
|
||||
"pdfTitle": "Import PDF",
|
||||
"pdfDescription": "Use Gemini into structured resume data",
|
||||
"importing": "Importing, please wait...",
|
||||
"geminiConfigRequired": "Please configure Gemini API Key and Model ID in AI settings first",
|
||||
"pdfSuccess": "PDF imported successfully",
|
||||
"pdfError": "PDF import failed. Please check PDF content or Gemini configuration"
|
||||
},
|
||||
"notice": {
|
||||
"title": "Attention",
|
||||
"description": "It is recommended to configure a resume backup folder in the settings to prevent your data from being lost when the browser cache is cleared",
|
||||
@@ -191,6 +204,12 @@
|
||||
"apiKey": "OpenAI API Key",
|
||||
"modelId": "Model ID",
|
||||
"apiEndpoint": "API Endpoint, example: https://openai.example.org/v1"
|
||||
},
|
||||
"gemini": {
|
||||
"title": "Gemini",
|
||||
"description": "Supports polish, grammar check, and PDF image resume import. Recommended model: gemini-flash-latest",
|
||||
"apiKey": "Gemini API Key",
|
||||
"modelId": "Gemini Model ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -11,7 +11,9 @@
|
||||
"cancel": "取消",
|
||||
"confirm": "确认",
|
||||
"deleteSuccess": "删除成功",
|
||||
"deleteModuleConfirm": "确定要删除此模块吗?此操作无法撤销。"
|
||||
"deleteModuleConfirm": "确定要删除此模块吗?此操作无法撤销。",
|
||||
"configured": "已配置",
|
||||
"notConfigured": "未配置"
|
||||
},
|
||||
"home": {
|
||||
"header": {
|
||||
@@ -116,10 +118,21 @@
|
||||
"create": "新建简历",
|
||||
"newResume": "新建简历",
|
||||
"newResumeDescription": "创建一个新简历以开始。",
|
||||
"import": "导入 JSON 配置",
|
||||
"import": "导入简历",
|
||||
"untitled": "未命名简历",
|
||||
"importSuccess": "配置导入成功",
|
||||
"importError": "配置导入失败,请检查文件格式",
|
||||
"importDialog": {
|
||||
"title": "导入简历",
|
||||
"jsonTitle": "导入 JSON",
|
||||
"jsonDescription": "导入已导出的简历配置文件(.json)",
|
||||
"pdfTitle": "导入 PDF",
|
||||
"pdfDescription": "使用 Gemini 自动识别生成简历结构",
|
||||
"importing": "正在导入,请稍候...",
|
||||
"geminiConfigRequired": "请先在 AI 服务商设置页配置 Gemini API Key 和模型 ID",
|
||||
"pdfSuccess": "PDF 导入成功",
|
||||
"pdfError": "PDF 导入失败,请检查 PDF 内容或 Gemini 配置"
|
||||
},
|
||||
"notice": {
|
||||
"title": "注意",
|
||||
"description": "建议在设置里中配置简历备份文件夹,防止您的数据可能会在浏览器清除缓存后丢失",
|
||||
@@ -192,6 +205,12 @@
|
||||
"apiKey": "API Key",
|
||||
"modelId": "模型 ID",
|
||||
"apiEndpoint": "API 端点,如:https://openai.example.org/v1"
|
||||
},
|
||||
"gemini": {
|
||||
"title": "Gemini",
|
||||
"description": "支持润色、语法检查和 PDF 图片识别导入。推荐模型 ID:gemini-flash-latest",
|
||||
"apiKey": "Gemini API Key",
|
||||
"modelId": "Gemini 模型 ID"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||
import { ProxyAgent, setGlobalDispatcher } from "undici";
|
||||
|
||||
let proxyDispatcherInitialized = false;
|
||||
|
||||
export const ensureGeminiProxyDispatcher = () => {
|
||||
if (proxyDispatcherInitialized) return;
|
||||
|
||||
const proxyUrl =
|
||||
process.env.HTTPS_PROXY ||
|
||||
process.env.https_proxy ||
|
||||
process.env.HTTP_PROXY ||
|
||||
process.env.http_proxy;
|
||||
|
||||
if (!proxyUrl) {
|
||||
proxyDispatcherInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setGlobalDispatcher(new ProxyAgent(proxyUrl));
|
||||
} catch (error) {
|
||||
console.warn("Failed to initialize proxy dispatcher for Gemini:", error);
|
||||
} finally {
|
||||
proxyDispatcherInitialized = true;
|
||||
}
|
||||
};
|
||||
|
||||
export const getGeminiModelInstance = (params: {
|
||||
apiKey: string;
|
||||
model: string;
|
||||
systemInstruction?: string;
|
||||
generationConfig?: Record<string, unknown>;
|
||||
}) => {
|
||||
ensureGeminiProxyDispatcher();
|
||||
const genAI = new GoogleGenerativeAI(params.apiKey);
|
||||
|
||||
return genAI.getGenerativeModel({
|
||||
model: params.model,
|
||||
systemInstruction: params.systemInstruction,
|
||||
generationConfig: params.generationConfig,
|
||||
});
|
||||
};
|
||||
|
||||
export const formatGeminiErrorMessage = (error: unknown) => {
|
||||
const anyError = error as any;
|
||||
const baseMessage =
|
||||
typeof anyError?.message === "string" && anyError.message
|
||||
? anyError.message
|
||||
: "Gemini request failed";
|
||||
const details = anyError?.errorDetails;
|
||||
|
||||
if (!details) return baseMessage;
|
||||
|
||||
try {
|
||||
const detailText = Array.isArray(details)
|
||||
? JSON.stringify(details)
|
||||
: String(details);
|
||||
return `${baseMessage} | details: ${detailText}`;
|
||||
} catch (stringifyError) {
|
||||
return baseMessage;
|
||||
}
|
||||
};
|
||||
+46
-14
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai";
|
||||
import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini";
|
||||
|
||||
export const Route = createFileRoute("/api/grammar")({
|
||||
server: {
|
||||
@@ -20,18 +21,7 @@ export const Route = createFileRoute("/api/grammar")({
|
||||
throw new Error("Invalid model type");
|
||||
}
|
||||
|
||||
const response = await fetch(modelConfig.url(apiEndpoint), {
|
||||
method: "POST",
|
||||
headers: modelConfig.headers(apiKey),
|
||||
body: JSON.stringify({
|
||||
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
|
||||
response_format: {
|
||||
type: "json_object"
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。
|
||||
const systemPrompt = `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。
|
||||
|
||||
**严格禁止**:
|
||||
1. ❌ **禁止**提供任何风格、语气、润色或改写建议。如果句子在语法上是正确的(即使读起来不够优美),也**绝对不要**报错。
|
||||
@@ -59,7 +49,46 @@ export const Route = createFileRoute("/api/grammar")({
|
||||
]
|
||||
}
|
||||
|
||||
再次强调:**只找错别字和标点错误,不要做任何润色!**`
|
||||
再次强调:**只找错别字和标点错误,不要做任何润色!**`;
|
||||
|
||||
if (modelType === "gemini") {
|
||||
const geminiModel = model || "gemini-1.5-flash";
|
||||
const modelInstance = getGeminiModelInstance({
|
||||
apiKey,
|
||||
model: geminiModel,
|
||||
systemInstruction: systemPrompt,
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await modelInstance.generateContent(content);
|
||||
const text = result.response.text() || "";
|
||||
|
||||
return Response.json({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: text,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(modelConfig.url(apiEndpoint), {
|
||||
method: "POST",
|
||||
headers: modelConfig.headers(apiKey),
|
||||
body: JSON.stringify({
|
||||
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
|
||||
response_format: {
|
||||
type: "json_object"
|
||||
},
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@@ -73,7 +102,10 @@ export const Route = createFileRoute("/api/grammar")({
|
||||
return Response.json(data);
|
||||
} catch (error) {
|
||||
console.error("Error in grammar check:", error);
|
||||
return Response.json({ error: "Failed to check grammar" }, { status: 500 });
|
||||
return Response.json(
|
||||
{ error: formatGeminiErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+57
-11
@@ -1,5 +1,6 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai";
|
||||
import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini";
|
||||
|
||||
export const Route = createFileRoute("/api/polish")({
|
||||
server: {
|
||||
@@ -20,15 +21,7 @@ export const Route = createFileRoute("/api/polish")({
|
||||
throw new Error("Invalid model type");
|
||||
}
|
||||
|
||||
const response = await fetch(modelConfig.url(apiEndpoint), {
|
||||
method: "POST",
|
||||
headers: modelConfig.headers(apiKey),
|
||||
body: JSON.stringify({
|
||||
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。
|
||||
const systemPrompt = `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。
|
||||
|
||||
优化原则:
|
||||
1. 使用更专业的词汇和表达方式
|
||||
@@ -38,7 +31,57 @@ export const Route = createFileRoute("/api/polish")({
|
||||
5. 保持原有信息的完整性
|
||||
6. 严格保留原有的 Markdown 格式结构(列表项保持为列表项,加粗保持加粗等)
|
||||
|
||||
请直接返回优化后的 Markdown 文本,不要包含任何解释或其他内容。`
|
||||
请直接返回优化后的 Markdown 文本,不要包含任何解释或其他内容。`;
|
||||
|
||||
if (modelType === "gemini") {
|
||||
const geminiModel = model || "gemini-1.5-flash";
|
||||
const modelInstance = getGeminiModelInstance({
|
||||
apiKey,
|
||||
model: geminiModel,
|
||||
systemInstruction: systemPrompt,
|
||||
generationConfig: {
|
||||
temperature: 0.4,
|
||||
},
|
||||
});
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
const result = await modelInstance.generateContentStream(content);
|
||||
for await (const chunk of result.stream) {
|
||||
const chunkText = chunk.text();
|
||||
if (chunkText) {
|
||||
controller.enqueue(encoder.encode(chunkText));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
controller.error(error);
|
||||
return;
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(modelConfig.url(apiEndpoint), {
|
||||
method: "POST",
|
||||
headers: modelConfig.headers(apiKey),
|
||||
body: JSON.stringify({
|
||||
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: systemPrompt
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@@ -102,7 +145,10 @@ export const Route = createFileRoute("/api/polish")({
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Polish error:", error);
|
||||
return Response.json({ error: "Failed to polish content" }, { status: 500 });
|
||||
return Response.json(
|
||||
{ error: formatGeminiErrorMessage(error) },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini";
|
||||
|
||||
const parseJsonPayload = (content: string) => {
|
||||
const text = content.trim();
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (error) {}
|
||||
|
||||
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (fenced?.[1]) {
|
||||
try {
|
||||
return JSON.parse(fenced[1].trim());
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
const objectBlock = text.match(/\{[\s\S]*\}/);
|
||||
if (objectBlock?.[0]) {
|
||||
try {
|
||||
return JSON.parse(objectBlock[0]);
|
||||
} catch (error) {}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const extractBase64Payload = (value: string) => {
|
||||
const matched = value.match(/^data:(.*?);base64,(.*)$/);
|
||||
if (matched) {
|
||||
return {
|
||||
mimeType: matched[1] || "image/jpeg",
|
||||
data: matched[2] || "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
mimeType: "image/jpeg",
|
||||
data: value,
|
||||
};
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/api/resume-import")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { apiKey, model, content, images, locale } = body as {
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
content?: string;
|
||||
images?: string[];
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
if (!apiKey || (!content && (!images || images.length === 0))) {
|
||||
return Response.json(
|
||||
{ error: "Missing API key or resume content/images" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const language = locale === "en" ? "English" : "Chinese";
|
||||
const geminiModel = model || "gemini-flash-latest";
|
||||
const imageParts = Array.isArray(images)
|
||||
? images.map((image) => {
|
||||
const payload = extractBase64Payload(image);
|
||||
return {
|
||||
inlineData: {
|
||||
mimeType: payload.mimeType,
|
||||
data: payload.data,
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
const modelInstance = getGeminiModelInstance({
|
||||
apiKey,
|
||||
model: geminiModel,
|
||||
systemInstruction: `你是一个专业的简历结构化助手。根据用户提供的简历内容,提取信息并只输出一个合法 JSON 对象。
|
||||
|
||||
输出约束:
|
||||
1. 只允许输出 JSON,不要输出 Markdown,不要输出解释。
|
||||
2. 如果某个字段不确定,使用空字符串或空数组。
|
||||
3. 请使用 ${language} 输出内容文本。
|
||||
4. description/details 字段输出字符串数组,每一项为一句可读内容。
|
||||
|
||||
JSON 结构:
|
||||
{
|
||||
"title": "简历标题",
|
||||
"basic": {
|
||||
"name": "",
|
||||
"title": "",
|
||||
"email": "",
|
||||
"phone": "",
|
||||
"location": "",
|
||||
"employementStatus": "",
|
||||
"birthDate": ""
|
||||
},
|
||||
"education": [
|
||||
{
|
||||
"school": "",
|
||||
"major": "",
|
||||
"degree": "",
|
||||
"startDate": "",
|
||||
"endDate": "",
|
||||
"gpa": "",
|
||||
"description": ["", ""]
|
||||
}
|
||||
],
|
||||
"experience": [
|
||||
{
|
||||
"company": "",
|
||||
"position": "",
|
||||
"date": "",
|
||||
"details": ["", ""]
|
||||
}
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"name": "",
|
||||
"role": "",
|
||||
"date": "",
|
||||
"description": ["", ""],
|
||||
"link": ""
|
||||
}
|
||||
],
|
||||
"skills": ["", ""]
|
||||
}`,
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
responseMimeType: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const inputParts = [
|
||||
{
|
||||
text:
|
||||
content ||
|
||||
"请识别以下简历页面图片中的信息,并严格按 JSON 结构输出。",
|
||||
},
|
||||
...imageParts,
|
||||
];
|
||||
|
||||
const result = await modelInstance.generateContent(inputParts);
|
||||
const aiContent = result.response.text();
|
||||
|
||||
if (!aiContent || typeof aiContent !== "string") {
|
||||
return Response.json(
|
||||
{ error: "AI did not return structured content" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const parsedResume = parseJsonPayload(aiContent);
|
||||
if (!parsedResume) {
|
||||
return Response.json(
|
||||
{ error: "Failed to parse AI JSON output" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ resume: parsedResume });
|
||||
} catch (error) {
|
||||
console.error("Error in resume import:", error);
|
||||
const status =
|
||||
typeof (error as any)?.status === "number"
|
||||
? (error as any).status
|
||||
: 500;
|
||||
return Response.json(
|
||||
{ error: formatGeminiErrorMessage(error) },
|
||||
{ status }
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -11,6 +11,8 @@ interface AIConfigState {
|
||||
openaiApiKey: string;
|
||||
openaiModelId: string;
|
||||
openaiApiEndpoint: string;
|
||||
geminiApiKey: string;
|
||||
geminiModelId: string;
|
||||
setSelectedModel: (model: AIModelType) => void;
|
||||
setDoubaoApiKey: (apiKey: string) => void;
|
||||
setDoubaoModelId: (modelId: string) => void;
|
||||
@@ -19,6 +21,8 @@ interface AIConfigState {
|
||||
setOpenaiApiKey: (apiKey: string) => void;
|
||||
setOpenaiModelId: (modelId: string) => void;
|
||||
setOpenaiApiEndpoint: (endpoint: string) => void;
|
||||
setGeminiApiKey: (apiKey: string) => void;
|
||||
setGeminiModelId: (modelId: string) => void;
|
||||
isConfigured: () => boolean;
|
||||
}
|
||||
|
||||
@@ -33,6 +37,8 @@ export const useAIConfigStore = create<AIConfigState>()(
|
||||
openaiApiKey: "",
|
||||
openaiModelId: "",
|
||||
openaiApiEndpoint: "",
|
||||
geminiApiKey: "",
|
||||
geminiModelId: "gemini-1.5-flash",
|
||||
setSelectedModel: (model: AIModelType) => set({ selectedModel: model }),
|
||||
setDoubaoApiKey: (apiKey: string) => set({ doubaoApiKey: apiKey }),
|
||||
setDoubaoModelId: (modelId: string) => set({ doubaoModelId: modelId }),
|
||||
@@ -41,6 +47,8 @@ export const useAIConfigStore = create<AIConfigState>()(
|
||||
setOpenaiApiKey: (apiKey: string) => set({ openaiApiKey: apiKey }),
|
||||
setOpenaiModelId: (modelId: string) => set({ openaiModelId: modelId }),
|
||||
setOpenaiApiEndpoint: (endpoint: string) => set({ openaiApiEndpoint: endpoint }),
|
||||
setGeminiApiKey: (apiKey: string) => set({ geminiApiKey: apiKey }),
|
||||
setGeminiModelId: (modelId: string) => set({ geminiModelId: modelId }),
|
||||
isConfigured: () => {
|
||||
const state = get();
|
||||
const config = AI_MODEL_CONFIGS[state.selectedModel];
|
||||
|
||||
@@ -97,13 +97,29 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
|
||||
deepseekApiKey,
|
||||
deepseekModelId,
|
||||
openaiApiKey,
|
||||
openaiModelId
|
||||
openaiModelId,
|
||||
openaiApiEndpoint,
|
||||
geminiApiKey,
|
||||
geminiModelId
|
||||
} = useAIConfigStore.getState();
|
||||
|
||||
const config = AI_MODEL_CONFIGS[selectedModel];
|
||||
const apiKey = selectedModel === "doubao" ? doubaoApiKey : selectedModel === "openai" ? openaiApiKey : deepseekApiKey;
|
||||
const apiKey =
|
||||
selectedModel === "doubao"
|
||||
? doubaoApiKey
|
||||
: selectedModel === "openai"
|
||||
? openaiApiKey
|
||||
: selectedModel === "gemini"
|
||||
? geminiApiKey
|
||||
: deepseekApiKey;
|
||||
const modelId =
|
||||
selectedModel === "doubao" ? doubaoModelId : selectedModel === "openai" ? openaiModelId : deepseekModelId;
|
||||
selectedModel === "doubao"
|
||||
? doubaoModelId
|
||||
: selectedModel === "openai"
|
||||
? openaiModelId
|
||||
: selectedModel === "gemini"
|
||||
? geminiModelId
|
||||
: deepseekModelId;
|
||||
|
||||
set({ isChecking: true });
|
||||
|
||||
@@ -118,6 +134,7 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
|
||||
apiKey,
|
||||
model: config.requiresModelId ? modelId : config.defaultModel,
|
||||
modelType: selectedModel,
|
||||
apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -7,6 +7,12 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3000
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["pdfjs-dist"]
|
||||
},
|
||||
ssr: {
|
||||
noExternal: ["pdfjs-dist"]
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
tanstackStart({
|
||||
|
||||
Reference in New Issue
Block a user