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"
|
"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"
|
||||||
|
|||||||
Generated
+153
@@ -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: {}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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, "&")
|
||||||
|
.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 = () => {
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
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),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 图片识别导入。推荐模型 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 { 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
@@ -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 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
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];
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user