add standalone window for llm config

This commit is contained in:
geekgeekrun
2025-04-06 23:27:56 +08:00
parent 5b78dc5d63
commit 7458067708
8 changed files with 290 additions and 12 deletions
@@ -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-R114B)模型',
config: {
model: 'deepseek-r1:14b',
providerApiSecret: 'ollama',
providerCompleteApiUrl: 'http://127.0.0.1:11434'
}
},
{
name: '通过 Ollama 部署的 Qwen2.57B)模型',
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"
/>&nbsp;天<br />
<div v-if="enableRechatLimit">
不再跟进&nbsp;<span class="text-orange">{{ rechatLimitDateString }}</span>)之前列表中没有进展的聊天
不再跟进&nbsp;<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'),