Merge branch 'feature/auto-reminder-gpt' into feature/ui

This commit is contained in:
geekgeekrun
2025-04-26 12:53:15 +08:00
62 changed files with 4130 additions and 729 deletions

View File

@@ -9,6 +9,10 @@
"expectJobRegExpStr": "",
"autoReminder": {
"throttleIntervalMinutes": 10,
"rechatLimitDay": 21
"rechatLimitDay": 21,
"geminiApiKey": "",
"rechatContentSource": 1,
"recentMessageQuantityForLlm": 8,
"rechatLlmFallback": 1
}
}

View File

@@ -0,0 +1,8 @@
[{
"providerCompleteApiUrl": "",
"providerApiSecret": "",
"model": "",
"enabled": true,
"serveWeight": 100,
"_extra": {}
}]

View File

@@ -1,4 +1,4 @@
[
"青钱","软通动力","南天","睿服","中电金信","佰钧成","云链","博彦","汉克时代","柯莱特","拓保","亿达信息","纬创","微创","微澜","诚迈科技","法本","兆尹","诚迈","联合永道","新致软件","宇信科技",
"抖音","字节","字跳","有竹居","脸萌","头条","懂车帝","滴滴","嘀嘀","小桔","网易","有道","腾讯","酷狗","酷我","阅文","搜狗","京东","沃东天骏","达达","百度","度小满","爱奇艺","携程","趣拿","去哪儿","集度","理想","蔚来","顺丰","讯飞","同程","艺龙","马蜂窝","贝壳","自如","链家","我爱我家","相寓","多点","金山","小米","猎豹","新浪","微博","阿里","蚂蚁","飞猪","高德","乌鸫","饿了么","美团","三快","猫眼","快手","映客","小红书","行吟","奇虎","360","三六零","鸿盈","奇富","奇元","亚信","启明星辰","奇安信","深信服","长亭","绿盟","天融信","商汤","SenseTime","大华","海康威视","hikvision","汽车之家","车好多","瓜子","易车","昆仑万维","昆仑天工","闲徕","趣加","FunPlus","完美","马上消费","轻松","水滴","白龙马","58","车欢欢","五八","红布林","致美","快狗","天鹅到家","转转","美餐","知乎","智者四海","易点云","搜狐","用友","畅捷通","猿辅导","小猿","猿力","好未来","学而思","希望学","新东方","东方甄选","东方优选","作业帮","高途","跟谁学","学科网","天学网","一起教育","一起作业","美术宝","火花思维","粉笔","老虎国际","一心向上","向上一意","联想","拉勾","乐视","欢聚","竞技世界","拼多多","寻梦","得物","Moka","希瑞亚斯","北森","OPPO","欧珀","vivo","维沃","小天才","步步高","读书郎","货拉拉","陌陌","探探","Shopee","首汽租车","神州租车","天眼查","旷视","小冰","美图","智谱华章","MiniMax","石头科技"
"青钱","软通动力","南天","睿服","中电金信","佰钧成","云链","博彦","汉克时代","柯莱特","拓保","亿达信息","纬创","微创","微澜","诚迈科技","法本","兆尹","诚迈","联合永道","新致软件","宇信科技","华为","德科","FESCO","科锐","科之锐",
"抖音","字节","字跳","有竹居","脸萌","头条","懂车帝","滴滴","嘀嘀","小桔","网易","有道","腾讯","酷狗","酷我","阅文","搜狗","京东","沃东天骏","达达","达冠","百度","度小满","爱奇艺","携程","趣拿","去哪儿","集度","理想","蔚来","顺丰","讯飞","同程","艺龙","马蜂窝","贝壳","自如","链家","我爱我家","相寓","多点","金山","小米","猎豹","新浪","微博","阿里","蚂蚁","高德","LAZADA","来赞达","飞猪","菜鸟","哈啰","钉钉","乌鸫","饿了么","美团","三快","猫眼","快手","映客","小红书","行吟","奇虎","360","三六零","鸿盈","奇富","奇元","亚信","启明星辰","奇安信","深信服","长亭","绿盟","天融信","商汤","SenseTime","大华","海康威视","hikvision","汽车之家","车好多","瓜子","易车","昆仑万维","昆仑天工","闲徕","趣加","FunPlus","完美","马上消费","轻松","水滴","白龙马","58","车欢欢","五八","红布林","致美","快狗","天鹅到家","转转","美餐","知乎","智者四海","易点云","搜狐","用友","畅捷通","猿辅导","小猿","猿力","好未来","学而思","希望学","新东方","东方甄选","东方优选","作业帮","高途","跟谁学","学科网","天学网","一起教育","一起作业","美术宝","火花思维","粉笔","老虎国际","一心向上","向上一意","联想","拉勾","乐视","欢聚","竞技世界","拼多多","寻梦","得物","Moka","希瑞亚斯","北森","OPPO","欧珀","vivo","维沃","小天才","步步高","读书郎","货拉拉","陌陌","探探","Shopee","首汽租车","神州租车","天眼查","旷视","小冰","美图","智谱华章","MiniMax","石头科技","迅雷","TP","希音","SHEIN","稀宇","深言","百川智能","与爱为舞","牵手"
]

View File

