import { ipcMain, BrowserWindow } from "electron"; import { spawn } from "child_process"; import { showPrompt } from "../utils/tools"; import { preload, indexHtml, ELECTRON_RENDERER_URL } from "../config"; import os from "os"; import http from "http"; let InstallWindows: BrowserWindow | null = null; export function setupWorkflowHandlers() { let lastJobSummary = "这是我们今天介绍的第一个岗位"; // 打开安装窗口 ipcMain.handle("open-install-window", async (_, args) => { try { if (InstallWindows) { InstallWindows.focus(); showPrompt("下载已打开", "info"); return { success: true }; } const { width, height, path } = args; let installUrl = `${ELECTRON_RENDERER_URL}/#/${path}`; console.log(installUrl); InstallWindows = new BrowserWindow({ title: "模型下载", width, height, minimizable: false, // 是否可以最小化 maximizable: false, // 是否可以最小化 closable: true, // 窗口是否可关闭 alwaysOnTop: false, // 窗口是否永远在别的窗口的上面 webPreferences: { preload, nodeIntegration: true, contextIsolation: false, }, }); // InstallWindows.webContents.openDevTools(); InstallWindows.on("closed", () => { InstallWindows = null; }); if (ELECTRON_RENDERER_URL) { InstallWindows.loadURL(installUrl); } else { InstallWindows.loadFile(indexHtml, { hash: `/${path}` }); } return { success: true }; } catch (error: any) { return { success: false, error: error.message }; } }); // 监听来自渲染器进程的 'install-ollama' 事件 ipcMain.handle("install-ollama-and-model", async (event) => { const webContents = event.sender; // 获取发送事件的窗口 const platform = os.platform(); const modelToPull = "qwen3:8b"; const sendStatus = (status) => { if (webContents && !webContents.isDestroyed()) { webContents.send("install-progress", { status }); } }; try { sendStatus("Checking Ollama installation..."); try { await streamCommand( "ollama", ["-v"], webContents, "install-progress", ); sendStatus("Ollama is already installed."); } catch (error) { // Ollama 未安装,执行安装 sendStatus("Ollama not found. Starting installation..."); if (platform === "darwin" || platform === "linux") { // macOS / Linux - 使用官方的 curl 脚本 const installCommand = "curl -fsSL https://ollama.com/install.sh | sh"; await streamCommand( "sh", ["-c", installCommand], webContents, "install-progress", ); } else if (platform === "win32") { // Windows - 使用 PowerShell 下载并静默安装 const psScript = ` $ProgressPreference = 'SilentlyContinue'; $tempPath = [System.IO.Path]::Combine($env:TEMP, 'OllamaSetup.exe'); Write-Host 'Downloading OllamaSetup.exe...'; Invoke-WebRequest -Uri 'https://ollama.com/download/OllamaSetup.exe' -OutFile $tempPath; Write-Host 'Download complete. Starting silent installer...'; Start-Process -FilePath $tempPath -ArgumentList '/S' -Wait; Write-Host 'Installation complete. Cleaning up...'; Remove-Item $tempPath; Write-Host 'Done.'; `; await streamCommand( "powershell", [ "-ExecutionPolicy", "Bypass", "-NoProfile", "-Command", psScript, ], webContents, "install-progress", ); } else { throw new Error(`Unsupported platform: ${platform}`); } sendStatus("Ollama installation complete."); } // --- 步骤 2: 拉取模型 --- sendStatus( `Pulling model: ${modelToPull}... (This may take a while)`, ); await streamCommand( "ollama", ["pull", modelToPull], webContents, "install-progress", ); sendStatus(`Model ${modelToPull} pull complete.`); return { success: true, message: "Installation and model pull successful.", }; } catch (error: any) { console.error(error); sendStatus(`Error: ${error.message}`); return { success: false, message: error.message }; } }); // 将整个工作流封装在 IPC Handler 中 ipcMain.handle("run-job-workflow", async (_, userQuery) => { console.log("工作流: 正在准备工作..."); let currentJobData = userQuery || {}; let answerText = ""; try { console.log("工作流: 正在调用 Ollama 生成脚本..."); const systemPromptTemplate = `# 角色 (Role) \n你是一个顶级的招聘KOL和直播带岗专家。你的风格是:专业、中立、风趣,能一针见血地分析岗位优劣。你不是一个AI助手,你就是这个角色。 \n\n# 上下文 (Context) \n我正在运行一个自动化工作流。我会\"一个一个\"地喂给你岗位数据。\n\n # 任务 (Task) \n你的任务是执行以下两个操作,并严格按照“输出格式”返回内容:\n1. 生成口播稿:根据【输入数据A】(上一个岗位的摘要) 和【输入数据B】(当前岗位的JSON),生成一段完整的、约90秒的口播稿。\n2. 生成新摘要:为【输入数据B】的“当前岗位”生成一个简短的摘要(例如:XX公司的XX岗),以便在下一次调用时使用。\n\n# 核心指令 (Core Instruction)\n### 口播稿的生成规则 (Rules for the Script) \n1. 衔接:口播稿必须以一个自然的“过渡句”开头(基于【输入数据A】)。 特殊情况:如果【输入数据A】是“这是我们今天介绍的第一个岗位。”,则开头应是“热场”或“总起”,而不是衔接。 \n2. 内容:必须介绍岗位名称 \`jobTitle\`。 \n3. 提炼:从 \`jobLocation\`, \`companyName\`, \`education\`,\`experience\`,\`scale\` 中提炼“亮点 (Pro)”。 \n4. 翻译:用“人话”翻译 \`description\`。 \n5. 视角:你是在“评测”这个岗位,而不是在“推销”。\n\n### 口播稿的纯文本要求 (Pure Text Rules for the Script ONLY) \n**[重要]** 以下规则 *仅适用于* “口播稿”部分,不适用于“新摘要”部分: \n1. 绝不包含任何Markdown格式 (\`**\`, \`#\`)。 \n2. 绝不包含任何标签、括号或元数据 (\`[]\`, \`()\`)。 \n3. 绝不包含任何寒暄、问候、或自我介绍 (例如 \"你好\", \"当然\")。 \n4. 必须是可以直接朗读的、完整的、流畅的纯文本。\n\n# 输入数据 (Input Data)\n\n## 输入数据A (上一个岗位摘要) \n${lastJobSummary}\n\n## 输入数据B (当前岗位JSON)\n\`\`\`json\n${JSON.stringify(currentJobData, null, 2)}\n\`\`\`\n\n# 输出格式 (Output Format)\n**[绝对严格的指令]** \n你必须严格按照下面这个“两部分”格式输出,使用 \`---NEXT_SUMMARY---\` 作为唯一的分隔符。 绝不在分隔符之外添加任何多余的文字、解释或Markdown。 \n\n[这里是AI生成的、符合上述所有“纯文本要求”的完整口播稿] \n---NEXT_SUMMARY--- \n[这里是AI为“当前岗位”生成的简短新摘要]`; answerText = await runOllamaNonStream( systemPromptTemplate, "qwen3:8b", ); if (!answerText) { throw new Error("Ollama 返回为空"); } } catch (e) { return "抱歉,AI 模型在生成脚本时出错。"; } try { console.log("工作流: 正在解析 AI 输出..."); let script = "抱歉,AI没有按预定格式返回脚本,请稍后重试。"; let summary = "这是我们今天介绍的第一个岗位。"; // 这是一个安全的“重置”摘要 if (answerText && typeof answerText === "string") { const parts = answerText.split("---NEXT_SUMMARY---"); if (parts[0] && parts[0].trim() !== "") { script = parts[0].trim(); } if (parts[1] && parts[1].trim() !== "") { summary = parts[1].trim(); } } console.log("工作流: 正在更新状态..."); lastJobSummary = summary; // 关键:更新主进程中的状态 console.log("工作流: 完成,返回口播稿。"); return { success: true, data: script }; // 将最终的“口播稿”返回给渲染进程 } catch (e: any) { console.error("代码运行或变量更新节点出错:", e); return "抱歉,处理 AI 响应时出错。"; } }); // 检查 Ollama 服务器是否正在运行 ipcMain.handle("check-ollama-status", async () => { return await checkOllamaServer(); }); // 处理器:检查服务,如果没运行,就用一个轻量命令唤醒它 ipcMain.handle("ensure-ollama-running", async () => { let isRunning = await checkOllamaServer(); if (isRunning) { return { success: true, message: "Ollama服务器已在运行.", }; } // 服务未运行。 // 我们运行 'ollama ps'。这个命令会与服务通信, // 如果服务没启动,Ollama CLI 会自动启动它。 try { await runCommand("ollama", ["ps"]); // 给服务一点启动时间 (例如 2 秒) await new Promise((resolve) => setTimeout(resolve, 2000)); // 再次检查 isRunning = await checkOllamaServer(); if (isRunning) { return { success: true, message: "Ollama 已在后台启动", }; } else { return { success: false, message: "服务启动失败", }; } } catch (error: any) { console.error("错误:", error); return { success: false, message: `错误: ${error.message}`, }; } }); } // 辅助函数:检查 Ollama API 是否可访问 function checkOllamaServer() { return new Promise((resolve) => { // 默认端口是 11434 const req = http.get("http://127.0.0.1:11434/", (res) => { // "Ollama is running" 的响应码是 200 resolve(res.statusCode === 200); }); // 如果连接被拒绝 (ECONNREFUSED),则服务未运行 req.on("error", () => { resolve(false); }); }); } // 辅助函数:运行一个简单的命令并等待它完成 function runCommand(command, args) { return new Promise((resolve, reject) => { const process = spawn(command, args, { shell: true }); process.on("close", (code) => { if (code === 0) { resolve(null); } else { reject(new Error(`Command failed with code ${code}`)); } }); process.on("error", (err) => reject(err)); }); } // 这是一个非流式的 Ollama 助手函数 async function runOllamaNonStream(prompt, model = "qwen3:8b") { try { const response = await fetch("http://127.0.0.1:11434/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: model, messages: [{ role: "user", content: prompt }], stream: false, // 关键:关闭流式 }), }); if (!response.ok) { throw new Error(`Ollama API error: ${response.statusText}`); } const data = await response.json(); // data.message.content 包含了完整的 AI 回复 return data.message.content; } catch (error) { console.error("Ollama Chat Error:", error); return null; // 返回 null 以便后续逻辑处理 } } function streamCommand(command, args, webContents, eventName) { return new Promise((resolve, reject) => { const process = spawn(command, args, { shell: true }); const send = (channel, data) => { if (webContents && !webContents.isDestroyed()) { webContents.send(channel, data); } }; process.stdout.on("data", (data) => { send(eventName, { type: "stdout", data: data.toString() }); }); process.stderr.on("data", (data) => { send(eventName, { type: "stderr", data: data.toString() }); }); process.on("close", (code) => { if (code === 0) { resolve(null); } else { reject(new Error(`Process exited with code ${code}`)); } }); process.on("error", (err) => { reject(err); }); }); }