diff --git a/src/app/api/grammar/route.ts b/src/app/api/grammar/route.ts index c495ca9..ec26873 100644 --- a/src/app/api/grammar/route.ts +++ b/src/app/api/grammar/route.ts @@ -24,24 +24,35 @@ export async function POST(req: NextRequest) { messages: [ { role: "system", - content: `你是一个专业的中文简历错别字检查助手。请完整检查以下文本,不要遗漏,以下是要求: - 1.所有考虑中文语境下的语法组词错误,包括拼写错误,语境用词错误,专业术语错误。 - 2.验证是否有遗漏文字未检查 - 3.对每个发现的问题,请按JSON格式返回 + content: `你是一个专业的中文简历校对助手。你的任务是**仅**找出简历中的**错别字**和**标点符号错误**。 + + **严格禁止**: + 1. ❌ **禁止**提供任何风格、语气、润色或改写建议。如果句子在语法上是正确的(即使读起来不够优美),也**绝对不要**报错。 + 2. ❌ **禁止**报告“无明显错误”或类似的信息。如果没有发现错别字或标点错误,"errors" 数组必须为空。 + 3. ❌ **禁止**对专业术语进行过度纠正,除非通过上下文非常确定是打字错误。 - 以下是示例格式: - { - "errors": [ - { - "text": "错误的文本", - "message": "详细的错误说明", - "type": "spelling"或"grammar", - "suggestions": ["建议修改1", "建议修改2"] - } - ] - } + **仅检查以下两类错误**: + 1. ✅ **错别字**:例如将“作为”写成“做为”,将“经理”写成“经里”。 + 2. ✅ **严重标点错误**:仅报告重复标点(如“,,”)或完全错误的符号位置。 + + **重要例外(绝不报错)**: + - ❌ **忽略中英文标点混用**:在技术简历中,中文内容使用英文标点(如使用英文逗号, 代替中文逗号,或使用英文句点. 代替中文句号)是**完全接受**的风格。**绝对不要**报告此类“错误”。 + - ❌ **忽略空格使用**:不要报告中英文之间的空格遗漏或多余。 - 请确保返回的格式可以正常解析`, + 返回格式示例(JSON): + { + "errors": [ + { + "context": "包含错误的完整句子(必须是原文)", + "text": "具体的错误部分(必须是原文中实际存在的字符串)", + "suggestion": "仅包含修正后的词汇或片段(**不要**返回整句,除非整句都是错误的)", + "reason": "错别字 / 标点错误", + "type": "spelling" + } + ] + } + + 再次强调:**只找错别字和标点错误,不要做任何润色!**`, }, { role: "user", diff --git a/src/components/editor/EditorHeader.tsx b/src/components/editor/EditorHeader.tsx index ef38b87..f8f9dca 100644 --- a/src/components/editor/EditorHeader.tsx +++ b/src/components/editor/EditorHeader.tsx @@ -15,6 +15,7 @@ import { HoverCardContent } from "@/components/ui/hover-card"; import { Button } from "@/components/ui/button"; +import { GrammarCheckDrawer } from "./grammar/GrammarCheckDrawer"; interface EditorHeaderProps { isMobile?: boolean; @@ -53,59 +54,17 @@ export function EditorHeader({ isMobile }: EditorHeaderProps) {
+ {errors.length > 0 && ( - - -
+
document.dispatchEvent(new CustomEvent('open-grammar-drawer'))} + > - 发现 {errors.length} 个问题 + {t("grammarCheck.found_issues", { count: errors.length })} -
- - -
-

语法检查结果

-
- {errors.map((error, index) => ( -
-
- -
-
-

{error.message}

- -
- {error.suggestions.length > 0 && ( -
-

- 建议修改: -

- {error.suggestions.map((suggestion, i) => ( -

- {suggestion} -

- ))} -
- )} -
-
-
- ))} -
-
-
- +
)} { + if (errors.length > 0) { + setIsOpen(true); + } + }, [errors.length]); + + useEffect(() => { + const handleOpenDrawer = () => setIsOpen(true); + document.addEventListener("open-grammar-drawer", handleOpenDrawer); + return () => { + document.removeEventListener("open-grammar-drawer", handleOpenDrawer); + }; + }, []); + + const handleOpenChange = (open: boolean) => { + setIsOpen(open); + }; + + const handleAccept = (index: number) => { + const error = errors[index]; + if (!error || !activeResume) return; + + // 递归查找并替换简历数据中的文本 + const newResume = JSON.parse(JSON.stringify(activeResume)); + let replaced = false; + + const traverseAndReplace = (obj: any) => { + for (const key in obj) { + if (typeof obj[key] === "string") { + // 优先匹配 context + if (error.context && obj[key].includes(error.context)) { + // 在 context 中查找并替换 text,确保只替换错误部分 + // 1. 获取原文片段 + const originalContext = error.context; + // 2. 在原文片段中替换错误词汇为建议词汇 + // 注意:这里假设 text 在 context 中只出现一次,或者我们需要替换的是 context 中的那个实例 + // 为了安全,我们只替换 context 中的 text + if (originalContext.includes(error.text)) { + const correctedContext = originalContext.replace(error.text, error.suggestion); + // 3. 将修改后的 context 替换回 obj[key] + obj[key] = obj[key].replace(originalContext, correctedContext); + replaced = true; + } else { + // 如果 text 不在 context 中(奇怪的情况),尝试直接替换 context + // 这通常是 fallback,但如果是整句替换也可以 + // 但根据新的 Prompt,suggestion 应该是片段,所以这里可能不适用 + // 暂时保留作为最后的手段,但记录日志或谨慎处理 + // obj[key] = obj[key].replace(error.context, error.suggestion); + // 由于 suggestion 现在是片段,直接替换 context 是错误的。 + } + } + // 如果 context 没找到,尝试全局匹配 text(风险较高,但在某些情况下有效) + // 只有当 text 足够长或者很独特时才安全 + else if (error.text && obj[key].includes(error.text) && error.text.length > 2) { + obj[key] = obj[key].replace(error.text, error.suggestion); + replaced = true; + } + } else if (typeof obj[key] === "object" && obj[key] !== null) { + traverseAndReplace(obj[key]); + } + } + }; + + traverseAndReplace(newResume); + + if (replaced) { + updateResume(activeResume.id, newResume); + dismissError(index); + toast.success(t("applied_success")); + } else { + toast.error(t("apply_error")); + } + }; + + const handleIgnore = (index: number) => { + dismissError(index); + }; + + return ( + + +
+
+
+
+ +
+
+ {t("title")} + + {t("description", { count: errors.length })} + +
+
+ +
+
+ + +
+ + {errors.map((error, index) => ( + selectError(index)} + > + {/* 卡片头部 */} +
+ + {error.type === "spelling" ? t("spelling") : t("punctuation")} + + {/* 只有当 reason 与 Badge 内容不同时才显示 */} + {error.reason && error.reason !== "错别字" && error.reason !== "标点符号" && ( + + {error.reason} + + )} +
+ +
+ {/* 内容对比区 */} +
+ {/* 原文 */} +
+
+ {t("original")} +
+
+ + {error.context} + + {error.text && error.text !== error.context && ( +
+ + {error.text} +
+ )} +
+
+ + {/* 箭头 */} +
+ +
+ + {/* 建议 */} +
+
+ {t("suggestion")} +
+
+ {error.suggestion} +
+
+
+ + {/* 按钮区 */} +
+ + +
+
+
+ ))} +
+ + {errors.length === 0 && ( +
+
+ +
+

{t("no_errors_title")}

+

+ {t("no_errors_desc")} +

+
+ )} +
+
+
+
+ ); +} diff --git a/src/hooks/useGrammarCheck.ts b/src/hooks/useGrammarCheck.ts index 90d9580..5115a0a 100644 --- a/src/hooks/useGrammarCheck.ts +++ b/src/hooks/useGrammarCheck.ts @@ -15,6 +15,7 @@ export const useGrammarCheck = () => { checkGrammar, clearErrors, selectError, + dismissError, } = useGrammarStore(); return { @@ -24,5 +25,6 @@ export const useGrammarCheck = () => { checkGrammar, clearErrors, selectError, + dismissError, }; }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 34c98f6..e7a1d92 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -642,5 +642,23 @@ "confirmText": "Delete", "cancelText": "Cancel" } + }, + "grammarCheck": { + "title": "AI Grammar Check", + "description": "Found {count} suggestions", + "exit": "Exit", + "spelling": "Spelling", + "punctuation": "Punctuation", + "original": "Original", + "error_point": "Error", + "suggestion": "Suggestion", + "reason": "Reason", + "accept": "Apply", + "ignore": "Ignore", + "found_issues": "Found {count} issues", + "applied_success": "Changes applied", + "apply_error": "Text not found, cannot apply automatically", + "no_errors_title": "Perfect!", + "no_errors_desc": "No grammar or punctuation errors found." } } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index b0a0341..56da5e2 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -644,5 +644,23 @@ "confirmText": "删除", "cancelText": "取消" } + }, + "grammarCheck": { + "title": "AI 语法检查", + "description": "发现 {count} 个建议优化项", + "exit": "退出检查", + "spelling": "错别字", + "punctuation": "标点符号", + "original": "原文", + "error_point": "错误点", + "suggestion": "建议修改", + "reason": "原因", + "accept": "应用", + "ignore": "忽略", + "found_issues": "发现 {count} 个问题", + "applied_success": "已应用修改", + "apply_error": "未找到对应文本,无法自动修改", + "no_errors_title": "太棒了!", + "no_errors_desc": "文档非常完美,没有发现任何错别字或标点错误。" } } diff --git a/src/store/useGrammarStore.ts b/src/store/useGrammarStore.ts index 108dd25..5eed195 100644 --- a/src/store/useGrammarStore.ts +++ b/src/store/useGrammarStore.ts @@ -6,11 +6,11 @@ import { AI_MODEL_CONFIGS } from "@/config/ai"; import { cn } from "@/lib/utils"; export interface GrammarError { - error: string; - suggestion: string; context: string; - type?: string; - text?: string; + text: string; + suggestion: string; + reason: string; + type: "spelling" | "grammar"; } interface GrammarStore { @@ -25,6 +25,7 @@ interface GrammarStore { checkGrammar: (text: string) => Promise; clearErrors: () => void; selectError: (index: number) => void; + dismissError: (index: number) => void; } export const useGrammarStore = create((set, get) => ({ @@ -159,4 +160,29 @@ export const useGrammarStore = create((set, get) => ({ }); } }, + dismissError: (index: number) => { + set((state) => { + const newErrors = [...state.errors]; + newErrors.splice(index, 1); + + const preview = document.getElementById("resume-preview"); + if (preview) { + // 重新标记剩余错误 + const marker = new Mark(preview); + marker.unmark(); + newErrors.forEach((error, i) => { + marker.mark(error.context || error.text || "", { + className: cn( + "bg-yellow-200 dark:bg-yellow-900", + state.selectedErrorIndex === i && "bg-green-200 dark:bg-green-900" + // 注意:selectedErrorIndex 可能因为删除而需要调整,这里简化处理,稍后在选中逻辑中可能需要优化 + // 为了保持一致性,最好是重新选中当前索引或重置选中 + ) + }); + }); + } + + return { errors: newErrors, selectedErrorIndex: null }; + }); + }, })); diff --git a/src/types/mark.js.d.ts b/src/types/mark.js.d.ts new file mode 100644 index 0000000..e3a9487 --- /dev/null +++ b/src/types/mark.js.d.ts @@ -0,0 +1,7 @@ +declare module 'mark.js' { + export default class Mark { + constructor(element: string | HTMLElement | NodeList | null); + mark(keyword: string | string[], options?: any): void; + unmark(options?: any): void; + } +}