mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-09-05 15:38:46 +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 defaultBossConf from './default-config-file/boss.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 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' }
|
||||
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 = {
|
||||
'boss.json': JSON.stringify(defaultBossConf),
|
||||
'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')
|
||||
|
||||
@@ -30,6 +30,7 @@ import { pipeWriteRegardlessError } from '../../utils/pipe'
|
||||
import { WriteStream } from 'node:fs'
|
||||
// eslint-disable-next-line vue/prefer-import-from-vue
|
||||
import { hasOwn } from '@vue/shared'
|
||||
import { createLlmConfigWindow, llmConfigWindow } from '../../../window/llmConfigWindow'
|
||||
|
||||
export default function initIpc() {
|
||||
ipcMain.on('open-external-link', (_, link) => {
|
||||
@@ -435,6 +436,32 @@ export default function initIpc() {
|
||||
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', () => {
|
||||
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"
|
||||
:autosize="{ minRows: 4 }"
|
||||
type="textarea"
|
||||
@blur="handleExpectCompaniesInputBlur"
|
||||
@blur="normalizeExpectCompanies"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
@@ -123,13 +123,14 @@ const handleSubmit = async () => {
|
||||
})
|
||||
}
|
||||
const handleSave = async () => {
|
||||
normalizeExpectCompanies()
|
||||
await formRef.value!.validate()
|
||||
await electron.ipcRenderer.invoke('save-config-file-from-ui', JSON.stringify(formContent.value))
|
||||
ElMessage.success('Configuration saved.')
|
||||
}
|
||||
|
||||
const handleExpectCompaniesInputBlur = (event) => {
|
||||
event.target.value = (event.target?.value ?? '')
|
||||
const normalizeExpectCompanies = () => {
|
||||
formContent.value.expectCompanies = formContent.value.expectCompanies
|
||||
.split(/,|,/)
|
||||
.map((it) => it.trim())
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<el-form-item>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
@@ -24,14 +24,37 @@
|
||||
</el-radio>
|
||||
<br />
|
||||
<el-radio :label="RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT">
|
||||
由 AI(根据你的简历及和当前Boss聊天的上下文)生成的内容
|
||||
由大语言模型(根据简历及当前聊天上下文)生成的内容
|
||||
</el-radio>
|
||||
</div>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<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="
|
||||
formContent.autoReminder.rechatContentSource ===
|
||||
RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT
|
||||
@@ -57,7 +80,7 @@
|
||||
<el-input type="textarea" v-model="formContent.autoReminder.resumeAbstract" />
|
||||
</div>
|
||||
<el-button class="mt-8px">预览Prompt</el-button>
|
||||
</el-form-item>
|
||||
</el-form-item> -->
|
||||
</div>
|
||||
<el-form-item label="跟进间隔(分钟)" prop="throttleIntervalMinutes">
|
||||
<el-input-number
|
||||
@@ -81,7 +104,8 @@
|
||||
:disabled="!enableRechatLimit"
|
||||
/> 天<br />
|
||||
<div v-if="enableRechatLimit">
|
||||
不再跟进 (<span class="text-orange">{{ rechatLimitDateString }}</span>)之前列表中没有进展的聊天
|
||||
不再跟进 (<span class="text-orange">{{ rechatLimitDateString }}</span
|
||||
>)之前列表中没有进展的聊天
|
||||
</div>
|
||||
<div v-else>这将会跟进列表中所有聊天(<span class="text-orange">不建议</span>)</div>
|
||||
</div>
|
||||
@@ -209,8 +233,12 @@ const rechatLimitDateString = computed(() => {
|
||||
).format('YYYY-MM-DD HH:mm:ss')
|
||||
})
|
||||
|
||||
const goToGeminiNanoApiKeyPage = () => {
|
||||
electron.ipcRenderer.send('open-external-link', 'https://aistudio.google.com/app/apikey')
|
||||
const handleClickConfigLlm = async () => {
|
||||
try {
|
||||
await electron.ipcRenderer.invoke('llm-config')
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ const routes: Array<RouteRecordRaw> = [
|
||||
title: 'Cookie 助手'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/llmConfig',
|
||||
component: () => import('@renderer/page/LlmConfig/index.vue'),
|
||||
meta: {
|
||||
title: '大语言模型设置'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/main-layout',
|
||||
component: () => import('@renderer/page/MainLayout/index.vue'),
|
||||
|
||||
Reference in New Issue
Block a user