feat: Integrate Gemini API for resume import && AI polishing && AI grammar

This commit is contained in:
JOYCEQL
2026-03-02 00:30:58 +08:00
parent 5c6dc7fee2
commit 7dda46a82e
18 changed files with 1230 additions and 170 deletions
+3
View File
@@ -12,6 +12,7 @@
"release": "bumpp" "release": "bumpp"
}, },
"dependencies": { "dependencies": {
"@google/generative-ai": "^0.24.1",
"@heroui/checkbox": "^2.3.30", "@heroui/checkbox": "^2.3.30",
"@heroui/date-input": "^2.3.30", "@heroui/date-input": "^2.3.30",
"@heroui/react": "^2.8.8", "@heroui/react": "^2.8.8",
@@ -62,6 +63,7 @@
"mark.js": "^8.11.1", "mark.js": "^8.11.1",
"markdown-exit": "1.0.0-beta.8", "markdown-exit": "1.0.0-beta.8",
"next-themes": "^0.4.3", "next-themes": "^0.4.3",
"pdfjs-dist": "^5.4.624",
"puppeteer": "^23.9.0", "puppeteer": "^23.9.0",
"puppeteer-core": "^23.9.0", "puppeteer-core": "^23.9.0",
"react": "^18", "react": "^18",
@@ -75,6 +77,7 @@
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"turndown": "^7.2.2", "turndown": "^7.2.2",
"undici": "^7.22.0",
"uuid": "^11.0.5", "uuid": "^11.0.5",
"vaul": "^1.1.1", "vaul": "^1.1.1",
"zustand": "^4.5.4" "zustand": "^4.5.4"
+153
View File
@@ -8,6 +8,9 @@ importers:
.: .:
dependencies: dependencies:
'@google/generative-ai':
specifier: ^0.24.1
version: 0.24.1
'@heroui/checkbox': '@heroui/checkbox':
specifier: ^2.3.30 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) 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: next-themes:
specifier: ^0.4.3 specifier: ^0.4.3
version: 0.4.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) 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: puppeteer:
specifier: ^23.9.0 specifier: ^23.9.0
version: 23.11.1(typescript@5.7.3) version: 23.11.1(typescript@5.7.3)
@@ -197,6 +203,9 @@ importers:
turndown: turndown:
specifier: ^7.2.2 specifier: ^7.2.2
version: 7.2.2 version: 7.2.2
undici:
specifier: ^7.22.0
version: 7.22.0
uuid: uuid:
specifier: ^11.0.5 specifier: ^11.0.5
version: 11.0.5 version: 11.0.5
@@ -787,6 +796,10 @@ packages:
'@formatjs/intl-localematcher@0.5.10': '@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==} 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': '@heroui/accordion@2.2.27':
resolution: {integrity: sha512-ay/pIMUo8+ZUQKPvBZ5qjqvW6luqBuxObACCk1zZZCDtCUIRW6agVzN5oQD+gmoDadDMGeAE8mRr1pfG93K39A==} resolution: {integrity: sha512-ay/pIMUo8+ZUQKPvBZ5qjqvW6luqBuxObACCk1zZZCDtCUIRW6agVzN5oQD+gmoDadDMGeAE8mRr1pfG93K39A==}
peerDependencies: peerDependencies:
@@ -1522,6 +1535,81 @@ packages:
'@mixmark-io/domino@2.2.0': '@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} 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': '@next/env@14.2.3':
resolution: {integrity: sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==} resolution: {integrity: sha512-W7fd7IbkfmeeY2gXrzJYDx8D2lWKbVoTIj1o1ScPHNzvp30s1AuoEFSdr39bC5sjxJaxTtq3OTCZboNp0lNWHA==}
@@ -4814,6 +4902,9 @@ packages:
node-fetch-native@1.6.7: node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} 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: node-releases@2.0.19:
resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==}
@@ -4924,6 +5015,10 @@ packages:
pathe@2.0.3: pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} 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: pend@1.2.0:
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
@@ -6357,6 +6452,8 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 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)': '@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: 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) '@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': {} '@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': '@next/env@14.2.3':
optional: true optional: true
@@ -11551,6 +11696,9 @@ snapshots:
node-fetch-native@1.6.7: {} node-fetch-native@1.6.7: {}
node-readable-to-web-readable-stream@0.4.2:
optional: true
node-releases@2.0.19: {} node-releases@2.0.19: {}
normalize-path@3.0.0: {} normalize-path@3.0.0: {}
@@ -11680,6 +11828,11 @@ snapshots:
pathe@2.0.3: {} 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: {} pend@1.2.0: {}
perfect-debounce@2.1.0: {} perfect-debounce@2.1.0: {}
+94 -62
View File
@@ -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 { useTranslations } from "@/i18n/compat/client";
import { ExternalLink } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import DeepSeekLogo from "@/components/ai/icon/IconDeepseek"; import DeepSeekLogo from "@/components/ai/icon/IconDeepseek";
import IconDoubao from "@/components/ai/icon/IconDoubao"; import IconDoubao from "@/components/ai/icon/IconDoubao";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAIConfigStore } from "@/store/useAIConfigStore"; import { useAIConfigStore } from "@/store/useAIConfigStore";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import IconOpenAi from "@/components/ai/icon/IconOpenAi"; import IconOpenAi from "@/components/ai/icon/IconOpenAi";
@@ -24,33 +17,38 @@ const AISettingsPage = () => {
openaiApiKey, openaiApiKey,
openaiModelId, openaiModelId,
openaiApiEndpoint, openaiApiEndpoint,
geminiApiKey,
geminiModelId,
setDoubaoApiKey, setDoubaoApiKey,
setDoubaoModelId, setDoubaoModelId,
setDeepseekApiKey, setDeepseekApiKey,
setOpenaiApiKey, setOpenaiApiKey,
setOpenaiModelId, setOpenaiModelId,
setOpenaiApiEndpoint, setOpenaiApiEndpoint,
setGeminiApiKey,
setGeminiModelId,
selectedModel, selectedModel,
setSelectedModel, setSelectedModel,
} = useAIConfigStore(); } = useAIConfigStore();
const [currentModel, setCurrentModel] = useState(selectedModel);
const [currentModel, setCurrentModel] = useState(""); const t = useTranslations();
useEffect(() => { useEffect(() => {
setCurrentModel(selectedModel); setCurrentModel(selectedModel);
}, [selectedModel]); }, [selectedModel]);
const t = useTranslations();
const handleApiKeyChange = async ( const handleApiKeyChange = async (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
type: "doubao" | "deepseek" | "openai" type: "doubao" | "deepseek" | "openai" | "gemini"
) => { ) => {
const newApiKey = e.target.value; const newApiKey = e.target.value;
if (type === "doubao") { if (type === "doubao") {
setDoubaoApiKey(newApiKey); setDoubaoApiKey(newApiKey);
} else if (type === "deepseek") { } else if (type === "deepseek") {
setDeepseekApiKey(newApiKey); setDeepseekApiKey(newApiKey);
} else if (type === "gemini") {
setGeminiApiKey(newApiKey);
} else { } else {
setOpenaiApiKey(newApiKey); setOpenaiApiKey(newApiKey);
} }
@@ -58,13 +56,15 @@ const AISettingsPage = () => {
const handleModelIdChange = async ( const handleModelIdChange = async (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
type: "doubao" | "deepseek" | "openai" type: "doubao" | "deepseek" | "openai" | "gemini"
) => { ) => {
const newModelId = e.target.value; const newModelId = e.target.value;
if (type === "doubao") { if (type === "doubao") {
setDoubaoModelId(newModelId); setDoubaoModelId(newModelId);
} else if (type === "openai") { } else if (type === "openai") {
setOpenaiModelId(newModelId); setOpenaiModelId(newModelId);
} else if (type === "gemini") {
setGeminiModelId(newModelId);
} }
}; };
@@ -109,76 +109,87 @@ const AISettingsPage = () => {
bgColor: "bg-blue-50 dark:bg-blue-950/50", bgColor: "bg-blue-50 dark:bg-blue-950/50",
isConfigured: !!(openaiApiKey && openaiModelId && openaiApiEndpoint), 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 ( return (
<div className="mx-auto py-4 px-4"> <div className="mx-auto py-4 px-4">
<div className="flex gap-8"> <div className="flex gap-8">
<div className="w-64 space-y-6"> <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"> <div className="flex flex-col space-y-1">
{models.map((model) => { {models.map((model) => {
const Icon = model.icon; const Icon = model.icon;
const isActive = currentModel === model.id; const isChecked = selectedModel === model.id;
const isViewing = currentModel === model.id;
return ( return (
<button <div
key={model.id} key={model.id}
onClick={() => setCurrentModel(model.id)} onClick={() => {
setCurrentModel(model.id as typeof currentModel);
}}
className={cn( className={cn(
"flex items-center gap-3 px-3 py-2 rounded-lg text-left relative", "w-full flex items-center gap-3 px-3 py-3 rounded-lg text-left border",
"transition-all duration-200", "transition-all duration-200 cursor-pointer",
"hover:bg-primary/10", "hover:bg-primary/10 hover:border-primary/30",
isActive && "bg-primary/10" isViewing
? "bg-primary/10 border-primary/40"
: "border-transparent"
)} )}
> >
<div <div
className={cn( className={cn(
"shrink-0", "shrink-0",
isActive ? "text-primary" : "text-muted-foreground" isViewing ? "text-primary" : "text-muted-foreground"
)} )}
> >
<Icon className="h-5 w-5" /> <Icon className="h-5 w-5" />
</div> </div>
<div className="flex flex-col items-start"> <div className="flex-1 min-w-0 flex flex-col items-start">
<span <span
className={cn( className={cn(
"font-medium text-sm", "font-medium text-sm",
isActive && "text-primary" isViewing && "text-primary"
)} )}
> >
{model.name} {model.name}
</span> </span>
<span className="text-xs text-muted-foreground truncate w-full">
{model.isConfigured
? t("common.configured")
: t("common.notConfigured")}
</span>
</div> </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> </div>
@@ -223,12 +234,14 @@ const AISettingsPage = () => {
? doubaoApiKey ? doubaoApiKey
: model.id === "openai" : model.id === "openai"
? openaiApiKey ? openaiApiKey
: model.id === "gemini"
? geminiApiKey
: deepseekApiKey : deepseekApiKey
} }
onChange={(e) => onChange={(e) =>
handleApiKeyChange( handleApiKeyChange(
e, e,
model.id as "doubao" | "deepseek" | "openai" model.id as "doubao" | "deepseek" | "openai" | "gemini"
) )
} }
type="password" type="password"
@@ -244,7 +257,7 @@ const AISettingsPage = () => {
/> />
</div> </div>
{currentModel === "doubao" && ( {model.id === "doubao" && (
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium"> <Label className="text-base font-medium">
{t("dashboard.settings.ai.doubao.modelId")} {t("dashboard.settings.ai.doubao.modelId")}
@@ -265,7 +278,7 @@ const AISettingsPage = () => {
</div> </div>
)} )}
{currentModel === "openai" && ( {model.id === "openai" && (
<div className="space-y-4"> <div className="space-y-4">
<Label className="text-base font-medium"> <Label className="text-base font-medium">
{t("dashboard.settings.ai.openai.modelId")} {t("dashboard.settings.ai.openai.modelId")}
@@ -286,7 +299,26 @@ const AISettingsPage = () => {
</div> </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"> <div className="space-y-4">
<Label className="text-base font-medium"> <Label className="text-base font-medium">
{t("dashboard.settings.ai.openai.apiEndpoint")} {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>
</>
);
};
+357 -49
View File
@@ -1,7 +1,8 @@
import React, { useEffect } from "react"; import React, { useEffect } from "react";
import { useTranslations, useLocale } from "@/i18n/compat/client"; import { useTranslations, useLocale } from "@/i18n/compat/client";
import { useRouter } from "@/lib/navigation"; 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 { motion, AnimatePresence } from "framer-motion";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -34,6 +35,142 @@ import { DEFAULT_TEMPLATES } from "@/config";
import { generateUUID } from "@/utils/uuid"; import { generateUUID } from "@/utils/uuid";
import { CreateResumeModal } from "./CreateResumeModal"; 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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 = () => { const ResumesList = () => {
return <ResumeWorkbench />; 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 ResumeWorkbench = () => {
const t = useTranslations(); const t = useTranslations();
const locale = useLocale(); const locale = useLocale();
const { const {
resumes, resumes,
setActiveResume, setActiveResume,
updateResume,
updateResumeFromFile, updateResumeFromFile,
addResume, addResume,
deleteResume, deleteResume,
createResume, createResume,
} = useResumeStore(); } = useResumeStore();
const {
geminiApiKey,
geminiModelId,
} = useAIConfigStore();
const router = useRouter(); const router = useRouter();
const [hasConfiguredFolder, setHasConfiguredFolder] = React.useState(false); const [hasConfiguredFolder, setHasConfiguredFolder] = React.useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = 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(() => { useEffect(() => {
const syncResumesFromFiles = async () => { const syncResumesFromFiles = async () => {
@@ -214,7 +406,7 @@ const ResumeWorkbench = () => {
const dirHandle = handle as FileSystemDirectoryHandle; 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")) { if (entry.kind === "file" && entry.name.endsWith(".json")) {
try { try {
const file = await entry.getFile(); const file = await entry.getFile();
@@ -234,7 +426,7 @@ const ResumeWorkbench = () => {
if (Object.keys(resumes).length === 0) { if (Object.keys(resumes).length === 0) {
syncResumesFromFiles(); syncResumesFromFiles();
} }
}, [resumes, updateResume]); }, [resumes, updateResumeFromFile]);
useEffect(() => { useEffect(() => {
const loadSavedConfig = async () => { const loadSavedConfig = async () => {
@@ -283,39 +475,158 @@ const ResumeWorkbench = () => {
setActiveResume(newId); setActiveResume(newId);
router.push(`/app/workbench/${newId}`); router.push(`/app/workbench/${newId}`);
}; };
const importResumeFromJson = async (file: File) => {
const content = await file.text();
const config = JSON.parse(content);
const handleImportJson = () => { const now = new Date().toISOString();
const input = document.createElement("input"); const newResume = {
input.type = "file"; ...initialResumeState,
input.accept = ".json"; ...config,
id: generateUUID(),
input.onchange = async (e) => { createdAt: now,
const file = (e.target as HTMLInputElement).files?.[0]; updatedAt: now,
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 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 ( return (
@@ -389,20 +700,7 @@ const ResumeWorkbench = () => {
{t("dashboard.resumes.myResume")} {t("dashboard.resumes.myResume")}
</h1> </h1>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<motion.div <AnimatedImportButton onClick={() => setIsImportDialogOpen(true)} t={t} />
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>
<motion.div <motion.div
whileHover={{ scale: 1.05 }} whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }} whileTap={{ scale: 0.95 }}
@@ -481,6 +779,16 @@ const ResumeWorkbench = () => {
onOpenChange={setIsCreateModalOpen} onOpenChange={setIsCreateModalOpen}
onCreate={handleCreateFromModal} onCreate={handleCreateFromModal}
/> />
<ImportResumeDialog
open={isImportDialogOpen}
isImporting={isImporting}
onOpenChange={setIsImportDialogOpen}
jsonFileInputRef={jsonFileInputRef}
pdfFileInputRef={pdfFileInputRef}
onJsonFileChange={handleJsonFileChange}
onPdfFileChange={handlePdfFileChange}
/>
</motion.div> </motion.div>
</ScrollArea> </ScrollArea>
); );
+21 -6
View File
@@ -58,6 +58,8 @@ export default function AIPolishDialog({
openaiApiKey, openaiApiKey,
openaiModelId, openaiModelId,
openaiApiEndpoint, openaiApiEndpoint,
geminiApiKey,
geminiModelId,
isConfigured isConfigured
} = useAIConfigStore(); } = useAIConfigStore();
const abortControllerRef = useRef<AbortController | null>(null); const abortControllerRef = useRef<AbortController | null>(null);
@@ -77,6 +79,23 @@ export default function AIPolishDialog({
abortControllerRef.current = new AbortController(); abortControllerRef.current = new AbortController();
const config = AI_MODEL_CONFIGS[selectedModel]; 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", { const response = await fetch("/api/polish", {
method: "POST", method: "POST",
headers: { headers: {
@@ -84,13 +103,9 @@ export default function AIPolishDialog({
}, },
body: JSON.stringify({ body: JSON.stringify({
content: turndownService.turndown(content), content: turndownService.turndown(content),
apiKey: selectedModel === "doubao" ? doubaoApiKey : selectedModel === "openai" ? openaiApiKey : deepseekApiKey, apiKey,
apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined, apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined,
model: model: config.requiresModelId ? modelId : config.defaultModel,
selectedModel === "doubao"
? doubaoModelId
: selectedModel === "openai" ? openaiModelId
: config.requiresModelId ? deepseekModelId : deepseekApiKey,
modelType: selectedModel modelType: selectedModel
}), }),
signal: abortControllerRef.current.signal signal: abortControllerRef.current.signal
+35
View File
@@ -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
View File
@@ -1,4 +1,4 @@
export type AIModelType = "doubao" | "deepseek" | "openai"; export type AIModelType = "doubao" | "deepseek" | "openai" | "gemini";
export interface AIValidationContext { export interface AIValidationContext {
doubaoApiKey?: string; doubaoApiKey?: string;
@@ -8,10 +8,12 @@ export interface AIValidationContext {
openaiApiKey?: string; openaiApiKey?: string;
openaiModelId?: string; openaiModelId?: string;
openaiApiEndpoint?: string; openaiApiEndpoint?: string;
geminiApiKey?: string;
geminiModelId?: string;
} }
export interface AIModelConfig { export interface AIModelConfig {
url: (endpoint: string) => string; url: (endpoint?: string) => string;
requiresModelId: boolean; requiresModelId: boolean;
defaultModel?: string; defaultModel?: string;
headers: (apiKey: string) => Record<string, string>; headers: (apiKey: string) => Record<string, string>;
@@ -20,7 +22,7 @@ export interface AIModelConfig {
export const AI_MODEL_CONFIGS: Record<AIModelType, AIModelConfig> = { export const AI_MODEL_CONFIGS: Record<AIModelType, AIModelConfig> = {
doubao: { 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, requiresModelId: true,
headers: (apiKey: string) => ({ headers: (apiKey: string) => ({
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -29,36 +31,31 @@ export const AI_MODEL_CONFIGS: Record<AIModelType, AIModelConfig> = {
validate: (context: AIValidationContext) => !!(context.doubaoApiKey && context.doubaoModelId), validate: (context: AIValidationContext) => !!(context.doubaoApiKey && context.doubaoModelId),
}, },
deepseek: { deepseek: {
url: (endpoint: string) => "https://api.deepseek.com/v1/chat/completions", url: () => "https://api.deepseek.com/v1/chat/completions",
requiresModelId: false, requiresModelId: false,
defaultModel: "deepseek-chat", defaultModel: "deepseek-chat",
headers: (apiKey: string) => ({ headers: (apiKey: string) => ({
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}), }),
validate: (context: AIValidationContext) => validate: (context: AIValidationContext) => !!context.deepseekApiKey,
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,
}, },
openai: { openai: {
url: (endpoint: string) => `${endpoint}/chat/completions`, url: (endpoint?: string) => `${endpoint}/chat/completions`,
requiresModelId: true, requiresModelId: true,
headers: (apiKey: string) => ({ headers: (apiKey: string) => ({
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
}), }),
validate: (context: AIValidationContext) => !!(context.openaiApiKey && context.openaiModelId && context.openaiApiEndpoint), 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),
},
}; };
+21 -2
View File
@@ -11,7 +11,9 @@
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"deleteSuccess": "Deleted successfully", "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": { "home": {
"header": { "header": {
@@ -115,10 +117,21 @@
"create": "Create Resume", "create": "Create Resume",
"newResume": "New Resume", "newResume": "New Resume",
"newResumeDescription": "Create a new resume to get started.", "newResumeDescription": "Create a new resume to get started.",
"import": "Import JSON Config", "import": "Import Resume",
"untitled": "Untitled Resume", "untitled": "Untitled Resume",
"importSuccess": "Configuration imported successfully", "importSuccess": "Configuration imported successfully",
"importError": "Import failed, please check the file format", "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": { "notice": {
"title": "Attention", "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", "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", "apiKey": "OpenAI API Key",
"modelId": "Model ID", "modelId": "Model ID",
"apiEndpoint": "API Endpoint, example: https://openai.example.org/v1" "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"
} }
} }
}, },
+21 -2
View File
@@ -11,7 +11,9 @@
"cancel": "取消", "cancel": "取消",
"confirm": "确认", "confirm": "确认",
"deleteSuccess": "删除成功", "deleteSuccess": "删除成功",
"deleteModuleConfirm": "确定要删除此模块吗?此操作无法撤销。" "deleteModuleConfirm": "确定要删除此模块吗?此操作无法撤销。",
"configured": "已配置",
"notConfigured": "未配置"
}, },
"home": { "home": {
"header": { "header": {
@@ -116,10 +118,21 @@
"create": "新建简历", "create": "新建简历",
"newResume": "新建简历", "newResume": "新建简历",
"newResumeDescription": "创建一个新简历以开始。", "newResumeDescription": "创建一个新简历以开始。",
"import": "导入 JSON 配置", "import": "导入简历",
"untitled": "未命名简历", "untitled": "未命名简历",
"importSuccess": "配置导入成功", "importSuccess": "配置导入成功",
"importError": "配置导入失败,请检查文件格式", "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": { "notice": {
"title": "注意", "title": "注意",
"description": "建议在设置里中配置简历备份文件夹,防止您的数据可能会在浏览器清除缓存后丢失", "description": "建议在设置里中配置简历备份文件夹,防止您的数据可能会在浏览器清除缓存后丢失",
@@ -192,6 +205,12 @@
"apiKey": "API Key", "apiKey": "API Key",
"modelId": "模型 ID", "modelId": "模型 ID",
"apiEndpoint": "API 端点,如:https://openai.example.org/v1" "apiEndpoint": "API 端点,如:https://openai.example.org/v1"
},
"gemini": {
"title": "Gemini",
"description": "支持润色、语法检查和 PDF 图片识别导入。推荐模型 IDgemini-flash-latest",
"apiKey": "Gemini API Key",
"modelId": "Gemini 模型 ID"
} }
} }
}, },
+63
View File
@@ -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
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai"; import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai";
import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini";
export const Route = createFileRoute("/api/grammar")({ export const Route = createFileRoute("/api/grammar")({
server: { server: {
@@ -20,18 +21,7 @@ export const Route = createFileRoute("/api/grammar")({
throw new Error("Invalid model type"); throw new Error("Invalid model type");
} }
const response = await fetch(modelConfig.url(apiEndpoint), { const systemPrompt = `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。
method: "POST",
headers: modelConfig.headers(apiKey),
body: JSON.stringify({
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
response_format: {
type: "json_object"
},
messages: [
{
role: "system",
content: `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。
**严格禁止**: **严格禁止**:
1. ❌ **禁止**提供任何风格、语气、润色或改写建议。如果句子在语法上是正确的(即使读起来不够优美),也**绝对不要**报错。 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", role: "user",
@@ -73,7 +102,10 @@ export const Route = createFileRoute("/api/grammar")({
return Response.json(data); return Response.json(data);
} catch (error) { } catch (error) {
console.error("Error in grammar check:", 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
View File
@@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai"; import { AIModelType, AI_MODEL_CONFIGS } from "@/config/ai";
import { formatGeminiErrorMessage, getGeminiModelInstance } from "@/lib/server/gemini";
export const Route = createFileRoute("/api/polish")({ export const Route = createFileRoute("/api/polish")({
server: { server: {
@@ -20,15 +21,7 @@ export const Route = createFileRoute("/api/polish")({
throw new Error("Invalid model type"); throw new Error("Invalid model type");
} }
const response = await fetch(modelConfig.url(apiEndpoint), { const systemPrompt = `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。
method: "POST",
headers: modelConfig.headers(apiKey),
body: JSON.stringify({
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
messages: [
{
role: "system",
content: `你是一个专业的简历优化助手。请帮助优化以下 Markdown 格式的文本,使其更加专业和有吸引力。
优化原则: 优化原则:
1. 使用更专业的词汇和表达方式 1. 使用更专业的词汇和表达方式
@@ -38,7 +31,57 @@ export const Route = createFileRoute("/api/polish")({
5. 保持原有信息的完整性 5. 保持原有信息的完整性
6. 严格保留原有的 Markdown 格式结构(列表项保持为列表项,加粗保持加粗等) 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", role: "user",
@@ -102,7 +145,10 @@ export const Route = createFileRoute("/api/polish")({
}); });
} catch (error) { } catch (error) {
console.error("Polish error:", error); console.error("Polish error:", error);
return Response.json({ error: "Failed to polish content" }, { status: 500 }); return Response.json(
{ error: formatGeminiErrorMessage(error) },
{ status: 500 }
);
} }
} }
} }
+177
View File
@@ -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 }
);
}
},
},
},
});
+8
View File
@@ -11,6 +11,8 @@ interface AIConfigState {
openaiApiKey: string; openaiApiKey: string;
openaiModelId: string; openaiModelId: string;
openaiApiEndpoint: string; openaiApiEndpoint: string;
geminiApiKey: string;
geminiModelId: string;
setSelectedModel: (model: AIModelType) => void; setSelectedModel: (model: AIModelType) => void;
setDoubaoApiKey: (apiKey: string) => void; setDoubaoApiKey: (apiKey: string) => void;
setDoubaoModelId: (modelId: string) => void; setDoubaoModelId: (modelId: string) => void;
@@ -19,6 +21,8 @@ interface AIConfigState {
setOpenaiApiKey: (apiKey: string) => void; setOpenaiApiKey: (apiKey: string) => void;
setOpenaiModelId: (modelId: string) => void; setOpenaiModelId: (modelId: string) => void;
setOpenaiApiEndpoint: (endpoint: string) => void; setOpenaiApiEndpoint: (endpoint: string) => void;
setGeminiApiKey: (apiKey: string) => void;
setGeminiModelId: (modelId: string) => void;
isConfigured: () => boolean; isConfigured: () => boolean;
} }
@@ -33,6 +37,8 @@ export const useAIConfigStore = create<AIConfigState>()(
openaiApiKey: "", openaiApiKey: "",
openaiModelId: "", openaiModelId: "",
openaiApiEndpoint: "", openaiApiEndpoint: "",
geminiApiKey: "",
geminiModelId: "gemini-1.5-flash",
setSelectedModel: (model: AIModelType) => set({ selectedModel: model }), setSelectedModel: (model: AIModelType) => set({ selectedModel: model }),
setDoubaoApiKey: (apiKey: string) => set({ doubaoApiKey: apiKey }), setDoubaoApiKey: (apiKey: string) => set({ doubaoApiKey: apiKey }),
setDoubaoModelId: (modelId: string) => set({ doubaoModelId: modelId }), setDoubaoModelId: (modelId: string) => set({ doubaoModelId: modelId }),
@@ -41,6 +47,8 @@ export const useAIConfigStore = create<AIConfigState>()(
setOpenaiApiKey: (apiKey: string) => set({ openaiApiKey: apiKey }), setOpenaiApiKey: (apiKey: string) => set({ openaiApiKey: apiKey }),
setOpenaiModelId: (modelId: string) => set({ openaiModelId: modelId }), setOpenaiModelId: (modelId: string) => set({ openaiModelId: modelId }),
setOpenaiApiEndpoint: (endpoint: string) => set({ openaiApiEndpoint: endpoint }), setOpenaiApiEndpoint: (endpoint: string) => set({ openaiApiEndpoint: endpoint }),
setGeminiApiKey: (apiKey: string) => set({ geminiApiKey: apiKey }),
setGeminiModelId: (modelId: string) => set({ geminiModelId: modelId }),
isConfigured: () => { isConfigured: () => {
const state = get(); const state = get();
const config = AI_MODEL_CONFIGS[state.selectedModel]; const config = AI_MODEL_CONFIGS[state.selectedModel];
+20 -3
View File
@@ -97,13 +97,29 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
deepseekApiKey, deepseekApiKey,
deepseekModelId, deepseekModelId,
openaiApiKey, openaiApiKey,
openaiModelId openaiModelId,
openaiApiEndpoint,
geminiApiKey,
geminiModelId
} = useAIConfigStore.getState(); } = useAIConfigStore.getState();
const config = AI_MODEL_CONFIGS[selectedModel]; 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 = const modelId =
selectedModel === "doubao" ? doubaoModelId : selectedModel === "openai" ? openaiModelId : deepseekModelId; selectedModel === "doubao"
? doubaoModelId
: selectedModel === "openai"
? openaiModelId
: selectedModel === "gemini"
? geminiModelId
: deepseekModelId;
set({ isChecking: true }); set({ isChecking: true });
@@ -118,6 +134,7 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
apiKey, apiKey,
model: config.requiresModelId ? modelId : config.defaultModel, model: config.requiresModelId ? modelId : config.defaultModel,
modelType: selectedModel, modelType: selectedModel,
apiEndpoint: selectedModel === "openai" ? openaiApiEndpoint : undefined,
}), }),
}); });
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+6
View File
@@ -7,6 +7,12 @@ export default defineConfig({
server: { server: {
port: 3000 port: 3000
}, },
optimizeDeps: {
exclude: ["pdfjs-dist"]
},
ssr: {
noExternal: ["pdfjs-dist"]
},
plugins: [ plugins: [
tsconfigPaths(), tsconfigPaths(),
tanstackStart({ tanstackStart({