mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-09-09 01:17:06 +08:00
add standalone window for llm config
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"providerCompleteApiUrl": "",
|
||||||
|
"providerApiSecret": "",
|
||||||
|
"model": "",
|
||||||
|
"_extra": {}
|
||||||
|
}
|
||||||
@@ -6,16 +6,18 @@ import os from 'node:os'
|
|||||||
import defaultDingtalkConf from './default-config-file/dingtalk.json' assert {type: 'json'}
|
import defaultDingtalkConf from './default-config-file/dingtalk.json' assert {type: 'json'}
|
||||||
import defaultBossConf from './default-config-file/boss.json' assert {type: 'json'}
|
import defaultBossConf from './default-config-file/boss.json' assert {type: 'json'}
|
||||||
import defaultTargetCompanyListConf from './default-config-file/target-company-list.json' assert {type: 'json'}
|
import defaultTargetCompanyListConf from './default-config-file/target-company-list.json' assert {type: 'json'}
|
||||||
|
import defaultLlmConf from './default-config-file/llm.json' assert { type: 'json' }
|
||||||
|
|
||||||
import defaultBossCookieStorage from './default-storage-file/boss-cookies.json' assert { type: 'json' }
|
import defaultBossCookieStorage from './default-storage-file/boss-cookies.json' assert { type: 'json' }
|
||||||
import defaultBossLocalStorageStorage from './default-storage-file/boss-local-storage.json' assert { type: 'json' }
|
import defaultBossLocalStorageStorage from './default-storage-file/boss-local-storage.json' assert { type: 'json' }
|
||||||
import defaultJobNotSuitReasonCodeToTextCacheStorage from './default-storage-file/job-not-suit-reason-code-to-text-cache.json' assert { type: 'json' }
|
import defaultJobNotSuitReasonCodeToTextCacheStorage from './default-storage-file/job-not-suit-reason-code-to-text-cache.json' assert { type: 'json' }
|
||||||
export const configFileNameList = ['boss.json', 'dingtalk.json', 'target-company-list.json']
|
export const configFileNameList = ['boss.json', 'dingtalk.json', 'target-company-list.json', 'llm.json']
|
||||||
|
|
||||||
const defaultConfigFileContentMap = {
|
const defaultConfigFileContentMap = {
|
||||||
'boss.json': JSON.stringify(defaultBossConf),
|
'boss.json': JSON.stringify(defaultBossConf),
|
||||||
'dingtalk.json': JSON.stringify(defaultDingtalkConf),
|
'dingtalk.json': JSON.stringify(defaultDingtalkConf),
|
||||||
'target-company-list.json': JSON.stringify(defaultTargetCompanyListConf)
|
'target-company-list.json': JSON.stringify(defaultTargetCompanyListConf),
|
||||||
|
'llm.json': JSON.stringify(defaultLlmConf)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtimeFolderPath = path.join(os.homedir(), '.geekgeekrun')
|
const runtimeFolderPath = path.join(os.homedir(), '.geekgeekrun')
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { pipeWriteRegardlessError } from '../../utils/pipe'
|
|||||||
import { WriteStream } from 'node:fs'
|
import { WriteStream } from 'node:fs'
|
||||||
// eslint-disable-next-line vue/prefer-import-from-vue
|
// eslint-disable-next-line vue/prefer-import-from-vue
|
||||||
import { hasOwn } from '@vue/shared'
|
import { hasOwn } from '@vue/shared'
|
||||||
|
import { createLlmConfigWindow, llmConfigWindow } from '../../../window/llmConfigWindow'
|
||||||
|
|
||||||
export default function initIpc() {
|
export default function initIpc() {
|
||||||
ipcMain.on('open-external-link', (_, link) => {
|
ipcMain.on('open-external-link', (_, link) => {
|
||||||
@@ -435,6 +436,32 @@ export default function initIpc() {
|
|||||||
return await getJobHistoryByEncryptId(encryptJobId)
|
return await getJobHistoryByEncryptId(encryptJobId)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('llm-config', async () => {
|
||||||
|
createLlmConfigWindow({
|
||||||
|
parent: mainWindow!,
|
||||||
|
modal: true,
|
||||||
|
show: true
|
||||||
|
})
|
||||||
|
const defer = Promise.withResolvers()
|
||||||
|
async function saveLlmConfigHandler(_, configToSave) {
|
||||||
|
const configToPatch = await readConfigFile('llm.json')
|
||||||
|
for (const k of Object.keys(configToSave)) {
|
||||||
|
configToPatch[k] = configToSave[k]
|
||||||
|
}
|
||||||
|
await writeConfigFile('llm.json', configToPatch)
|
||||||
|
defer.resolve()
|
||||||
|
ipcMain.removeHandler('save-llm-config')
|
||||||
|
llmConfigWindow?.close()
|
||||||
|
}
|
||||||
|
ipcMain.handle('save-llm-config', saveLlmConfigHandler)
|
||||||
|
llmConfigWindow?.once('closed', () => {
|
||||||
|
ipcMain.removeHandler('save-llm-config')
|
||||||
|
defer.reject(new Error('cancel'))
|
||||||
|
})
|
||||||
|
return defer.promise
|
||||||
|
})
|
||||||
|
ipcMain.on('close-llm-config', () => llmConfigWindow?.close())
|
||||||
|
|
||||||
ipcMain.handle('exit-app-immediately', () => {
|
ipcMain.handle('exit-app-immediately', () => {
|
||||||
app.exit(0)
|
app.exit(0)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { BrowserWindow, ipcMain } from 'electron'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export let llmConfigWindow: BrowserWindow | null = null
|
||||||
|
export function createLlmConfigWindow(
|
||||||
|
opt?: Electron.BrowserWindowConstructorOptions
|
||||||
|
): BrowserWindow {
|
||||||
|
// Create the browser window.
|
||||||
|
if (llmConfigWindow) {
|
||||||
|
llmConfigWindow!.show()
|
||||||
|
}
|
||||||
|
llmConfigWindow = new BrowserWindow({
|
||||||
|
width: 540,
|
||||||
|
height: 400,
|
||||||
|
resizable: false,
|
||||||
|
show: false,
|
||||||
|
autoHideMenuBar: true,
|
||||||
|
frame: false,
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, '../preload/index.js'),
|
||||||
|
sandbox: false
|
||||||
|
},
|
||||||
|
...opt
|
||||||
|
})
|
||||||
|
|
||||||
|
llmConfigWindow.on('ready-to-show', () => {
|
||||||
|
llmConfigWindow!.show()
|
||||||
|
})
|
||||||
|
|
||||||
|
// HMR for renderer base on electron-vite cli.
|
||||||
|
// Load the remote URL for development or the local html file for production.
|
||||||
|
if (process.env.NODE_ENV === 'development' && process.env['ELECTRON_RENDERER_URL']) {
|
||||||
|
llmConfigWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/llmConfig')
|
||||||
|
} else {
|
||||||
|
llmConfigWindow.loadURL(
|
||||||
|
'file://' + path.join(__dirname, '../renderer/index.html') + '#/llmConfig'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
llmConfigWindow!.once('closed', () => {
|
||||||
|
llmConfigWindow = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return llmConfigWindow!
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<template>
|
||||||
|
<div class="llm-config-page">
|
||||||
|
<div class="mt1em mb1em flex flex-items-center flex-justify-between">
|
||||||
|
<span>大语言模型设置</span>
|
||||||
|
<el-dropdown @command="(item) => handlePresetClick(item)">
|
||||||
|
<el-button size="small"
|
||||||
|
>配置模板 <el-icon class="el-icon--right"><arrow-down /></el-icon
|
||||||
|
></el-button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<el-dropdown-item v-for="item in llmPresetList" :key="item.name" :command="item">{{
|
||||||
|
item.name
|
||||||
|
}}</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
<el-form
|
||||||
|
ref="formRef"
|
||||||
|
:model="formContent"
|
||||||
|
:rules="formRules"
|
||||||
|
label-position="top"
|
||||||
|
class="llm-config-form"
|
||||||
|
>
|
||||||
|
<el-form-item prop="providerCompleteApiUrl" label="服务提供商 Base Url">
|
||||||
|
<el-input
|
||||||
|
v-model="formContent.providerCompleteApiUrl"
|
||||||
|
:autosize="{
|
||||||
|
minRows: 10,
|
||||||
|
maxRows: 10
|
||||||
|
}"
|
||||||
|
font-size-12px
|
||||||
|
></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="providerCompleteApiUrl" label="要使用的模型">
|
||||||
|
<el-input
|
||||||
|
v-model="formContent.model"
|
||||||
|
:autosize="{
|
||||||
|
minRows: 10,
|
||||||
|
maxRows: 10
|
||||||
|
}"
|
||||||
|
font-size-12px
|
||||||
|
></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item prop="providerApiSecret" label="从服务提供商处获取的 API Secret">
|
||||||
|
<el-input
|
||||||
|
v-model="formContent.providerApiSecret"
|
||||||
|
:autosize="{
|
||||||
|
minRows: 10,
|
||||||
|
maxRows: 10
|
||||||
|
}"
|
||||||
|
font-size-12px
|
||||||
|
></el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<footer flex mt20px pb20px flex-justify-between>
|
||||||
|
<div>
|
||||||
|
<!-- <el-button type="text" @click="handleTestAvailability">测试可用性</el-button> -->
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<el-button @click="handleCancel">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ElForm, ElDropdown, ElDropdownMenu, ElDropdownItem, ElIcon, ElButton } from 'element-plus'
|
||||||
|
import { ArrowDown } from '@element-plus/icons-vue'
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
|
||||||
|
interface LlmConfigItem {
|
||||||
|
providerCompleteApiUrl: string
|
||||||
|
providerApiSecret: string
|
||||||
|
model: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const formRef = ref<InstanceType<typeof ElForm>>()
|
||||||
|
const formContent = ref<LlmConfigItem>({
|
||||||
|
providerCompleteApiUrl: '',
|
||||||
|
providerApiSecret: '',
|
||||||
|
model: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const formRules = {}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
electron.ipcRenderer.send('close-llm-config')
|
||||||
|
}
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
electron.ipcRenderer.invoke('save-llm-config', JSON.parse(JSON.stringify(formContent.value)))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const savedFileContent = (await electron.ipcRenderer.invoke('fetch-config-file-content'))
|
||||||
|
?.config?.['llm.json']
|
||||||
|
if (!savedFileContent) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const k of Object.keys(formContent.value)) {
|
||||||
|
formContent.value[k] = savedFileContent[k]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const llmPresetList: {
|
||||||
|
name: string
|
||||||
|
config: LlmConfigItem
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
name: '由 DeepSeek 提供 DeepSeek-V3 模型',
|
||||||
|
config: {
|
||||||
|
model: 'deepseek-chat',
|
||||||
|
providerApiSecret: '',
|
||||||
|
providerCompleteApiUrl: 'https://api.deepseek.com'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '通过 Ollama 部署的 DeepSeek-R1(14B)模型',
|
||||||
|
config: {
|
||||||
|
model: 'deepseek-r1:14b',
|
||||||
|
providerApiSecret: 'ollama',
|
||||||
|
providerCompleteApiUrl: 'http://127.0.0.1:11434'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '通过 Ollama 部署的 Qwen2.5(7B)模型',
|
||||||
|
config: {
|
||||||
|
model: 'qwen2.5:7b',
|
||||||
|
providerApiSecret: 'ollama',
|
||||||
|
providerCompleteApiUrl: 'http://127.0.0.1:11434'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function handlePresetClick(selected: (typeof llmPresetList)[number]) {
|
||||||
|
for (const k of Object.keys(formContent.value)) {
|
||||||
|
formContent.value[k] = selected.config[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTestAvailability() {}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.llm-config-page {
|
||||||
|
margin: 0 auto;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.llm-config-form.el-form {
|
||||||
|
.el-form-item__label {
|
||||||
|
width: 20em;
|
||||||
|
}
|
||||||
|
.el-form-item__error--inline {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
v-model="formContent.expectCompanies"
|
v-model="formContent.expectCompanies"
|
||||||
:autosize="{ minRows: 4 }"
|
:autosize="{ minRows: 4 }"
|
||||||
type="textarea"
|
type="textarea"
|
||||||
@blur="handleExpectCompaniesInputBlur"
|
@blur="normalizeExpectCompanies"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item
|
<el-form-item
|
||||||
@@ -123,13 +123,14 @@ const handleSubmit = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
normalizeExpectCompanies()
|
||||||
await formRef.value!.validate()
|
await formRef.value!.validate()
|
||||||
await electron.ipcRenderer.invoke('save-config-file-from-ui', JSON.stringify(formContent.value))
|
await electron.ipcRenderer.invoke('save-config-file-from-ui', JSON.stringify(formContent.value))
|
||||||
ElMessage.success('Configuration saved.')
|
ElMessage.success('Configuration saved.')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleExpectCompaniesInputBlur = (event) => {
|
const normalizeExpectCompanies = () => {
|
||||||
event.target.value = (event.target?.value ?? '')
|
formContent.value.expectCompanies = formContent.value.expectCompanies
|
||||||
.split(/,|,/)
|
.split(/,|,/)
|
||||||
.map((it) => it.trim())
|
.map((it) => it.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<el-form-item>
|
<el-form-item>
|
||||||
<div>
|
<div>
|
||||||
<div>BOSS直聘 Cookie</div>
|
<div>BOSS直聘 Cookie</div>
|
||||||
<el-button size="small" type="primary" font-size-inherit @click="handleClickLaunchLogin"
|
<el-button size="small" type="primary" @click="handleClickLaunchLogin"
|
||||||
>编辑Cookie</el-button
|
>编辑Cookie</el-button
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,14 +24,37 @@
|
|||||||
</el-radio>
|
</el-radio>
|
||||||
<br />
|
<br />
|
||||||
<el-radio :label="RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT">
|
<el-radio :label="RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT">
|
||||||
由 AI(根据你的简历及和当前Boss聊天的上下文)生成的内容
|
由大语言模型(根据简历及当前聊天上下文)生成的内容
|
||||||
</el-radio>
|
</el-radio>
|
||||||
</div>
|
</div>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<div class="ml-30px">
|
<div class="ml-30px">
|
||||||
<el-form-item
|
<template
|
||||||
|
v-if="
|
||||||
|
formContent.autoReminder.rechatContentSource ===
|
||||||
|
RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<el-form-item prop="geminiApiKey">
|
||||||
|
<div class="flex flex-items-center">
|
||||||
|
<el-button size="small" type="primary" @click="handleClickConfigLlm">
|
||||||
|
配置大语言模型
|
||||||
|
</el-button>
|
||||||
|
<div class="ml1em">
|
||||||
|
支持
|
||||||
|
<span
|
||||||
|
class="pl10px pr10px color-white border-rd-full"
|
||||||
|
style="background-color: #3c4efd"
|
||||||
|
>DeepSeek-V3</span
|
||||||
|
>
|
||||||
|
模型
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
<!-- <el-form-item
|
||||||
v-if="
|
v-if="
|
||||||
formContent.autoReminder.rechatContentSource ===
|
formContent.autoReminder.rechatContentSource ===
|
||||||
RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT
|
RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT
|
||||||
@@ -57,7 +80,7 @@
|
|||||||
<el-input type="textarea" v-model="formContent.autoReminder.resumeAbstract" />
|
<el-input type="textarea" v-model="formContent.autoReminder.resumeAbstract" />
|
||||||
</div>
|
</div>
|
||||||
<el-button class="mt-8px">预览Prompt</el-button>
|
<el-button class="mt-8px">预览Prompt</el-button>
|
||||||
</el-form-item>
|
</el-form-item> -->
|
||||||
</div>
|
</div>
|
||||||
<el-form-item label="跟进间隔(分钟)" prop="throttleIntervalMinutes">
|
<el-form-item label="跟进间隔(分钟)" prop="throttleIntervalMinutes">
|
||||||
<el-input-number
|
<el-input-number
|
||||||
@@ -81,7 +104,8 @@
|
|||||||
:disabled="!enableRechatLimit"
|
:disabled="!enableRechatLimit"
|
||||||
/> 天<br />
|
/> 天<br />
|
||||||
<div v-if="enableRechatLimit">
|
<div v-if="enableRechatLimit">
|
||||||
不再跟进 (<span class="text-orange">{{ rechatLimitDateString }}</span>)之前列表中没有进展的聊天
|
不再跟进 (<span class="text-orange">{{ rechatLimitDateString }}</span
|
||||||
|
>)之前列表中没有进展的聊天
|
||||||
</div>
|
</div>
|
||||||
<div v-else>这将会跟进列表中所有聊天(<span class="text-orange">不建议</span>)</div>
|
<div v-else>这将会跟进列表中所有聊天(<span class="text-orange">不建议</span>)</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,8 +233,12 @@ const rechatLimitDateString = computed(() => {
|
|||||||
).format('YYYY-MM-DD HH:mm:ss')
|
).format('YYYY-MM-DD HH:mm:ss')
|
||||||
})
|
})
|
||||||
|
|
||||||
const goToGeminiNanoApiKeyPage = () => {
|
const handleClickConfigLlm = async () => {
|
||||||
electron.ipcRenderer.send('open-external-link', 'https://aistudio.google.com/app/apikey')
|
try {
|
||||||
|
await electron.ipcRenderer.invoke('llm-config')
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ const routes: Array<RouteRecordRaw> = [
|
|||||||
title: 'Cookie 助手'
|
title: 'Cookie 助手'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/llmConfig',
|
||||||
|
component: () => import('@renderer/page/LlmConfig/index.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '大语言模型设置'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/main-layout',
|
path: '/main-layout',
|
||||||
component: () => import('@renderer/page/MainLayout/index.vue'),
|
component: () => import('@renderer/page/MainLayout/index.vue'),
|
||||||
|
|||||||
Reference in New Issue
Block a user