mirror of
https://github.com/JOYCEQL/magic-resume.git
synced 2026-07-03 14:07:11 +02:00
feat: ai config deepseek
This commit is contained in:
@@ -1,48 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { AIModelType } from "@/store/useAIConfigStore";
|
||||
import { AI_MODEL_CONFIGS } from "@/config/ai";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { apiKey, model, content } = body;
|
||||
const { apiKey, model, content, modelType } = body;
|
||||
|
||||
const response = await fetch(
|
||||
"https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
|
||||
if (!modelConfig) {
|
||||
throw new Error("Invalid model type");
|
||||
}
|
||||
|
||||
const response = await fetch(modelConfig.url, {
|
||||
method: "POST",
|
||||
headers: modelConfig.headers(apiKey),
|
||||
|
||||
body: JSON.stringify({
|
||||
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
|
||||
response_format: {
|
||||
type: "json_object",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的中文简历语法检查助手。请完整检查以下文本中的语法语境错别字,包括:
|
||||
1. 只考虑语法组词中的错别字
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的中文简历错别字检查助手。请帮助检查以下文本,以下是要求:
|
||||
1.仅需考虑组词中的错别字,不要考虑语法和格式错误。
|
||||
2.对每个发现的问题,请按JSON格式返回
|
||||
|
||||
对每个发现的问题,请以JSON格式返回,格式如下:
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"text": "错误的文本",
|
||||
"message": "详细的错误说明",
|
||||
"type": "spelling"或"grammar",
|
||||
"suggestions": ["建议修改1", "建议修改2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
以下是示例格式:
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"text": "错误的文本",
|
||||
"message": "详细的错误说明",
|
||||
"type": "spelling"或"grammar",
|
||||
"suggestions": ["建议修改1", "建议修改2"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
请确保返回的是可解析的JSON格式。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: content,
|
||||
},
|
||||
],
|
||||
}),
|
||||
}
|
||||
);
|
||||
请确保返回的格式可以正常解析`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: content,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
@@ -1,44 +1,45 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { AIModelType } from "@/store/useAIConfigStore";
|
||||
import { AI_MODEL_CONFIGS } from "@/config/ai";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { apiKey, model, content } = body;
|
||||
const { apiKey, model, content, modelType } = body;
|
||||
|
||||
const response = await fetch(
|
||||
"https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。
|
||||
|
||||
优化原则:
|
||||
1. 使用更专业的词汇和表达方式
|
||||
2. 突出关键成就和技能
|
||||
3. 保持简洁清晰
|
||||
4. 使用主动语气
|
||||
5. 保持原有信息的完整性
|
||||
6. 保留我输入的格式
|
||||
|
||||
请直接返回优化后的文本,不要包含任何解释或其他内容。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
}),
|
||||
}
|
||||
);
|
||||
const modelConfig = AI_MODEL_CONFIGS[modelType as AIModelType];
|
||||
if (!modelConfig) {
|
||||
throw new Error("Invalid model type");
|
||||
}
|
||||
|
||||
const response = await fetch(modelConfig.url, {
|
||||
method: "POST",
|
||||
headers: modelConfig.headers(apiKey),
|
||||
body: JSON.stringify({
|
||||
model: modelConfig.requiresModelId ? model : modelConfig.defaultModel,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: `你是一个专业的简历优化助手。请帮助优化以下文本,使其更加专业和有吸引力。
|
||||
|
||||
优化原则:
|
||||
1. 使用更专业的词汇和表达方式
|
||||
2. 突出关键成就和技能
|
||||
3. 保持简洁清晰
|
||||
4. 使用主动语气
|
||||
5. 保持原有信息的完整性
|
||||
6. 保留我输入的格式
|
||||
|
||||
请直接返回优化后的文本,不要包含任何解释或其他内容。`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
|
||||
@@ -15,6 +15,13 @@ import { useAIConfigStore } from "@/store/useAIConfigStore";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import {
|
||||
getFileHandle,
|
||||
@@ -30,8 +37,18 @@ const SettingsPage = () => {
|
||||
useState<FileSystemDirectoryHandle | null>(null);
|
||||
const [folderPath, setFolderPath] = useState<string>("");
|
||||
|
||||
const { doubaoApiKey, doubaoModelId, setDoubaoApiKey, setDoubaoModelId } =
|
||||
useAIConfigStore();
|
||||
const {
|
||||
selectedModel,
|
||||
doubaoApiKey,
|
||||
doubaoModelId,
|
||||
deepseekApiKey,
|
||||
deepseekModelId,
|
||||
setSelectedModel,
|
||||
setDoubaoApiKey,
|
||||
setDoubaoModelId,
|
||||
setDeepseekApiKey,
|
||||
setDeepseekModelId,
|
||||
} = useAIConfigStore();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
@@ -79,160 +96,236 @@ const SettingsPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleApiKeyChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const handleApiKeyChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
type: "doubao" | "deepseek"
|
||||
) => {
|
||||
const newApiKey = e.target.value;
|
||||
setDoubaoApiKey(newApiKey);
|
||||
if (type === "doubao") {
|
||||
setDoubaoApiKey(newApiKey);
|
||||
} else {
|
||||
setDeepseekApiKey(newApiKey);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelIdChange = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
e: React.ChangeEvent<HTMLInputElement>,
|
||||
type: "doubao" | "deepseek"
|
||||
) => {
|
||||
const newModelId = e.target.value;
|
||||
setDoubaoModelId(newModelId);
|
||||
if (type === "doubao") {
|
||||
setDoubaoModelId(newModelId);
|
||||
} else {
|
||||
setDeepseekModelId(newModelId);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-6 px-4 pt-6 mx-auto">
|
||||
<div className="flex-1 space-y-8 px-4 pt-6 mx-auto max-w-4xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 to-purple-500 text-transparent bg-clip-text">
|
||||
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 text-transparent bg-clip-text">
|
||||
{t("dashboard.settings.title")}
|
||||
</h2>
|
||||
</div>
|
||||
<Separator className="my-6" />
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2">
|
||||
<Card
|
||||
className={cn(
|
||||
"col-span-1 border-2 transition-all duration-200",
|
||||
"hover:border-indigo-500/20 hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<div className="p-2 rounded-lg bg-indigo-50 dark:bg-indigo-950">
|
||||
<Folder className="h-5 w-5 text-indigo-500" />
|
||||
<div className="grid gap-8">
|
||||
{/* 同步目录卡片 */}
|
||||
<Card className="overflow-hidden border-0 shadow-lg shadow-indigo-500/5 hover:shadow-indigo-500/10 transition-all duration-300">
|
||||
<CardHeader className="space-y-4 bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-indigo-500/10 to-purple-500/10 dark:from-indigo-500/20 dark:to-purple-500/20">
|
||||
<Folder className="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
{t("dashboard.settings.syncDirectory.title")}
|
||||
<span className="bg-gradient-to-br from-indigo-600 to-purple-600 dark:from-indigo-400 dark:to-purple-400 text-transparent bg-clip-text">
|
||||
{t("dashboard.settings.sync.title")}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{t("dashboard.settings.syncDirectory.description")}
|
||||
<CardDescription className="text-base text-slate-600 dark:text-slate-400">
|
||||
{t("dashboard.settings.sync.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
{folderPath ? (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
{t(
|
||||
"dashboard.settings.syncDirectory.currentSyncFolder"
|
||||
)}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-xs py-2 px-3"
|
||||
>
|
||||
<Folder className="h-3 w-3 mr-1" />
|
||||
{folderPath}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-6 bg-muted/30 rounded-lg">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"dashboard.settings.syncDirectory.noFolderConfigured"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={folderPath ? "outline" : "default"}
|
||||
onClick={handleSelectDirectory}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
!folderPath &&
|
||||
"text-white hover:from-indigo-600 hover:to-purple-600"
|
||||
)}
|
||||
>
|
||||
{folderPath
|
||||
? t("dashboard.settings.syncDirectory.changeFolder")
|
||||
: t("dashboard.settings.syncDirectory.selectFolder")}
|
||||
</Button>
|
||||
</div>
|
||||
<CardContent className="pt-6 space-y-4 px-8 pb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
value={folderPath}
|
||||
readOnly
|
||||
className="h-11 bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500"
|
||||
placeholder={t(
|
||||
"dashboard.settings.syncDirectory.noFolderConfigured"
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleSelectDirectory}
|
||||
className="h-11 min-w-[120px] bg-gradient-to-r from-indigo-500 to-purple-500 hover:from-indigo-600 hover:to-purple-600 text-white shadow-md shadow-indigo-500/20 hover:shadow-indigo-500/30 transition-all duration-300"
|
||||
>
|
||||
{t("dashboard.settings.sync.select")}
|
||||
</Button>
|
||||
</div>
|
||||
{directoryHandle && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-2 py-1.5 text-sm border-slate-200 dark:border-slate-800"
|
||||
>
|
||||
<Folder className="h-4 w-4" />
|
||||
{folderPath}
|
||||
</Badge>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className={cn(
|
||||
"col-span-1 border-2 transition-all duration-200",
|
||||
"hover:border-purple-500/20 hover:shadow-lg"
|
||||
)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-xl">
|
||||
<div className="p-2 rounded-lg bg-purple-50 dark:bg-purple-950">
|
||||
<Bot className="h-5 w-5 text-purple-500" />
|
||||
{/* AI配置卡片 */}
|
||||
<Card className="overflow-hidden border-0 shadow-lg shadow-indigo-500/5 hover:shadow-indigo-500/10 transition-all duration-300">
|
||||
<CardHeader className="space-y-4 bg-gradient-to-b from-slate-50 to-white dark:from-slate-950 dark:to-slate-900">
|
||||
<CardTitle className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-gradient-to-br from-indigo-500/10 to-purple-500/10 dark:from-indigo-500/20 dark:to-purple-500/20">
|
||||
<Bot className="h-6 w-6 text-indigo-600 dark:text-indigo-400" />
|
||||
</div>
|
||||
{t("dashboard.settings.aiConfig.title")}
|
||||
<span className="bg-gradient-to-br from-indigo-600 to-purple-600 dark:from-indigo-400 dark:to-purple-400 text-transparent bg-clip-text">
|
||||
{t("dashboard.settings.ai.title")}
|
||||
</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm text-muted-foreground">
|
||||
{t("dashboard.settings.aiConfig.description")}
|
||||
<CardDescription className="text-base text-slate-600 dark:text-slate-400">
|
||||
{t("dashboard.settings.ai.description")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="p-1.5 rounded-md bg-purple-50 dark:bg-purple-950">
|
||||
<Key className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
{t("dashboard.settings.aiConfig.apiKey.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="doubaoApiKey"
|
||||
type="password"
|
||||
value={doubaoApiKey}
|
||||
onChange={handleApiKeyChange}
|
||||
placeholder={t(
|
||||
"dashboard.settings.aiConfig.apiKey.placeholder"
|
||||
)}
|
||||
className="font-mono bg-muted/30"
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.settings.aiConfig.apiKey.description")}
|
||||
<CardContent className="pt-6 space-y-8 px-8 pb-8">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base text-slate-700 dark:text-slate-300">
|
||||
{t("dashboard.settings.ai.selectedModel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedModel}
|
||||
onValueChange={(value) =>
|
||||
setSelectedModel(value as "doubao" | "deepseek")
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-11 bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-800">
|
||||
<SelectValue placeholder="Select a model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="doubao">
|
||||
<span className="font-medium">
|
||||
{t("dashboard.settings.ai.doubao.title")}
|
||||
</span>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</SelectItem>
|
||||
<SelectItem value="deepseek">
|
||||
<span className="font-medium">
|
||||
{t("dashboard.settings.ai.deepseek.title")}
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-6 rounded-xl p-6 transition-all duration-300",
|
||||
selectedModel === "doubao"
|
||||
? "bg-gradient-to-br from-slate-50 to-white dark:from-slate-900 dark:to-slate-900/50 border border-indigo-100 dark:border-indigo-500/20 shadow-lg shadow-indigo-500/5"
|
||||
: "bg-slate-50/50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800/50 opacity-50"
|
||||
)}
|
||||
>
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-slate-800 dark:text-slate-200">
|
||||
<Bot className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
{t("dashboard.settings.ai.doubao.title")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base flex items-center gap-2 text-slate-700 dark:text-slate-300">
|
||||
<Key className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
|
||||
{t("dashboard.settings.ai.doubao.apiKey")}
|
||||
<a
|
||||
href="https://www.doubao.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 hover:underline inline-flex items-center"
|
||||
>
|
||||
{t("dashboard.settings.aiConfig.apiKey.description")}
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</a>
|
||||
</Label>
|
||||
<Input
|
||||
id="doubaoApiKey"
|
||||
value={doubaoApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e, "doubao")}
|
||||
type="password"
|
||||
className="h-11 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500"
|
||||
placeholder={t(
|
||||
"dashboard.settings.aiConfig.apiKey.placeholder"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base flex items-center gap-2 text-slate-700 dark:text-slate-300">
|
||||
<Hash className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
|
||||
{t("dashboard.settings.ai.doubao.modelId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="doubaoModelId"
|
||||
value={doubaoModelId}
|
||||
onChange={(e) => handleModelIdChange(e, "doubao")}
|
||||
className="h-11 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500"
|
||||
placeholder={t(
|
||||
"dashboard.settings.aiConfig.modelId.placeholder"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="flex items-center gap-2 text-sm font-medium">
|
||||
<div className="p-1.5 rounded-md bg-purple-50 dark:bg-purple-950">
|
||||
<Hash className="h-4 w-4 text-purple-500" />
|
||||
</div>
|
||||
{t("dashboard.settings.aiConfig.modelId.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id="doubaoModelId"
|
||||
type="text"
|
||||
value={doubaoModelId}
|
||||
onChange={handleModelIdChange}
|
||||
placeholder={t(
|
||||
"dashboard.settings.aiConfig.modelId.placeholder"
|
||||
)}
|
||||
className="font-mono bg-muted/30"
|
||||
/>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.settings.aiConfig.modelId.description")}
|
||||
</span>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<div
|
||||
className={cn(
|
||||
"space-y-6 rounded-xl p-6 transition-all duration-300",
|
||||
selectedModel === "deepseek"
|
||||
? "bg-gradient-to-br from-slate-50 to-white dark:from-slate-900 dark:to-slate-900/50 border border-indigo-100 dark:border-indigo-500/20 shadow-lg shadow-indigo-500/5"
|
||||
: "bg-slate-50/50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-800/50 opacity-50"
|
||||
)}
|
||||
>
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2 text-slate-800 dark:text-slate-200">
|
||||
<Bot className="h-5 w-5 text-indigo-600 dark:text-indigo-400" />
|
||||
{t("dashboard.settings.ai.deepseek.title")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base flex items-center gap-2 text-slate-700 dark:text-slate-300">
|
||||
<Key className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
|
||||
{t("dashboard.settings.ai.deepseek.apiKey")}
|
||||
<a
|
||||
href="https://platform.deepseek.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-indigo-600 dark:text-indigo-400 hover:text-indigo-700 dark:hover:text-indigo-300 hover:underline inline-flex items-center"
|
||||
>
|
||||
{t("dashboard.settings.aiConfig.apiKey.description")}
|
||||
<ExternalLink className="h-3 w-3 ml-1" />
|
||||
</a>
|
||||
</Label>
|
||||
<Input
|
||||
id="deepseekApiKey"
|
||||
value={deepseekApiKey}
|
||||
onChange={(e) => handleApiKeyChange(e, "deepseek")}
|
||||
type="password"
|
||||
className="h-11 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500"
|
||||
placeholder={t(
|
||||
"dashboard.settings.aiConfig.apiKey.placeholder"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base flex items-center gap-2 text-slate-700 dark:text-slate-300">
|
||||
<Hash className="h-4 w-4 text-indigo-600 dark:text-indigo-400" />
|
||||
{t("dashboard.settings.ai.deepseek.modelId")}
|
||||
</Label>
|
||||
<Input
|
||||
id="deepseekModelId"
|
||||
value={deepseekModelId}
|
||||
onChange={(e) => handleModelIdChange(e, "deepseek")}
|
||||
className="h-11 bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-800 focus-visible:ring-indigo-500"
|
||||
placeholder={t(
|
||||
"dashboard.settings.aiConfig.modelId.placeholder"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -7,16 +9,12 @@ import ThemeToggle from "../shared/ThemeToggle";
|
||||
import { useResumeStore } from "@/store/useResumeStore";
|
||||
import { getThemeConfig } from "@/theme/themeConfig";
|
||||
import { useGrammarCheck } from "@/hooks/useGrammarCheck";
|
||||
import { useEffect } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardTrigger,
|
||||
HoverCardContent,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
interface EditorHeaderProps {
|
||||
isMobile?: boolean;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useGrammarCheck } from "@/hooks/useGrammarCheck";
|
||||
import { useAIConfigStore } from "@/store/useAIConfigStore";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AI_MODEL_CONFIGS } from "@/config/ai";
|
||||
|
||||
export type IconProps = React.HTMLAttributes<SVGElement>;
|
||||
|
||||
@@ -54,11 +55,25 @@ export const PreviewDock = ({
|
||||
const router = useRouter();
|
||||
const t = useTranslations("previewDock");
|
||||
const { checkGrammar, isChecking } = useGrammarCheck();
|
||||
const { doubaoApiKey, doubaoModelId } = useAIConfigStore();
|
||||
const {
|
||||
selectedModel,
|
||||
doubaoApiKey,
|
||||
doubaoModelId,
|
||||
deepseekApiKey,
|
||||
deepseekModelId,
|
||||
} = useAIConfigStore();
|
||||
|
||||
const handleGrammarCheck = useCallback(async () => {
|
||||
if (!resumeContentRef.current) return;
|
||||
const config = AI_MODEL_CONFIGS[selectedModel];
|
||||
const isConfigured =
|
||||
selectedModel === "doubao"
|
||||
? doubaoApiKey && doubaoModelId
|
||||
: config.requiresModelId
|
||||
? deepseekApiKey && deepseekModelId
|
||||
: deepseekApiKey;
|
||||
|
||||
if (!doubaoApiKey || !doubaoModelId) {
|
||||
if (!isConfigured) {
|
||||
toast.error(
|
||||
<>
|
||||
<span>{t("grammarCheck.configurePrompt")}</span>
|
||||
@@ -79,7 +94,15 @@ export const PreviewDock = ({
|
||||
} catch (error) {
|
||||
toast.error(t("grammarCheck.errorToast"));
|
||||
}
|
||||
}, [resumeContentRef, doubaoApiKey, doubaoModelId, t]);
|
||||
}, [
|
||||
resumeContentRef,
|
||||
selectedModel,
|
||||
doubaoApiKey,
|
||||
doubaoModelId,
|
||||
deepseekApiKey,
|
||||
deepseekModelId,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleGoGitHub = () => {
|
||||
window.open(GITHUB_REPO_URL, "_blank");
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAIConfigStore } from "@/store/useAIConfigStore";
|
||||
import { AI_MODEL_CONFIGS } from "@/config/ai";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AIPolishDialogProps {
|
||||
@@ -29,12 +30,32 @@ export default function AIPolishDialog({
|
||||
}: AIPolishDialogProps) {
|
||||
const [isPolishing, setIsPolishing] = useState(false);
|
||||
const [polishedContent, setPolishedContent] = useState("");
|
||||
const { doubaoApiKey, doubaoModelId } = useAIConfigStore();
|
||||
const {
|
||||
selectedModel,
|
||||
doubaoApiKey,
|
||||
doubaoModelId,
|
||||
deepseekApiKey,
|
||||
deepseekModelId,
|
||||
} = useAIConfigStore();
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const polishedContentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handlePolish = async () => {
|
||||
try {
|
||||
const config = AI_MODEL_CONFIGS[selectedModel];
|
||||
const isConfigured =
|
||||
selectedModel === "doubao"
|
||||
? doubaoApiKey && doubaoModelId
|
||||
: config.requiresModelId
|
||||
? deepseekApiKey && deepseekModelId
|
||||
: deepseekApiKey;
|
||||
|
||||
if (!isConfigured) {
|
||||
toast.error("请先配置 AI 模型");
|
||||
onOpenChange(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPolishing(true);
|
||||
setPolishedContent("");
|
||||
|
||||
@@ -47,8 +68,14 @@ export default function AIPolishDialog({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content,
|
||||
apiKey: doubaoApiKey,
|
||||
model: doubaoModelId,
|
||||
apiKey: selectedModel === "doubao" ? doubaoApiKey : deepseekApiKey,
|
||||
model:
|
||||
selectedModel === "doubao"
|
||||
? doubaoModelId
|
||||
: config.requiresModelId
|
||||
? deepseekModelId
|
||||
: config.defaultModel,
|
||||
modelType: selectedModel,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AIModelType } from "@/store/useAIConfigStore";
|
||||
|
||||
export interface AIModelConfig {
|
||||
url: string;
|
||||
requiresModelId: boolean;
|
||||
defaultModel?: string;
|
||||
headers: (apiKey: string) => Record<string, string>;
|
||||
}
|
||||
|
||||
export const AI_MODEL_CONFIGS: Record<AIModelType, AIModelConfig> = {
|
||||
doubao: {
|
||||
url: "https://ark.cn-beijing.volces.com/api/v3/chat/completions",
|
||||
requiresModelId: true,
|
||||
headers: (apiKey: string) => ({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
}),
|
||||
},
|
||||
deepseek: {
|
||||
url: "https://api.deepseek.com/v1/chat/completions",
|
||||
requiresModelId: false,
|
||||
defaultModel: "deepseek-chat",
|
||||
headers: (apiKey: string) => ({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -79,6 +79,26 @@
|
||||
"placeholder": "Please enter Doubao Model ID",
|
||||
"description": "Obtain Model ID from Doubao Open Platform"
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
"title": "Sync Directory",
|
||||
"description": "Choose a folder to sync and backup your resumes.",
|
||||
"select": "Select Folder"
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Model Configuration",
|
||||
"description": "Configure AI models to provide intelligent optimization and suggestions for your resume.",
|
||||
"selectedModel": "Select Model",
|
||||
"doubao": {
|
||||
"title": "Doubao Settings",
|
||||
"apiKey": "API Key",
|
||||
"modelId": "Model ID"
|
||||
},
|
||||
"deepseek": {
|
||||
"title": "Deepseek Settings",
|
||||
"apiKey": "API Key",
|
||||
"modelId": "Model ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -79,6 +79,26 @@
|
||||
"placeholder": "请输入豆包模型 ID",
|
||||
"description": "在豆包开放平台获取模型 ID"
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
"title": "同步目录",
|
||||
"description": "选择一个文件夹来同步和备份您的简历。",
|
||||
"select": "选择文件夹"
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI 模型配置",
|
||||
"description": "配置 AI 模型,让 AI 助手为你的简历提供智能优化和建议。",
|
||||
"selectedModel": "选择模型",
|
||||
"doubao": {
|
||||
"title": "豆包",
|
||||
"apiKey": "API 密钥",
|
||||
"modelId": "模型 ID"
|
||||
},
|
||||
"deepseek": {
|
||||
"title": "Deepseek",
|
||||
"apiKey": "API 密钥",
|
||||
"modelId": "模型 ID"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export type AIModelType = "doubao" | "deepseek";
|
||||
|
||||
interface AIConfigState {
|
||||
selectedModel: AIModelType;
|
||||
doubaoApiKey: string;
|
||||
doubaoModelId: string;
|
||||
deepseekApiKey: string;
|
||||
deepseekModelId: string;
|
||||
setSelectedModel: (model: AIModelType) => void;
|
||||
setDoubaoApiKey: (apiKey: string) => void;
|
||||
setDoubaoModelId: (modelId: string) => void;
|
||||
setDeepseekApiKey: (apiKey: string) => void;
|
||||
setDeepseekModelId: (modelId: string) => void;
|
||||
}
|
||||
|
||||
export const useAIConfigStore = create<AIConfigState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
selectedModel: "doubao",
|
||||
doubaoApiKey: "",
|
||||
doubaoModelId: "",
|
||||
deepseekApiKey: "",
|
||||
deepseekModelId: "",
|
||||
setSelectedModel: (model: AIModelType) => set({ selectedModel: model }),
|
||||
setDoubaoApiKey: (apiKey: string) => set({ doubaoApiKey: apiKey }),
|
||||
setDoubaoModelId: (modelId: string) => set({ doubaoModelId: modelId }),
|
||||
setDeepseekApiKey: (apiKey: string) => set({ deepseekApiKey: apiKey }),
|
||||
setDeepseekModelId: (modelId: string) =>
|
||||
set({ deepseekModelId: modelId }),
|
||||
}),
|
||||
{
|
||||
name: "ai-config-storage",
|
||||
|
||||
@@ -2,6 +2,8 @@ import { create } from "zustand";
|
||||
import { toast } from "sonner";
|
||||
import Mark from "mark.js";
|
||||
import { useAIConfigStore } from "@/store/useAIConfigStore";
|
||||
import { AI_MODEL_CONFIGS } from "@/config/ai";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface GrammarError {
|
||||
error: string;
|
||||
@@ -38,7 +40,19 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
|
||||
set((state) => ({ highlightKey: state.highlightKey + 1 })),
|
||||
|
||||
checkGrammar: async (text: string) => {
|
||||
const { doubaoApiKey, doubaoModelId } = useAIConfigStore.getState();
|
||||
const {
|
||||
selectedModel,
|
||||
doubaoApiKey,
|
||||
doubaoModelId,
|
||||
deepseekApiKey,
|
||||
deepseekModelId,
|
||||
} = useAIConfigStore.getState();
|
||||
|
||||
const config = AI_MODEL_CONFIGS[selectedModel];
|
||||
const apiKey = selectedModel === "doubao" ? doubaoApiKey : deepseekApiKey;
|
||||
const modelId =
|
||||
selectedModel === "doubao" ? doubaoModelId : deepseekModelId;
|
||||
|
||||
set({ isChecking: true });
|
||||
|
||||
try {
|
||||
@@ -49,8 +63,9 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: text,
|
||||
apiKey: doubaoApiKey,
|
||||
model: doubaoModelId,
|
||||
apiKey,
|
||||
model: config.requiresModelId ? modelId : config.defaultModel,
|
||||
modelType: selectedModel,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -70,6 +85,7 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
|
||||
try {
|
||||
const grammarErrors = JSON.parse(aiResponse);
|
||||
if (grammarErrors.errors.length === 0) {
|
||||
set({ errors: [] });
|
||||
toast.success("无语法错误");
|
||||
return;
|
||||
}
|
||||
@@ -106,47 +122,33 @@ export const useGrammarStore = create<GrammarStore>((set, get) => ({
|
||||
},
|
||||
|
||||
selectError: (index: number) => {
|
||||
const state = get();
|
||||
const error = state.errors[index];
|
||||
const { errors } = get();
|
||||
const error = errors[index];
|
||||
if (!error) return;
|
||||
|
||||
set({ selectedErrorIndex: index });
|
||||
|
||||
const preview = document.getElementById("resume-preview");
|
||||
if (preview) {
|
||||
const marker = new Mark(preview);
|
||||
marker.unmark();
|
||||
if (!preview) return;
|
||||
|
||||
state.errors.forEach((error) => {
|
||||
marker.mark(error.context || error.text || "", {
|
||||
className: `grammar-error ${error.type || ""}`,
|
||||
acrossElements: true,
|
||||
separateWordSearch: false,
|
||||
caseSensitive: true,
|
||||
});
|
||||
const marker = new Mark(preview);
|
||||
marker.unmark();
|
||||
|
||||
errors.forEach((err, i) => {
|
||||
marker.mark(err.context || err.text || "", {
|
||||
className: cn(
|
||||
"bg-yellow-200 dark:bg-yellow-900",
|
||||
i === index && "bg-green-200 dark:bg-green-900"
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
const errorElements = preview.querySelectorAll(
|
||||
`.grammar-error[data-markjs=true]`
|
||||
);
|
||||
|
||||
errorElements.forEach((el) => {
|
||||
el.classList.remove("active");
|
||||
});
|
||||
|
||||
errorElements.forEach((el) => {
|
||||
if (el.textContent === (error.context || error.text)) {
|
||||
el.classList.add(`active-${state.highlightKey}`);
|
||||
|
||||
el.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
el.classList.remove(`active-${state.highlightKey}`);
|
||||
}, 2000);
|
||||
}
|
||||
const marks = preview.querySelectorAll("mark");
|
||||
const selectedMark = marks[index];
|
||||
if (selectedMark) {
|
||||
selectedMark.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user