feat: ai polish

This commit is contained in:
JOYCEQL
2024-12-26 23:57:35 +08:00
committed by qingchen
parent 1c8b4f4705
commit 641cf37a0c
5 changed files with 479 additions and 107 deletions
+103
View File
@@ -0,0 +1,103 @@
import { NextResponse } from "next/server";
export async function POST(req: Request) {
try {
const body = await req.json();
const { apiKey, model, content } = 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 encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
if (!response.body) {
controller.close();
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
controller.close();
break;
}
const chunk = decoder.decode(value);
const lines = chunk
.split("\n")
.filter((line) => line.trim() !== "");
for (const line of lines) {
if (line.includes("[DONE]")) continue;
if (!line.startsWith("data:")) continue;
try {
const data = JSON.parse(line.slice(5));
const content = data.choices[0]?.delta?.content;
if (content) {
controller.enqueue(encoder.encode(content));
}
} catch (e) {
console.error("Error parsing JSON:", e);
}
}
}
} catch (error) {
console.error("Stream reading error:", error);
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
} catch (error) {
console.error("Polish error:", error);
return NextResponse.json(
{ error: "Failed to polish content" },
{ status: 500 }
);
}
}
+90 -59
View File
@@ -1,19 +1,23 @@
"use client";
import { useState } from "react";
import { CalendarIcon } from "lucide-react";
import { format } from "date-fns";
import { cn } from "@/lib/utils";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import RichTextEditor from "@/components/shared/rich-editor/RichEditor";
import { Button } from "../ui/button";
import RichTextEditor from "../shared/rich-editor/RichEditor";
import AIPolishDialog from "../shared/ai/AIPolishDialog";
import { useAIConfigStore } from "@/store/useAIConfigStore";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
type FieldProps = {
interface FieldProps {
label?: string;
value?: string;
onChange: (value: string) => void;
@@ -21,9 +25,9 @@ type FieldProps = {
placeholder?: string;
required?: boolean;
className?: string;
};
}
const Field: React.FC<FieldProps> = ({
const Field = ({
label,
value,
onChange,
@@ -31,72 +35,79 @@ const Field: React.FC<FieldProps> = ({
placeholder,
required,
className,
}) => {
const renderLabel = () => (
<span
className={cn(
"text-sm font-medium flex gap-1 items-center",
"dark:text-neutral-300 text-gray-600"
)}
>
{label}
{required && <span className="text-red-500">*</span>}
</span>
);
}: FieldProps) => {
const router = useRouter();
const [showPolishDialog, setShowPolishDialog] = useState(false);
const { doubaoModelId, doubaoApiKey } = useAIConfigStore();
const renderLabel = () => {
if (!label) return null;
return (
<div className="flex items-center justify-between">
<span
className={cn(
"block text-sm font-medium",
"text-gray-700 dark:text-neutral-300"
)}
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</span>
</div>
);
};
const inputStyles = cn(
"mt-1.5 block w-full transition-all duration-200",
"rounded-lg px-4 py-2.5",
"border"
"block w-full rounded-md border-0 py-1.5 px-3",
"text-gray-900 dark:text-neutral-300",
"shadow-sm ring-1 ring-inset ring-gray-300",
"placeholder:text-gray-400",
"focus:ring-2 focus:ring-inset focus:ring-primary",
"dark:bg-neutral-900/30 dark:ring-neutral-700 dark:focus:ring-primary",
"sm:text-sm sm:leading-6",
className
);
if (type === "date") {
const date = value ? new Date(value) : undefined;
return (
<label className="block">
<span className={cn("text-sm font-medium flex gap-1 items-center")}>
{label}
{required && <span className="text-red-500">*</span>}
</span>
<div className="block">
{renderLabel()}
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn("w-full flex items-center justify-between")}
variant={"outline"}
className={cn(
"w-full justify-start text-left font-normal mt-1.5",
!value && "text-muted-foreground"
)}
>
<span>{date ? format(date, "yyyy-MM-dd") : "选择日期"}</span>
<CalendarIcon className="w-4 h-4 opacity-50" />
<CalendarIcon className="mr-2 h-4 w-4" />
{value ? (
format(new Date(value), "PPP")
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<PopoverContent className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={(date) => onChange(date ? date.toISOString() : "")}
selected={value ? new Date(value) : undefined}
onSelect={(date) => onChange(date?.toISOString() || "")}
initialFocus
className={cn("rounded-lg p-3")}
/>
</PopoverContent>
</Popover>
</label>
</div>
);
}
if (type === "textarea") {
return (
<label className="block">
<span
className={cn(
"text-sm font-medium flex gap-1 items-center",
"dark:text-neutral-300 text-gray-600"
)}
>
{label}
{required && <span className="text-red-500">*</span>}
</span>
{renderLabel()}
<motion.textarea
value={value || ""}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputStyles}
@@ -108,6 +119,7 @@ const Field: React.FC<FieldProps> = ({
</label>
);
}
if (type === "editor") {
return (
<motion.div className="block">
@@ -117,30 +129,49 @@ const Field: React.FC<FieldProps> = ({
content={value || ""}
onChange={onChange}
placeholder={placeholder}
onPolish={() => {
if (!doubaoApiKey || !doubaoModelId) {
toast.error(
<>
<span> ApiKey Id</span>
<Button
className="p-0 h-auto text-white"
onClick={() => router.push("/dashboard/settings")}
>
</Button>
</>
);
return;
}
}}
/>
</div>
<AIPolishDialog
open={showPolishDialog}
onOpenChange={setShowPolishDialog}
content={value || ""}
onApply={(content) => {
onChange(content);
}}
/>
</motion.div>
);
}
return (
<label className="block">
<span
className={cn(
"text-sm font-medium flex gap-1 items-center",
"dark:text-neutral-300 text-gray-600"
)}
>
{label}
{required && <span className="text-red-500">*</span>}
</span>
<Input
{renderLabel()}
<motion.input
type="text"
value={value || ""}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
className={inputStyles}
required={required}
whileHover={{ scale: 1.005 }}
whileTap={{ scale: 0.995 }}
/>
</label>
);
@@ -0,0 +1,215 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { Loader2, Sparkles } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useAIConfigStore } from "@/store/useAIConfigStore";
interface AIPolishDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
content: string;
onApply: (content: string) => void;
}
export default function AIPolishDialog({
open,
onOpenChange,
content,
onApply,
}: AIPolishDialogProps) {
const [isPolishing, setIsPolishing] = useState(false);
const [polishedContent, setPolishedContent] = useState("");
const { doubaoApiKey, doubaoModelId } = useAIConfigStore();
const abortControllerRef = useRef<AbortController | null>(null);
const polishedContentRef = useRef<HTMLDivElement>(null);
const handlePolish = async () => {
try {
setIsPolishing(true);
setPolishedContent("");
abortControllerRef.current = new AbortController();
const response = await fetch("/api/polish", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content,
apiKey: doubaoApiKey,
model: doubaoModelId,
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error("Failed to polish content");
}
if (!response.body) {
throw new Error("No response body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
setPolishedContent((prev) => {
const newContent = prev + chunk;
requestAnimationFrame(() => {
if (polishedContentRef.current) {
const container = polishedContentRef.current;
container.scrollTop = container.scrollHeight;
}
});
return newContent;
});
}
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
console.log("Polish aborted");
return;
}
console.error("Polish error:", error);
toast.error("润色失败");
onOpenChange(false);
} finally {
setIsPolishing(false);
}
};
useEffect(() => {
if (open) {
handlePolish();
} else {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setPolishedContent("");
}
}, [open]);
const handleClose = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
onOpenChange(false);
setPolishedContent("");
};
const handleApply = () => {
onApply(polishedContent);
handleClose();
toast.success("已应用润色内容");
};
const handleOpenChange = (open: boolean) => {
if (!open && !isPolishing) {
onOpenChange(open);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
className="sm:max-w-[1000px] bg-[#1a1d21] border-gray-800"
onPointerDownOutside={(e) => {
e.preventDefault();
}}
onEscapeKeyDown={(e) => {
e.preventDefault();
}}
onInteractOutside={(e) => {
e.preventDefault();
}}
>
<DialogHeader className="pb-6">
<DialogTitle className="flex items-center gap-2 text-2xl text-white">
<Sparkles className="h-6 w-6 text-primary animate-pulse" />
AI
</DialogTitle>
<DialogDescription className="text-base text-gray-400">
{isPolishing
? "正在为您润色内容..."
: "已经为您优化了内容,请查看效果"}
</DialogDescription>
</DialogHeader>
<div className="grid grid-cols-2 gap-6">
<div className="space-y-2">
<div className="flex items-center gap-2 px-3">
<div className="w-1.5 h-1.5 rounded-full bg-gray-500"></div>
<span className="text-sm font-medium text-gray-400">
</span>
</div>
<div className="relative rounded-xl border border-gray-800 bg-[#24282c] p-6 h-[400px] overflow-auto shadow-sm">
<div
className="prose prose-invert max-w-none text-gray-300"
dangerouslySetInnerHTML={{ __html: content }}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 px-3">
<div className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse"></div>
<span className="text-sm font-medium text-primary">
</span>
</div>
<div
ref={polishedContentRef}
className="relative rounded-xl border border-primary/20 bg-primary/[0.03] p-6 h-[400px] overflow-auto shadow-sm scroll-smooth"
>
<div
className="prose prose-invert max-w-none text-white"
dangerouslySetInnerHTML={{ __html: polishedContent }}
/>
</div>
</div>
</div>
<DialogFooter className="mt-6 flex items-center gap-3">
<Button
onClick={handlePolish}
disabled={isPolishing}
className="flex-1 bg-gradient-to-r from-[#9333EA] to-[#EC4899] hover:opacity-90 text-white border-none h-11 shadow-lg shadow-purple-500/20"
>
{isPolishing ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : (
"重新生成"
)}
</Button>
<Button
onClick={handleApply}
disabled={isPolishing || !polishedContent}
className="flex-1 bg-primary hover:bg-primary/90 text-white h-11 shadow-lg shadow-primary/20"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,5 +1,5 @@
"use client";
import React from "react";
import React, { useEffect } from "react";
import { useEditor, EditorContent, BubbleMenu } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import TextAlign from "@tiptap/extension-text-align";
@@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger
PopoverTrigger,
} from "@/components/ui/popover";
import {
Bold,
@@ -29,7 +29,8 @@ import {
PaintBucket,
Type,
ChevronDown,
Highlighter
Highlighter,
Wand2,
} from "lucide-react";
import Highlight from "@tiptap/extension-highlight";
import { cn } from "@/lib/utils";
@@ -40,6 +41,7 @@ interface RichTextEditorProps {
content?: string;
onChange: (content: string) => void;
placeholder?: string;
onPolish?: () => void;
}
const COLORS = [
@@ -57,7 +59,7 @@ const COLORS = [
{ label: "蓝色", value: "#0000FF" },
{ label: "紫色", value: "#6600FF" },
{ label: "紫红", value: "#CC00FF" },
{ label: "粉色", value: "#FF00FF" }
{ label: "粉色", value: "#FF00FF" },
];
const BG_COLORS = COLORS;
@@ -86,7 +88,7 @@ const MenuButton = ({
disabled = false,
children,
className = "",
tooltip
tooltip,
}: MenuButtonProps) => {
const [showTooltip, setShowTooltip] = React.useState(false);
@@ -155,7 +157,7 @@ const TextColorButton = ({ editor }) => {
color: activeColor || "currentColor",
filter: activeColor
? "drop-shadow(0 1px 1px rgba(0,0,0,0.1))"
: "none"
: "none",
}}
/>
<span className="sr-only"></span>
@@ -187,7 +189,7 @@ const TextColorButton = ({ editor }) => {
style={{
backgroundColor: color.value,
borderColor:
color.value === "#FFFFFF" ? "#E2E8F0" : color.value
color.value === "#FFFFFF" ? "#E2E8F0" : color.value,
}}
onClick={() => {
editor.chain().focus().setColor(color.value).run();
@@ -229,7 +231,7 @@ const BackgroundColorButton = ({ editor }) => {
color: activeBgColor ? "currentColor" : "currentColor",
filter: activeBgColor
? "drop-shadow(0 1px 1px rgba(0,0,0,0.1))"
: "none"
: "none",
}}
/>
{activeBgColor && (
@@ -267,7 +269,7 @@ const BackgroundColorButton = ({ editor }) => {
${activeBgColor === color.value ? "ring-2 ring-primary ring-offset-2" : ""}`}
style={{
backgroundColor: color.value,
borderColor: "transparent"
borderColor: "transparent",
}}
onClick={() => {
editor
@@ -320,7 +322,7 @@ const HeadingSelect = ({ editor }) => {
{ label: "正文", value: "p" },
{ label: "标题 1", value: "1" },
{ label: "标题 2", value: "2" },
{ label: "标题 3", value: "3" }
{ label: "标题 3", value: "3" },
].map((item) => (
<Button
key={item.value}
@@ -357,7 +359,11 @@ const HeadingSelect = ({ editor }) => {
);
};
const RichTextEditor = ({ content = "", onChange }: RichTextEditorProps) => {
const RichTextEditor = ({
content = "",
onChange,
onPolish,
}: RichTextEditorProps) => {
const editor = useEditor({
extensions: [
StarterKit.configure({
@@ -365,28 +371,28 @@ const RichTextEditor = ({ content = "", onChange }: RichTextEditorProps) => {
orderedList: false,
listItem: false,
heading: {
levels: [1, 2, 3]
}
levels: [1, 2, 3],
},
}),
BulletList.configure({
HTMLAttributes: {
class: "custom-list"
}
class: "custom-list",
},
}),
OrderedList.configure({
HTMLAttributes: {
class: "custom-list-ordered"
}
class: "custom-list-ordered",
},
}),
ListItem,
TextAlign.configure({
types: ["heading", "paragraph"],
alignments: ["left", "center", "right", "justify"]
alignments: ["left", "center", "right", "justify"],
}),
TextStyle,
Underline,
Color,
Highlight.configure({ multicolor: true })
Highlight.configure({ multicolor: true }),
],
content,
onUpdate: ({ editor }) => {
@@ -405,12 +411,18 @@ const RichTextEditor = ({ content = "", onChange }: RichTextEditorProps) => {
"dark:prose-blockquote:border-neutral-700",
"dark:prose-ul:text-neutral-300",
"dark:prose-ol:text-neutral-300"
)
}
),
},
},
immediatelyRender: false
immediatelyRender: false,
});
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content);
}
}, [content, editor]);
if (!editor) {
return null;
}
@@ -530,21 +542,32 @@ const RichTextEditor = ({ content = "", onChange }: RichTextEditorProps) => {
<div className={cn("h-5 w-px", "bg-border/60 dark:bg-neutral-800")} />
<div className="flex items-center gap-0.5">
<div className="flex items-center space-x-1">
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
tooltip="撤销"
>
<Undo className="h-5 w-5" />
<Undo className="h-4 w-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
tooltip="重做"
>
<Redo className="h-5 w-5" />
<Redo className="h-4 w-4" />
</MenuButton>
{onPolish && (
<Button
size="sm"
variant="outline"
onClick={onPolish}
className="bg-gradient-to-r from-purple-400 to-pink-500 hover:from-pink-500 hover:to-purple-400 text-white border-none shadow-md transition-all duration-300"
>
<Wand2 className="h-4 w-4 mr-2" />
AI
</Button>
)}
</div>
</div>
+23 -23
View File
@@ -1,18 +1,18 @@
"use client"
"use client";
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
@@ -26,8 +26,8 @@ const DialogOverlay = React.forwardRef<
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
@@ -45,13 +45,13 @@ const DialogContent = React.forwardRef<
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<X className="h-8 w-8" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
@@ -64,8 +64,8 @@ const DialogHeader = ({
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
@@ -78,8 +78,8 @@ const DialogFooter = ({
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
@@ -93,8 +93,8 @@ const DialogTitle = React.forwardRef<
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
@@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
@@ -119,4 +119,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
}
};