/** * AI 生成功能模組 * 處理 LLM API 調用和表單自動填充 */ import { DEFAULT_AI_PROMPTS } from './prompts.js'; // ==================== 工具函數 ==================== /** * 消毒 HTML 字串,防止 XSS 攻擊 */ export function sanitizeHTML(str) { if (str === null || str === undefined) return ''; const temp = document.createElement('div'); temp.textContent = str; return temp.innerHTML; } /** * 安全設定元素文字內容 */ export function safeSetText(element, text) { if (element) { element.textContent = text; } } /** * 只在欄位為空時填入值 */ export function fillIfEmpty(elementId, value) { const el = document.getElementById(elementId); if (el && !el.value.trim() && value) { el.value = value; return true; } return false; } /** * 獲取欄位當前值 */ export function getFieldValue(elementId) { const el = document.getElementById(elementId); return el ? el.value.trim() : ''; } /** * 獲取空白欄位列表 */ export function getEmptyFields(fieldIds) { return fieldIds.filter(id => !getFieldValue(id)); } /** * 獲取 Ollama 模型選項 */ export function getOllamaModel() { const select = document.getElementById('ollamaModel'); return select ? select.value : 'llama3.2'; } // ==================== AI API 調用 ==================== /** * 調用 LLM API */ export async function callClaudeAPI(prompt, api = 'ollama') { try { const requestData = { api: api, prompt: prompt, max_tokens: 2000 }; if (api === 'ollama') { requestData.model = getOllamaModel(); } const response = await fetch("/api/llm/generate", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify(requestData) }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || `API 請求失敗: ${response.status}`); } const data = await response.json(); if (!data.success) { throw new Error(data.error || 'API 調用失敗'); } let responseText = data.text; // 移除 markdown 代碼塊標記 responseText = responseText.replace(/```json\n?/g, "").replace(/```\n?/g, "").trim(); // 嘗試提取 JSON 物件 const jsonMatch = responseText.match(/\{[\s\S]*\}/); if (jsonMatch) { const allMatches = responseText.match(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g); if (allMatches && allMatches.length > 0) { for (let i = allMatches.length - 1; i >= 0; i--) { try { const parsed = JSON.parse(allMatches[i]); if (Object.keys(parsed).length > 0) { return parsed; } } catch (e) { continue; } } } try { return JSON.parse(jsonMatch[0]); } catch (e) { // 繼續嘗試其他方法 } } return JSON.parse(responseText); } catch (error) { console.error("Error calling LLM API:", error); let errorDetails = error.message; try { const errorJson = JSON.parse(error.message); errorDetails = JSON.stringify(errorJson, null, 2); } catch (e) { // 不是 JSON,使用原始訊息 } showCopyableError({ title: 'AI 生成錯誤', message: error.message, details: errorDetails, suggestions: [ 'Flask 後端已啟動 (python app.py)', '已在 .env 文件中配置有效的 LLM API Key', '網路連線正常', '確認 Prompt 要求返回純 JSON 格式', '嘗試使用不同的 LLM API (切換到其他模型)', '檢查 LLM 模型是否支援繁體中文' ] }); throw error; } } // ==================== 按鈕狀態控制 ==================== export function setButtonLoading(btn, loading) { if (loading) { btn.disabled = true; btn.innerHTML = '
AI 生成中...'; } else { btn.disabled = false; btn.innerHTML = '✨ I\'m feeling lucky'; } } // ==================== AI 幫我想功能 ==================== /** * 初始化所有 prompt 編輯器 */ export function initializePromptEditors() { const modules = Object.keys(DEFAULT_AI_PROMPTS); modules.forEach(module => { const textarea = document.getElementById(`prompt_${module}`); if (textarea) { const savedPrompt = localStorage.getItem(`aiPrompt_${module}`); textarea.value = savedPrompt || DEFAULT_AI_PROMPTS[module]; textarea.addEventListener('input', () => { localStorage.setItem(`aiPrompt_${module}`, textarea.value); }); } }); } /** * 重置 prompt 為預設值 */ export function resetPromptToDefault(module) { const textarea = document.getElementById(`prompt_${module}`); if (textarea && DEFAULT_AI_PROMPTS[module]) { textarea.value = DEFAULT_AI_PROMPTS[module]; localStorage.removeItem(`aiPrompt_${module}`); if (typeof showToast === 'function') { showToast('已重置為預設 Prompt'); } } } /** * 執行 AI 幫我想 */ export async function executeAIHelper(module) { const container = document.querySelector(`.ai-helper-container[data-module="${module}"]`); const btn = container.querySelector('.ai-helper-btn'); const textarea = document.getElementById(`prompt_${module}`); if (!textarea || !textarea.value.trim()) { if (typeof showToast === 'function') { showToast('請輸入 Prompt 指令', 'error'); } return; } const originalHTML = btn.innerHTML; btn.disabled = true; btn.innerHTML = 'AI 生成中...'; try { const prompt = textarea.value.trim(); const data = await callClaudeAPI(prompt); fillFormWithAIHelperResult(module, data); if (typeof showToast === 'function') { showToast('AI 生成完成!已填入表單'); } } catch (error) { console.error('AI Helper error:', error); } finally { btn.disabled = false; btn.innerHTML = originalHTML; } } /** * 根據模組填入 AI 結果 */ export function fillFormWithAIHelperResult(module, data) { const fieldMappings = { positionBasic: { positionCode: 'positionCode', positionName: 'positionName', positionCategory: 'positionCategory', positionNature: 'positionNature', headcount: 'headcount', positionLevel: 'positionLevel', positionDesc: 'positionDesc', positionRemark: 'positionRemark' }, positionRecruit: { education: 'recruitEducation', experience: 'recruitExperience', skills: 'recruitSkills', certificates: 'recruitCertificates', languages: 'recruitLanguages', specialRequirements: 'recruitSpecialReq' }, jobBasic: { jobCode: 'jobCode', jobName: 'jobName', jobGrade: 'jobGrade', jobCategory: 'jobCategory', careerPath: 'careerPath', jobDesc: 'jobDesc' }, deptFunction: { deptCode: 'deptCode', deptName: 'deptName', parentDept: 'parentDept', deptHead: 'deptHead', deptFunction: 'deptFunction', kpi: 'kpi' }, jobDesc: { positionName: 'descPositionName', department: 'descDepartment', directSupervisor: 'descDirectSupervisor', positionPurpose: 'descPositionPurpose', mainResponsibilities: 'descMainResponsibilities', education: 'descEducation', basicSkills: 'descBasicSkills', professionalKnowledge: 'descProfessionalKnowledge' } }; const mapping = fieldMappings[module]; if (!mapping) return; let filledCount = 0; for (const [dataKey, elementId] of Object.entries(mapping)) { if (data[dataKey] !== undefined) { const value = Array.isArray(data[dataKey]) ? data[dataKey].join('\n') : String(data[dataKey]); if (fillIfEmpty(elementId, value)) { filledCount++; } } } if (filledCount > 0) { console.log(`[AI Helper] 已填入 ${filledCount} 個欄位`); } } // ==================== 錯誤顯示 ==================== /** * 顯示可複製的錯誤訊息 */ export function showCopyableError(options) { const { title, message, details, suggestions } = options; const modal = document.createElement('div'); modal.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); display: flex; align-items: center; justify-content: center; z-index: 10000; animation: fadeIn 0.3s; `; modal.innerHTML = ` `; document.body.appendChild(modal); modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); } }); } // ==================== 各表單 AI 生成函數 ==================== /** * 生成崗位基礎資料 */ export async function generatePositionBasic(event) { const btn = event.target.closest('.ai-generate-btn'); const allFields = ['positionCode', 'positionName', 'positionCategory', 'positionNature', 'headcount', 'positionLevel', 'positionDesc', 'positionRemark']; const emptyFields = getEmptyFields(allFields); if (emptyFields.length === 0) { if (typeof showToast === 'function') showToast('所有欄位都已填寫完成!'); return; } setButtonLoading(btn, true); try { const existingData = {}; allFields.forEach(field => { const value = getFieldValue(field); if (value) existingData[field] = value; }); const contextInfo = Object.keys(existingData).length > 0 ? `\n\n已填寫的資料(請參考這些內容來生成相關的資料):\n${JSON.stringify(existingData, null, 2)}` : ''; const prompt = `你是專業人資顧問,熟悉半導體製造業的人資所有流程。請生成崗位基礎資料。請用繁體中文回覆。 ${contextInfo} 請「只生成」以下這些尚未填寫的欄位:${emptyFields.join(', ')} 欄位說明: - positionCode: 崗位編號(格式如 ENG-001, MGR-002, SAL-003) - positionName: 崗位名稱 - positionCategory: 崗位類別代碼(01=技術職, 02=管理職, 03=業務職, 04=行政職) - positionNature: 崗位性質代碼(FT=全職, PT=兼職, CT=約聘, IN=實習) - headcount: 編制人數(1-10之間的數字字串) - positionLevel: 崗位級別(L1到L7) - positionDesc: 崗位描述(條列式,用換行分隔)) - positionRemark: 崗位備注(條列式,用換行分隔) 重要:請「只」返回純JSON格式,不要有任何解釋、思考過程或額外文字。格式如下: { ${emptyFields.map(f => `"${f}": "..."`).join(',\n ')} }`; const data = await callClaudeAPI(prompt); let filledCount = 0; if (fillIfEmpty('positionCode', data.positionCode)) filledCount++; if (fillIfEmpty('positionName', data.positionName)) filledCount++; if (fillIfEmpty('positionCategory', data.positionCategory)) { filledCount++; if (typeof updateCategoryName === 'function') updateCategoryName(); } if (fillIfEmpty('positionNature', data.positionNature)) { filledCount++; if (typeof updateNatureName === 'function') updateNatureName(); } if (fillIfEmpty('headcount', data.headcount)) filledCount++; if (fillIfEmpty('positionLevel', data.positionLevel)) filledCount++; if (fillIfEmpty('positionDesc', data.positionDesc)) filledCount++; if (fillIfEmpty('positionRemark', data.positionRemark)) filledCount++; if (typeof updatePreview === 'function') updatePreview(); if (typeof showToast === 'function') showToast(`✨ AI 已補充 ${filledCount} 個欄位!`); } catch (error) { if (typeof showToast === 'function') showToast('生成失敗,請稍後再試'); } finally { setButtonLoading(btn, false); } } /** * 生成招聘要求資料 */ export async function generatePositionRecruit(event) { const btn = event.target.closest('.ai-generate-btn'); const allFields = ['minEducation', 'requiredGender', 'salaryRange', 'workExperience', 'minAge', 'maxAge', 'jobType', 'recruitPosition', 'jobTitle', 'jobDesc', 'positionReq', 'skillReq', 'langReq', 'otherReq']; const emptyFields = getEmptyFields(allFields); if (emptyFields.length === 0) { if (typeof showToast === 'function') showToast('所有欄位都已填寫完成!'); return; } setButtonLoading(btn, true); try { const positionName = getFieldValue('positionName') || '一般職位'; const existingData = { positionName }; allFields.forEach(field => { const value = getFieldValue(field); if (value) existingData[field] = value; }); const prompt = `請生成「${positionName}」的招聘要求資料。請用繁體中文回覆。 已填寫的資料(請參考這些內容來生成相關的資料): ${JSON.stringify(existingData, null, 2)} 請「只生成」以下這些尚未填寫的欄位:${emptyFields.join(', ')} 欄位說明: - minEducation: 最低學歷代碼(HS=高中職, JC=專科, BA=大學, MA=碩士, PHD=博士) - requiredGender: 要求性別(空字串=不限, M=男, F=女) - salaryRange: 薪酬范圍代碼(A=30000以下, B=30000-50000, C=50000-80000, D=80000-120000, E=120000以上, N=面議) - workExperience: 工作經驗年數(0=不限, 1, 3, 5, 10) - minAge: 最小年齡(18-30之間的數字字串) - maxAge: 最大年齡(35-55之間的數字字串) - jobType: 工作性質代碼(FT=全職, PT=兼職, CT=約聘, DP=派遣) - recruitPosition: 招聘職位代碼(ENG=工程師, MGR=經理, AST=助理, OP=作業員, SAL=業務) - jobTitle: 職位名稱 - jobDesc: 職位描述(2-3句話) - positionReq: 崗位要求(條列式,用換行分隔) - skillReq: 技能要求(條列式,用換行分隔) - langReq: 語言要求(條列式,用換行分隔) - otherReq: 其他要求(條列式,用換行分隔) 重要:請「只」返回純JSON格式,不要有任何解釋、思考過程或額外文字。格式如下: { ${emptyFields.map(f => `"${f}": "..."`).join(',\n ')} }`; const data = await callClaudeAPI(prompt); let filledCount = 0; if (fillIfEmpty('minEducation', data.minEducation)) filledCount++; if (fillIfEmpty('requiredGender', data.requiredGender)) filledCount++; if (fillIfEmpty('salaryRange', data.salaryRange)) filledCount++; if (fillIfEmpty('workExperience', data.workExperience)) filledCount++; if (fillIfEmpty('minAge', data.minAge)) filledCount++; if (fillIfEmpty('maxAge', data.maxAge)) filledCount++; if (fillIfEmpty('jobType', data.jobType)) filledCount++; if (fillIfEmpty('recruitPosition', data.recruitPosition)) filledCount++; if (fillIfEmpty('jobTitle', data.jobTitle)) filledCount++; if (fillIfEmpty('jobDesc', data.jobDesc)) filledCount++; if (fillIfEmpty('positionReq', data.positionReq)) filledCount++; if (fillIfEmpty('skillReq', data.skillReq)) filledCount++; if (fillIfEmpty('langReq', data.langReq)) filledCount++; if (fillIfEmpty('otherReq', data.otherReq)) filledCount++; if (typeof updatePreview === 'function') updatePreview(); if (typeof showToast === 'function') showToast(`✨ AI 已補充 ${filledCount} 個欄位!`); } catch (error) { if (typeof showToast === 'function') showToast('生成失敗,請稍後再試'); } finally { setButtonLoading(btn, false); } } /** * 生成職務基礎資料 */ export async function generateJobBasic(event) { const btn = event.target.closest('.ai-generate-btn'); const allFields = ['jobCategoryCode', 'jobCode', 'jobName', 'jobNameEn', 'jobHeadcount', 'jobSortOrder', 'jobRemark', 'jobLevel']; const emptyFields = getEmptyFields(allFields); const attBonusChk = document.getElementById('job_hasAttBonus'); const houseAllowChk = document.getElementById('job_hasHouseAllow'); const needCheckboxes = !(attBonusChk?.checked) && !(houseAllowChk?.checked); if (emptyFields.length === 0 && !needCheckboxes) { if (typeof showToast === 'function') showToast('所有欄位都已填寫完成!'); return; } setButtonLoading(btn, true); try { const existingData = {}; allFields.forEach(field => { const value = getFieldValue(field); if (value) existingData[field] = value; }); const contextInfo = Object.keys(existingData).length > 0 ? `\n\n已填寫的資料(請參考這些內容來生成相關的資料):\n${JSON.stringify(existingData, null, 2)}` : ''; const fieldsToGenerate = [...emptyFields]; if (needCheckboxes) { fieldsToGenerate.push('hasAttendanceBonus', 'hasHousingAllowance'); } const prompt = `你是專業人資顧問,熟悉半導體製造業的人資所有流程。請生成職務基礎資料。請用繁體中文回覆。 ${contextInfo} 請「只生成」以下這些尚未填寫的欄位:${fieldsToGenerate.join(', ')} 欄位說明: - jobCategoryCode: 職務類別代碼(MGR=管理職, TECH=技術職, SALE=業務職, ADMIN=行政職, RD=研發職, PROD=生產職) - jobCode: 職務編號(格式如 MGR-001, TECH-002) - jobName: 職務名稱 - jobNameEn: 職務英文名稱 - jobHeadcount: 編制人數(1-20之間的數字字串) - jobSortOrder: 排列順序(10, 20, 30...的數字字串) - jobRemark: 備注說明 - jobLevel: 職務層級(可以是 *保密* 或具體層級) - hasAttendanceBonus: 是否有全勤(true/false) - hasHousingAllowance: 是否住房補貼(true/false) 重要:請「只」返回純JSON格式,不要有任何解釋、思考過程或額外文字。格式如下: { ${fieldsToGenerate.map(f => `"${f}": "..."`).join(',\n ')} }`; const data = await callClaudeAPI(prompt); let filledCount = 0; if (fillIfEmpty('jobCategoryCode', data.jobCategoryCode)) { filledCount++; if (typeof updateJobCategoryName === 'function') updateJobCategoryName(); } if (fillIfEmpty('jobCode', data.jobCode)) filledCount++; if (fillIfEmpty('jobName', data.jobName)) filledCount++; if (fillIfEmpty('jobNameEn', data.jobNameEn)) filledCount++; if (fillIfEmpty('jobHeadcount', data.jobHeadcount)) filledCount++; if (fillIfEmpty('jobSortOrder', data.jobSortOrder)) filledCount++; if (fillIfEmpty('jobRemark', data.jobRemark)) filledCount++; if (fillIfEmpty('jobLevel', data.jobLevel)) filledCount++; if (needCheckboxes) { const attendanceCheckbox = document.getElementById('job_hasAttBonus'); const housingCheckbox = document.getElementById('job_hasHouseAllow'); if (data.hasAttendanceBonus === true && attendanceCheckbox) { attendanceCheckbox.checked = true; document.getElementById('attendanceLabel').textContent = '是'; filledCount++; } if (data.hasHousingAllowance === true && housingCheckbox) { housingCheckbox.checked = true; document.getElementById('housingLabel').textContent = '是'; filledCount++; } } if (typeof updatePreview === 'function') updatePreview(); if (typeof showToast === 'function') showToast(`✨ AI 已補充 ${filledCount} 個欄位!`); } catch (error) { if (typeof showToast === 'function') showToast('生成失敗,請稍後再試'); } finally { setButtonLoading(btn, false); } } /** * 生成崗位描述資料 */ export async function generateJobDesc(event) { const btn = event.target.closest('.ai-generate-btn'); const allFields = ['jd_empNo', 'jd_empName', 'jd_positionCode', 'jd_versionDate', 'jd_positionName', 'jd_department', 'jd_positionEffectiveDate', 'jd_directSupervisor', 'jd_directReports', 'jd_workLocation', 'jd_empAttribute', 'jd_positionPurpose', 'jd_mainResponsibilities', 'jd_education', 'jd_basicSkills', 'jd_professionalKnowledge', 'jd_workExperienceReq', 'jd_otherRequirements']; const emptyFields = allFields.filter(id => { const el = document.getElementById(id); const value = el ? el.value.trim() : ''; if (id === 'jd_mainResponsibilities') { return !value || value === '1、\n2、\n3、\n4、' || value === '1、\n2、\n3、\n4、\n5、'; } return !value; }); if (emptyFields.length === 0) { if (typeof showToast === 'function') showToast('所有欄位都已填寫完成!'); return; } setButtonLoading(btn, true); try { const existingData = {}; allFields.forEach(field => { const el = document.getElementById(field); const value = el ? el.value.trim() : ''; if (value && value !== '1、\n2、\n3、\n4、') { existingData[field.replace('jd_', '')] = value; } }); const contextInfo = Object.keys(existingData).length > 0 ? `\n\n已填寫的資料(請參考這些內容來生成相關的資料):\n${JSON.stringify(existingData, null, 2)}` : ''; const fieldsToGenerate = emptyFields.map(f => f.replace('jd_', '')); const prompt = `請生成崗位描述資料。請用繁體中文回覆。 ${contextInfo} 請「只生成」以下這些尚未填寫的欄位:${fieldsToGenerate.join(', ')} 欄位說明: - empNo: 工號(格式如 A001234) - empName: 員工姓名 - positionCode: 崗位代碼 - versionDate: 版本日期(YYYY-MM-DD格式) - positionName: 崗位名稱 - department: 所屬部門 - positionEffectiveDate: 崗位生效日期(YYYY-MM-DD格式) - directSupervisor: 直接領導職務 - directReports: 直接下級(格式如「工程師 x 5人」) - workLocation: 任職地點代碼(HQ=總部, TPE=台北, TYC=桃園, KHH=高雄, SH=上海, SZ=深圳) - empAttribute: 員工屬性代碼(FT=正式員工, CT=約聘, PT=兼職, IN=實習, DP=派遣) - positionPurpose: 崗位設置目的(1句話說明) - mainResponsibilities: 主要崗位職責(用「1、」「2、」「3、」「4、」「5、」格式,每項換行,用\\n分隔) - education: 教育程度要求(條列式,用換行分隔) - basicSkills: 基本技能要求(條列式,用換行分隔) - professionalKnowledge: 專業知識要求(條列式,用換行分隔) - workExperienceReq: 工作經驗要求(條列式,用換行分隔) - otherRequirements: 其他要求(條列式,用換行分隔) 重要:請「只」返回純JSON格式,不要有任何解釋、思考過程或額外文字。格式如下: { ${fieldsToGenerate.map(f => `"${f}": "..."`).join(',\n ')} }`; const data = await callClaudeAPI(prompt); let filledCount = 0; const fieldMapping = { 'empNo': 'jd_empNo', 'empName': 'jd_empName', 'positionCode': 'jd_positionCode', 'versionDate': 'jd_versionDate', 'positionName': 'jd_positionName', 'department': 'jd_department', 'positionEffectiveDate': 'jd_positionEffectiveDate', 'directSupervisor': 'jd_directSupervisor', 'directReports': 'jd_directReports', 'workLocation': 'jd_workLocation', 'empAttribute': 'jd_empAttribute', 'positionPurpose': 'jd_positionPurpose', 'mainResponsibilities': 'jd_mainResponsibilities', 'education': 'jd_education', 'basicSkills': 'jd_basicSkills', 'professionalKnowledge': 'jd_professionalKnowledge', 'workExperienceReq': 'jd_workExperienceReq', 'otherRequirements': 'jd_otherRequirements' }; Object.keys(fieldMapping).forEach(apiField => { const htmlId = fieldMapping[apiField]; if (data[apiField]) { const el = document.getElementById(htmlId); const currentValue = el ? el.value.trim() : ''; const isEmpty = !currentValue || currentValue === '1、\n2、\n3、\n4、'; if (isEmpty) { el.value = data[apiField]; filledCount++; } } }); if (typeof updatePreview === 'function') updatePreview(); if (typeof showToast === 'function') showToast(`✨ AI 已補充 ${filledCount} 個欄位!`); } catch (error) { if (typeof showToast === 'function') showToast('生成失敗,請稍後再試'); } finally { setButtonLoading(btn, false); } }