@@ -79,6 +79,21 @@ const targetCompanyList = readConfigFile('target-company-list.json').filter(it =
const anyCombineRecommendJobFilter = readConfigFile('boss.json').anyCombineRecommendJobFilter
const expectJobRegExpStr = readConfigFile('boss.json').expectJobRegExpStr
let {
expectJobNameRegExpStr,
expectJobTypeRegExpStr,
expectJobDescRegExpStr,
} = readConfigFile('boss.json')
if (
expectJobRegExpStr &&
!expectJobNameRegExpStr &&
!expectJobTypeRegExpStr &&
!expectJobDescRegExpStr
) {
expectJobNameRegExpStr = expectJobRegExpStr
expectJobTypeRegExpStr = expectJobRegExpStr
expectJobDescRegExpStr = expectJobRegExpStr
}
const localStoragePageUrl = `https://www.zhipin.com/desktop/`
const recommendJobPageUrl = `https://www.zhipin.com/web/geek/job-recommend`
@@ -191,22 +206,32 @@ async function markJobAsNotSuitInRecommendPage (reasonCode) {
return result
}
export function testIfJobTitleOrDescriptionSuit (jobInfo, regExpStr) {
if (!regExpStr) {
return true
}
export function testIfJobTitleOrDescriptionSuit (jobInfo) {
let isJobNameSuit = true
try {
const regExp = new RegExp(regExpStr, 'i')
if (
!regExp.test(jobInfo.jobName)
&& !regExp.test(jobInfo.positionName)
&& !regExp.test(jobInfo.postDescription)
) {
return false
if (expectJobNameRegExpStr.trim()) {
const regExp = new RegExp(expectJobNameRegExpStr, 'i')
isJobNameSuit = regExp.test(jobInfo.jobName)
}
} catch {
}
return true
let isJobTypeSuit = true
try {
if (expectJobTypeRegExpStr.trim()) {
const regExp = new RegExp(expectJobTypeRegExpStr, 'i')
isJobTypeSuit = regExp.test(jobInfo.positionName)
}
} catch {
}
let isJobDescSuit = true
try {
if (expectJobDescRegExpStr.trim()) {
const regExp = new RegExp(expectJobDescRegExpStr, 'i')
isJobDescSuit = regExp.test(jobInfo.postDescription)
}
} catch {
}
return isJobNameSuit && isJobTypeSuit && isJobDescSuit
}
async function setFilterCondition (selectedFilters) {
@@ -239,7 +264,7 @@ async function setFilterCondition (selectedFilters) {
const placeholderText = placeholderTexts[i]
const filterDropdownProxy = await (async () => {
const jsHandle = (await page.evaluateHandle((placeholderText) => {
const filterBar = document.querySelector('.job-recommend-main .job-recommend-search')
const filterBar = document.querySelector('.page-jobs-main .filter-condition-inner')
const dropdownEntry = filterBar.__vue__.$children.find(it => it.placeholder === placeholderText)
return dropdownEntry.$el
}, placeholderText)).asElement();
@@ -264,18 +289,18 @@ async function setFilterCondition (selectedFilters) {
const optionKaPrefix = optionKaPrefixes[i]
if (!currentFilterConditions.length) {
if (placeholderText === '公司行业') {
const activeOptionElAtCurrentFilterProxyList = await page.$$(`.job-recommend-main .recommend-search-more .active[ka^="${optionKaPrefix}"]`)
const activeOptionElAtCurrentFilterProxyList = await page.$$(`.page-jobs-main .filter-condition-inner .active[ka^="${optionKaPrefix}"]`)
for (const it of activeOptionElAtCurrentFilterProxyList) {
await it.click()
}
} else {
// select 不限 immediately
const buxianOptionElProxy = await page.$(`.job-recommend-main .recommend-search-more [ka="${optionKaPrefix}${0}"]`)
const buxianOptionElProxy = await page.$(`.page-jobs-main .filter-condition-inner [ka="${optionKaPrefix}${0}"]`)
await buxianOptionElProxy.click()
}
} else {
//#region uncheck options perviously checked but not existed in current filter.
const activeOptionElAtCurrentFilterProxyList = await page.$$(`.job-recommend-main .recommend-search-more .active[ka^="${optionKaPrefix}"]`)
const activeOptionElAtCurrentFilterProxyList = await page.$$(`.page-jobs-main .filter-condition-inner .active[ka^="${optionKaPrefix}"]`)
const activeOptionValues = (await Promise.all(
activeOptionElAtCurrentFilterProxyList.map(elProxy => {
return elProxy.evaluate((el) => {
@@ -314,7 +339,7 @@ async function setFilterCondition (selectedFilters) {
optionValue = conditionToCheck[j]
}
await sleepWithRandomDelay(500)
const optionElProxy = await page.$(`.job-recommend-main .recommend-search-more [ka="${optionKaPrefix}${optionValue}"]`)
const optionElProxy = await page.$(`.page-jobs-main .filter-condition-inner [ka="${optionKaPrefix}${optionValue}"]`)
if (!optionElProxy) {
continue;
}
@@ -384,7 +409,7 @@ async function toRecommendPage (hooks) {
}
}
const INIT_START_EXCEPT_JOB_INDEX = 1
const INIT_START_EXCEPT_JOB_INDEX = 0
let currentExceptJobIndex = INIT_START_EXCEPT_JOB_INDEX
afterPageLoad: while (true) {
let expectJobList
@@ -397,20 +422,21 @@ async function toRecommendPage (hooks) {
await sleepWithRandomDelay(2500)
await Promise.all([
page.waitForSelector('.job-recommend-main .recommend-search-expect .recommend-job-btn'),
page.waitForSelector('.c-expect-select .expect-list .expect-item'),
page.waitForSelector('.job-list-container .rec-job-list')
])
await page.click(`.c-expect-select .expect-list .expect-item`)
const currentActiveJobIndex = await page.evaluate(`
[...document.querySelectorAll('.job-recommend-main .recommend-search-expect .recommend-job-btn')].findIndex(it => it.classList.contains('active'))
[...document.querySelectorAll('.c-expect-select .expect-list .expect-item')].findIndex(it => it.classList.contains('active'))
`)
expectJobList = await page.evaluate(`document.querySelector('.job-recommend-search')?.__vue__?.expectList`)
expectJobList = await page.evaluate(`document.querySelector('.c-expect-select')?.__vue__?.expectList`)
if (currentActiveJobIndex === currentExceptJobIndex) {
// first navigation and can immediately start chat (recommend job)
} else {
// not first navigation and should choose a job (except job)
// click first expect job
const expectJobTabHandlers = await page.$$('.job-recommend-main .recommend-search-expect .recommend-job-btn')
const expectJobTabHandlers = await page.$$('.c-expect-select .expect-list .expect-item')
await expectJobTabHandlers[currentExceptJobIndex].click()
await page.waitForResponse(
response => {
@@ -461,7 +487,7 @@ async function toRecommendPage (hooks) {
// job list
const recommendJobListElProxy = await page.$('.job-list-container .rec-job-list')
let jobListData = await page.evaluate(`document.querySelector('.job-recommend-main')?.__vue__?.jobList`)
let jobListData = await page.evaluate(`document.querySelector('.page-jobs-main')?.__vue__?.jobList`)
let hasReachLastPage = false
let targetJobIndex = -1
let targetJobData
@@ -494,10 +520,10 @@ async function toRecommendPage (hooks) {
) {
scrolledHeight += increase
await page.mouse.wheel({deltaY: increase});
await sleep(1)
await sleep(100)
await requestNextPagePromiseWithResolver?.promise
hasReachLastPage = await page.evaluate(`
!(document.querySelector('.job-recommend-main')?.__vue__?.hasMore)
!(document.querySelector('.page-jobs-main')?.__vue__?.hasMore)
`)
if (hasReachLastPage) {
console.log(`Arrive the terminal of the job list.`)
@@ -505,16 +531,16 @@ async function toRecommendPage (hooks) {
}
requestNextPagePromiseWithResolver = null
await sleep(3000)
await sleep(5000)
jobListData = await page.evaluate(
`
document.querySelector('.job-recommend-main')?.__vue__?.jobList
document.querySelector('.page-jobs-main')?.__vue__?.jobList
`
)
tempTargetJobIndexToCheckDetail = jobListData.findIndex(it =>
!blockBossNotNewChat.has(it.encryptBossId) &&
!blockBossNotActive.has(it.encryptBossId) &&
[...expectCompanySet].find(name => it.brandName.includes(name)) &&
[...expectCompanySet].find(name => it.brandName?.toLowerCase?.()?.includes(name.toLowerCase())) &&
!blockJobNotSuit.has(it.encryptJobId)
)
}
@@ -543,6 +569,10 @@ async function toRecommendPage (hooks) {
const recommendJobItemList = await recommendJobListElProxy.$$('ul.rec-job-list li.job-card-box')
const targetJobElProxy = recommendJobItemList[tempTargetJobIndexToCheckDetail]
// click that element
await page.evaluate(() => {
document.documentElement.scrollTop = 0
})
await sleep(500)
await targetJobElProxy.click()
await page.waitForResponse(
response => {
@@ -594,7 +624,7 @@ async function toRecommendPage (hooks) {
continue continueFind
}
if (
!testIfJobTitleOrDescriptionSuit(targetJobData.jobInfo, expectJobRegExpStr)
!testIfJobTitleOrDescriptionSuit(targetJobData.jobInfo)
) {
blockJobNotSuit.add(targetJobData.jobInfo.encryptId)
try {
@@ -612,7 +642,6 @@ async function toRecommendPage (hooks) {
)
} catch {
}
debugger
continue continueFind
}
const startChatButtonInnerHTML = await page.evaluate('document.querySelector(".job-detail-box .op-btn.op-btn-chat")?.innerHTML.trim()')
@@ -646,6 +675,10 @@ async function toRecommendPage (hooks) {
await hooks.newChatWillStartup?.promise(targetJobData)
const startChatButtonProxy = await page.$('.job-detail-box .op-btn.op-btn-chat')
await page.evaluate(() => {
document.documentElement.scrollTop = 0
})
await sleep(500)
//#region click the chat button
await startChatButtonProxy.click()
@@ -714,9 +747,11 @@ async function toRecommendPage (hooks) {
}
// for of reach terminal
if (
currentExceptJobIndex + 1 > expectJobList.length
currentExceptJobIndex + 1 >= expectJobList.length
) {
hooks.noPositionFoundForCurrentJob?.call()
hooks.noPositionFoundAfterTraverseAllJob?.call()
await sleep((20 + 30 * Math.random()) * 1000)
await Promise.all([
page.reload(),
page.waitForNavigation()
@@ -724,8 +759,7 @@ async function toRecommendPage (hooks) {
currentExceptJobIndex = INIT_START_EXCEPT_JOB_INDEX
} else {
hooks.noPositionFoundForCurrentJob?.call()
hooks.noPositionFoundAfterTraverseAllJob?.call()
await sleep((10 + 15 * Math.random()) * 1000)
currentExceptJobIndex += 1
}
}

View File

@@ -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')
@@ -69,8 +71,12 @@ export const readConfigFile = (fileName) => {
)
} catch {
fs.existsSync(joinedPath) && fs.unlinkSync(joinedPath)
ensureConfigFileExist()
o = JSON.parse(defaultConfigFileContentMap[fileName])
if (defaultConfigFileContentMap[fileName]) {
ensureConfigFileExist()
o = JSON.parse(defaultConfigFileContentMap[fileName])
} else {
o = null
}
}
return o
@@ -112,7 +118,8 @@ export const ensureStorageFileExist = () => {
)
}
export const readStorageFile = (fileName) => {
export const readStorageFile = (fileName, { isJson } = {}) => {
isJson = isJson ?? true
const joinedPath = path.join(storageFilePath, fileName)
if (!fs.existsSync(
@@ -123,21 +130,36 @@ export const readStorageFile = (fileName) => {
let o
try {
o = JSON.parse(
fs.readFileSync(joinedPath)
)
const content = fs.readFileSync(joinedPath)
if (isJson) {
o = JSON.parse(content)
}
else {
o = content.toString()
}
} catch {
fs.existsSync(joinedPath) && fs.unlinkSync(joinedPath)
ensureStorageFileExist()
o = JSON.parse(defaultStorageFileContentMap[fileName])
if (isJson) {
o = JSON.parse(defaultStorageFileContentMap[fileName] ?? 'null')
}
else {
o = defaultStorageFileContentMap[fileName] ?? null
}
}
return o
}
export const writeStorageFile = async (fileName, content) => {
export const writeStorageFile = async (fileName, content, { isJson } = {}) => {
isJson = isJson ?? true
const filePath = path.join(storageFilePath, fileName)
const fileContent = JSON.stringify(content)
let fileContent
if (isJson) {
fileContent = JSON.stringify(content)
} else {
fileContent = content
}
return fsPromise.writeFile(
filePath,
fileContent

View File

@@ -6,68 +6,28 @@ import {
sleep,
sleepWithRandomDelay
} from '@geekgeekrun/utils/sleep.mjs'
import extractZip from 'extract-zip'
import { blockNavigation } from '@geekgeekrun/utils/puppeteer/block-navigation.mjs'
import {
writeStorageFile
} from '@geekgeekrun/geek-auto-start-chat-with-boss/runtime-file-utils.mjs'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path';
import JSON5 from 'json5'
import url from 'url';
import packageJson from './package.json' assert {type: 'json'}
import {
runtimeFolderPath,
ensureEditThisCookie,
editThisCookieExtensionPath,
isRunFromUi,
} from './utils.mjs'
import { EventEmitter } from 'node:events'
export const loginEventBus = new EventEmitter()
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const isRunFromUi = Boolean(process.env.MAIN_BOSSGEEKGO_UI_RUN_MODE)
const isUiDev = process.env.NODE_ENV === 'development'
const runtimeFolderPath = path.join(os.homedir(), '.geekgeekrun')
const extensionDir = path.join(
runtimeFolderPath,
'chrome-extensions'
)
if (!fs.existsSync(
runtimeFolderPath
)) {
fs.mkdirSync(runtimeFolderPath)
}
if (!fs.existsSync(extensionDir)) {
fs.mkdirSync(extensionDir)
}
const editThisCookieExtensionPath = path.join(extensionDir, 'EditThisCookie')
let editThisCookieZipPath
async function getEditThisCookieZipPath () {
if (editThisCookieZipPath) {
return editThisCookieZipPath
}
if (isRunFromUi) {
const { app } = await import('electron')
editThisCookieZipPath = path.join(app.getAppPath(), './node_modules', packageJson.name, 'extensions', 'EditThisCookie.zip')
} else {
editThisCookieZipPath = path.join(__dirname, 'extensions', 'EditThisCookie.zip')
}
return editThisCookieZipPath
}
export async function main() {
if (!fs.existsSync(
path.join(editThisCookieExtensionPath, 'manifest.json')
)) {
await extractZip(
await getEditThisCookieZipPath(),
{
dir: extensionDir
}
)
}
await ensureEditThisCookie()
const { puppeteer } = await initPuppeteer()
const browser = await puppeteer.launch({
headless: false,

View File

@@ -0,0 +1,82 @@
import path from 'node:path';
import fs from 'node:fs'
import os from 'node:os'
import extractZip from 'extract-zip'
import packageJson from './package.json' assert {type: 'json'}
export const isRunFromUi = Boolean(process.env.MAIN_BOSSGEEKGO_UI_RUN_MODE)
const isUiDev = process.env.NODE_ENV === 'development'
export const runtimeFolderPath = path.join(os.homedir(), '.geekgeekrun')
const extensionDir = path.join(
runtimeFolderPath,
'chrome-extensions'
)
if (!fs.existsSync(
runtimeFolderPath
)) {
fs.mkdirSync(runtimeFolderPath)
}
if (!fs.existsSync(extensionDir)) {
fs.mkdirSync(extensionDir)
}
export const editThisCookieExtensionPath = path.join(extensionDir, 'EditThisCookie')
let editThisCookieZipPath
async function getEditThisCookieZipPath () {
if (editThisCookieZipPath) {
return editThisCookieZipPath
}
if (isRunFromUi) {
const { app } = await import('electron')
editThisCookieZipPath = path.join(app.getAppPath(), './node_modules', packageJson.name, 'extensions', 'EditThisCookie.zip')
} else {
editThisCookieZipPath = path.join(__dirname, 'extensions', 'EditThisCookie.zip')
}
return editThisCookieZipPath
}
export async function ensureEditThisCookie () {
let isNeedExtractEditThisCookie = false
const manifestFilePath = path.join(editThisCookieExtensionPath, 'manifest.json')
if (!fs.existsSync(
manifestFilePath
)) {
isNeedExtractEditThisCookie = true
} else {
let manifest
try {
manifest = JSON.parse(fs.readFileSync(manifestFilePath, { encoding: 'utf-8' }))
if (!manifest.manifest_version || manifest.manifest_version <= 2) {
isNeedExtractEditThisCookie = true
}
}
catch {
console.log(`未能获取到文件内容`)
isNeedExtractEditThisCookie = true
}
}
if (isNeedExtractEditThisCookie) {
if (
fs.existsSync(
editThisCookieExtensionPath
)
) {
fs.rmSync(
editThisCookieExtensionPath,
{
recursive: true,
force: true
}
)
}
await extractZip(
await getEditThisCookieZipPath(),
{
dir: extensionDir
}
)
}
}

View File

@@ -25,7 +25,7 @@ export class ChatMessageRecord {
@Column({
nullable: true
})
style?: 'sent' | 'receive';
style?: 'sent' | 'received';
@Column({
nullable: true

View File

@@ -0,0 +1,64 @@
import { requireTypeorm } from "../utils/module-loader";
const { Entity, PrimaryGeneratedColumn, Column, Index } = requireTypeorm()
@Entity()
@Index(["providerCompleteApiUrl", "model", "providerApiSecret"])
export class LlmModelUsageRecord {
@PrimaryGeneratedColumn()
id: number;
@Column()
providerCompleteApiUrl: string
@Column()
model: string
@Column({
nullable: true
})
providerApiSecret: string
@Column({
nullable: true
})
completionTokens?: number;
@Column({
nullable: true
})
promptTokens?: number;
@Column({
nullable: true
})
promptCacheHitTokens?: number
@Column({
nullable: true
})
promptCacheMissTokens?: number
@Column({
nullable: true
})
totalTokens?: number;
@Column()
requestStartTime: Date
@Column({
nullable: true
})
requestEndTime?: Date
@Column()
hasError: boolean
@Column()
errorMessage: string
@Column({
nullable: true
})
requestScene?: number
}

View File

@@ -10,6 +10,7 @@ import { CompanyInfoChangeLog } from "./entity/CompanyInfoChangeLog";
import { JobInfoChangeLog } from "./entity/JobInfoChangeLog";
import { MarkAsNotSuitLog } from "./entity/MarkAsNotSuitLog";
import { ChatMessageRecord } from "./entity/ChatMessageRecord";
import { LlmModelUsageRecord } from "./entity/LlmModelUsageRecord";
function getBossInfoIfIsEqual (savedOne, currentOne) {
if (savedOne === currentOne) {
@@ -307,3 +308,21 @@ export async function saveChatMessageRecord(
//#endregion
return
}
export async function saveGptCompletionRequestRecord(
ds: DataSource,
records: LlmModelUsageRecord[]
) {
//#region mark-as-not-suit-log
const list = records.map(it => {
const o = new LlmModelUsageRecord()
for (const k of Object.keys(it)) {
o[k] = it[k]
}
return o
})
const chatMessageRecordRepository = ds.getRepository(LlmModelUsageRecord);
await chatMessageRecordRepository.save(list);
//#endregion
return
}

View File

@@ -19,6 +19,7 @@ import { VJobLibrary } from "./entity/VJobLibrary";
import { VCompanyLibrary } from "./entity/VCompanyLibrary"
import { VMarkAsNotSuitLog } from "./entity/VMarkAsNotSuitLog"
import { ChatMessageRecord } from './entity/ChatMessageRecord'
import { LlmModelUsageRecord } from './entity/LlmModelUsageRecord'
import sqlite3 from 'sqlite3';
import { saveChatStartupRecord, saveJobInfoFromRecommendPage, saveMarkAsNotSuitRecord } from "./handlers";
@@ -53,10 +54,11 @@ export function initDb(dbFilePath) {
MarkAsNotSuitLog,
VMarkAsNotSuitLog,
ChatMessageRecord,
LlmModelUsageRecord,
],
migrations: [
UpdateChatStartupLogTable1729182577167,
UpdateBossInfoTable1732032381304
UpdateBossInfoTable1732032381304,
],
migrationsRun: true
});

View File

@@ -5,31 +5,28 @@ const viewNames = [
"v_chat_startup_log",
"v_company_library",
"v_job_library",
"v_mark_as_not_suit_log"
"v_mark_as_not_suit_log",
];
export class UpdateBossInfoTable1732032381304 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
for (const viewName of viewNames) {
await queryRunner.query(`DROP VIEW IF EXISTS "${viewName}"`);
}
if (await queryRunner.hasTable("boss_info")) {
if (await queryRunner.hasColumn("boss_info", "encryptCompanyId")) {
await queryRunner.changeColumn(
'boss_info',
'encryptCompanyId',
new TableColumn({
name: 'encryptCompanyId',
type: 'varchar',
isNullable: true
})
)
debugger
}
}
public async up(queryRunner: QueryRunner): Promise<void> {
for (const viewName of viewNames) {
await queryRunner.query(`DROP VIEW IF EXISTS "${viewName}"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasTable("boss_info")) {
if (await queryRunner.hasColumn("boss_info", "encryptCompanyId")) {
await queryRunner.changeColumn(
"boss_info",
"encryptCompanyId",
new TableColumn({
name: "encryptCompanyId",
type: "varchar",
isNullable: true,
})
);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {}
}

View File

@@ -41,7 +41,8 @@
"minimist": "^1.2.8",
"node-machine-id": "^1.1.12",
"puppeteer": "20.1.0",
"puppeteer-extra-plugin-stealth": "2.11.2"
"puppeteer-extra-plugin-stealth": "2.11.2",
"uuid": "^11.1.0"
},
"devDependencies": {
"@electron-toolkit/eslint-config": "^1.0.2",
@@ -72,6 +73,7 @@
"normalize.css": "^8.0.1",
"prettier": "^3.2.4",
"sass": "^1.70.0",
"spark-md5": "^3.0.2",
"terser": "^5.37.0",
"typescript": "^5.3.3",
"unocss": "^0.58.5",

View File

@@ -0,0 +1 @@
export const SINGLE_ITEM_DEFAULT_SERVE_WEIGHT = 1

View File

@@ -8,3 +8,13 @@ export enum AUTO_CHAT_ERROR_EXIT_CODE {
AUTO_START_CHAT_DAEMON_PROCESS_SUICIDE = 86,
AUTO_START_CHAT_MAIN_PROCESS_SUICIDE = 87,
}
export enum RECHAT_CONTENT_SOURCE {
LOOK_FORWARD_EMOTION = 1,
GEMINI_WITH_CHAT_CONTEXT = 2
}
export enum RECHAT_LLM_FALLBACK {
SEND_LOOK_FORWARD_EMOTION = 1,
EXIT_REMINDER_PROGRAM = 2
}

View File

@@ -0,0 +1,91 @@
export interface ResumeContent {
name: string
workYearDesc: string
expectJob: string
userDescription: string
geekWorkExpList: Array<{
company: string
positionName: string
startYearMon: string | null
endYearMon: string | null
performance: string
workDescription: string
}>
geekProjExpList: Array<{
name: string
startYearMon: string
endYearMon: string
roleName: string
projectDescription: string
performance: string
}>
expectSalary: [string, string]
}
export function formatResumeJsonToMarkdown(resume) {
const basicInfoText = [
['# 姓名', resume.content.name],
['# 工作年限', resume.content.workYearDesc],
['# 期望职位', resume.content.expectJob],
['# 个人优势', resume.content.userDescription]
]
.filter((it) => {
return Boolean(it[1]?.trim())
})
.map((it) => it.join('\n'))
.join('\n\n')
let formattedWorkExpText = resume.content.geekWorkExpList
.filter((it) => Boolean(it.company?.trim()))
.map((it) => {
const info = [
[`职务`, it.positionName],
[`任职时间`],
[`工作描述`, it.workDescription],
[`工作业绩`, it.performance]
].filter((it) => {
return Boolean(it[1]?.trim())
})
return [[`## ${it.company}`], ...info].map((it) => it.join('\n')).join('\n\n')
})
.join('\n')
if (formattedWorkExpText?.trim()) {
formattedWorkExpText = '# 工作经历\n' + formattedWorkExpText
}
let formattedProjWorkExpText = resume.content.geekProjExpList
.filter((it) => Boolean(it.name?.trim()))
.map((it) => {
const info = [
[`## ${it.name}`],
[`项目角色`, it.roleName],
[`项目时间`],
[`工作描述`, it.projectDescription],
[`工作业绩`, it.performance]
].filter((it) => {
return Boolean(it[1]?.trim())
})
return [[`## ${it.name}`], ...info].map((it) => it.join('\n')).join('\n\n')
})
.join('\n')
if (formattedProjWorkExpText?.trim()) {
formattedProjWorkExpText = '# 项目经历\n' + formattedProjWorkExpText
}
const result = `${basicInfoText}\n\n${formattedWorkExpText}\n\n${formattedProjWorkExpText}`
return result
}
export function checkIsResumeContentValid(resumeItem: { content: ResumeContent }) {
return (
!!resumeItem?.content &&
resumeItem.content.geekProjExpList?.[0]?.name?.trim() &&
resumeItem.content.geekWorkExpList?.[0]?.positionName?.trim()
)
}
export function resumeContentEnoughDetect(resumeItem: { content: ResumeContent }) {
return resumeItem?.content && formatResumeJsonToMarkdown(resumeItem)?.length > 800
}

View File

@@ -0,0 +1,22 @@
import { saveGptCompletionRequestRecord } from '@geekgeekrun/sqlite-plugin/dist/handlers'
export enum RequestSceneEnum {
testing = 1,
readNoReplyAutoReminder = 2,
geekAutoStartChatWithBoss = 3
}
let dbInitPromise
export const recordGptCompletionRequest = async (payload) => {
const { getPublicDbFilePath } = await import(
'@geekgeekrun/geek-auto-start-chat-with-boss/runtime-file-utils.mjs'
)
const { initDb } = await import('@geekgeekrun/sqlite-plugin')
if (!dbInitPromise) {
dbInitPromise = initDb(getPublicDbFilePath())
}
const ds = await dbInitPromise
const o = { ...payload }
await saveGptCompletionRequestRecord(ds, [o])
}

View File

@@ -1,6 +1,5 @@
import { app } from 'electron'
import { initPuppeteer } from '@geekgeekrun/geek-auto-start-chat-with-boss/index.mjs'
import extractZip from 'extract-zip'
import {
readStorageFile,
writeStorageFile
@@ -17,10 +16,6 @@ import { getPublicDbFilePath } from '@geekgeekrun/geek-auto-start-chat-with-boss
import { MarkAsNotSuitReason } from '@geekgeekrun/sqlite-plugin/dist/enums'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import url from 'url'
import packageJson from '@geekgeekrun/launch-bosszhipin-login-page-with-preload-extension/package.json' assert { type: 'json' }
import { Target } from 'puppeteer'
import { pipeWriteRegardlessError } from '../utils/pipe'
import * as JSONStream from 'JSONStream'
@@ -31,38 +26,11 @@ import { type ChatMessageRecord } from '@geekgeekrun/sqlite-plugin/src/entity/Ch
import { BossInfo } from '@geekgeekrun/sqlite-plugin/dist/entity/BossInfo'
import { messageForSaveFilter } from '../../../common/utils/chat-list'
const __dirname = url.fileURLToPath(new URL('.', import.meta.url))
const isRunFromUi = Boolean(process.env.MAIN_BOSSGEEKGO_UI_RUN_MODE)
import {
ensureEditThisCookie,
editThisCookieExtensionPath
} from '@geekgeekrun/launch-bosszhipin-login-page-with-preload-extension/utils.mjs'
const runtimeFolderPath = path.join(os.homedir(), '.geekgeekrun')
const extensionDir = path.join(runtimeFolderPath, 'chrome-extensions')
if (!fs.existsSync(runtimeFolderPath)) {
fs.mkdirSync(runtimeFolderPath)
}
if (!fs.existsSync(extensionDir)) {
fs.mkdirSync(extensionDir)
}
const editThisCookieExtensionPath = path.join(extensionDir, 'EditThisCookie')
let editThisCookieZipPath
async function getEditThisCookieZipPath() {
if (editThisCookieZipPath) {
return editThisCookieZipPath
}
if (isRunFromUi) {
const { app } = await import('electron')
editThisCookieZipPath = path.join(
app.getAppPath(),
'./node_modules',
packageJson.name,
'extensions',
'EditThisCookie.zip'
)
} else {
editThisCookieZipPath = path.join(__dirname, 'extensions', 'EditThisCookie.zip')
}
return editThisCookieZipPath
}
const dbInitPromise = initDb(getPublicDbFilePath())
const attachRequestsListener = async (target: Target) => {
@@ -221,7 +189,7 @@ const attachRequestsListener = async (target: Target) => {
mappedItem.mid = it.mid
mappedItem.encryptFromUserId = it.isSelf ? currentUserInfo.encryptUserId : bossInfo.encryptBossId
mappedItem.encryptToUserId = it.isSelf ? bossInfo.encryptBossId : currentUserInfo.encryptUserId
mappedItem.style = it.style
mappedItem.style = it.isSelf ? 'sent' : 'received'
mappedItem.type = it.type
mappedItem.time = it.time ? new Date(it.time) : null
mappedItem.text = it.text
@@ -247,12 +215,7 @@ const attachRequestsListener = async (target: Target) => {
export async function launchBossSite() {
app.dock?.hide()
if (!fs.existsSync(path.join(editThisCookieExtensionPath, 'manifest.json'))) {
await extractZip(await getEditThisCookieZipPath(), {
dir: extensionDir
})
}
await ensureEditThisCookie()
const bossCookies = readStorageFile('boss-cookies.json')
const bossLocalStorage = readStorageFile('boss-local-storage.json')

View File

@@ -4,6 +4,7 @@ import { createMainWindow } from '../../window/mainWindow'
import './app-menu'
import initIpc from './ipc'
import gtag from '../../utils/gtag'
import initPublicIpc from '../../utils/initPublicIpc'
export function openSettingWindow() {
// TODO: singleton lock; how can we check if there is another process should run as singleton with arguments?
if (!app.requestSingleInstanceLock()) {
@@ -31,6 +32,7 @@ export function openSettingWindow() {
// IPC test
ipcMain.on('ping', () => console.log('pong'))
initPublicIpc()
initIpc()
app.on('activate', function () {

View File

@@ -1,5 +1,5 @@
import { ipcMain, shell, app } from 'electron'
import path from 'path'
import * as childProcess from 'node:child_process'
import {
ensureConfigFileExist,
@@ -8,7 +8,8 @@ import {
readConfigFile,
writeConfigFile,
readStorageFile,
writeStorageFile
writeStorageFile,
storageFilePath
} from '@geekgeekrun/geek-auto-start-chat-with-boss/runtime-file-utils.mjs'
import { ChildProcess } from 'child_process'
import * as JSONStream from 'JSONStream'
@@ -30,14 +31,27 @@ 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'
import { createResumeEditorWindow, resumeEditorWindow } from '../../../window/resumeEditorWindow'
import {
getValidTemplate,
requestNewMessageContent
} from '../../READ_NO_REPLY_AUTO_REMINDER/boss-operation'
import {
autoReminderPromptTemplateFileName,
writeDefaultAutoRemindPrompt
} from '../../READ_NO_REPLY_AUTO_REMINDER/boss-operation'
import {
checkIsResumeContentValid,
resumeContentEnoughDetect
} from '../../../../common/utils/resume'
import {
createReadNoReplyReminderLlmMockWindow,
readNoReplyReminderLlmMockWindow
} from '../../../window/readNoReplyReminderLlmMockWindow'
import { RequestSceneEnum } from '../../../features/llm-request-log'
export default function initIpc() {
ipcMain.on('open-external-link', (_, link) => {
shell.openExternal(link, {
activate: true
})
})
ipcMain.handle('fetch-config-file-content', async () => {
const configFileContentList = configFileNameList.map((fileName) => {
return readConfigFile(fileName)
@@ -69,8 +83,15 @@ export default function initIpc() {
if (hasOwn(payload, 'anyCombineRecommendJobFilter')) {
bossConfig.anyCombineRecommendJobFilter = payload.anyCombineRecommendJobFilter
}
if (hasOwn(payload, 'expectJobRegExpStr')) {
bossConfig.expectJobRegExpStr = payload.expectJobRegExpStr
delete bossConfig.expectJobRegExpStr
if (hasOwn(payload, 'expectJobNameRegExpStr')) {
bossConfig.expectJobNameRegExpStr = payload.expectJobNameRegExpStr
}
if (hasOwn(payload, 'expectJobTypeRegExpStr')) {
bossConfig.expectJobTypeRegExpStr = payload.expectJobTypeRegExpStr
}
if (hasOwn(payload, 'expectJobDescRegExpStr')) {
bossConfig.expectJobDescRegExpStr = payload.expectJobDescRegExpStr
}
if (hasOwn(payload, 'autoReminder')) {
bossConfig.autoReminder = payload.autoReminder
@@ -435,6 +456,130 @@ 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) {
await writeConfigFile('llm.json', configToSave)
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('resume-edit', async () => {
createResumeEditorWindow({
parent: mainWindow!,
modal: true,
show: true
})
const defer = Promise.withResolvers()
async function saveResumeHandler(_, resumeContent) {
await writeConfigFile('resumes.json', [
{
name: '默认简历',
updateTime: Number(new Date()),
content: resumeContent
}
])
defer.resolve()
resumeEditorWindow?.close()
}
ipcMain.handle('save-resume-content', saveResumeHandler)
resumeEditorWindow?.once('closed', () => {
ipcMain.removeHandler('save-resume-content')
ipcMain.removeHandler('fetch-resume-content')
defer.reject(new Error('cancel'))
})
ipcMain.handle('fetch-resume-content', async () => {
const res = (await readConfigFile('resumes.json'))?.[0]
return res?.content ?? null
})
return defer.promise
})
ipcMain.on('no-reply-reminder-prompt-edit', async () => {
const template = await readStorageFile(autoReminderPromptTemplateFileName, { isJson: false })
if (!template) {
await writeDefaultAutoRemindPrompt()
}
const filePath = path.join(storageFilePath, autoReminderPromptTemplateFileName)
shell.openPath(filePath)
})
ipcMain.on('close-resume-editor', () => resumeEditorWindow?.close())
ipcMain.handle('check-if-auto-remind-prompt-valid', async () => {
await getValidTemplate()
})
ipcMain.handle('check-is-resume-content-valid', async () => {
const res = (await readConfigFile('resumes.json'))?.[0]
return checkIsResumeContentValid(res)
})
ipcMain.handle('resume-content-enough-detect', async () => {
const res = (await readConfigFile('resumes.json'))?.[0]
return resumeContentEnoughDetect(res)
})
ipcMain.handle('overwrite-auto-remind-prompt-with-default', async () => {
await writeDefaultAutoRemindPrompt()
})
ipcMain.handle('check-if-llm-config-list-valid', async () => {
const llmConfigList = await readConfigFile('llm.json')
if (!Array.isArray(llmConfigList) || !llmConfigList?.length) {
throw new Error('CANNOT_FIND_VALID_CONFIG')
}
if (llmConfigList.some((it) => !/^http(s)?:\/\//.test(it.providerCompleteApiUrl))) {
throw new Error('CANNOT_FIND_VALID_CONFIG')
}
if (llmConfigList.length > 1) {
const firstEnabledModel = llmConfigList.find((it) => it.enabled)
if (!firstEnabledModel) {
throw new Error('CANNOT_FIND_VALID_CONFIG')
}
}
})
ipcMain.on('test-llm-config-effect', (_, { autoReminderConfig } = {}) => {
createReadNoReplyReminderLlmMockWindow(
{
parent: mainWindow!,
modal: true,
show: true
},
{
autoReminderConfig
}
)
async function requestLlm(_, requestPayload) {
return await requestNewMessageContent(requestPayload.messageList, {
requestScene: RequestSceneEnum.testing,
llmConfigIdForPick: requestPayload.llmConfigIdForPick ?? null
})
}
ipcMain.handle('request-llm-for-test', requestLlm)
readNoReplyReminderLlmMockWindow?.once('closed', () => {
ipcMain.removeHandler('request-llm-for-test')
})
async function getLlmConfigList() {
return await readConfigFile('llm.json')
}
ipcMain.handle('get-llm-config-for-test', getLlmConfigList)
readNoReplyReminderLlmMockWindow?.once('closed', () => {
ipcMain.removeHandler('get-llm-config-for-test')
})
})
ipcMain.on('close-read-no-reply-reminder-llm-mock-window', () =>
readNoReplyReminderLlmMockWindow?.close()
)
ipcMain.handle('exit-app-immediately', () => {
app.exit(0)
})

View File

@@ -47,7 +47,10 @@ const payloadHandler = {
const [data, totalItemCount] = await measureExecutionTime(
userRepository.findAndCount({
skip: (pageNo - 1) * pageSize,
take: pageSize
take: pageSize,
order: {
date: 'DESC'
}
})
)
return {
@@ -69,7 +72,10 @@ const payloadHandler = {
const [data, totalItemCount] = await measureExecutionTime(
recordRepository.findAndCount({
skip: (pageNo - 1) * pageSize,
take: pageSize
take: pageSize,
order: {
date: 'DESC'
}
})
)
return {

View File

@@ -1,5 +1,16 @@
import { Page } from 'puppeteer'
import { sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
import { sleepWithRandomDelay, sleep } from '@geekgeekrun/utils/sleep.mjs'
import { completes } from '@geekgeekrun/utils/gpt-request.mjs'
import { recordGptCompletionRequest, RequestSceneEnum } from '../../features/llm-request-log'
import {
readConfigFile,
readStorageFile,
writeStorageFile
} from '@geekgeekrun/geek-auto-start-chat-with-boss/runtime-file-utils.mjs'
import { formatResumeJsonToMarkdown } from '../../../common/utils/resume'
import { SINGLE_ITEM_DEFAULT_SERVE_WEIGHT } from '../../../common/constant'
import { LlmModelUsageRecord } from '@geekgeekrun/sqlite-plugin/dist/entity/LlmModelUsageRecord'
import gtag from '../../utils/gtag'
export const sendLookForwardReplyEmotion = async (page: Page) => {
const emotionEntryButtonProxy = await page.$('.chat-conversation .message-controls .btn-emotion')
@@ -15,3 +26,241 @@ export const sendLookForwardReplyEmotion = async (page: Page) => {
)
await lookForwardReplyEmojiProxy!.click()
}
const pickLlmConfigFromList = (llmConfigList, blockModelSet) => {
if (llmConfigList.length === 1) {
llmConfigList[0].enabled = true
llmConfigList[0].serveWeight = SINGLE_ITEM_DEFAULT_SERVE_WEIGHT
}
llmConfigList = llmConfigList.filter((it) => it.enabled && !blockModelSet.has(it.id))
if (!llmConfigList.length) {
return null
}
llmConfigList.forEach((conf) => {
if (!Number(conf.serveWeight) || conf.serveWeight < 1) {
conf.serveWeight = 1
}
if (conf.serveWeight > 100) {
conf.serveWeight = 100
}
})
const pool: number[] = []
for (let i = 0; i < llmConfigList.length; i++) {
for (let j = 0; j < Math.floor(llmConfigList[i].serveWeight); j++) {
pool.push(llmConfigList[i].id)
}
}
if (!pool.length) {
return null
}
const index = Math.floor(pool.length * Math.random())
return llmConfigList.find((it) => it.id === pool[index]) ?? null
}
// let _index = 0
const RESUME_PLACEHOLDER = `__REPLACE_REAL_RESUME_HERE__`
const defaultPrompt = `**核心指令:**
你是一个智能求职助手需要根据用户简历生成30字左右的提醒消息满足以下要求
1. 每次生成需满足:
- √ 包含1个核心技能 + 1个成果量化
- √ 使用不同句式模板至少准备5种
- √ 谦虚一些,头衔、工作年限等在历史记录信息中出现一次就好
- ✗ 严禁与最近发送的几条相似或雷同
- ✗ 严禁出现简历之外的词语
- ✗ 严禁包含最近8条已经发过的内容包括但不限于职位名称
**简历分析层:**
请从以下简历内容中提取关键要素:\n\`\`\`markdown\n${RESUME_PLACEHOLDER}\n\`\`\`\n
---
要求提取:
1. 硬技能:编程语言/技术栈/工具证书等至少提取5项
2. 项目经历与成果业绩、带量化数据的结果至少3条
3. 软技能:沟通/管理等至少2项
4. 特殊成就:奖项/专利等(可选)
**消息生成层:**
根据上述要素随机组合生成消息
**质量控制层:**
每次生成前执行:
1. 检查历史记录
2. 确保技能/成果组合未重复
3. 确保所生成的新消息不包含最近8条已经发过的内容包括但不限于职位名称
4. 字数严格控制在10-40字
5. 避免感叹号等激进符号
6. 减少头衔“资深”、“高级”出现的频率严禁出现“专家”、“老兵”减少工作年限“x年”出现的频率
**输出格式:**
请确保仅回复一句话以JSON响应不要包含其他解释或内容数据结构参考\`{"response": "这里是将会发送给招聘者的内容"}\``
export const autoReminderPromptTemplateFileName = 'auto-reminder-resume-system-message-template.md'
export const getValidTemplate = async () => {
let template = await readStorageFile(autoReminderPromptTemplateFileName, { isJson: false })
if (!template) {
await writeDefaultAutoRemindPrompt()
template = defaultPrompt
}
if (!template.includes(RESUME_PLACEHOLDER)) {
const e = new Error(`简历内容占位符字符串不存在。占位字符串是 ${RESUME_PLACEHOLDER}`)
e.name = `RESUME_PLACEHOLDER_NOT_EXIST`
throw e
}
return template
}
export const writeDefaultAutoRemindPrompt = async () => {
await writeStorageFile(autoReminderPromptTemplateFileName, defaultPrompt, { isJson: false })
}
export const requestNewMessageContent = async (
chatRecords,
{
requestScene,
llmConfigIdForPick
}: { requestScene?: RequestSceneEnum; llmConfigIdForPick?: string[] } = {}
) => {
const template = await getValidTemplate()
const resumeObject = (await readConfigFile('resumes.json'))?.[0]
const resumeContent = formatResumeJsonToMarkdown(resumeObject)
const chatList = [
{
role: 'system',
content: template.replace(RESUME_PLACEHOLDER, resumeContent)
}
]
chatList.push({
role: 'user',
content:
'请根据我的简历帮我写一句谦逊有礼貌的开场白。开头包含“您好”等类似敬语、结尾包含“期待回复”等类似话术。不必包含简历中的具体内容但需要表达出应聘意向。请确保仅响应一句话以JSON响应数据结构参考`{"response": "这里是将会发送给招聘者的内容"}`'
})
// chatRecords = chatRecords.slice(chatRecords.length - _index)
// debugger
for (const record of chatRecords) {
const assistantJsonContent = JSON.stringify({
response: record.text
})
chatList.push({
role: 'assistant',
content: `\`\`\`json\n${assistantJsonContent}\n\`\`\``
})
chatList.push({
role: 'user',
content:
'围绕我简历中关于自我介绍、技术栈、工作经历、项目描述、项目业绩等内容写一句自我介绍。开头不必包含“您好”、结尾不必包含“期待回复”务必确保本次所回复的内容不能与之前所回复的内容雷同或相似。请确保仅回复一句话以JSON响应不要包含其他解释或内容数据结构参考`{"response": "这里是将会发送给招聘者的内容"}`'
})
}
console.log(chatList)
let res, llmConfig
const llmRequestRecord: Omit<LlmModelUsageRecord, 'id' | 'providerApiSecretMd5'> & {
providerApiSecret: string
} = {}
const blockModelSet = new Set()
while (!res) {
let llmConfigList = await readConfigFile('llm.json')
if (llmConfigIdForPick?.length) {
llmConfigList = llmConfigList.filter((it) => {
return llmConfigIdForPick.includes(it.id)
})
}
llmConfig = pickLlmConfigFromList(llmConfigList, blockModelSet)
if (!llmConfig) {
throw new Error(`CANNOT_FIND_A_USABLE_MODEL`)
}
console.log(llmConfig.providerCompleteApiUrl)
Object.assign(llmRequestRecord, {
providerCompleteApiUrl: llmConfig.providerCompleteApiUrl,
model: llmConfig.model,
providerApiSecret: llmConfig.providerApiSecret,
requestStartTime: new Date(),
hasError: false,
errorMessage: '',
requestScene
})
try {
const completion = await completes(
{
baseURL: llmConfig.providerCompleteApiUrl,
apiKey: llmConfig.providerApiSecret,
model: llmConfig.model
},
chatList
)
res = completion?.choices?.[0] ?? null
Object.assign(llmRequestRecord, {
completionTokens: completion.usage?.completion_tokens ?? null,
promptCacheHitTokens: completion.usage?.prompt_cache_hit_tokens ?? null,
promptCacheMissTokens: completion.usage?.prompt_cache_miss_tokens ?? null,
promptTokens: completion.usage?.prompt_tokens ?? null,
totalTokens: completion.usage?.total_tokens ?? null
} as LlmModelUsageRecord)
} catch (err) {
console.log('request failed', err)
blockModelSet.add(llmConfig.id)
Object.assign(llmRequestRecord, {
hasError: true,
errorMessage: err?.message ?? ''
})
} finally {
llmRequestRecord.requestEndTime = new Date()
try {
await recordGptCompletionRequest(llmRequestRecord)
} catch (err) {
console.log('CANNOT_SAVE_LLM_COMPLETION_LOG', err)
}
}
}
console.log(res)
// _index++
let textToSend
try {
const rawMarkdownText = res?.message?.content
try {
textToSend = JSON.parse(
rawMarkdownText.replace(/^```json/m, '').replace(/```$/m, '')
)?.response
} catch (err) {
gtag('encounter_error_when_parse_llm_text', {
err,
model: llmConfig?.model,
providerCompleteApiUrl: llmConfig?.providerCompleteApiUrl
})
throw err
}
textToSend = textToSend?.replace(/。$/, '')
if (!textToSend) {
gtag('llm_respond_text_is_empty', {
model: llmConfig?.model,
providerCompleteApiUrl: llmConfig?.providerCompleteApiUrl
})
throw new Error(`empty content. ${err?.message} ${res?.message?.content}`)
}
} catch (err) {
throw new Error(`fail to parse response. ${err?.message} ${res?.message?.content}`)
}
return {
responseText: textToSend,
usedLlmConfig: llmConfig,
recordInfo: llmRequestRecord
}
}
export async function sendGptContent(page: Page, chatRecords) {
const textToSend = (
await requestNewMessageContent(chatRecords, {
requestScene: RequestSceneEnum.readNoReplyAutoReminder
})
).responseText
const chatInputSelector = `.chat-conversation .message-controls .chat-input`
const chatInputHandle = await page.$(chatInputSelector)
await chatInputHandle.click()
await sleep(500)
await chatInputHandle.click()
await chatInputHandle.type(textToSend, {
delay: 50
})
await sleep(1000)
const sendButtonSelector = `.chat-conversation .message-controls .chat-op .btn-send:not(.disabled)`
await page.click(sendButtonSelector)
}

View File

@@ -1,10 +1,10 @@
import { bootstrap, launchBoss } from './bootstrap'
import { MsgStatus, type ChatListItem } from './types'
import { Browser, Page } from 'puppeteer'
import { sendLookForwardReplyEmotion } from './boss-operation'
import { sendGptContent, sendLookForwardReplyEmotion } from './boss-operation'
import { sleep, sleepWithRandomDelay } from '@geekgeekrun/utils/sleep.mjs'
import attachListenerForKillSelfOnParentExited from '../../utils/attachListenerForKillSelfOnParentExited'
import { app } from 'electron'
import { app, dialog } from 'electron'
import { initDb } from '@geekgeekrun/sqlite-plugin'
import {
getPublicDbFilePath,
@@ -17,10 +17,21 @@ import * as fs from 'fs'
import { pipeWriteRegardlessError } from '../utils/pipe'
import { BossInfo } from '@geekgeekrun/sqlite-plugin/dist/entity/BossInfo'
import { messageForSaveFilter } from '../../../common/utils/chat-list'
import { RECHAT_CONTENT_SOURCE, RECHAT_LLM_FALLBACK } from '../../../common/enums/auto-start-chat'
import gtag from '../../utils/gtag'
const throttleIntervalMinutes =
readConfigFile('boss.json').autoReminder?.throttleIntervalMinutes ?? 10
const rechatLimitDay = readConfigFile('boss.json').autoReminder?.rechatLimitDay ?? 21
const recentMessageQuantityForLlm =
readConfigFile('boss.json').autoReminder?.recentMessageQuantityForLlm ?? 8
const rechatContentSource =
readConfigFile('boss.json').autoReminder?.rechatContentSource ??
RECHAT_CONTENT_SOURCE.LOOK_FORWARD_EMOTION
const rechatLlmFallback =
readConfigFile('boss.json').autoReminder?.rechatLlmFallback ??
RECHAT_LLM_FALLBACK.SEND_LOOK_FORWARD_EMOTION
const dbInitPromise = initDb(getPublicDbFilePath())
export const pageMapByName: {
@@ -172,6 +183,9 @@ const mainLoop = async () => {
// eslint-disable-next-line no-constant-condition
while (true) {
await pageMapByName.boss?.waitForFunction(() => {
return Array.isArray(document.querySelector('.main-wrap .chat-user')?.__vue__?.list)
})
// find target boss - with unread icon, or recommend system message
const friendListData = (await pageMapByName.boss!.evaluate(
`
@@ -277,7 +291,31 @@ const mainLoop = async () => {
(throttleIntervalMinutes + 4 * Math.random()) * 60 * 1000
) {
await sleepWithRandomDelay(3250)
await sendLookForwardReplyEmotion(pageMapByName.boss!)
if (rechatContentSource === RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT) {
try {
const messageListForGpt = historyMessageList
.filter((it) => it.bizType !== 101 && it.isSelf)
.slice(-recentMessageQuantityForLlm)
await sendGptContent(pageMapByName.boss!, messageListForGpt)
gtag('rnrr_llm_content_sent')
} catch (err) {
console.log(err)
if (rechatLlmFallback === RECHAT_LLM_FALLBACK.SEND_LOOK_FORWARD_EMOTION) {
await sendLookForwardReplyEmotion(pageMapByName.boss!)
gtag('rnrr_look_forward_reply_emotion_sent', {
fallback: true
})
} else {
gtag('rnrr_encounter_error', {
error: err
})
throw err
}
}
} else {
await sendLookForwardReplyEmotion(pageMapByName.boss!)
gtag('rnrr_look_forward_reply_emotion_sent')
}
} else {
cursorToContinueFind += 1
}
@@ -337,6 +375,17 @@ export async function runEntry() {
)
process.exit(1)
}
if (err instanceof Error && err.message === 'CANNOT_FIND_A_USABLE_MODEL') {
gtag('cannot_find_a_usable_model')
await dialog.showMessageBox({
type: 'error',
message:
'未找到可以使用的模型,请确定您所配置的模型均可使用。重启本程序或许可以解决这个问题',
buttons: ['退出']
})
process.exit(0)
break;
}
} finally {
pageMapByName['boss'] = null
await sleep(rerunInterval)

View File

@@ -1,4 +1,5 @@
import buildInfo from '../../../common/build-info.json'
import os from 'node:os'
type LowercaseLetter =
| 'a'
@@ -55,6 +56,7 @@ function getCommonParams() {
return {
app_version: buildInfo.version,
app_build_hash: buildInfo.buildHash,
os_info: `${os.type()} | ${os.release()} | ${os.arch()}`,
t: Number(new Date())
}
}
@@ -68,9 +70,14 @@ export default async function gtag<T extends string>(
...getCommonParams(),
...params
}
Object.keys(params).forEach((k) => {
if ([null, undefined].includes(params[k])) {
delete params[k]
}
})
// ServiceWorker环境下直接调用上报函数
const reporter = (await import('./Analytics')).default
return reporter.fireEvent(name, params)
return reporter.fireEvent(name.replace(/-/g, '_'), params)
}
// Fire a page view event.

View File

@@ -0,0 +1,58 @@
import { BrowserWindow, ipcMain, shell } from 'electron'
import gtag from './gtag'
import buildInfo from '../../common/build-info.json'
import os from 'node:os'
export default function initPublicIpc() {
ipcMain.on(
'update-window-size',
(
ev,
size: {
width: number
height: number
animate?: boolean
}
) => {
const win = BrowserWindow.fromWebContents(ev.sender)
if (!win) {
return
}
win.setSize(size.width, size.height, size.animate)
}
)
ipcMain.on('open-external-link', (_, link) => {
shell.openExternal(link, {
activate: true
})
})
ipcMain.on('gtag', (ev, { name, params } = {}) => {
gtag(name, {
...params,
electron_log_source: 'renderer'
})
})
ipcMain.on('send-feed-back-to-github-issue', (ev, payload) => {
const getIssueUrlWithBody = (issueBody: string = '') => {
const baseUrl = `https://github.com/geekgeekrun/geekgeekrun/issues/new`
issueBody = issueBody || ''
if (!issueBody || !issueBody.trim()) {
return baseUrl
}
const urlObj = new URL(baseUrl)
urlObj.searchParams.append('body', issueBody)
return urlObj.toString()
}
shell.openExternal(
getIssueUrlWithBody(`\n\n\n-----
版本号:${buildInfo.version}(${buildInfo.buildVersion})
提交:${buildInfo.buildHash.substring(0, 6)}
操作系统信息: \`${os.type()}\` / \`${os.release()}\` / \`${os.arch()}\``),
{
activate: true
}
)
})
}

View File

@@ -47,14 +47,5 @@ export const initIpc = () => {
createFirstLaunchNoticeApproveFlag()
firstLaunchNoticeWindow?.close()
})
ipcMain.on('update-window-size', (ev, size: {
width: number, height: number, animate?: boolean
}) => {
const win = BrowserWindow.fromWebContents(ev.sender)
if (!win) {
return
}
win.setSize(size.width, size.height, size.animate)
} )
}
initIpc()

View File

@@ -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: 576,
height: 410,
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!
}

View File

@@ -23,8 +23,6 @@ export function createMainWindow(): BrowserWindow {
}
})
process.env.NODE_ENV === 'development' && openDevTools(mainWindow)
mainWindow.on('ready-to-show', () => {
mainWindow.show()
})
@@ -36,6 +34,12 @@ export function createMainWindow(): BrowserWindow {
show: true
})
})
mainWindow.on('ready-to-show', async () => {
process.env.NODE_ENV === 'development' &&
setTimeout(() => {
mainWindow && openDevTools(mainWindow)
}, 500)
})
mainWindow.webContents.setWindowOpenHandler((details) => {
shell.openExternal(details.url)

View File

@@ -0,0 +1,52 @@
import { BrowserWindow } from 'electron'
import path from 'path'
import { URL } from 'node:url'
export let readNoReplyReminderLlmMockWindow: BrowserWindow | null = null
export function createReadNoReplyReminderLlmMockWindow(
opt?: Electron.BrowserWindowConstructorOptions,
{ autoReminderConfig } = {}
): BrowserWindow {
// Create the browser window.
if (readNoReplyReminderLlmMockWindow) {
readNoReplyReminderLlmMockWindow!.show()
}
readNoReplyReminderLlmMockWindow = new BrowserWindow({
width: 600,
height: 800,
resizable: false,
show: false,
autoHideMenuBar: true,
frame: false,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
sandbox: false
},
...opt
})
readNoReplyReminderLlmMockWindow.on('ready-to-show', () => {
readNoReplyReminderLlmMockWindow!.show()
})
// HMR for renderer base on electron-vite cli.
// Load the remote URL for development or the local html file for production.
let urlObj: URL
if (process.env.NODE_ENV === 'development' && process.env['ELECTRON_RENDERER_URL']) {
urlObj = new URL(process.env['ELECTRON_RENDERER_URL'] + '#/readNoReplyReminderLlmMock')
} else {
urlObj = new URL(
'file://' + path.join(__dirname, '../renderer/index.html') + '#/readNoReplyReminderLlmMock'
)
}
for (const [k, v] of Object.entries(autoReminderConfig || {})) {
urlObj.searchParams.append(k, v)
}
readNoReplyReminderLlmMockWindow.loadURL(String(urlObj))
readNoReplyReminderLlmMockWindow!.once('closed', () => {
readNoReplyReminderLlmMockWindow = null
})
return readNoReplyReminderLlmMockWindow!
}

View File

@@ -0,0 +1,45 @@
import { BrowserWindow, ipcMain } from 'electron'
import path from 'path'
export let resumeEditorWindow: BrowserWindow | null = null
export function createResumeEditorWindow(
opt?: Electron.BrowserWindowConstructorOptions
): BrowserWindow {
// Create the browser window.
if (resumeEditorWindow) {
resumeEditorWindow!.show()
}
resumeEditorWindow = new BrowserWindow({
width: 960,
height: 720,
resizable: true,
show: false,
autoHideMenuBar: true,
// frame: false,
webPreferences: {
preload: path.join(__dirname, '../preload/index.js'),
sandbox: false
},
...opt
})
resumeEditorWindow.on('ready-to-show', () => {
resumeEditorWindow!.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']) {
resumeEditorWindow.loadURL(process.env['ELECTRON_RENDERER_URL'] + '#/resumeEditor')
} else {
resumeEditorWindow.loadURL(
'file://' + path.join(__dirname, '../renderer/index.html') + '#/resumeEditor'
)
}
resumeEditorWindow!.once('closed', () => {
resumeEditorWindow = null
})
return resumeEditorWindow!
}

View File

@@ -9,4 +9,11 @@
<script setup lang="ts">
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import { onMounted } from 'vue'
import { gtagRenderer } from './utils/gtag'
gtagRenderer('app_component_before_create')
onMounted(() => {
gtagRenderer('app_component_mounted')
})
</script>

View File

@@ -24,7 +24,11 @@
<template #header>
<div class="diff-table-header">
{{ transformUtcDateToLocalDate(item.value).format('YYYY-MM-DD HH:mm:ss') }}
<el-tooltip content="待对比条目少于2个" :disabled="tableProps.length > 1">
<el-tooltip
content="待对比条目少于2个"
:disabled="tableProps.length > 1"
@show="gtagRenderer('tooltip_show_about_lake_of_compare_item')"
>
<el-radio v-model="diffPivot" :label="item.value" :disabled="tableProps.length <= 1">作为diff基准</el-radio>
</el-tooltip>
</div>
@@ -44,6 +48,7 @@ import { JobInfoChangeLog } from '@geekgeekrun/sqlite-plugin/src/entity/JobInfoC
import { ElTable, ElTableColumn, ElForm, ElFormItem, ElRow, ElCol, ElDivider } from 'element-plus'
import TextDiff from '../../components/TextDiff.vue'
import { transformUtcDateToLocalDate } from '@geekgeekrun/utils/date.mjs'
import { gtagRenderer } from '@renderer/utils/gtag'
const props = defineProps({
jobInfo: {

View File

@@ -3,9 +3,20 @@
<el-form-item label="公司">{{ jobInfo.companyName }}</el-form-item>
<el-form-item label="职位名称">{{ jobInfo.jobName }}</el-form-item>
<el-form-item label="职位分类">{{ jobInfo.positionName }}</el-form-item>
<el-form-item label="开聊时间">{{
transformUtcDateToLocalDate(jobInfo.date).format('YYYY-MM-DD HH:mm:ss')
}}</el-form-item>
<el-form-item v-if="scene === 'startChatRecord'" label="开聊时间">
{{
jobInfo.date
? transformUtcDateToLocalDate(jobInfo.date).format('YYYY-MM-DD HH:mm:ss')
: '无记录'
}}
</el-form-item>
<el-form-item v-if="scene === 'markAsNotSuitRecord'" label="标记时间">
{{
jobInfo.date
? transformUtcDateToLocalDate(jobInfo.date).format('YYYY-MM-DD HH:mm:ss')
: '无记录'
}}
</el-form-item>
<el-form-item label="工作经验">{{ jobInfo.experienceName }}</el-form-item>
<el-form-item label="薪资">{{
`${jobInfo.salaryLow}-${jobInfo.salaryHigh}k` +
@@ -14,19 +25,26 @@
<el-form-item label="职位描述">
<pre class="of-auto">{{ jobInfo.description }}</pre>
</el-form-item>
<el-form-item label="BOSS">{{ jobInfo.bossName }} - {{ jobInfo.bossTitle }}</el-form-item>
<el-form-item label="BOSS"
>{{ jobInfo.bossName
}}<template v-if="jobInfo.bossTitle"> - {{ jobInfo.bossTitle }}</template></el-form-item
>
</el-form>
</template>
<script setup lang="ts">
import { PropType } from 'vue'
import { type VChatStartupLog } from '@geekgeekrun/sqlite-plugin/src/entity/VChatStartupLog'
import { type VMarkAsNotSuitLog } from '@geekgeekrun/sqlite-plugin/src/entity/VMarkAsNotSuitLog'
import { transformUtcDateToLocalDate } from '@geekgeekrun/utils/date.mjs'
const props = defineProps({
defineProps({
jobInfo: {
type: Object as PropType<VChatStartupLog>,
type: Object as PropType<VChatStartupLog | VMarkAsNotSuitLog>,
required: true
},
scene: {
type: String
}
})
</script>

View File

@@ -26,6 +26,7 @@
import { useRouter } from 'vue-router'
import { onMounted, ref } from 'vue'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { gtagRenderer } from '@renderer/utils/gtag'
const router = useRouter()
@@ -33,10 +34,12 @@ const checkDependenciesResult = ref({})
const downloadProcessWaitee = ref(null)
onMounted(async () => {
gtagRenderer('bootstrap_mounted')
checkDependenciesResult.value = await electron.ipcRenderer.invoke('check-dependencies')
downloadProcessWaitee.value = Promise.withResolvers()
if (Object.values(checkDependenciesResult.value).includes(false)) {
gtagRenderer('dependencies_need_download')
router.replace('/downloadingDependencies')
} else {
downloadProcessWaitee.value!.resolve()
@@ -45,10 +48,11 @@ onMounted(async () => {
downloadProcessWaitee.value!.promise.then(async () => {
const isCookieFileValid = await electron.ipcRenderer.invoke('check-boss-zhipin-cookie-file')
if (!isCookieFileValid) {
gtagRenderer('found_cookie_invalid_when_bootstrap')
router.replace('/cookieAssistant')
} else {
await sleep(1000)
router.replace('/configuration')
router.replace('/main-layout')
}
})
})

View File

@@ -13,6 +13,7 @@
<script lang="ts" setup>
import { ref, onUnmounted, PropType } from 'vue'
import { ElMessageBox } from 'element-plus'
import { gtagRenderer } from '@renderer/utils/gtag'
const props = defineProps({
dependenciesStatus: {
@@ -47,9 +48,11 @@ const processDownloadBrowser = async () => {
const promiseList: Array<Promise<void>> = []
const processTasks = async () => {
if (!props.dependenciesStatus.puppeteerExecutableAvailable) {
gtagRenderer('start_download_puppeteer')
const p = processDownloadBrowser()
promiseList.push(p)
p.then(() => {
gtagRenderer('puppeteer_download_success')
props.dependenciesStatus.puppeteerExecutableAvailable = true
})
}
@@ -64,6 +67,7 @@ const processTasks = async () => {
})
await p
} catch {
gtagRenderer('encounter_error_when_download_deps')
await ElMessageBox.confirm('需要重试吗?', '核心组件下载失败', {
closeOnClickModal: false,
closeOnPressEscape: false,
@@ -72,9 +76,11 @@ const processTasks = async () => {
cancelButtonText: '退出程序'
})
.then(() => {
gtagRenderer('start_retry_download_deps')
processTasks()
})
.catch(() => {
gtagRenderer('cancel_download_deps_and_exit')
promiseList.length = 0
electron.ipcRenderer.invoke('exit-app-immediately')
})

View File

@@ -1,162 +0,0 @@
<template>
<div class="form-wrap">
<el-form ref="formRef" :model="formContent" label-position="top" :rules="formRules">
<el-form-item label="BOSS直聘 Cookie">
<el-button size="small" type="primary" font-size-inherit @click="handleClickLaunchLogin"
>编辑Cookie</el-button
>
</el-form-item>
<el-form-item
label="钉钉机器人 AccessToken用于记录开聊请勿使用公司内部群"
prop="dingtalkRobotAccessToken"
>
<el-input v-model="formContent.dingtalkRobotAccessToken" />
</el-form-item>
<el-form-item
label="期望职位白名单正则(按照职位名称+职位描述筛选职位,为空时将不按此条件筛选)"
prop="expectJobRegExpStr"
>
<el-input v-model="formContent.expectJobRegExpStr" />
</el-form-item>
<el-form-item label="期望公司(以逗号分隔,为空时将不按此条件筛选)" prop="expectCompanies">
<el-input
v-model="formContent.expectCompanies"
:autosize="{ minRows: 4 }"
type="textarea"
@blur="handleExpectCompaniesInputBlur"
/>
</el-form-item>
<el-form-item
label="推荐职位筛选器(当前求职期望找不到合适职位时,将尝试所有可能的筛选组合,查找新工作)"
prop="filter"
>
<AnyCombineBossRecommendFilter v-model="formContent.anyCombineRecommendJobFilter" />
<div>
当前组合条件数{{ currentAnyCombineRecommendJobFilterCombinationCount.toLocaleString() }}
<span
v-if="
currentAnyCombineRecommendJobFilterCombinationCount >= 10 &&
currentAnyCombineRecommendJobFilterCombinationCount < 100
"
class="color-orange"
>组合条件太多,建议少选择一些</span
>
<span
v-if="currentAnyCombineRecommendJobFilterCombinationCount >= 100"
class="color-orange"
>你开心就好</span
>
</div>
</el-form-item>
<el-form-item label="标记不合适机制" class="color-orange">
1. 如果查找到的职位活跃时间为“本月活跃”或更往前的时间,则这个职位将被标记为不合适<br />
2. 如果查找到的职位,职位名称、职位类型、职位描述与期望职位白名单正则不匹配,则这个职位将被标记为不合适
</el-form-item>
<el-form-item class="last-form-item">
<el-button @click="handleSave">仅保存配置</el-button>
<el-button type="primary" @click="handleSubmit"> 保存配置,并开始求职! </el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElForm, ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import AnyCombineBossRecommendFilter from '@renderer/features/AnyCombineBossRecommendFilter/index.vue'
import { calculateTotalCombinations } from '@geekgeekrun/geek-auto-start-chat-with-boss/combineCalculator.mjs'
const router = useRouter()
const formContent = ref({
dingtalkRobotAccessToken: '',
expectCompanies: '',
anyCombineRecommendJobFilter: {},
expectJobRegExpStr: ''
})
const currentAnyCombineRecommendJobFilterCombinationCount = computed(() => {
return calculateTotalCombinations(formContent.value.anyCombineRecommendJobFilter)
})
electron.ipcRenderer.invoke('fetch-config-file-content').then((res) => {
console.log(res)
formContent.value.dingtalkRobotAccessToken = res.config['dingtalk.json']['groupRobotAccessToken']
formContent.value.expectCompanies = res.config['target-company-list.json'].join(',')
formContent.value.anyCombineRecommendJobFilter = res.config['boss.json']
?.anyCombineRecommendJobFilter ?? {
salaryList: [],
experienceList: [],
degreeList: [],
scaleList: [],
industryList: []
}
formContent.value.expectJobRegExpStr = res.config['boss.json']?.expectJobRegExpStr ?? ''
})
const formRules = {
expectJobRegExpStr: {
validator(_, value, cb) {
if (!value) {
cb()
return
}
try {
new RegExp(value, 'ig')
cb()
} catch (err) {
cb(new Error(`正则无效:${err.message}`))
}
}
}
}
const formRef = ref<InstanceType<typeof ElForm>>()
const handleSubmit = async () => {
formContent.value.expectJobRegExpStr = (formContent.value.expectJobRegExpStr || '').trim()
await formRef.value!.validate()
await electron.ipcRenderer.invoke('save-config-file-from-ui', JSON.stringify(formContent.value))
router.replace({
path: '/geekAutoStartChatWithBoss/prepareRun',
query: { flow: 'geek-auto-start-chat-with-boss' }
})
}
const handleSave = async () => {
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 ?? '')
.split(/,|/)
.map((it) => it.trim())
.filter(Boolean)
.join(',')
}
const handleClickLaunchLogin = () => {
router.replace('/cookieAssistant')
}
</script>
<style scoped lang="scss">
.form-wrap {
margin: 0 auto;
max-width: 1000px;
max-height: 100vh;
overflow: auto;
padding-left: 20px;
padding-right: 20px;
:deep(.el-form) {
padding-top: 8px;
}
.last-form-item {
:deep(.el-form-item__content) {
margin-top: 0px;
justify-content: flex-end;
}
}
}
</style>

View File

@@ -1,176 +0,0 @@
<template>
<div class="form-wrap">
<el-form
ref="formRef"
:rules="formRules"
:model="formContent.autoReminder"
label-position="top"
>
<el-form-item label="BOSS直聘 Cookie">
<el-button size="small" type="primary" font-size-inherit @click="handleClickLaunchLogin"
>编辑Cookie</el-button
>
</el-form-item>
<el-form-item label="跟进话术" class="color-orange">
当发现已读不回的Boss时将向Boss发出[盼回复]表情
</el-form-item>
<el-form-item label="跟进间隔(分钟)" prop="throttleIntervalMinutes">
<el-input-number
v-model="formContent.autoReminder.throttleIntervalMinutes"
class="w-150px"
:min="3"
:precision="1"
:step="0.5"
@blur="handleThrottleIntervalMinutesBlur"
/>&nbsp;分钟内不多次跟进同一Boss
</el-form-item>
<el-form-item label="跟进时限(天)" prop="rechatLimitDay">
<div>
<div><el-checkbox v-model="enableRechatLimit" />&nbsp;启用</div>
<el-input-number
v-model="formContent.autoReminder.rechatLimitDay"
class="w-150px"
:min="0"
:precision="1"
:step="0.5"
:disabled="!enableRechatLimit"
/>&nbsp;<br />
<div v-if="enableRechatLimit">
不再跟进&nbsp;<span class="text-orange">{{ rechatLimitDateString }}</span>之前列表中没有进展的聊天
</div>
<div v-else>这将会跟进列表中所有聊天<span class="text-orange">不建议</span></div>
</div>
</el-form-item>
<el-form-item class="last-form-item">
<el-button type="primary" @click="handleSubmit">开始提醒</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { dayjs, ElForm } from 'element-plus'
import { useRouter } from 'vue-router'
const router = useRouter()
const formContent = ref({
autoReminder: {
throttleIntervalMinutes: 10,
rechatLimitDay: 21
}
})
const enableRechatLimit = computed({
get() {
return Boolean(formContent.value.autoReminder?.rechatLimitDay)
},
set(val) {
if (!val) {
formContent.value.autoReminder.rechatLimitDay = 0
} else {
formContent.value.autoReminder.rechatLimitDay = 21
}
}
})
electron.ipcRenderer.invoke('fetch-config-file-content').then((res) => {
const conf = res.config['boss.json']?.autoReminder || {}
conf.throttleIntervalMinutes = conf.throttleIntervalMinutes ?? 10
conf.rechatLimitDay = conf.rechatLimitDay ?? 21
formContent.value.autoReminder = conf
})
const formRules = {
throttleIntervalMinutes: {
validator(_, value, cb) {
if (/[^0-9.]/.test(String(value)) || isNaN(parseFloat(value)) || isNaN(Number(value))) {
cb(new Error(`请输入数字!`))
} else {
cb()
}
}
},
rechatLimitDay: {
validator(_, value, cb) {
if (/[^0-9.]/.test(String(value)) || isNaN(parseFloat(value)) || isNaN(Number(value))) {
cb(new Error(`请输入数字!`))
} else {
cb()
}
}
}
}
const formRef = ref<InstanceType<typeof ElForm>>()
watch(
() => formContent.value.autoReminder,
() => {
nextTick(() => {
formRef.value?.validate?.()
})
},
{
immediate: true
}
)
const handleSubmit = async () => {
await formRef.value!.validate()
await electron.ipcRenderer.invoke('save-config-file-from-ui', JSON.stringify(formContent.value))
router.replace({
path: '/geekAutoStartChatWithBoss/prepareRun',
query: { flow: 'read-no-reply-reminder' }
})
}
function handleThrottleIntervalMinutesBlur() {
if (formContent.value.autoReminder.throttleIntervalMinutes < 3) {
formContent.value.autoReminder.throttleIntervalMinutes = 3
}
formContent.value.autoReminder.throttleIntervalMinutes = Number(
formContent.value.autoReminder.throttleIntervalMinutes
)
}
const handleClickLaunchLogin = () => {
router.replace('/cookieAssistant')
}
const currentStamp = ref(new Date())
let timer = 0
function updateCurrentStamp() {
currentStamp.value = new Date()
timer = window.setTimeout(updateCurrentStamp, 1000)
}
updateCurrentStamp()
onUnmounted(() => {
window.clearTimeout(timer)
})
const rechatLimitDateString = computed(() => {
return dayjs(
+currentStamp.value - formContent.value.autoReminder.rechatLimitDay * 24 * 60 * 60 * 1000
).format('YYYY-MM-DD HH:mm:ss')
})
</script>
<style scoped lang="scss">
.form-wrap {
margin: 0 auto;
max-width: 1000px;
max-height: 100vh;
overflow: auto;
padding-left: 20px;
padding-right: 20px;
:deep(.el-form) {
padding-top: 40px;
}
.last-form-item {
:deep(.el-form-item__content) {
margin-top: 40px;
justify-content: flex-end;
}
}
}
</style>

View File

@@ -1,148 +0,0 @@
<template>
<div class="flex h100vh">
<div class="flex flex-col w160px pt30px pl30px aside-nav of-hidden">
<div class="nav-list flex-1 of-auto">
<RouterLink to="./GeekAutoStartChatWithBoss">Boss炸弹</RouterLink>
<RouterLink to="./ReadNoReplyReminder">已读不回提醒器</RouterLink>
<hr />
<a href="javascript:void(0)" @click="handleLaunchBossSite">
手动逛Boss<el-icon><TopRight /></el-icon>
</a>
<hr />
<RouterLink to="./StartChatRecord">开聊记录</RouterLink>
<RouterLink to="./MarkAsNotSuitRecord">标记不合适记录</RouterLink>
<RouterLink to="./JobLibrary">职位库</RouterLink>
<RouterLink to="./BossLibrary">Boss库</RouterLink>
<RouterLink to="./CompanyLibrary">公司库</RouterLink>
</div>
<div class="pt-16px pb-16px flex-0 font-size-12px">
<div>当前版本: {{ buildInfo.version }}({{ buildInfo.buildVersion }})</div>
<div class="feedback-area flex flex-items-center mt-8px">
<el-button type="text" size="small" @click="handleGotoProjectPageClick"
>项目首页</el-button
>
|
<el-button type="text" size="small" @click="handleFeedbackClick">反馈问题</el-button>
</div>
</div>
</div>
<RouterView v-slot="{ Component }" class="flex-1">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</RouterView>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElIcon } from 'element-plus'
import { TopRight } from '@element-plus/icons-vue'
import useBuildInfo from '@renderer/hooks/useBuildInfo'
import { debounce } from 'lodash-es'
const router = useRouter()
const unmountedCbs: Array<InstanceType<typeof Function>> = []
onUnmounted(() => {
while (unmountedCbs.length) {
const fn = unmountedCbs.shift()!
try {
fn()
} catch {}
}
})
const goToCheckBossZhipinCookieFile = () => router.replace('/cookieAssistant')
onMounted(() => {
electron.ipcRenderer.on('check-boss-zhipin-cookie-file', goToCheckBossZhipinCookieFile)
})
onUnmounted(() => {
electron.ipcRenderer.removeListener(
'check-boss-zhipin-cookie-file',
goToCheckBossZhipinCookieFile
)
})
;(async () => {
const checkDependenciesResult = await electron.ipcRenderer.invoke('check-dependencies')
if (Object.values(checkDependenciesResult).includes(false)) {
router.replace('/')
return
}
const isCookieFileValid = await electron.ipcRenderer.invoke('check-boss-zhipin-cookie-file')
if (!isCookieFileValid) {
router.replace('/cookieAssistant')
return
}
})()
const { buildInfo } = useBuildInfo()
const getIssueUrlWithBody = (issueBody: string = '') => {
const baseUrl = `https://github.com/geekgeekrun/geekgeekrun/issues/new`
issueBody = issueBody || ''
if (!issueBody || !issueBody.trim()) {
return baseUrl
}
const urlObj = new URL(baseUrl)
urlObj.searchParams.append('body', issueBody)
return urlObj.toString()
}
const handleFeedbackClick = () => {
electron.ipcRenderer.send(
'open-external-link',
getIssueUrlWithBody(`\n\n\n-----
版本号:${buildInfo.value.version}(${buildInfo.value.buildVersion})
提交:${buildInfo.value.buildHash.substring(0, 6)}`)
)
}
const handleGotoProjectPageClick = () => {
electron.ipcRenderer.send('open-external-link', 'https://github.com/geekgeekrun/geekgeekrun')
}
const handleLaunchBossSite = debounce(
async () => {
return await electron.ipcRenderer.invoke('open-site-with-boss-cookie', {
url: `https://www.zhipin.com/`
})
},
1000,
{ leading: true, trailing: false }
)
</script>
<style lang="scss" scoped>
.aside-nav {
background-image: linear-gradient(45deg, #eaf4f1, #dcf6f2);
.nav-list {
> a {
display: flex;
align-items: center;
height: 2.5em;
box-sizing: border-box;
padding-left: 2em;
&.router-link-active {
background-color: #fff;
font-weight: 700;
color: #2faa9e;
border-radius: 9999px 0 0 9999px;
}
}
> hr {
border: 0 solid;
height: 1px;
background-color: #b3c8c3;
margin-top: 0;
margin-bottom: 0;
width: 140px;
margin-right: 0;
}
}
.feedback-area {
:deep(.el-button) {
height: fit-content;
padding: 0;
margin-left: 0;
}
}
}
</style>

View File

@@ -1,6 +1,6 @@
<template>
<div class="cookie-assistant-page">
<div ml1em mt1em mb1em >Cookie 助手</div>
<div ml1em mt1em mb1em font-size-16px>Cookie 助手</div>
<el-alert
v-if="cookieInvalid"
type="warning"
@@ -11,11 +11,11 @@
</el-alert>
<div ml1em mt1em line-height-normal>
如果您了解如何获取Cookie了解有效的Cookie格式可以直接在下方输入框中进行编辑由于手动编辑较为麻烦建议您打开已登录过Boss直聘的浏览器使用<a
color-blue
decoration-none
class="color-blue! decoration-none"
href="javascript:void(0)"
@click.prevent="handleEditThisCookieExtensionStoreLinkClick"
>EditThisCookie 扩展程序</a
>
EditThisCookie 扩展程序 </a
>复制Cookie然后粘贴在下方输入框中文本格式为被序列化为JSON的数组不含两侧引号
</div>
<br />
@@ -85,7 +85,12 @@
size="small"
type="primary"
font-size-inherit
@click="fillCollectedCookie"
@click="
() => {
gtagRenderer('replace_inputted_cookie_by_collected')
fillCollectedCookie()
}
"
>使用获取到的Cookie</el-button
></template
></el-alert
@@ -103,7 +108,8 @@
import { ElForm, ElMessage } from 'element-plus'
import { ref, onUnmounted, onMounted } from 'vue'
import { checkCookieListFormat } from '../../../../common/utils/cookie'
import { useRouter } from 'vue-router';
import { useRouter } from 'vue-router'
import { gtagRenderer } from '@renderer/utils/gtag'
const router = useRouter()
const cookieInvalid = ref(false)
@@ -138,11 +144,13 @@ const formRules = {
`JSON格式无效 - 存在语法错误: ${err.message}建议使用EditThisCookie扩展程序进行复制。`
)
)
gtagRenderer('wrong_cookie_format_json_syntax_error')
return
}
if (!checkCookieListFormat(JSON.parse(formContent.value.collectedCookies))) {
cb(new Error(`Cookie格式无效 - 部分字段缺失建议使用EditThisCookie扩展程序进行复制。`))
gtagRenderer('wrong_cookie_format_field_loss')
return
}
cb()
@@ -158,6 +166,9 @@ const handleCookieCollected = (_, payload) => {
collectedCookie.value = payload.cookies
if (!hasUserMutateInput.value) {
fillCollectedCookie()
gtagRenderer('cookie_collected_and_auto_filled')
} else {
gtagRenderer('cookie_collected_after_changed_input')
}
}
const fillCollectedCookie = () => {
@@ -169,28 +180,33 @@ const fillCollectedCookie = () => {
}
const handleClickLaunchLogin = () => {
gtagRenderer('launch_login_button_clicked')
electron.ipcRenderer.send('launch-bosszhipin-login-page-with-preload-extension')
loginCookieWaitingStatus.value = LOGIN_COOKIE_WAITING_STATUS.WAITING_FOR_LOGIN
}
const handleEditThisCookieExtensionStoreLinkClick = () => {
gtagRenderer('etc_extension_link_clicked')
electron.ipcRenderer.send(
'open-external-link',
'https://chromewebstore.google.com/detail/editthiscookie/fngmhnnpilhplaeedifhccceomclgfbg'
'https://chromewebstore.google.com/detail/editthiscookie-v3/ojfebgpkimhlhcblbalbfjblapadhbol'
)
}
const handleCancel = () => {
router.replace('/configuration')
gtagRenderer('cancel_clicked')
router.replace('/main-layout')
}
const handleSubmit = async () => {
gtagRenderer('save_clicked')
await formRef.value!.validate()
await electron.ipcRenderer.invoke('write-storage-file', {
fileName: 'boss-cookies.json',
data: formContent.value.collectedCookies
})
ElMessage.success('Boss直聘 Cookie 保存成功')
router.replace('/configuration')
gtagRenderer('save_cookie_done')
router.replace('/main-layout')
}
const handleBossZhipinLoginPageClosed = () => {
@@ -199,6 +215,9 @@ const handleBossZhipinLoginPageClosed = () => {
}
}
onMounted(() => {
gtagRenderer('cookie_assistant_mounted')
})
onMounted(async () => {
electron.ipcRenderer.once('BOSS_ZHIPIN_COOKIE_COLLECTED', handleCookieCollected)
electron.ipcRenderer.on('BOSS_ZHIPIN_LOGIN_PAGE_CLOSED', handleBossZhipinLoginPageClosed)
@@ -226,6 +245,7 @@ onUnmounted(() => {
.cookie-assistant-page {
max-width: 640px;
margin: 0 auto;
font-size: 14px;
}
</style>

View File

@@ -87,6 +87,8 @@
<script lang="ts" setup>
import { ElCheckbox, ElCheckboxGroup, ElMessage } from 'element-plus'
import { ref, onMounted, onBeforeMount } from 'vue';
import { gtagRenderer } from '@renderer/utils/gtag'
const electron = window.electron
const readmeItemCheckStatusList = ref<number[]>([])
@@ -97,8 +99,10 @@ const handleCancel = () => {
const unreadItemsAfterClickSubmit = ref<Record<number, true>>({})
const handleSubmit = () => {
gtagRenderer('submit_clicked')
const COUNT = 9
if (readmeItemCheckStatusList.value.length !== COUNT) {
gtagRenderer('agreement_not_finish_read_tip_displayed')
ElMessage.warning(
`您还有${COUNT - readmeItemCheckStatusList.value.length}条没有读完,读完就打勾标记一下吧`
)
@@ -111,6 +115,7 @@ const handleSubmit = () => {
return
}
electron.ipcRenderer.invoke('first-launch-notice-approve')
gtagRenderer('submit_done')
}
const handleReadmeItemCheckStatusListChange = (value: number[]) => {
value.forEach((it) => {

View File

@@ -18,22 +18,26 @@ import { ref, onUnmounted, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import FlyingCompanyLogoList from '../../features/FlyingCompanyLogoList/index.vue'
import { ElMessage } from 'element-plus';
import { gtagRenderer } from '@renderer/utils/gtag'
const { ipcRenderer } = electron
const router = useRouter()
const handleStopButtonClick = async () => {
gtagRenderer('gascwb_stop_button_clicked')
ipcRenderer.invoke('stop-geek-auto-start-chat-with-boss')
}
const isStopping = ref(false)
const handleStopping = () => {
gtagRenderer('gascwb_become_stopping')
isStopping.value = true
}
ipcRenderer.once('geek-auto-start-chat-with-boss-stopping', handleStopping)
const handleStopped = () => {
router.replace('/configuration/GeekAutoStartChatWithBoss')
gtagRenderer('gascwb_become_stopped')
router.replace('/main-layout/GeekAutoStartChatWithBoss')
}
ipcRenderer.once('geek-auto-start-chat-with-boss-stopped', handleStopped)
@@ -47,12 +51,14 @@ onMounted(async () => {
await electron.ipcRenderer.invoke('run-geek-auto-start-chat-with-boss')
} catch (err) {
if (err instanceof Error && err.message.includes('NEED_TO_CHECK_RUNTIME_DEPENDENCIES')) {
gtagRenderer('gascwb_cannot_run_for_corrupt')
ElMessage.error({
message: `核心组件损坏,正在尝试修复`
})
router.replace('/')
}
console.error(err)
gtagRenderer('gascwb_cannot_run_for_unknown_error', { err })
}
})
</script>

View File

@@ -16,22 +16,26 @@ import { ref, onUnmounted, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import FlyingCompanyLogoList from '../../features/FlyingCompanyLogoList/index.vue'
import { ElMessage } from 'element-plus'
import { gtagRenderer } from '@renderer/utils/gtag'
const { ipcRenderer } = electron
const router = useRouter()
const handleStopButtonClick = async () => {
gtagRenderer('rnrr_stop_button_clicked')
ipcRenderer.invoke('stop-geek-auto-start-chat-with-boss')
}
const isStopping = ref(false)
const handleStopping = () => {
gtagRenderer('rnrr_become_stopping')
isStopping.value = true
}
ipcRenderer.once('geek-auto-start-chat-with-boss-stopping', handleStopping)
const handleStopped = () => {
router.replace('/configuration/ReadNoReplyReminder')
gtagRenderer('rnrr_become_stopped')
router.replace('/main-layout/ReadNoReplyReminder')
}
ipcRenderer.once('geek-auto-start-chat-with-boss-stopped', handleStopped)
@@ -45,12 +49,14 @@ onMounted(async () => {
await electron.ipcRenderer.invoke('run-read-no-reply-auto-reminder')
} catch (err) {
if (err instanceof Error && err.message.includes('NEED_TO_CHECK_RUNTIME_DEPENDENCIES')) {
gtagRenderer('rnrr_cannot_run_for_corrupt')
ElMessage.error({
message: `核心组件损坏,正在尝试修复`
})
router.replace('/')
}
console.error(err)
gtagRenderer('rnrr_cannot_run_for_unknown_error', { err })
}
})
</script>

View File

@@ -0,0 +1,563 @@
<template>
<div class="llm-config-page">
<div class="main-wrapper">
<main>
<div class="mt1em mb1em">
<div class="flex flex-items-center flex-justify-between">
<div>大语言模型设置</div>
<el-dropdown
@command="
(item) => {
gtagRenderer('provider_url_for_secret_clicked', {
name: item.name
})
openExternalLink(item.url)
}
"
>
<el-button size="small"
>获取 API Secret <el-icon class="el-icon--right"><arrow-down /></el-icon
></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item v-for="item in providerList" :key="item.name" :command="item">{{
item.name
}}</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<el-alert type="info" :closable="false" mb20px line-height-1.25em>
<ul pl16px m0>
<li>
请确保当前服务商提供的模型支持<a
:style="{
color: 'var(--el-color-primary)'
}"
href="javascript:void(0)"
@click.prevent="
() => {
gtagRenderer('chat_completion_intro_doc_link_clicked')
openExternalLink(
'https://api-docs.deepseek.com/zh-cn/api/create-chat-completion'
)
}
"
>对话补全</a
>且兼容
<a
:style="{
color: 'var(--el-color-primary)'
}"
href="javascript:void(0)"
@click.prevent="
() => {
gtagRenderer('openai_sdk_intro_doc_link_clicked')
openExternalLink('https://www.npmjs.com/package/openai')
}
"
>OpenAI SDK</a
>
</li>
<li><b class="color-red">暂不支持推理模型</b>例如 DeepSeek-R1</li>
<li>
请自行确保您所接入的服务商能够保护您的隐私<b class="color-red"
>此处所列举服务商-模型由第三方提供仅供配置参考本程序不能保证它们能够合法使用您的数据不表示本程序认可相关模型</b
>
</li>
</ul>
</el-alert>
<el-form
ref="formRef"
:model="formContentForElForm"
:rules="formRulesForElForm"
label-position="top"
class="llm-config-form"
:validate-on-rule-change="false"
>
<div v-for="(conf, index) in formContent" :key="conf.id" class="flex gap12px">
<div
v-if="formContent.length > 1"
:style="{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
height: 'fit-content',
marginTop: '10px'
}"
>
<el-button
:disabled="index <= 0"
style="margin: 0"
circle
size="small"
:icon="ArrowUp"
@click="moveConfigUp(index)"
/>
<el-button
:disabled="index >= formContent.length - 1"
style="margin: 0"
circle
size="small"
:icon="ArrowDown"
@click="moveConfigDown(index)"
/>
<el-button
:disabled="1 >= formContent.length"
style="margin: 0"
circle
size="small"
:icon="Delete"
@click="removeConfig(index)"
/>
</div>
<div class="w-full">
<el-form-item :prop="`${index}_providerCompleteApiUrl`">
<div
class="el-form-item__label flex-items-center flex-justify-between w-full pr0px"
:style="{ display: 'flex' }"
>
<div>服务提供商 Base URL</div>
<el-dropdown @command="(item) => handlePresetClick(item, index)">
<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-input
ref="firstInputRefList"
v-model="conf.providerCompleteApiUrl"
:autosize="{
minRows: 10,
maxRows: 10
}"
font-size-12px
></el-input>
</el-form-item>
<el-form-item prop="model" label="要使用的模型model参数">
<el-input
v-model="conf.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="conf.providerApiSecret"
:autosize="{
minRows: 10,
maxRows: 10
}"
font-size-12px
></el-input>
</el-form-item>
<div class="serve-weight-config">
<div class="flex">
<el-form-item prop="enabled">
<div class="el-form-item__label">启用</div>
<el-checkbox
v-if="formContent.length > 1"
v-model="conf.enabled"
font-size-12px
@click="
nextTick(() =>
gtagRenderer('model_enable_status_changed', { enabled: conf.enabled })
)
"
></el-checkbox>
<el-checkbox v-else :model-value="true" font-size-12px disabled />
</el-form-item>
<el-form-item prop="serveWeight" class="ml40px">
<div class="el-form-item__label">权重</div>
<el-input-number
v-if="formContent.length > 1"
v-model="conf.serveWeight"
:min="1"
:max="100"
:step="1"
step-strictly
:precision="0"
font-size-12px
placeholder="1 ~ 100"
@change="
(new_val, old_val) => {
gtagRenderer('serve_weight_changed', { new_val, old_val })
}
"
></el-input-number>
<el-input-number
v-else
font-size-12px
:model-value="SINGLE_ITEM_DEFAULT_SERVE_WEIGHT"
disabled
/>
</el-form-item>
</div>
<div class="flex">
<!-- <el-form-item class="ml20px">
<el-button type="text">测试设置</el-button>
</el-form-item> -->
</div>
</div>
<div
v-if="index !== formContent.length - 1"
class="mt6px mb20px h1px"
style="background-color: #dcdcdc"
/>
</div>
</div>
</el-form>
</main>
</div>
<footer pt10px pb10px flex flex-justify-center>
<div w480px flex flex-justify-between>
<div>
<el-button font-size-12px type="text" @click="addConfig"
>添加备用模型<span v-if="formContent.length <= 1">以生成更随机的内容</span></el-button
>
</div>
<div>
<el-button @click="handleCancel">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</div>
</div>
</footer>
</div>
</template>
<script lang="ts" setup>
import {
ElForm,
ElDropdown,
ElDropdownMenu,
ElDropdownItem,
ElIcon,
ElButton,
ElInput,
ElMessage
} from 'element-plus'
import { ArrowUp, ArrowDown, Delete } from '@element-plus/icons-vue'
import { ref, onMounted, watch, nextTick, computed } from 'vue'
import { gtagRenderer } from '@renderer/utils/gtag'
import { SINGLE_ITEM_DEFAULT_SERVE_WEIGHT } from '../../../../common/constant'
import { v4 as uuid } from 'uuid'
interface LlmConfigItem {
id: string
providerCompleteApiUrl: string
providerApiSecret: string
model: string
serveWeight: number
enabled: true
}
function getNewConfigItem(): LlmConfigItem {
return {
id: uuid(),
providerCompleteApiUrl: '',
providerApiSecret: '',
model: '',
serveWeight: 10,
enabled: true
}
}
const formRef = ref<InstanceType<typeof ElForm>>()
const formContent = ref<LlmConfigItem[]>([getNewConfigItem()])
const formContentForElForm = computed(() => {
const valueMap = {}
formContent.value.forEach((configItem, i) => {
valueMap[`${i}_providerCompleteApiUrl`] = configItem.providerCompleteApiUrl
})
return valueMap
})
const formRulesForElForm = computed(() => {
const valueMap = {}
formContent.value.forEach((_, i) => {
valueMap[`${i}_providerCompleteApiUrl`] = [
{
required: true,
message: '请输入服务提供商 Base URL'
},
{
trigger: 'blur',
validator(_, value, cb) {
try {
new URL(value?.trim())
} catch (err) {
cb(`URL 格式无效,请重新输入`)
}
if (/^http(s)?:\/\//.test(value)) {
cb()
return
}
cb(`服务提供商 Base URL 无效,请重新输入`)
}
}
]
})
return valueMap
})
const handleCancel = () => {
gtagRenderer('cancel_clicked')
electron.ipcRenderer.send('close-llm-config')
gtagRenderer('cancel_done')
}
const handleSubmit = async () => {
gtagRenderer('submit_clicked', { llm_config_length: formContent.value.length })
await formRef.value?.validate()
if (!formContent.value.length) {
gtagRenderer('empty_model_list')
ElMessage.warning({
message: '可选模型列表为空,请出现填写'
})
formContent.value = [getNewConfigItem()]
return
} else if (formContent.value.length > 1) {
const firstEnabledModel = formContent.value.find(it => it.enabled)
if (!firstEnabledModel) {
gtagRenderer('no_enabled_model_find_in_model_list')
ElMessage.warning({
dangerouslyUseHTMLString: true,
grouping: true,
message: '<div style="white-space: nowrap">所有模型均被禁用;请至少启用一个模型</div>'
})
return
}
}
electron.ipcRenderer.invoke('save-llm-config', JSON.parse(JSON.stringify(formContent.value)))
gtagRenderer('submit_done')
}
onMounted(async () => {
const savedFileContent = (await electron.ipcRenderer.invoke('fetch-config-file-content'))
?.config?.['llm.json']
if (!savedFileContent?.length) {
return
}
const keyOfItem = Object.keys(getNewConfigItem())
formContent.value = savedFileContent.map((it) => {
const conf: any = {}
for (const k of keyOfItem) {
conf[k] = it[k]
}
if (!it.id) {
conf.id = uuid()
}
return conf
})
})
const llmPresetList: {
name: string
config: Omit<LlmConfigItem, 'id'>
}[] = [
{
name: '由 DeepSeek 提供的 DeepSeek-V3 模型',
config: {
model: 'deepseek-chat',
providerApiSecret: '',
providerCompleteApiUrl: 'https://api.deepseek.com/v1',
serveWeight: 100,
enabled: true
}
},
{
name: '由 火山引擎 提供的 DeepSeek-V3 模型',
config: {
model: 'deepseek-v3-250324',
providerApiSecret: '',
providerCompleteApiUrl: 'https://ark.cn-beijing.volces.com/api/v3',
serveWeight: 100,
enabled: true
}
},
{
name: '由 阿里云百炼 提供的 DeepSeek-V3 模型',
config: {
model: 'deepseek-v3',
providerApiSecret: '',
providerCompleteApiUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
serveWeight: 100,
enabled: true
}
},
// TODO:
// {
// name: '通过 Ollama 部署的 DeepSeek-R114B模型',
// config: {
// model: 'deepseek-r1:14b',
// providerApiSecret: 'ollama',
// providerCompleteApiUrl: 'http://127.0.0.1:11434/v1',
// serveWeight: 10,
// enabled: true
// }
// },
{
name: '由 free.v36.cm 提供的 GPT-4o mini 模型',
config: {
model: 'gpt-4o-mini',
providerApiSecret: 'sk-P3kvkV6UZ9WMy6AH792480Fc5e1c4dAb8aE17b20FcAc4eC3',
providerCompleteApiUrl: 'https://free.v36.cm/v1',
serveWeight: 20,
enabled: true
}
},
{
name: '通过 Ollama 部署的 Qwen2.57B模型',
config: {
model: 'qwen2.5:7b',
providerApiSecret: 'ollama',
providerCompleteApiUrl: 'http://127.0.0.1:11434/v1',
serveWeight: 10,
enabled: true
}
}
]
const providerList: Array<{ name: string; url: string }> = [
{
name: 'DeepSeek',
url: 'https://platform.deepseek.com/'
},
{
name: '火山引擎 - 火山方舟',
url: 'https://console.volcengine.com/ark'
},
{
name: '阿里云百炼',
url: 'https://bailian.console.aliyun.com/?tab=model#/api-key'
},
{
name: 'OpenAI (国内可能不可用)',
url: 'https://platform.openai.com/api-keys'
},
{
name: 'FREE-CHATGPT-API (免费)',
url: 'https://github.com/popjane/free_chatgpt_api'
}
]
function handlePresetClick(selected: (typeof llmPresetList)[number], index) {
gtagRenderer('model_preset_clicked', {
name: selected.name
})
for (const k of Object.keys(formContent.value[index])) {
formContent.value[index][k] = selected.config[k]
}
if (!formContent.value[index].id) {
formContent.value[index].id = uuid()
}
}
const firstInputRefList = ref<InstanceType<typeof ElInput>[]>([])
function addConfig() {
gtagRenderer('new_config_item_added', { config_list_length_before_add: formContent.value.length })
formContent.value.push(getNewConfigItem())
nextTick(() => {
firstInputRefList.value[firstInputRefList.value.length - 1]?.focus()
})
}
function moveConfigUp(index) {
;[formContent.value[index], formContent.value[index - 1]] = [
formContent.value[index - 1],
formContent.value[index]
]
gtagRenderer('config_item_moved_up')
}
function moveConfigDown(index) {
;[formContent.value[index], formContent.value[index + 1]] = [
formContent.value[index + 1],
formContent.value[index]
]
gtagRenderer('config_item_moved_down')
}
function removeConfig(index) {
formContent.value.splice(index, 1)
gtagRenderer('config_item_removed')
}
watch(
() => formContent.value.length,
(nVal) => {
if (nVal <= 1) {
electron.ipcRenderer.send('update-window-size', {
width: window.innerWidth,
height: 550
})
} else {
electron.ipcRenderer.send('update-window-size', {
width: window.innerWidth,
height: 730
})
}
},
{
immediate: true
}
)
const openExternalLink = (url) => {
electron.ipcRenderer.send('open-external-link', url)
}
// function handleTestAvailability() {}
</script>
<style lang="scss" scoped>
.llm-config-page {
margin: 0 auto;
display: flex;
flex-direction: column;
height: 100vh;
.main-wrapper {
flex: 1;
overflow: auto;
main {
margin: 0 auto;
max-width: 480px;
}
}
footer {
background-color: #f0f0f0;
}
}
</style>
<style lang="scss">
.llm-config-form.el-form {
.el-form-item__error--inline {
margin-left: 0;
margin-top: 10px;
}
.serve-weight-config {
display: flex;
justify-content: space-between;
.el-form-item__label {
margin-bottom: 0;
}
.el-form-item__content {
display: flex;
flex-wrap: nowrap;
}
}
}
</style>

View File

@@ -19,7 +19,17 @@
</div>
<div class="flex flex-0 flex-justify-between pt10px pb10px">
<div class="w100px">
<el-button :loading="isTableLoading" size="small" @click="getBossLibrary">刷新</el-button>
<el-button
:loading="isTableLoading"
size="small"
@click="
() => {
gtagRenderer('boss_library_refresh_clicked')
getBossLibrary()
}
"
>刷新</el-button
>
</div>
<ElPagination
v-model:current-page="pagination.pageNo"
@@ -42,6 +52,7 @@ import { ref, onMounted, onBeforeUnmount } from 'vue'
import { ElTable, ElTableColumn, ElButton, ElPagination } from 'element-plus'
import { type VChatStartupLog } from '@geekgeekrun/sqlite-plugin/src/entity/VChatStartupLog'
import { PageReq, PagedRes } from '../../../../common/types/pagination'
import { gtagRenderer } from '@renderer/utils/gtag'
const tableData = ref<VChatStartupLog[]>([])
const pageSizeList = ref<number[]>([100, 200, 300, 400])
@@ -54,6 +65,10 @@ const tableRef = ref<InstanceType<typeof ElTable>>()
const isTableLoading = ref(false)
async function getBossLibrary() {
try {
gtagRenderer('boss_library_request_sent', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
isTableLoading.value = true
const { data: res } = (await electron.ipcRenderer.invoke('get-boss-library', {
pageNo: pagination.value.pageNo,
@@ -65,7 +80,16 @@ async function getBossLibrary() {
pageNo: res.pageNo,
pageSize: pagination.value.pageSize
}
gtagRenderer('boss_library_request_success', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
} catch (err) {
gtagRenderer('boss_library_request_error', {
err,
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
console.log(err)
tableData.value = []
} finally {

View File

@@ -23,7 +23,15 @@
</div>
<div class="flex flex-0 flex-justify-between pt10px pb10px">
<div class="w100px">
<el-button :loading="isTableLoading" size="small" @click="getCompanyLibrary"
<el-button
:loading="isTableLoading"
size="small"
@click="
() => {
gtagRenderer('company_library_refresh_clicked')
getCompanyLibrary()
}
"
>刷新</el-button
>
</div>
@@ -49,6 +57,7 @@ import { ElTable, ElTableColumn, ElButton, ElPagination } from 'element-plus'
import { type VChatStartupLog } from '@geekgeekrun/sqlite-plugin/src/entity/VChatStartupLog'
import { PageReq, PagedRes } from '../../../../common/types/pagination'
import { formatCompanyScale } from '@geekgeekrun/sqlite-plugin/src/utils/parser'
import { gtagRenderer } from '@renderer/utils/gtag'
const tableData = ref<VChatStartupLog[]>([])
const pageSizeList = ref<number[]>([100, 200, 300, 400])
@@ -64,6 +73,10 @@ const tableRef = ref<InstanceType<typeof ElTable>>()
const isTableLoading = ref(false)
async function getCompanyLibrary() {
try {
gtagRenderer('company_library_request_sent', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
isTableLoading.value = true
const { data: res } = (await electron.ipcRenderer.invoke('get-company-library', {
pageNo: pagination.value.pageNo,
@@ -75,7 +88,16 @@ async function getCompanyLibrary() {
pageNo: res.pageNo,
pageSize: pagination.value.pageSize
}
gtagRenderer('company_library_request_success', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
} catch (err) {
gtagRenderer('company_library_request_error', {
err,
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
console.log(err)
tableData.value = []
} finally {

View File

@@ -0,0 +1,499 @@
<template>
<div class="form-wrap geek-auto-start-run-with-boss">
<el-form ref="formRef" :model="formContent" label-position="top" :rules="formRules">
<el-form-item label="BOSS直聘 Cookie">
<el-button size="small" type="primary" @click="handleClickLaunchLogin"
>编辑Cookie</el-button
>
</el-form-item>
<!-- <el-form-item
label="钉钉机器人 AccessToken用于记录开聊请勿使用公司内部群"
prop="dingtalkRobotAccessToken"
>
<el-input v-model="formContent.dingtalkRobotAccessToken" />
</el-form-item> -->
<div>
<el-form-item mb0>
<div>
是否查看职位详情的条件
<span font-size-12px>以下条件为空表示不筛选</span>
</div>
</el-form-item>
<el-form-item prop="expectCompanies" mb10px>
<div
v-full
font-size-12px
flex
:style="{
justifyContent: 'space-between',
alignItems: 'center',
width: '100%'
}"
>
<div>
期望公司以逗号分隔不区分大小写<el-tooltip
effect="light"
placement="bottom-start"
@show="gtagRenderer('tooltip_show_about_expect_company_figure')"
>
<template #content>
<img block h-270px src="./resources/intro-of-job-entry.png" />
</template>
<el-button type="text" font-size-12px
><span><QuestionFilled w-1em h-1em mr2px /></span>期望公司信息位置图示</el-button
>
</el-tooltip>
</div>
<el-dropdown @command="handleExpectCompanyTemplateClicked">
<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 expectCompanyTemplateList"
:key="item.name"
:command="item"
>{{ item.name }}</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<el-input
v-model="formContent.expectCompanies"
:autosize="{ minRows: 4 }"
max-h-8lh
type="textarea"
@blur="normalizeExpectCompanies"
/>
</el-form-item>
</div>
<div mb42px>
<el-form-item mb0>
查看职位详情后是发起投递还是标记不合适的条件
<span font-size-12px>以下条件为空表示不筛选</span>
</el-form-item>
<div
flex
:style="{
alignItems: 'center',
justifyContent: 'space-between'
}"
>
<div>
<el-tooltip
effect="light"
placement="bottom"
@show="gtagRenderer('tooltip_show_about_expect_job_info_figure')"
>
<template #content>
<img block h-270px src="./resources/intro-of-job-info.png" />
</template>
<el-button type="text" font-size-12px
><span><QuestionFilled w-1em h-1em mr2px /></span>如下各信息位置图示</el-button
>
</el-tooltip>
<el-tooltip
effect="light"
placement="bottom-start"
@show="gtagRenderer('tooltip_show_about_mark_not_suit_intro')"
>
<template #content>
<ol m0 line-height-1.5em w-400px pl2em>
<li>
如果查找到的职位职位名称职位类型职位描述与如下正则不匹配则这个职位将被标记为不合适
</li>
<li>
如果查找到的职位活跃时间为本月活跃或更往前的时间则这个职位将被标记为不合适
</li>
<li>
如有错误标记请在左侧<a
href="javascript:void(0)"
style="color: var(--el-color-primary)"
@click.prevent="
() => {
gtagRenderer('click_view_mansr_from_boss_b_tooltip')
$router.push('/main-layout/MarkAsNotSuitRecord')
}
"
>标记不合适</a
>记录中找到相关记录手动对这些职位发起会话
</li>
</ol>
</template>
<el-button type="text" font-size-12px
><span><QuestionFilled w-1em h-1em mr2px /></span>标记不合适机制</el-button
>
</el-tooltip>
</div>
<el-dropdown ml20px @command="handleExpectJobFilterTemplateClicked">
<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 expectJobFilterTemplateList"
:key="item.name"
:command="item"
>{{ item.name }}</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div
:style="{
display: 'grid',
gridTemplateColumns: '1fr 1em 1fr 1em 1fr',
gap: '5px',
width: '100%',
alignItems: 'end'
}"
>
<el-form-item mb0 prop="expectJobNameRegExpStr">
<div font-size-12px>职位名称正则不区分大小写</div>
<el-input
v-model="formContent.expectJobNameRegExpStr"
@blur="
formContent.expectJobNameRegExpStr =
formContent.expectJobNameRegExpStr?.trim() ?? ''
"
/>
</el-form-item>
<div mb10px font-size-12px flex flex-justify-center></div>
<el-form-item mb0 prop="expectJobTypeRegExpStr">
<div font-size-12px>职位类型正则不区分大小写</div>
<el-input
v-model="formContent.expectJobTypeRegExpStr"
@blur="
formContent.expectJobTypeRegExpStr =
formContent.expectJobTypeRegExpStr?.trim() ?? ''
"
/>
</el-form-item>
<div mb10px font-size-12px flex flex-justify-center></div>
<el-form-item mb0 prop="expectJobDescRegExpStr">
<div font-size-12px>职位描述正则不区分大小写</div>
<el-input
v-model="formContent.expectJobDescRegExpStr"
@blur="
formContent.expectJobDescRegExpStr =
formContent.expectJobDescRegExpStr?.trim() ?? ''
"
/>
</el-form-item>
</div>
</div>
<el-form-item
label="职位备选筛选条件当前求职期望无合适职位时自动更改Boss筛选条件查找新工作"
prop="filter"
mb0
>
<AnyCombineBossRecommendFilter v-model="formContent.anyCombineRecommendJobFilter" />
<div>
当前组合条件数{{ currentAnyCombineRecommendJobFilterCombinationCount.toLocaleString() }}
<span
v-if="currentAnyCombineRecommendJobFilterCombinationCount >= 20"
class="color-orange"
>不建议选择太多组合条件</span
>
</div>
</el-form-item>
<el-form-item class="last-form-item mb0">
<el-button @click="handleSave">仅保存配置</el-button>
<el-button type="primary" @click="handleSubmit"> 保存配置并开始求职 </el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ElForm, ElMessage } from 'element-plus'
import { QuestionFilled } from '@element-plus/icons-vue'
import { useRouter } from 'vue-router'
import AnyCombineBossRecommendFilter from '@renderer/features/AnyCombineBossRecommendFilter/index.vue'
import { calculateTotalCombinations } from '@geekgeekrun/geek-auto-start-chat-with-boss/combineCalculator.mjs'
import { gtagRenderer } from '@renderer/utils/gtag'
import defaultTargetCompanyListConf from '@geekgeekrun/geek-auto-start-chat-with-boss/default-config-file/target-company-list.json'
import { ArrowDown } from '@element-plus/icons-vue'
const router = useRouter()
const formContent = ref({
dingtalkRobotAccessToken: '',
expectCompanies: '',
anyCombineRecommendJobFilter: {},
expectJobNameRegExpStr: '',
expectJobTypeRegExpStr: '',
expectJobDescRegExpStr: ''
})
const currentAnyCombineRecommendJobFilterCombinationCount = computed(() => {
return calculateTotalCombinations(formContent.value.anyCombineRecommendJobFilter)
})
electron.ipcRenderer.invoke('fetch-config-file-content').then((res) => {
console.log(res)
formContent.value.dingtalkRobotAccessToken = res.config['dingtalk.json']['groupRobotAccessToken']
formContent.value.expectCompanies = res.config['target-company-list.json'].join(',')
formContent.value.anyCombineRecommendJobFilter = res.config['boss.json']
?.anyCombineRecommendJobFilter ?? {
salaryList: [],
experienceList: [],
degreeList: [],
scaleList: [],
industryList: []
}
//
if (
res.config['boss.json']?.expectJobRegExpStr &&
typeof res.config['boss.json']?.expectJobNameRegExpStr === 'undefined' &&
typeof res.config['boss.json']?.expectJobTypeRegExpStr === 'undefined' &&
typeof res.config['boss.json']?.expectJobDescRegExpStr === 'undefined'
) {
res.config['boss.json'].expectJobNameRegExpStr = res.config['boss.json'].expectJobRegExpStr
res.config['boss.json'].expectJobTypeRegExpStr = res.config['boss.json'].expectJobRegExpStr
res.config['boss.json'].expectJobDescRegExpStr = res.config['boss.json'].expectJobRegExpStr
}
formContent.value.expectJobNameRegExpStr = res.config['boss.json'].expectJobNameRegExpStr?.trim()
formContent.value.expectJobTypeRegExpStr = res.config['boss.json'].expectJobTypeRegExpStr?.trim()
formContent.value.expectJobDescRegExpStr = res.config['boss.json'].expectJobDescRegExpStr?.trim()
})
const formRules = {
expectJobNameRegExpStr: {
validator(_, value, cb) {
if (!value) {
cb()
gtagRenderer('empty_reg_exp_for_expect_job_name')
return
}
try {
new RegExp(value, 'ig')
gtagRenderer('valid_reg_exp_for_expect_job_name')
cb()
} catch (err) {
cb(new Error(`正则无效:${err?.message}`))
gtagRenderer('invalid_reg_exp_for_expect_job_name')
}
}
},
expectJobTypeRegExpStr: {
validator(_, value, cb) {
if (!value) {
cb()
gtagRenderer('empty_reg_exp_for_expect_job_type')
return
}
try {
new RegExp(value, 'ig')
gtagRenderer('valid_reg_exp_for_expect_job_type')
cb()
} catch (err) {
cb(new Error(`正则无效:${err?.message}`))
gtagRenderer('invalid_reg_exp_for_expect_job_type')
}
}
},
expectJobDescRegExpStr: {
validator(_, value, cb) {
if (!value) {
cb()
gtagRenderer('empty_reg_exp_for_expect_job_desc')
return
}
try {
new RegExp(value, 'ig')
gtagRenderer('valid_reg_exp_for_expect_job_desc')
cb()
} catch (err) {
cb(new Error(`正则无效:${err?.message}`))
gtagRenderer('invalid_reg_exp_for_expect_job_desc')
}
}
}
}
const formRef = ref<InstanceType<typeof ElForm>>()
const handleSubmit = async () => {
gtagRenderer('save_config_and_launch_clicked', {
has_dingtalk_robot_token: !!formContent.value?.dingtalkRobotAccessToken
})
formContent.value.expectJobRegExpStr = undefined
try {
await formRef.value!.validate()
} catch (err) {
ElMessage.error({
message: '表单校验失败,请检查有误的内容',
grouping: true
})
console.log(err)
return
}
await electron.ipcRenderer.invoke('save-config-file-from-ui', JSON.stringify(formContent.value))
router.replace({
path: '/geekAutoStartChatWithBoss/prepareRun',
query: { flow: 'geek-auto-start-chat-with-boss' }
})
gtagRenderer('config_saved_and_launch_auto_start_chat', {
has_dingtalk_robot_token: !!formContent.value?.dingtalkRobotAccessToken
})
}
const handleSave = async () => {
gtagRenderer('save_config_clicked', {
has_dingtalk_robot_token: !!formContent.value?.dingtalkRobotAccessToken
})
normalizeExpectCompanies()
try {
await formRef.value!.validate()
} catch (err) {
ElMessage.error({
message: '表单校验失败,请检查有误的内容',
grouping: true
})
console.log(err)
return
}
await electron.ipcRenderer.invoke('save-config-file-from-ui', JSON.stringify(formContent.value))
ElMessage.success('配置保存成功')
gtagRenderer('config_saved')
}
const normalizeExpectCompanies = () => {
formContent.value.expectCompanies = formContent.value.expectCompanies
.split(/,|/)
.map((it) => it.trim())
.filter(Boolean)
.join(',')
}
const handleClickLaunchLogin = () => {
gtagRenderer('launch_login_clicked')
router.replace('/cookieAssistant')
}
const expectCompanyTemplateList = [
{
name: '默认值',
value: defaultTargetCompanyListConf.join(',')
},
{
name: '不限公司(随便投)',
value: ''
},
{
name: '大厂及关联企业',
value: `抖音,字节,字跳,有竹居,脸萌,头条,懂车帝,滴滴,嘀嘀,小桔,网易,有道,腾讯,酷狗,酷我,阅文,搜狗,京东,沃东天骏,达达,达冠,百度,度小满,爱奇艺,携程,趣拿,去哪儿,集度,理想,蔚来,顺丰,讯飞,同程,艺龙,马蜂窝,贝壳,自如,链家,我爱我家,相寓,多点,金山,小米,猎豹,新浪,微博,阿里,蚂蚁,飞猪,乌鸫,饿了么,LAZADA,来赞达,飞猪,菜鸟,哈啰,钉钉,高德,美团,三快,猫眼,快手,映客,小红书,行吟,奇虎,360,三六零,鸿盈,奇富,奇元,亚信,启明星辰,奇安信,深信服,长亭,绿盟,天融信,商汤,SenseTime,大华,海康威视,hikvision,汽车之家,车好多,瓜子,易车,昆仑万维,昆仑天工,闲徕,趣加,FunPlus,完美,马上消费,轻松,水滴,白龙马,58,车欢欢,五八,红布林,致美,快狗,天鹅到家,转转,美餐,知乎,智者四海,易点云,搜狐,用友,畅捷通,猿辅导,小猿,猿力,好未来,学而思,希望学,新东方,东方甄选,东方优选,作业帮,高途,跟谁学,学科网,天学网,一起教育,一起作业,美术宝,火花思维,粉笔,老虎国际,一心向上,向上一意,联想,拉勾,乐视,欢聚,竞技世界,拼多多,寻梦,得物,Moka,希瑞亚斯,北森,OPPO,欧珀,vivo,维沃,小天才,步步高,读书郎,货拉拉,陌陌,探探,Shopee,首汽租车,神州租车,天眼查,旷视,小冰,美图,智谱华章,MiniMax,石头科技,迅雷,TP,希音,SHEIN,稀宇,深言,百川智能,与爱为舞,牵手`
},
{
name: '阿里系',
value: `阿里,蚂蚁,飞猪,乌鸫,饿了么,LAZADA,来赞达,菜鸟,哈啰,钉钉,高德,白龙马,新浪,微博`
},
{
name: '字节(头条/抖音)系',
value: `抖音,字节,字跳,有竹居,脸萌,头条,懂车帝`
},
{
name: '百度系',
value: `百度,度小满,爱奇艺,携程,趣拿,集度,作业帮`
},
{
name: '腾讯系',
value: `腾讯,酷狗,酷我,阅文,搜狗,京东,沃东天骏,达达,达冠,美团,三快,猫眼,快手,拼多多,寻梦,Shopee,滴滴,嘀嘀,小桔`
},
{
name: '外包、劳务派遣企业',
value: `青钱,软通动力,南天,睿服,中电金信,佰钧成,云链,博彦,汉克时代,柯莱特,拓保,亿达信息,纬创,微创,微澜,诚迈科技,法本,兆尹,诚迈,联合永道,新致软件,宇信科技,华为,德科,FESCO,科锐,科之锐`
}
]
function handleExpectCompanyTemplateClicked(item) {
gtagRenderer('expect_company_tpl_clicked', {
name: item.name
})
formContent.value.expectCompanies = item.value
}
const expectJobFilterTemplateList = [
{
name: '不限职位(随便投)',
config: {
expectJobNameRegExpStr: '',
expectJobTypeRegExpStr: '',
expectJobDescRegExpStr: ''
}
},
{
name: '研发 - 前端开发工程师',
config: {
expectJobNameRegExpStr: '前端|H5|FE',
expectJobTypeRegExpStr: '前端开发|javascript',
expectJobDescRegExpStr: '前端|vue|react|node|js|javascript|H5'
}
},
{
name: '研发 - Java',
config: {
expectJobNameRegExpStr: 'Java',
expectJobTypeRegExpStr: 'Java',
expectJobDescRegExpStr: 'JVM|Java|消息队列|MySQL|Nginx|Redis|Dubbo'
}
},
{
name: '人力 - 员工关系',
config: {
expectJobNameRegExpStr: '员工关系|劳动关系|SSC|人力资源|人资',
expectJobTypeRegExpStr: '员工关系|人力资源',
expectJobDescRegExpStr: '社保|考勤|入职|离职'
}
},
{
name: '人力 - 招聘',
config: {
expectJobNameRegExpStr: '招聘|招聘HR|招聘专员|招聘顾问|招聘专家|Recruiter|人力资源|人资',
expectJobTypeRegExpStr: '招聘|人力资源|猎头顾问',
expectJobDescRegExpStr: '简历筛选|面试安排|offer|猎头'
}
}
]
function handleExpectJobFilterTemplateClicked(item) {
gtagRenderer('expect_job_filter_tpl_clicked', {
name: item.name
})
Object.assign(formContent.value, {
...item.config
})
}
</script>
<style scoped lang="scss">
.form-wrap {
margin: 0 auto;
max-width: 1000px;
max-height: 100vh;
overflow: auto;
padding-left: 20px;
padding-right: 20px;
:deep(.el-form) {
padding-top: 8px;
}
.last-form-item {
:deep(.el-form-item__content) {
margin-top: 0px;
justify-content: flex-end;
}
}
}
</style>
<style lang="scss">
.form-wrap.geek-auto-start-run-with-boss {
.el-form-item__error.el-form-item__error {
font-size: 12px;
line-height: 1.2em;
}
}
</style>

View File

@@ -55,7 +55,17 @@
</div>
<div class="flex flex-0 flex-justify-between pt10px pb10px">
<div class="w100px">
<el-button :loading="isTableLoading" size="small" @click="getJobLibrary">刷新</el-button>
<el-button
:loading="isTableLoading"
size="small"
@click="
() => {
gtagRenderer('job_library_refresh_clicked')
getJobLibrary()
}
"
>刷新</el-button
>
</div>
<ElPagination
v-model:current-page="pagination.pageNo"
@@ -74,7 +84,13 @@
<JobInfoSnapshot
v-if="selectedJobInfoForViewSnapshot"
:job-info="selectedJobInfoForViewSnapshot"
@closed="selectedJobInfoForViewSnapshot = null"
scene="jobLibrary"
@closed="
() => {
gtagRenderer('job_info_snapshot_closed')
selectedJobInfoForViewSnapshot = null
}
"
/>
</ElDrawer>
<ElDialog
@@ -92,6 +108,7 @@
:job-info-history-list="selectedJobHistory ?? []"
@closed="
() => {
gtagRenderer('job_library_list_dialog_closed')
selectedJobInfoForViewHistory = null
selectedJobHistory = null
}
@@ -109,6 +126,7 @@ import { type JobInfoChangeLog } from '@geekgeekrun/sqlite-plugin/src/entity/Job
import { PageReq, PagedRes } from '../../../../common/types/pagination'
import JobInfoSnapshot from '../../features/JobInfoSnapshot/index.vue'
import JobInfoHistoryList from '../../features/JobInfoHistoryList/index.vue'
import { gtagRenderer } from '@renderer/utils/gtag'
const tableData = ref<VChatStartupLog[]>([])
const pageSizeList = ref<number[]>([100, 200, 300, 400])
@@ -121,6 +139,10 @@ const tableRef = ref<InstanceType<typeof ElTable>>()
const isTableLoading = ref(false)
async function getJobLibrary() {
try {
gtagRenderer('job_library_request_sent', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
isTableLoading.value = true
const { data: res } = (await electron.ipcRenderer.invoke('get-job-library', {
pageNo: pagination.value.pageNo,
@@ -132,7 +154,16 @@ async function getJobLibrary() {
pageNo: res.pageNo,
pageSize: pagination.value.pageSize
}
gtagRenderer('job_library_request_success', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
} catch (err) {
gtagRenderer('job_library_request_error', {
err,
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
console.log(err)
tableData.value = []
} finally {
@@ -160,10 +191,12 @@ const drawVisibleModelValue = ref(false)
const selectedJobInfoForViewSnapshot = ref<VChatStartupLog | null>(null)
function handleViewJobSnapshotButtonClick(record: VChatStartupLog) {
gtagRenderer('view_job_snapshot_button_clicked')
selectedJobInfoForViewSnapshot.value = record
drawVisibleModelValue.value = true
}
async function handleViewJobOnlineButtonClick(encryptJobId: string) {
gtagRenderer('view_job_online_button_clicked')
return await electron.ipcRenderer.invoke('open-site-with-boss-cookie', {
url: `https://www.zhipin.com/job_detail/${encryptJobId}.html`
})
@@ -173,6 +206,7 @@ const historyDialogVisibleModelValue = ref(false)
const selectedJobInfoForViewHistory = ref<VChatStartupLog | null>(null)
const selectedJobHistory = ref<null | JobInfoChangeLog[]>(null)
async function handleViewJobHistoryButtonClick(record: VChatStartupLog) {
gtagRenderer('view_job_history_button_clicked')
let { data: historyList } = await electron.ipcRenderer.invoke(
'get-job-history-by-encrypt-id',
record.encryptJobId
@@ -227,12 +261,13 @@ async function handleViewJobHistoryButtonClick(record: VChatStartupLog) {
// })
if (!historyList.length) {
gtagRenderer('job_history_is_not_found')
ElMessage.warning({
message: '未找到与此条目相关的历史变更记录,再多投一投吧'
})
return
}
gtagRenderer('job_history_is_found')
historyDialogVisibleModelValue.value = true
selectedJobInfoForViewHistory.value = record
selectedJobHistory.value = historyList

View File

@@ -72,7 +72,15 @@
</div>
<div class="flex flex-0 flex-justify-between pt10px pb10px">
<div class="w100px">
<el-button :loading="isTableLoading" size="small" @click="getMarkAsNotSuitRecord"
<el-button
:loading="isTableLoading"
size="small"
@click="
() => {
gtagRenderer('mansr_refresh_clicked')
getMarkAsNotSuitRecord()
}
"
>刷新</el-button
>
</div>
@@ -93,7 +101,13 @@
<JobInfoSnapshot
v-if="selectedJobInfoForViewSnapshot"
:job-info="selectedJobInfoForViewSnapshot"
@closed="selectedJobInfoForViewSnapshot = null"
scene="markAsNotSuitRecord"
@closed="
() => {
gtagRenderer('mansr_closed')
selectedJobInfoForViewSnapshot = null
}
"
/>
</ElDrawer>
</div>
@@ -107,6 +121,7 @@ import { PageReq, PagedRes } from '../../../../common/types/pagination'
import JobInfoSnapshot from '../../features/JobInfoSnapshot/index.vue'
import { MarkAsNotSuitReason } from '@geekgeekrun/sqlite-plugin/src/enums'
import { transformUtcDateToLocalDate } from '@geekgeekrun/utils/date.mjs'
import { gtagRenderer } from '@renderer/utils/gtag'
const tableData = ref<VMarkAsNotSuitLog[]>([])
const pageSizeList = ref<number[]>([100, 200, 300, 400])
@@ -122,6 +137,10 @@ const tableRef = ref<InstanceType<typeof ElTable>>()
const isTableLoading = ref(false)
async function getMarkAsNotSuitRecord() {
try {
gtagRenderer('mansr_request_sent', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
isTableLoading.value = true
const { data: res } = (await electron.ipcRenderer.invoke('get-mark-as-not-suit-record', {
pageNo: pagination.value.pageNo,
@@ -133,7 +152,16 @@ async function getMarkAsNotSuitRecord() {
pageNo: res.pageNo,
pageSize: pagination.value.pageSize
}
gtagRenderer('mansr_request_success', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
} catch (err) {
gtagRenderer('mansr_request_error', {
err,
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
console.log(err)
tableData.value = []
} finally {
@@ -158,6 +186,7 @@ onMounted(() => {
})
async function handleViewJobOnlineButtonClick(encryptJobId: string) {
gtagRenderer('view_job_online_button_clicked')
return await electron.ipcRenderer.invoke('open-site-with-boss-cookie', {
url: `https://www.zhipin.com/job_detail/${encryptJobId}.html`
})
@@ -167,6 +196,7 @@ const drawVisibleModelValue = ref(false)
const selectedJobInfoForViewSnapshot = ref<VMarkAsNotSuitLog | null>(null)
function handleViewJobSnapshotButtonClick(record: VMarkAsNotSuitLog) {
gtagRenderer('view_job_snapshot_button_clicked')
selectedJobInfoForViewSnapshot.value = record
drawVisibleModelValue.value = true
}

View File

@@ -0,0 +1,500 @@
<template>
<div class="form-wrap">
<el-form
ref="formRef"
:rules="formRules"
:model="formContent.autoReminder"
label-position="top"
>
<el-form-item label="BOSS直聘 Cookie">
<el-button size="small" type="primary" @click="handleClickLaunchLogin"
>编辑Cookie</el-button
>
</el-form-item>
<el-form-item class="mb0" label="跟进话术 - 当发现已读不回的Boss时将要向Boss发出">
<el-radio-group v-model="formContent.autoReminder.rechatContentSource">
<div>
<el-tooltip
effect="light"
placement="right"
:enterable="false"
@show="gtagRenderer('tooltip_show_about_lfr_emotion_figure')"
>
<template #content>
<img block h-100px src="./resources/look-forward-reply-emotion.gif" />
</template>
<el-radio :label="RECHAT_CONTENT_SOURCE.LOOK_FORWARD_EMOTION">
[盼回复] 表情
</el-radio>
</el-tooltip>
<br />
<el-radio :label="RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT">
由大语言模型根据简历及当前聊天上下文生成的内容
</el-radio>
</div>
</el-radio-group>
</el-form-item>
<div class="ml-30px">
<template
v-if="
formContent.autoReminder.rechatContentSource ===
RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT
"
>
<el-form-item class="mb4px">
<div>
<el-button size="small" type="primary" @click="handleClickConfigLlm">
配置大语言模型
</el-button>
<div class="font-size-12px color-#666">
支持
<span
class="pl6px pr6px pt4px pb2px color-white border-rd-full font-size-0.8em"
style="background-color: #3c4efd"
>DeepSeek-V3</span
>
<span
class="ml4px pl6px pr6px pt4px pb2px color-white border-rd-full font-size-0.8em"
style="background-color: #000000"
>GPT-4o mini</span
>
<span
class="ml4px pl6px pr6px pt4px pb2px color-white border-rd-full font-size-0.8em"
style="background-color: #462ac4"
>Qwen2.5</span
>
模型;支持多个“服务商-模型”组合按权重搭配使用
</div>
</div>
</el-form-item>
<el-form-item class="mb4px">
<div>
<el-button size="small" type="primary" @click="handleClickEditResume">
编辑简历
</el-button>
<div class="font-size-12px color-#666">
简历内容将提交给大语言模型,以用于生成已读不回提醒消息;提交内容及生成消息中不会包含期望薪资
</div>
</div>
</el-form-item>
<el-form-item class="mb4px">
<div>
<div>
<el-button size="small" type="primary" @click="handleClickEditPrompt">
使用外部编辑器编辑提示词模板 (Markdown)
</el-button>
<el-button
size="small"
type="primary"
@click="
() => {
gtagRenderer('reset_template_clicked_in_main_form')
restoreDefaultTemplate()
}
"
>
还原默认提示词模板
</el-button>
</div>
<div class="font-size-12px color-#666">
对生成效果不够满意?可在此查看、编辑提示词模板。请在模板中需要插入简历的位置插入
__REPLACE_REAL_RESUME_HERE__
</div>
</div>
</el-form-item>
<el-form-item prop="recentMessageQuantityForLlm">
<div>
携带最近
<el-input-number
v-model="formContent.autoReminder.recentMessageQuantityForLlm"
class="w-120px"
:min="8"
:max="20"
:precision="0"
:step="1"
></el-input-number>
次聊天内容作为上下文生成新消息
</div>
</el-form-item>
<el-form-item>
<el-button size="small" type="primary" @click="handleTestEffectClicked"
>使用当前配置模拟已读不回复聊过程</el-button
>
</el-form-item>
<el-form-item prop="recentMessageQuantityForLlm">
<div class="flex flex-items-center">
<span class="whitespace-nowrap">当所有模型均不可使用时&nbsp;</span>
<el-select
v-model="formContent.autoReminder.rechatLlmFallback"
class="w200px"
label="name"
>
<el-option
v-for="option in rechatLlmFallbackOptions"
:key="option.value"
:value="option.value"
:label="option.name"
/>
</el-select>
</div>
</el-form-item>
</template>
</div>
<el-form-item label="跟进间隔(分钟)" prop="throttleIntervalMinutes">
<el-input-number
v-model="formContent.autoReminder.throttleIntervalMinutes"
class="w-150px"
:min="3"
:precision="1"
:step="0.5"
@blur="handleThrottleIntervalMinutesBlur"
/>&nbsp;分钟内不多次跟进同一Boss
</el-form-item>
<el-form-item label="跟进时限(天)" prop="rechatLimitDay">
<div>
<div><el-checkbox v-model="enableRechatLimit" />&nbsp;启用</div>
<el-input-number
v-model="formContent.autoReminder.rechatLimitDay"
class="w-150px"
:min="0"
:precision="1"
:step="0.5"
:disabled="!enableRechatLimit"
/>&nbsp;天<br />
<div v-if="enableRechatLimit">
不再跟进&nbsp;<span class="text-orange">{{ rechatLimitDateString }}</span
>)之前列表中没有进展的聊天
</div>
<div v-else>这将会跟进列表中所有聊天(<span class="text-orange">不建议</span></div>
</div>
</el-form-item>
<el-form-item class="last-form-item">
<el-button type="primary" @click="handleSubmit">开始提醒</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { dayjs, ElForm, ElMessage, ElMessageBox, ElSelect, ElOption } from 'element-plus'
import { useRouter } from 'vue-router'
import {
RECHAT_CONTENT_SOURCE,
RECHAT_LLM_FALLBACK
} from '../../../../common/enums/auto-start-chat'
import { gtagRenderer } from '@renderer/utils/gtag'
const router = useRouter()
const formContent = ref({
autoReminder: {
throttleIntervalMinutes: 10,
rechatLimitDay: 21,
rechatContentSource: 1,
recentMessageQuantityForLlm: 8,
rechatLlmFallback: RECHAT_LLM_FALLBACK.SEND_LOOK_FORWARD_EMOTION
}
})
const enableRechatLimit = computed({
get() {
return Boolean(formContent.value.autoReminder?.rechatLimitDay)
},
set(val) {
if (!val) {
gtagRenderer('rechat_limit_disabled')
formContent.value.autoReminder.rechatLimitDay = 0
} else {
gtagRenderer('rechat_limit_enabled')
formContent.value.autoReminder.rechatLimitDay = 21
}
}
})
electron.ipcRenderer.invoke('fetch-config-file-content').then((res) => {
const conf = res.config['boss.json']?.autoReminder || {}
conf.throttleIntervalMinutes = conf.throttleIntervalMinutes ?? 10
conf.rechatLimitDay = conf.rechatLimitDay ?? 21
conf.rechatContentSource = conf.rechatContentSource ?? 1
conf.recentMessageQuantityForLlm =
typeof conf.recentMessageQuantityForLlm === 'number'
? conf.recentMessageQuantityForLlm > 20
? 20
: conf.recentMessageQuantityForLlm < 8
? 8
: parseInt(conf.recentMessageQuantityForLlm)
: 8
formContent.value.autoReminder = conf
})
const formRules = {
throttleIntervalMinutes: {
validator(_, value, cb) {
if (/[^0-9.]/.test(String(value)) || isNaN(parseFloat(value)) || isNaN(Number(value))) {
cb(new Error(`请输入数字!`))
} else {
cb()
}
}
},
rechatLimitDay: {
validator(_, value, cb) {
if (/[^0-9.]/.test(String(value)) || isNaN(parseFloat(value)) || isNaN(Number(value))) {
cb(new Error(`请输入数字!`))
} else {
cb()
}
}
}
}
const formRef = ref<InstanceType<typeof ElForm>>()
watch(
() => formContent.value.autoReminder,
() => {
nextTick(() => {
formRef.value?.validate?.()
})
},
{
immediate: true
}
)
async function checkIsCanRun() {
if (!(await electron.ipcRenderer.invoke('check-is-resume-content-valid'))) {
gtagRenderer('cannot_launch_for_invalid_rc_dialog_show')
try {
await ElMessageBox.confirm(`简历内容无效;您需要编辑一下您的简历`, {
cancelButtonText: '取消',
confirmButtonText: '好的,去编辑我的简历',
dangerouslyUseHTMLString: true
})
gtagRenderer('invalid_rc_dialog_click_confirm')
try {
await electron.ipcRenderer.invoke('resume-edit')
} catch (err) {
console.log(err)
}
} catch {
gtagRenderer('invalid_rc_dialog_click_cancel')
}
return false
}
try {
await electron.ipcRenderer.invoke('check-if-llm-config-list-valid')
} catch (err) {
if (err?.message?.includes(`CANNOT_FIND_VALID_CONFIG`)) {
gtagRenderer('cannot_launch_for_invalid_llm_config')
console.log(`大模型配置无效`, err)
ElMessageBox.confirm(
'大模型配置不存在或者包含无效配置<br />您是否希望查看并修正当前大模型配置?',
'',
{
confirmButtonText: '是',
cancelButtonText: '否',
type: 'warning',
closeOnClickModal: false,
dangerouslyUseHTMLString: true
}
)
.then(async () => {
gtagRenderer('invalid_llm_config_tip_dialog_confirm')
try {
await electron.ipcRenderer.invoke('llm-config')
} catch (err) {
console.log(err)
}
})
.catch(() => {
gtagRenderer('invalid_llm_config_tip_dialog_cancel')
})
} else {
gtagRenderer('cannot_launch_for_check_llm_config_error', { err })
ElMessage({
type: 'error',
message: '大模型配置检查未通过,请重试'
})
}
return false
}
try {
await electron.ipcRenderer.invoke('check-if-auto-remind-prompt-valid')
} catch (err) {
if (err?.message?.includes(`RESUME_PLACEHOLDER_NOT_EXIST`)) {
gtagRenderer('cannot_launch_for_no_resume_placehold')
console.log(`提示词模板无效`, err)
ElMessageBox.confirm(
'提示词模板缺少简历内容占位符:<br /><b>__REPLACE_REAL_RESUME_HERE__</b><br /><br />您是否希望还原默认的提示词模板?',
'',
{
confirmButtonText: '是',
cancelButtonText: '否',
type: 'warning',
closeOnClickModal: false,
dangerouslyUseHTMLString: true
}
)
.then(async () => {
gtagRenderer('confirm_invalid_rt_tip_dialog')
await restoreDefaultTemplate()
})
.catch(() => {
gtagRenderer('close_invalid_rt_tip_dialog')
})
} else {
gtagRenderer('cannot_launch_for_check_prompt_error', { err })
ElMessage({
type: 'error',
message: '用于生成自动提醒消息的提示词检查未通过,请重试'
})
}
return false
}
return true
}
const handleSubmit = async () => {
gtagRenderer('run_read_no_reply_reminder_clicked', {
throttle_interval_minutes: formContent.value.autoReminder.throttleIntervalMinutes,
rechat_limit_day: formContent.value.autoReminder.rechatLimitDay,
rechat_content_source: formContent.value.autoReminder.rechatContentSource,
recent_message_quantity_for_llm: formContent.value.autoReminder.recentMessageQuantityForLlm
})
await formRef.value!.validate()
await electron.ipcRenderer.invoke('save-config-file-from-ui', JSON.stringify(formContent.value))
gtagRenderer('config_saved')
if (
formContent.value.autoReminder?.rechatContentSource ===
RECHAT_CONTENT_SOURCE.GEMINI_WITH_CHAT_CONTEXT
) {
if (!(await checkIsCanRun())) {
return
}
if (!(await electron.ipcRenderer.invoke('resume-content-enough-detect'))) {
gtagRenderer('rc_not_enough_dialog_show')
try {
await ElMessageBox.confirm(
`简历内容可能不够充足(各个部分内容长度相加 <800 字)<br />后续大模型根据简历生成的内容将可能不符合预期(例如相同内容重复生成、生成预期之外的内容)<br /><br />要继续运行吗?`,
{
cancelButtonText: '不,我再看看',
confirmButtonText: '是的,继续运行',
dangerouslyUseHTMLString: true
}
)
gtagRenderer('rc_not_enough_dialog_click_confirm')
} catch {
gtagRenderer('rc_not_enough_dialog_click_cancel')
return
}
}
}
gtagRenderer('run_read_no_reply_reminder_launched')
router.replace({
path: '/geekAutoStartChatWithBoss/prepareRun',
query: { flow: 'read-no-reply-reminder' }
})
}
function handleThrottleIntervalMinutesBlur() {
if (formContent.value.autoReminder.throttleIntervalMinutes < 3) {
formContent.value.autoReminder.throttleIntervalMinutes = 3
}
formContent.value.autoReminder.throttleIntervalMinutes = Number(
formContent.value.autoReminder.throttleIntervalMinutes
)
}
const restoreDefaultTemplate = async () => {
await electron.ipcRenderer.invoke('overwrite-auto-remind-prompt-with-default')
ElMessage({
type: 'success',
message: '模板还原成功'
})
}
const handleClickLaunchLogin = () => {
gtagRenderer('launch_login_clicked')
router.replace('/cookieAssistant')
}
const currentStamp = ref(new Date())
let timer = 0
function updateCurrentStamp() {
currentStamp.value = new Date()
timer = window.setTimeout(updateCurrentStamp, 1000)
}
updateCurrentStamp()
onUnmounted(() => {
window.clearTimeout(timer)
})
const rechatLimitDateString = computed(() => {
return dayjs(
+currentStamp.value - formContent.value.autoReminder.rechatLimitDay * 24 * 60 * 60 * 1000
).format('YYYY-MM-DD HH:mm:ss')
})
const handleClickConfigLlm = async () => {
gtagRenderer('config_llm_clicked')
try {
await electron.ipcRenderer.invoke('llm-config')
} catch (err) {
console.log(err)
}
}
const handleClickEditResume = async () => {
gtagRenderer('edit_resume_clicked')
try {
await electron.ipcRenderer.invoke('resume-edit')
} catch (err) {
console.log(err)
}
}
const handleClickEditPrompt = async () => {
gtagRenderer('edit_prompt_clicked')
await electron.ipcRenderer.send('no-reply-reminder-prompt-edit')
}
const rechatLlmFallbackOptions = [
{
name: '发送“[盼回复]”表情',
value: RECHAT_LLM_FALLBACK.SEND_LOOK_FORWARD_EMOTION
},
{
name: '退出已读不回提醒器',
value: RECHAT_LLM_FALLBACK.EXIT_REMINDER_PROGRAM
}
]
async function handleTestEffectClicked() {
if (!(await checkIsCanRun())) {
return
}
electron.ipcRenderer.send('test-llm-config-effect', {
autoReminderConfig: JSON.parse(JSON.stringify(formContent.value.autoReminder))
})
}
</script>
<style scoped lang="scss">
.form-wrap {
margin: 0 auto;
max-width: 1000px;
max-height: 100vh;
overflow: auto;
padding-left: 20px;
padding-right: 20px;
:deep(.el-form) {
padding-top: 8px;
}
.last-form-item {
:deep(.el-form-item__content) {
margin-top: 0px;
justify-content: flex-end;
}
}
}
</style>

View File

@@ -55,7 +55,15 @@
</div>
<div class="flex flex-0 flex-justify-between pt10px pb10px">
<div class="w100px">
<el-button :loading="isTableLoading" size="small" @click="getAutoStartChatRecord"
<el-button
:loading="isTableLoading"
size="small"
@click="
() => {
gtagRenderer('start_chat_record_refresh_clicked')
getAutoStartChatRecord()
}
"
>刷新</el-button
>
</div>
@@ -76,7 +84,13 @@
<JobInfoSnapshot
v-if="selectedJobInfoForViewSnapshot"
:job-info="selectedJobInfoForViewSnapshot"
@closed="selectedJobInfoForViewSnapshot = null"
scene="startChatRecord"
@closed="
() => {
gtagRenderer('start_chat_record_closed')
selectedJobInfoForViewSnapshot = null
}
"
/>
</ElDrawer>
</div>
@@ -89,6 +103,7 @@ import { type VChatStartupLog } from '@geekgeekrun/sqlite-plugin/src/entity/VCha
import { transformUtcDateToLocalDate } from '@geekgeekrun/utils/date.mjs'
import { PageReq, PagedRes } from '../../../../common/types/pagination'
import JobInfoSnapshot from '../../features/JobInfoSnapshot/index.vue'
import { gtagRenderer } from '@renderer/utils/gtag'
const tableData = ref<VChatStartupLog[]>([])
const pageSizeList = ref<number[]>([100, 200, 300, 400])
@@ -104,6 +119,10 @@ const tableRef = ref<InstanceType<typeof ElTable>>()
const isTableLoading = ref(false)
async function getAutoStartChatRecord() {
try {
gtagRenderer('start_chat_record_request_sent', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
isTableLoading.value = true
const { data: res } = (await electron.ipcRenderer.invoke('get-auto-start-chat-record', {
pageNo: pagination.value.pageNo,
@@ -115,7 +134,16 @@ async function getAutoStartChatRecord() {
pageNo: res.pageNo,
pageSize: pagination.value.pageSize
}
gtagRenderer('start_chat_record_request_success', {
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
} catch (err) {
gtagRenderer('start_chat_record_request_error', {
err,
page_no: pagination.value.pageNo,
page_size: pagination.value.pageSize,
})
console.log(err)
tableData.value = []
} finally {

View File

@@ -0,0 +1,215 @@
<template>
<div class="flex h100vh">
<div class="flex flex-col w200px pt30px pl30px aside-nav of-hidden">
<div class="nav-list flex-1 of-auto">
<RouterLink to="./GeekAutoStartChatWithBoss">
Boss炸弹
<el-tooltip
placement="right"
:enterable="false"
@show="gtagRenderer('tooltip_show_for_nav_boss_b_entry')"
>
<template #content>
<div w-480px>
<div>扩列神器按照你所设置的规则自动开聊推荐职位列表中的匹配的Boss</div>
<br />
<div>匹配步骤</div>
<ol m0 pl2em>
<li>
按照公司名称查找职位查找到目标职位后自动点击这个职位右侧将会展示职位详情
</li>
<li>
检查Boss活跃度
<ul pl2em>
<li>
如果Boss活跃度为本月活跃或更往前的时间则会把职位标记为不合适一段时间内你将不会在Boss上看到这个职位且将会推荐新职位
</li>
</ul>
</li>
<li>
对职位名称职位类型职位描述进行匹配
<ul pl2em>
<li>如果匹配则自动点击开聊按钮</li>
<li>
不匹配则标记这个职位为不合适一段时间内你将不会在Boss上看到这个职位且将会推荐新职位
</li>
</ul>
</li>
</ol>
<br />
<div>异常情况</div>
<ol m0 pl2em>
<li>
当前页面筛选条件下如果没有更多职位则自动切换备选筛选条件以获取更多新职位
</li>
<li>
如当天开聊次数用完本程序会暂停运行60分钟之后尝试继续重新运行如重新运行时间已在第二天则将会继续开聊
</li>
</ol>
</div>
</template>
<QuestionFilled w-1em h-1em mr10px />
</el-tooltip>
</RouterLink>
<RouterLink to="./ReadNoReplyReminder">
已读不回提醒器
<el-tooltip
placement="right"
:enterable="false"
@show="gtagRenderer('tooltip_show_for_rnrr_entry')"
>
<template #content>
<div w-480px>
<div>
Boss不明原因已读不回简历就是投不出去<br />
已读不回提醒器有事没事提醒一下已读不回的 Ta助力把握每次机会
</div>
<br />
<div>匹配逻辑</div>
<div>在聊天列表中查找对你消息已读不回的Boss再发一条消息多次复聊同时</div>
<ul m0 pl2em>
<li>如果设置了跟进时限那么在这个时间之前活跃的聊天将不会被检查</li>
<li>
如果设置了跟进间隔且再次检查时发现Boss已读不回且距离上次提醒时间间隔小于这个时间那么聊天将暂时不会跟进直到下次检查时距离上次提醒时间间隔大于这个时间
</li>
</ul>
<br />
<div>发送内容</div>
<ul m0 pl2em>
<li>[盼回复]表情</li>
<li>由大语言模型根据简历及当前聊天上下文生成的内容</li>
</ul>
</div>
</template>
<QuestionFilled w-1em h-1em mr10px />
</el-tooltip>
</RouterLink>
<hr w180px />
<a href="javascript:void(0)" @click="handleLaunchBossSite">
手动逛Boss<TopRight w-1em h-1em mr10px />
</a>
<hr w180px />
<RouterLink to="./StartChatRecord">开聊记录</RouterLink>
<RouterLink to="./MarkAsNotSuitRecord">标记不合适记录</RouterLink>
<RouterLink to="./JobLibrary">职位库</RouterLink>
<RouterLink to="./BossLibrary">Boss库</RouterLink>
<RouterLink to="./CompanyLibrary">公司库</RouterLink>
</div>
<div class="pt-16px pb-16px flex-0 font-size-12px">
<div>当前版本: {{ buildInfo.version }}({{ buildInfo.buildVersion }})</div>
<div class="feedback-area flex flex-items-center mt-8px">
<el-button type="text" size="small" @click="handleGotoProjectPageClick"
>项目首页</el-button
>
|
<el-button type="text" size="small" @click="handleFeedbackClick">反馈问题</el-button>
</div>
</div>
</div>
<RouterView v-slot="{ Component }" class="flex-1">
<KeepAlive>
<component :is="Component" />
</KeepAlive>
</RouterView>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { TopRight, QuestionFilled } from '@element-plus/icons-vue'
import useBuildInfo from '@renderer/hooks/useBuildInfo'
import { debounce } from 'lodash-es'
import { gtagRenderer } from '@renderer/utils/gtag'
const router = useRouter()
const unmountedCbs: Array<InstanceType<typeof Function>> = []
onUnmounted(() => {
while (unmountedCbs.length) {
const fn = unmountedCbs.shift()!
try {
fn()
} catch {}
}
})
const goToCheckBossZhipinCookieFile = () => router.replace('/cookieAssistant')
onMounted(() => {
electron.ipcRenderer.on('check-boss-zhipin-cookie-file', goToCheckBossZhipinCookieFile)
})
onUnmounted(() => {
electron.ipcRenderer.removeListener(
'check-boss-zhipin-cookie-file',
goToCheckBossZhipinCookieFile
)
})
;(async () => {
const checkDependenciesResult = await electron.ipcRenderer.invoke('check-dependencies')
if (Object.values(checkDependenciesResult).includes(false)) {
router.replace('/')
return
}
const isCookieFileValid = await electron.ipcRenderer.invoke('check-boss-zhipin-cookie-file')
if (!isCookieFileValid) {
router.replace('/cookieAssistant')
return
}
})()
const { buildInfo } = useBuildInfo()
const handleFeedbackClick = () => {
gtagRenderer('goto_feedback_clicked')
electron.ipcRenderer.send('send-feed-back-to-github-issue')
}
const handleGotoProjectPageClick = () => {
gtagRenderer('goto_project_github_clicked')
electron.ipcRenderer.send('open-external-link', 'https://github.com/geekgeekrun/geekgeekrun')
}
const handleLaunchBossSite = debounce(
async () => {
gtagRenderer('launch_boss_site_clicked')
return await electron.ipcRenderer.invoke('open-site-with-boss-cookie', {
url: `https://www.zhipin.com/`
})
},
1000,
{ leading: true, trailing: false }
)
</script>
<style lang="scss" scoped>
.aside-nav {
background-image: linear-gradient(45deg, #eaf4f1, #dcf6f2);
.nav-list {
> a {
display: flex;
align-items: center;
justify-content: space-between;
height: 2.5em;
box-sizing: border-box;
padding-left: 2em;
&.router-link-active {
background-color: #fff;
font-weight: 700;
color: #2faa9e;
border-radius: 9999px 0 0 9999px;
}
}
> hr {
border: 0 solid;
height: 1px;
background-color: #b3c8c3;
margin-top: 0;
margin-bottom: 0;
margin-right: 0;
}
}
.feedback-area {
:deep(.el-button) {
height: fit-content;
padding: 0;
margin-left: 0;
}
}
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -0,0 +1,256 @@
<template>
<div class="h100vh flex flex-col">
<div
ref="scrollElRef"
:style="{
display: 'flex',
flexDirection: 'column',
flex: 1,
overflow: `auto`,
margin: `0 auto`,
alignItems: `flex-end`,
width: '100%'
}"
>
<div
v-if="messageList.length"
:style="{
width: '480px',
margin: '0 auto'
}"
>
<div class="pb20px"></div>
<div v-for="(item, index) in messageList" :key="index" flex flex-col flex-items-end>
<div class="message-item-wrap flex flex-col">
<div
class="message-item"
:class="{
'will-enter-context': getIsEnterContent(index)
}"
>
{{ item.text }}
</div>
<div
:style="{
width: 'fit-content',
alignSelf: 'flex-end'
}"
font-size-10px
>
{{ item.usedLlmConfig.model }}
</div>
<div
v-if="item?.usedLlmConfig?.providerCompleteApiUrl?.trim()"
:style="{
width: 'fit-content',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
alignSelf: 'flex-end',
color: '#bbb'
}"
font-size-10px
w-fit-content
max-w-20em
>
{{ item.usedLlmConfig.providerCompleteApiUrl }}
</div>
</div>
</div>
<div class="pb20px"></div>
</div>
<div v-else w-full h-full flex flex-item-center justify-center>
<el-empty>
<template #description>
<template v-if="!isLoading">
点击下方 <el-button
font-size-16px
h-fit-content
align-baseline
p0
type="text"
@click.prevent="sendLlmGeneratedContent"
>发送开场白</el-button
> 以开始模拟聊天
</template>
<template v-else>请稍候第一条消息正在回复的路上~</template>
</template>
</el-empty>
</div>
</div>
<div
:style="{
display: 'grid',
gridTemplateColumns: 'min-content 1fr min-content',
height: `fit-content`,
paddingTop: `10px`,
paddingBottom: `10px`,
backgroundColor: `#f0f0f0`
}"
>
<el-select v-model="selectedLlmConfig" ml10px w160px placeholder="随机使用一个模型">
<el-option
v-for="(it, index) in llmConfigListForRender"
:key="index"
:value="it.id"
:label="it.model"
:disabled="!it.enabled"
:style="{
paddingTop: '10px',
paddingBottom: '10px',
height: 'auto',
lineHeight: '1.25em'
}"
>
<div
:style="{
display: 'flex',
justifyContent: 'space-between'
}"
>
<div>{{ it.model }}</div>
<div class="font-size-12px color-#bbb">
{{ formatApiSecret(it.providerApiSecret) || '' }}
</div>
</div>
<div
v-if="it?.providerCompleteApiUrl?.trim?.()"
:style="{
color: '#bbb',
width: '35em',
fontSize: '12px',
overflow: 'hidden',
textOverflow: 'ellipsis'
}"
>
{{ it.providerCompleteApiUrl }}
</div>
</el-option>
</el-select>
<el-button
:loading="isLoading"
width-fit-content
type="primary"
@click="sendLlmGeneratedContent"
>
<template v-if="isLoading">正在生成消息请稍候...</template>
<template v-else-if="!messageList.length">发送开场白</template>
<template v-else>发送下一句提醒内容</template>
</el-button>
<el-button mr10px type="text" @click="closeWindow">关闭对话框</el-button>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { sleep } from '@geekgeekrun/utils/sleep.mjs'
import { ElMessage } from 'element-plus'
type MessageItem = {
text: string
usedLlmConfig: string
// recordInfo: any
}
const messageList = ref<MessageItem[]>([])
const recentMessageQuantityForLlm =
Number(new URL(location.href).searchParams.get('recentMessageQuantityForLlm')) || 8
function getIsEnterContent(index) {
return messageList.value.length - index - 1 < recentMessageQuantityForLlm
}
const llmConfigList = ref([])
const llmConfigListForRender = computed(() => {
return [
{
id: null,
model: '随机使用一个模型',
providerCompleteApiUrl: null,
enabled: true
},
...(llmConfigList.value ?? [])
]
})
async function getLlmConfigList() {
llmConfigList.value = await electron.ipcRenderer.invoke('get-llm-config-for-test')
}
getLlmConfigList().catch(() => {})
const selectedLlmConfig = ref(null)
const scrollElRef = ref(null)
const isLoading = ref(false)
async function sendLlmGeneratedContent() {
isLoading.value = true
try {
const response = await electron.ipcRenderer.invoke('request-llm-for-test', {
messageList: JSON.parse(JSON.stringify((messageList.value ?? []).slice(-8))),
llmConfigIdForPick: selectedLlmConfig.value ? [selectedLlmConfig.value] : null
})
console.log(response)
messageList.value.push({
text: response.responseText,
usedLlmConfig: response.usedLlmConfig
})
await sleep(50)
;(scrollElRef.value as any as HTMLDivElement)?.scrollTo({
top: scrollElRef.value?.scrollHeight,
behavior: 'smooth'
})
} catch (err) {
ElMessage.error({
dangerouslyUseHTMLString: true,
grouping: true,
message: `<div>本次测试所使用的模型不可用</div><div style="margin-top: 10px; white-space: nowrap;">建议在大语言模型配置中关闭相关模型</div>`
})
} finally {
isLoading.value = false
}
}
function closeWindow() {
electron.ipcRenderer.send(`close-read-no-reply-reminder-llm-mock-window`)
}
function formatApiSecret(text) {
if (typeof text !== 'string' || !text?.trim()) {
return ''
}
if (text === 'ollama') {
return text
}
if (text.length >= 8) {
return `${text.slice(0, 4)}***${text.slice(-4)}`
}
return `***`
}
</script>
<style lang="scss" scoped>
.message-item-wrap {
max-width: 420px;
margin-top: 20px;
.message-item {
line-height: 1.25em;
font-size: 14px;
background-color: #d1f0ef;
color: #333;
padding: 10px;
border-radius: 8px 8px 0 0;
&.will-enter-context {
position: relative;
&::before {
content: '聊天上下文';
display: flex;
font-size: 10px;
position: absolute;
top: 100%;
left: 0;
background-color: #10c7c3;
color: #fff;
line-height: 1;
padding: 2px 4px;
}
}
}
}
</style>

View File

@@ -0,0 +1,564 @@
<template>
<div class="resume-editor-page">
<div class="main-wrapper">
<main>
<div class="mt1em mb1em flex flex-items-center flex-justify-between">
<span>简历编辑器</span>
</div>
<el-alert type="info" :closable="false" mb20px line-height-1.25em>
<ul pl16px m0>
<li>
此简历将作为提示词的一部分提交给语言大模型仅在匹配职位生成已读不回提醒消息时使用大部分信息非必填但在不填写的情况下可能会匹配到不准确的职位或生成预料之外的已读不回提醒消息
</li>
<li>期望薪资仅作匹配职位使用不会用作生成已读不回提醒消息</li>
</ul>
</el-alert>
<el-form
ref="formRef"
:model="formContentForElForm"
:rules="formRulesForElForm"
label-position="top"
class="resume-editor-form"
:validate-on-rule-change="false"
>
<div
:style="{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '10px'
}"
>
<el-form-item label="姓名">
<el-input v-model="formContent.name" font-size-12px></el-input>
</el-form-item>
<el-form-item label="工作年限">
<el-input v-model="formContent.workYearDesc" font-size-12px></el-input>
</el-form-item>
<el-form-item label="期望职位">
<el-input v-model="formContent.expectJob" font-size-12px></el-input>
</el-form-item>
<el-form-item label="期望薪资k">
<div
:style="{
display: 'grid',
gridTemplateColumns: '1fr 1fr'
}"
>
<el-input v-model="formContent.expectSalary[0]" placeholder="下限" />
<el-input v-model="formContent.expectSalary[1]" placeholder="上限" />
</div>
</el-form-item>
</div>
<el-form-item label="个人优势">
<el-input
v-model="formContent.userDescription"
type="textarea"
:autosize="{
minRows: 6,
maxRows: 8
}"
font-size-12px
></el-input>
</el-form-item>
<el-form-item>
<div class="el-form-item__label">
工作经历
<el-button size="small" :icon="Plus" @click="addWorkExp">新增一条</el-button>
</div>
<div v-for="(exp, index) in formContent.geekWorkExpList" :key="index">
<div
:style="{
display: 'flex',
gap: '12px'
}"
>
<div
:style="{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
height: 'fit-content',
marginTop: '10px'
}"
>
<el-button
:disabled="index <= 0"
style="margin: 0"
circle
size="small"
:icon="ArrowUp"
@click="moveWorkExpUp(index)"
/>
<el-button
:disabled="index >= formContent.geekWorkExpList.length - 1"
style="margin: 0"
circle
size="small"
:icon="ArrowDown"
@click="moveWorkExpDown(index)"
/>
<el-button
:disabled="1 >= formContent.geekWorkExpList.length"
style="margin: 0"
circle
size="small"
:icon="Delete"
@click="removeWorkExp(index)"
/>
</div>
<div>
<div
:style="{
display: 'grid',
gridTemplateColumns: '1fr 1.25fr 1fr',
gap: '10px',
width: '100%'
}"
>
<el-form-item
label="公司名称"
style="margin-bottom: 18px"
:prop="`geekWorkExpList_${index}_company`"
>
<el-input v-model="exp.company" />
</el-form-item>
<el-form-item label="任职时间" style="margin-bottom: 18px">
<div
:style="{
display: 'grid',
gridTemplateColumns: '1fr 1fr'
}"
>
<el-date-picker
v-model="exp.startYearMon"
:style="{ '--el-date-editor-width': 'auto' }"
type="month"
placeholder="开始月份"
/>
<el-date-picker
v-model="exp.endYearMon"
:style="{ '--el-date-editor-width': 'auto' }"
type="month"
placeholder="结束月份"
/>
</div>
</el-form-item>
<el-form-item label="职务" style="margin-bottom: 18px">
<el-input v-model="exp.positionName" />
</el-form-item>
</div>
<el-form-item label="工作描述" style="margin-bottom: 18px">
<el-input
v-model="exp.workDescription"
type="textarea"
:autosize="{
minRows: 6,
maxRows: 8
}"
font-size-12px
/>
</el-form-item>
<el-form-item label="工作业绩">
<el-input
v-model="exp.performance"
type="textarea"
:autosize="{
minRows: 6,
maxRows: 8
}"
font-size-12px
/>
</el-form-item>
<div
v-if="index !== formContent.geekWorkExpList.length - 1"
class="mt20px mb20px h1px"
style="background-color: #dcdcdc"
/>
</div>
</div>
</div>
</el-form-item>
<el-form-item>
<div class="el-form-item__label">
项目经历
<el-button size="small" :icon="Plus" @click="addProjExp">新增一条</el-button>
</div>
<div v-for="(proj, index) in formContent.geekProjExpList" :key="index">
<div
:style="{
display: 'flex',
gap: '12px'
}"
>
<div
:style="{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: '10px',
height: 'fit-content',
marginTop: '10px'
}"
>
<el-button
:disabled="index <= 0"
style="margin: 0"
circle
size="small"
:icon="ArrowUp"
@click="moveProjExpUp(index)"
/>
<el-button
:disabled="index >= formContent.geekProjExpList.length - 1"
style="margin: 0"
circle
size="small"
:icon="ArrowDown"
@click="moveProjExpDown(index)"
/>
<el-button
:disabled="1 >= formContent.geekProjExpList.length"
style="margin: 0"
circle
size="small"
:icon="Delete"
@click="removeProjExp(index)"
/>
</div>
<div>
<div
:style="{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1.25fr',
gap: '10px',
width: '100%'
}"
>
<el-form-item
label="项目名称"
style="margin-bottom: 18px"
:prop="`geekProjExpList_${index}_name`"
>
<el-input v-model="proj.name" />
</el-form-item>
<el-form-item label="项目角色" style="margin-bottom: 18px">
<el-input v-model="proj.roleName" />
</el-form-item>
<el-form-item label="项目时间" style="margin-bottom: 18px">
<div
:style="{
display: 'grid',
gridTemplateColumns: '1fr 1fr'
}"
>
<el-date-picker
v-model="proj.startYearMon"
:style="{ '--el-date-editor-width': 'auto' }"
type="month"
placeholder="开始月份"
/>
<el-date-picker
v-model="proj.endYearMon"
:style="{ '--el-date-editor-width': 'auto' }"
type="month"
placeholder="结束月份"
/>
</div>
</el-form-item>
</div>
<el-form-item label="项目描述" style="margin-bottom: 18px">
<el-input
v-model="proj.projectDescription"
type="textarea"
:autosize="{
minRows: 6,
maxRows: 8
}"
font-size-12px
/>
</el-form-item>
<el-form-item label="项目业绩">
<el-input
v-model="proj.performance"
type="textarea"
:autosize="{
minRows: 6,
maxRows: 8
}"
font-size-12px
/>
</el-form-item>
<div
v-if="index !== formContent.geekProjExpList.length - 1"
class="mt20px mb20px h1px"
style="background-color: #dcdcdc"
/>
</div>
</div>
</div>
</el-form-item>
</el-form>
</main>
</div>
<footer pt10px pb10px flex flex-justify-center>
<div w768px flex 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>
</div>
</footer>
</div>
</template>
<script lang="ts" setup>
import { ElForm, ElButton, ElAlert, ElMessageBox } from 'element-plus'
import { ref, onMounted, computed } from 'vue'
import { ArrowUp, ArrowDown, Delete, Plus } from '@element-plus/icons-vue'
import { gtagRenderer } from '@renderer/utils/gtag'
import { type ResumeContent, resumeContentEnoughDetect } from '../../../../common/utils/resume'
const formRef = ref<InstanceType<typeof ElForm>>()
const getEmptyFormContent = () => {
const o: any = {
expectJob: '',
name: '',
userDescription: '',
workYearDesc: '',
expectSalary: ['', ''],
geekWorkExpList: [],
geekProjExpList: []
}
o.geekProjExpList = [getNewProjExpItem()]
o.geekWorkExpList = [getNewWorkExpItem()]
return o as ResumeContent
}
const formContent = ref<ResumeContent>(getEmptyFormContent())
const formContentForElForm = computed(() => {
const valueMap = {}
formContent.value.geekWorkExpList?.forEach((item, i) => {
valueMap[`geekWorkExpList_${i}_company`] = item.company
})
formContent.value.geekProjExpList?.forEach((item, i) => {
valueMap[`geekProjExpList_${i}_name`] = item.name
})
return valueMap
})
const formRulesForElForm = computed(() => {
const valueMap = {}
formContent.value.geekWorkExpList.forEach((_, i) => {
valueMap[`geekWorkExpList_${i}_company`] = [
{
required: true,
message: '请输入公司名称'
},
{
trigger: 'blur',
validator(_, value, cb) {
if (!value.trim()) {
cb(`请输入公司名称`)
} else {
cb()
}
}
}
]
})
formContent.value.geekProjExpList.forEach((_, i) => {
valueMap[`geekProjExpList_${i}_name`] = [
{
required: true,
message: '请输入项目名称'
},
{
trigger: 'blur',
validator(_, value, cb) {
if (!value.trim()) {
cb(`请输入项目名称`)
} else {
cb()
}
}
}
]
})
return valueMap
})
const handleCancel = () => {
gtagRenderer('cancel_clicked')
electron.ipcRenderer.send('close-resume-editor')
gtagRenderer('cancel_done')
}
const handleSubmit = async () => {
await formRef.value?.validate()
gtagRenderer('submit_clicked')
if (
!resumeContentEnoughDetect({
content: formContent.value
})
) {
try {
gtagRenderer('rc_not_enough_dialog_show')
await ElMessageBox.confirm(
`简历内容可能不够充足(各个部分内容长度相加 <800 字)<br />后续大模型根据简历生成的内容将可能不符合预期(例如相同内容重复生成、生成预期之外的内容)<br /><br />要继续保存吗?`,
{
cancelButtonText: '不,我再改改',
confirmButtonText: '是的,继续保存',
dangerouslyUseHTMLString: true
}
)
} catch {
return
}
}
electron.ipcRenderer.invoke('save-resume-content', JSON.parse(JSON.stringify(formContent.value)))
gtagRenderer('submit_done')
}
onMounted(async () => {
try {
const savedFileContent = await electron.ipcRenderer.invoke('fetch-resume-content')
if (!savedFileContent) {
return
}
for (const k of Object.keys(formContent.value)) {
formContent.value[k] = savedFileContent[k]
}
if (!formContent.value.expectSalary) {
formContent.value.expectSalary = ['', '']
}
if (!formContent.value.expectSalary?.[0] || /\D/.test(formContent.value.expectSalary?.[0])) {
formContent.value.expectSalary[0] = ''
}
if (!formContent.value.expectSalary?.[1] || /\D/.test(formContent.value.expectSalary?.[1])) {
formContent.value.expectSalary[1] = ''
}
if (!formContent.value.geekProjExpList?.length) {
formContent.value.geekProjExpList = [getNewProjExpItem()]
}
if (!formContent.value.geekWorkExpList?.length) {
formContent.value.geekWorkExpList = [getNewWorkExpItem()]
}
} catch (err) {
formContent.value = getEmptyFormContent()
}
})
// function handlePresetClick(selected: (typeof llmPresetList)[number]) {}
// #region edit work exp list
function getNewWorkExpItem() {
return {
company: '',
endYearMon: '',
positionName: '',
startYearMon: '',
performance: '',
workDescription: ''
}
}
function addWorkExp() {
formContent.value.geekWorkExpList.push(getNewWorkExpItem())
gtagRenderer('resume_work_exp_added')
}
function moveWorkExpUp(index) {
;[formContent.value.geekWorkExpList[index], formContent.value.geekWorkExpList[index - 1]] = [
formContent.value.geekWorkExpList[index - 1],
formContent.value.geekWorkExpList[index]
]
gtagRenderer('resume_work_exp_moved_up')
}
function moveWorkExpDown(index) {
;[formContent.value.geekWorkExpList[index], formContent.value.geekWorkExpList[index + 1]] = [
formContent.value.geekWorkExpList[index + 1],
formContent.value.geekWorkExpList[index]
]
gtagRenderer('resume_work_exp_moved_down')
}
function removeWorkExp(index) {
formContent.value.geekWorkExpList.splice(index, 1)
gtagRenderer('resume_work_exp_removed')
}
// #endregion
// #region edit proj list
function getNewProjExpItem() {
return {
name: '',
endYearMon: '',
roleName: '',
startYearMon: '',
performance: '',
projectDescription: ''
}
}
function addProjExp() {
formContent.value.geekProjExpList.push(getNewProjExpItem())
gtagRenderer('resume_proj_exp_added')
}
function moveProjExpUp(index) {
;[formContent.value.geekProjExpList[index], formContent.value.geekProjExpList[index - 1]] = [
formContent.value.geekProjExpList[index - 1],
formContent.value.geekProjExpList[index]
]
gtagRenderer('resume_proj_exp_moved_up')
}
function moveProjExpDown(index) {
;[formContent.value.geekProjExpList[index], formContent.value.geekProjExpList[index + 1]] = [
formContent.value.geekProjExpList[index + 1],
formContent.value.geekProjExpList[index]
]
gtagRenderer('resume_proj_exp_moved_down')
}
function removeProjExp(index) {
formContent.value.geekProjExpList.splice(index, 1)
gtagRenderer('resume_proj_exp_removed')
}
// #endregion
onMounted(() => {
gtagRenderer('resume_editor_mounted')
})
</script>
<style lang="scss" scoped>
.resume-editor-page {
margin: 0 auto;
display: flex;
flex-direction: column;
height: 100vh;
.main-wrapper {
overflow: auto;
main {
margin: 0 auto;
max-width: 768px;
}
}
footer {
background-color: #f0f0f0;
}
}
</style>
<style lang="scss">
.resume-editor-form.el-form {
.el-form-item__error--inline {
margin-left: 0;
margin-top: 10px;
}
}
</style>

View File

@@ -1,5 +1,6 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import BootstrapSplash from '@renderer/page/BootstrapSplash/index.vue'
import { gtagRenderer } from '@renderer/utils/gtag'
const routes: Array<RouteRecordRaw> = [
{
@@ -17,55 +18,76 @@ const routes: Array<RouteRecordRaw> = [
}
},
{
path: '/configuration',
component: () => import('@renderer/page/Configuration/index.vue'),
redirect: '/configuration/GeekAutoStartChatWithBoss',
path: '/llmConfig',
component: () => import('@renderer/page/LlmConfig/index.vue'),
meta: {
title: '大语言模型设置'
}
},
{
path: '/resumeEditor',
component: () => import('@renderer/page/ResumeEditor/index.vue'),
meta: {
title: '简历编辑'
}
},
{
path: '/readNoReplyReminderLlmMock',
component: () => import('@renderer/page/ReadNoReplyReminderLlmMock/index.vue'),
meta: {
title: '已读不回提醒器 大语言模型测试'
}
},
{
path: '/main-layout',
component: () => import('@renderer/page/MainLayout/index.vue'),
redirect: '/main-layout/GeekAutoStartChatWithBoss',
children: [
{
path: 'GeekAutoStartChatWithBoss',
component: () => import('@renderer/page/Configuration/GeekAutoStartChatWithBoss.vue'),
component: () => import('@renderer/page/MainLayout/GeekAutoStartChatWithBoss.vue'),
meta: {
title: 'BOSS炸弹'
}
},
{
path: 'ReadNoReplyReminder',
component: () => import('@renderer/page/Configuration/ReadNoReplyReminder.vue'),
component: () => import('@renderer/page/MainLayout/ReadNoReplyReminder.vue'),
meta: {
title: '已读不回提醒器'
}
},
{
path: 'StartChatRecord',
component: () => import('@renderer/page/Configuration/StartChatRecord.vue'),
component: () => import('@renderer/page/MainLayout/StartChatRecord.vue'),
meta: {
title: '开聊记录'
}
},
{
path: 'MarkAsNotSuitRecord',
component: () => import('@renderer/page/Configuration/MarkAsNotSuitRecord.vue'),
component: () => import('@renderer/page/MainLayout/MarkAsNotSuitRecord.vue'),
meta: {
title: '标记不合适记录'
}
},
{
path: 'JobLibrary',
component: () => import('@renderer/page/Configuration/JobLibrary.vue'),
component: () => import('@renderer/page/MainLayout/JobLibrary.vue'),
meta: {
title: '职位库'
}
},
{
path: 'BossLibrary',
component: () => import('@renderer/page/Configuration/BossLibrary.vue'),
component: () => import('@renderer/page/MainLayout/BossLibrary.vue'),
meta: {
title: 'Boss库'
}
},
{
path: 'CompanyLibrary',
component: () => import('@renderer/page/Configuration/CompanyLibrary.vue'),
component: () => import('@renderer/page/MainLayout/CompanyLibrary.vue'),
meta: {
title: '公司库'
}
@@ -114,7 +136,7 @@ const routes: Array<RouteRecordRaw> = [
component: () => import('@renderer/page/BootstrapSplash/page/DownloadingDependencies.vue'),
meta: {
title: '正在下载核心组件'
},
}
}
]
}
@@ -125,12 +147,16 @@ const router = createRouter({
routes
})
router.afterEach((to) => {
router.afterEach((to, from) => {
if (to.meta?.title) {
document.title = `${to.meta.title} - GeekGeekRun`
document.title = `${to.meta.title} - GeekGeekRun 牛人快跑`
} else {
document.title = `GeekGeekRun`
document.title = `GeekGeekRun 牛人快跑`
}
gtagRenderer('router_path_changed', {
from_path: from.fullPath,
to_path: to.fullPath
})
})
export default router

View File

@@ -0,0 +1,17 @@
export function gtagRenderer(name, params: any = null) {
try {
electron.ipcRenderer.send('gtag', {
name,
params: {
...(params ?? {}),
page_location: location.href,
page_title: document.title,
screen_w: window.screen?.width ?? null,
screen_h: window.screen?.height ?? null,
screen_dpr: window.devicePixelRatio
}
})
} catch (err) {
console.log('gtag error', err)
}
}

View File

@@ -0,0 +1,26 @@
import OpenAI from "openai";
export async function completes(
{
baseURL,
apiKey,
model
},
messages
) {
const openai = new OpenAI({
baseURL,
apiKey,
});
const completion = await openai.chat.completions.create({
messages,
model,
frequency_penalty: 0,
max_tokens: 100,
temperature: 0.1
});
console.log(completion.choices[0].message.content);
return completion;
}

View File

@@ -3,9 +3,10 @@
"private": true,
"version": "1.0.0",
"description": "utils",
"scripts": {
},
"scripts": {},
"author": "geekgeekrun",
"license": "ISC",
"dependencies": {}
"dependencies": {
"openai": "^4.91.1"
}
}

88
pnpm-lock.yaml generated
View File

@@ -141,6 +141,9 @@ importers:
puppeteer-extra-plugin-stealth:
specifier: 2.11.2
version: 2.11.2(puppeteer-extra@3.3.6)
uuid:
specifier: ^11.1.0
version: 11.1.0
devDependencies:
'@electron-toolkit/eslint-config':
specifier: ^1.0.2
@@ -226,6 +229,9 @@ importers:
sass:
specifier: ^1.70.0
version: 1.70.0
spark-md5:
specifier: ^3.0.2
version: 3.0.2
terser:
specifier: ^5.37.0
version: 5.37.0
@@ -257,7 +263,11 @@ importers:
specifier: ^1.8.27
version: 1.8.27(typescript@5.3.3)
packages/utils: {}
packages/utils:
dependencies:
openai:
specifier: ^4.91.1
version: 4.91.1
packages:
@@ -1679,6 +1689,13 @@ packages:
/@types/ms@0.7.34:
resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==}
/@types/node-fetch@2.6.12:
resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==}
dependencies:
'@types/node': 18.19.15
form-data: 4.0.0
dev: false
/@types/node@18.19.15:
resolution: {integrity: sha512-AMZ2UWx+woHNfM11PyAEQmfSxi05jm9OlkxczuHeEqmvwPkYj6MWv44gbzDPefYOLysTOFyI3ziiy2ONmUZfpA==}
dependencies:
@@ -2302,6 +2319,13 @@ packages:
requiresBuild: true
dev: false
/abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: false
/acorn-jsx@5.3.2(acorn@8.11.3):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -2349,7 +2373,6 @@ packages:
dependencies:
humanize-ms: 1.2.1
dev: false
optional: true
/aggregate-error@3.1.0:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
@@ -2546,7 +2569,6 @@ packages:
/asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
dev: true
/at-least-node@1.0.0:
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
@@ -2904,7 +2926,6 @@ packages:
engines: {node: '>= 0.8'}
dependencies:
delayed-stream: 1.0.0
dev: true
/commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -3108,7 +3129,6 @@ packages:
/delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
dev: true
/delegates@1.0.0:
resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==}
@@ -3607,6 +3627,11 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: false
/execa@5.1.1:
resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
engines: {node: '>=10'}
@@ -3776,6 +3801,10 @@ packages:
signal-exit: 4.1.0
dev: true
/form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
dev: false
/form-data@4.0.0:
resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==}
engines: {node: '>= 6'}
@@ -3783,7 +3812,14 @@ packages:
asynckit: 0.4.0
combined-stream: 1.0.8
mime-types: 2.1.35
dev: true
/formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
dev: false
/formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
@@ -4162,7 +4198,6 @@ packages:
dependencies:
ms: 2.1.2
dev: false
optional: true
/iconv-corefoundation@1.1.7:
resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==}
@@ -4643,14 +4678,12 @@ packages:
/mime-db@1.52.0:
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
engines: {node: '>= 0.6'}
dev: true
/mime-types@2.1.35:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
dependencies:
mime-db: 1.52.0
dev: true
/mime@2.6.0:
resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==}
@@ -5002,6 +5035,29 @@ packages:
mimic-fn: 2.1.0
dev: true
/openai@4.91.1:
resolution: {integrity: sha512-DbjrR0hIMQFbxz8+3qBsfPJnh3+I/skPgoSlT7f9eiZuhGBUissPQULNgx6gHNkLoZ3uS0uYS6eXPUdtg4nHzw==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
dependencies:
'@types/node': 18.19.15
'@types/node-fetch': 2.6.12
abort-controller: 3.0.0
agentkeepalive: 4.5.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.6.7
transitivePeerDependencies:
- encoding
dev: false
/optionator@0.9.3:
resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==}
engines: {node: '>= 0.8.0'}
@@ -5726,6 +5782,10 @@ packages:
requiresBuild: true
dev: true
/spark-md5@3.0.2:
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
dev: true
/sprintf-js@1.1.3:
resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==}
requiresBuild: true
@@ -6360,6 +6420,11 @@ packages:
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
/uuid@11.1.0:
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
hasBin: true
dev: false
/uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true
@@ -6499,6 +6564,11 @@ packages:
engines: {node: '>= 8'}
dev: false
/web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
dev: false
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: false