From e1c771dc07b24cf1cdf954fe8d5728c9d534c568 Mon Sep 17 00:00:00 2001 From: patricLee Date: Wed, 19 Aug 2026 12:11:24 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=E5=B2=97=E4=BD=8D=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E5=B2=97=E4=BD=8D=E8=AF=A6=E6=83=85=E5=B1=95?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E6=B2=9F=E9=80=9A=E8=AE=B0=E5=BD=95=E6=8C=89?= =?UTF-8?q?=E6=94=B6=E5=8F=91=E6=96=B9=E5=88=86=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 岗位描述的清洗与结构化收敛到 src-tauri/src/job_description.rs 一处。 抓下来的 JD 混着反爬注入的样式代码、噪声词和页面控件文本,原先前端自己洗 一遍、写入侧再洗一遍,喂给模型的却还是原文。现在只留一份实现两个出口: clean_text 供 prompt 与关键词匹配,parse 经 job_description_view 命令供前端。 - 岗位分析、自动回复、打招呼、语义复核、岗位过滤全部改用清洗后的正文 - 删掉前端 src/lib/job-detail.ts 与写入侧的 sanitize_boss_job_detail, 原文原样落库,页面改版时重新解析即可,不必重抓 - 详情弹窗新增「岗位详情」页签,按语义给小节配色,附工作地址与招聘者 沟通记录原先整段挤在左侧,根因是招聘者 uid 只挂在岗位卡片消息上,分页 拉不到就把整个会话判成对方发来。改为跨会话记住我方 uid 反推,存量受影响 的会话重新同步一次即可自愈。UI 一并补上头像、日期分隔与平台回执样式。 过滤规则的匹配文本随之变化:反爬类名与粘连技能标签不再参与命中判断。 --- src-tauri/src/agent/tasks.rs | 15 +- src-tauri/src/command/communicated_jobs.rs | 42 +- src-tauri/src/command/job.rs | 22 +- src-tauri/src/job_description.rs | 472 ++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/rpa/boss/handler/mod.rs | 2 +- .../src/rpa/boss/handler/reply_unread.rs | 138 ++++- .../src/rpa/boss/handler/sync_chat_history.rs | 9 +- src-tauri/src/rpa/run_flow.rs | 8 + src-tauri/src/verify.rs | 18 +- src/lib/job-description.ts | 47 ++ src/types/job-detail.ts | 32 ++ src/view/job-data/AnalysisReport.tsx | 15 +- src/view/job-data/ChatThread.tsx | 176 +++++++ src/view/job-data/JobBrief.tsx | 193 +++++++ src/view/job-data/chat-thread.test.tsx | 111 ++++ src/view/job-data/index.tsx | 167 ++----- src/view/job-data/job-brief.test.tsx | 123 +++++ src/view/job-data/style.css | 359 +++++++++++++ .../MockInterviewSetup.test.ts | 9 +- .../resume-optimizer/MockInterviewSetup.tsx | 27 +- src/view/resume-optimizer/index.tsx | 20 +- 22 files changed, 1817 insertions(+), 190 deletions(-) create mode 100644 src-tauri/src/job_description.rs create mode 100644 src/lib/job-description.ts create mode 100644 src/view/job-data/ChatThread.tsx create mode 100644 src/view/job-data/JobBrief.tsx create mode 100644 src/view/job-data/chat-thread.test.tsx create mode 100644 src/view/job-data/job-brief.test.tsx diff --git a/src-tauri/src/agent/tasks.rs b/src-tauri/src/agent/tasks.rs index 1f96aad..c17f5cc 100644 --- a/src-tauri/src/agent/tasks.rs +++ b/src-tauri/src/agent/tasks.rs @@ -11,6 +11,7 @@ use crate::agent::prompts; use crate::agent::run::AgentTask; use crate::config::{AppRuntimeConfig, RegexRule}; use crate::error::AppError; +use crate::job_description; use crate::llm::template; use crate::llm::JobSemanticMatch; use crate::rpa::common::RpaJob; @@ -106,7 +107,7 @@ impl AgentTask for ReplyDecisionTask<'_> { .map(|message| message.text.clone()) .unwrap_or_default(), "job_description": job - .map(|job| clip(&job.detail, LONG_TEXT_LIMIT)) + .map(|job| clip(&job_description::clean_text(&job.detail, &job.platform), LONG_TEXT_LIMIT)) .unwrap_or_default(), "job_content": job .map(|job| format!("{}|{}|{}", job.title, job.company_name, job.salary)) @@ -182,6 +183,10 @@ impl<'a> GreetTask<'a> { pub fn new(config: &'a AppRuntimeConfig, job: &'a RpaJob) -> Self { Self { config, job } } + + fn job_description(&self) -> String { + job_description::clean_text(&self.job.detail, self.job.platform.as_str()) + } } impl AgentTask for GreetTask<'_> { @@ -205,7 +210,7 @@ impl AgentTask for GreetTask<'_> { fn params(&self) -> Result { let mut params = json!({ "job_content": serde_json::to_string(self.job).unwrap_or_default(), - "job_description": clip(&self.job.detail, LONG_TEXT_LIMIT), + "job_description": clip(&self.job_description(), LONG_TEXT_LIMIT), "resume": resume_text(self.config), "resume_context": resume_text(self.config), }); @@ -275,6 +280,10 @@ impl<'a> JobMatchTask<'a> { pub fn new(config: &'a AppRuntimeConfig, job: &'a RpaJob) -> Self { Self { config, job } } + + fn job_description(&self) -> String { + job_description::clean_text(&self.job.detail, self.job.platform.as_str()) + } } impl AgentTask for JobMatchTask<'_> { @@ -307,7 +316,7 @@ impl AgentTask for JobMatchTask<'_> { "job": { "title": self.job.title, "company": self.job.company_name, - "description": clip(&self.job.detail, LONG_TEXT_LIMIT), + "description": clip(&self.job_description(), LONG_TEXT_LIMIT), }, "resume": resume_text(self.config), }); diff --git a/src-tauri/src/command/communicated_jobs.rs b/src-tauri/src/command/communicated_jobs.rs index a949a8d..449c099 100644 --- a/src-tauri/src/command/communicated_jobs.rs +++ b/src-tauri/src/command/communicated_jobs.rs @@ -12,7 +12,7 @@ use crate::{ command::base::CommandResult, dao::{chat_message_dao, job_detail_dao, model::JobDetail}, logger, - rpa::boss::handler::{parse_chat_messages, parse_encrypt_job_id}, + rpa::boss::handler::{parse_chat_messages, parse_encrypt_job_id, MessageDirection}, rpa::run_flow::{is_job_task_stop_requested, try_start_job_task, JobTaskRunningGuard}, utils::salary::decode_salary, }; @@ -116,6 +116,8 @@ async fn collect_communicated_jobs_from_browser( let mut jobs = Vec::new(); let mut seen_ids = HashSet::new(); let mut messages_inserted = 0; + // 我方 uid 一轮内不变,跨会话攒着用,见 [`MessageDirection`] + let mut direction = MessageDirection::default(); for page_no in 1..=page_limit { if is_job_task_stop_requested() { @@ -158,7 +160,7 @@ async fn collect_communicated_jobs_from_browser( continue; } - match collect_conversation_messages(page, &snapshot, &id) { + match collect_conversation_messages(page, &snapshot, &id, &mut direction) { Ok(inserted) => { messages_inserted += inserted; if inserted > 0 { @@ -263,6 +265,7 @@ fn collect_conversation_messages( page: &ChromiumPage, snapshot: &CommunicatedJobCardSnapshot, fallback_job_id: &str, + direction: &mut MessageDirection, ) -> Result { let history_listener = page.listen_url("zpchat/geek/historyMsg")?; let boss_data_listener = page.listen_url("zpchat/geek/getBossData")?; @@ -287,7 +290,7 @@ fn collect_conversation_messages( }; let body_str = String::from_utf8(body).context("historyMsg 响应非 UTF-8 编码")?; - let chat_messages = parse_chat_messages(&body_str)?; + let chat_messages = parse_chat_messages(&body_str, direction)?; // BOSS 的 encryptJobId 同时充当会话标识与岗位标识 let saved = chat_message_dao::upsert_incremental( chat_message_dao::ConversationKey { @@ -520,26 +523,15 @@ fn extract_detail_text(page: &Page) -> Result { })() "#; let value = page.run_js_await(script)?; - let raw_text = extract_remote_value(value) + // 原样落库:清洗规则跟着平台页面结构走,统一放在读取侧的 + // [`crate::job_description`] 里。写入时就洗掉,规则一改旧数据就再也救不回来了 + Ok(extract_remote_value(value) .as_str() .unwrap_or("") + .replace("\r\n", "\n") + .replace('\r', "\n") .trim() - .to_string(); - - Ok(sanitize_boss_job_detail(&raw_text)) -} - -fn sanitize_boss_job_detail(text: &str) -> String { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - let marker = "职位描述"; - let body = normalized - .find(marker) - .map(|index| &normalized[index + marker.len()..]) - .unwrap_or(normalized.as_str()); - - let cleaned = body.trim().trim_start_matches([':', ':', '-', '—']).trim(); - - cleaned.replace("\n\n\n", "\n\n") + .to_string()) } fn scroll_page_once(page: &ChromiumPage) -> Result<(), anyhow::Error> { @@ -640,13 +632,17 @@ mod tests { ); } + /// 抓到的原文原样落库,「职位描述」这类标题留给读取侧切小节用 #[test] - fn sanitizes_boss_detail_text_before_job_description_heading() { + fn keeps_scraped_detail_intact_for_the_read_side_parser() { let raw = "微信扫码分享\n举\n报\n职位描述\n负责推荐系统后端开发。\n要求熟悉 Rust。"; + let parsed = crate::job_description::parse(raw, "boss"); + assert_eq!(parsed.sections.len(), 1); + assert_eq!(parsed.sections[0].title, "职位描述"); assert_eq!( - sanitize_boss_job_detail(raw), - "负责推荐系统后端开发。\n要求熟悉 Rust。" + parsed.sections[0].items, + vec!["负责推荐系统后端开发。", "要求熟悉 Rust。"] ); } diff --git a/src-tauri/src/command/job.rs b/src-tauri/src/command/job.rs index 38dd02f..64880cc 100644 --- a/src-tauri/src/command/job.rs +++ b/src-tauri/src/command/job.rs @@ -2,6 +2,7 @@ use crate::command::base::CommandResult; use crate::config; use crate::dao::model::{ChatMessageRecord, InterviewJobAnalysis, JobDetail}; use crate::dao::{analysis_dao, chat_message_dao, job_detail_dao}; +use crate::job_description::ParsedJobDescription; use chrono::{Duration, Local, NaiveDate, NaiveDateTime, TimeZone}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -514,6 +515,22 @@ pub fn job_delete(id: String) -> CommandResult<()> { } } +/// 岗位描述的结构化视图。 +/// +/// 前端不再自己洗一遍 JD:清洗规则跟着平台页面结构走,前后端各存一份必然漂移。 +/// 页面要展示什么就从这个出口取,和喂给模型的是同一份文本 +#[tauri::command] +pub fn job_description_view(job_id: String) -> CommandResult { + match job_detail_dao::get_by_id(&job_id) { + Ok(Some(job)) => CommandResult::ok(crate::job_description::parse( + &job.detail, + &job.platform, + )), + Ok(None) => CommandResult::err(format!("岗位不存在: {}", job_id)), + Err(e) => CommandResult::err(e.to_string()), + } +} + #[tauri::command] pub fn chat_messages_by_job(job_id: String) -> CommandResult> { match chat_message_dao::find_by_job_id(&job_id) { @@ -559,6 +576,9 @@ struct LlmAnalysisOutput { } fn build_analysis_prompt(job: &JobDetail) -> String { + // 抓下来的 JD 混着反爬注入的样式代码和噪声词,原样喂进去既占额度又干扰判断, + // 统一走 job_description 这个出口洗一遍 + let detail = crate::job_description::clean_text(&job.detail, &job.platform); // 只填岗位骨架;resume_context / background_context / chat_context 这些 // 业务变量留给后续的模板渲染,两层的替换时机不同不能混做 crate::agent::prompts::compose( @@ -568,7 +588,7 @@ fn build_analysis_prompt(job: &JobDetail) -> String { ("JOB_COMPANY", &job.company_name), ("JOB_SALARY", &job.salary), ("JOB_LOCATION", job.location.as_deref().unwrap_or("-")), - ("JOB_DETAIL", &job.detail), + ("JOB_DETAIL", &detail), ], ) } diff --git a/src-tauri/src/job_description.rs b/src-tauri/src/job_description.rs new file mode 100644 index 0000000..e687dac --- /dev/null +++ b/src-tauri/src/job_description.rs @@ -0,0 +1,472 @@ +//! 岗位描述原文的清洗与结构化。 +//! +//! `JobDetail.detail` 存的是 RPA 抓页面时的 `textContent` 原文,没做过任何加工: +//! +//! - BOSS 混着反爬注入——插在正文中间的 `来自BOSS直聘`/`kanzhun`、 +//! 整段 `.xxx{display:none}` 的样式代码、开头粘成一坨的技能标签, +//! 末尾还粘着招聘者名片、App 引导和工作地址。 +//! - 猎聘存的根本不是 JD,而是列表页的岗位卡片文本,字段之间只有空格。 +//! +//! **全应用只有这一份实现**:喂给模型的 prompt、岗位过滤的关键词匹配、 +//! 前端的详情展示都从这里出去。规则跟着平台页面结构走,改一处就够, +//! 不必在前后端各维护一套彼此漂移的清洗逻辑。 +//! +//! 清洗只发生在读取侧,库里始终留着原文:页面改版时重新解析一遍就行, +//! 不需要把已经抓到的岗位再抓一次。 + +use std::sync::LazyLock; + +use regex::Regex; +use serde::Serialize; + +/// 正文里的小节标题。命中即另起一节,顺序无关 +const SECTION_TITLES: &[&str] = &[ + "职位描述", + "岗位描述", + "职位详情", + "岗位详情", + "岗位职责", + "工作职责", + "工作内容", + "职位要求", + "任职要求", + "岗位要求", + "任职资格", + "能力要求", + "加分项", + "福利待遇", + "薪资福利", + "我们提供", +]; + +/// BOSS 反爬往正文里插的噪声词,直接抹掉 +static BOSS_NOISE: LazyLock = + LazyLock::new(|| Regex::new(r"来自BOSS直聘|BOSS直聘|kanzhun").expect("valid regex")); + +/// 反爬用的内联样式块,如 `.HsDyPBbi{display:inline-block;...}`。 +/// 字符类写死成 ASCII:`\w` 在 Rust 正则里是 Unicode 的,会把紧随其后的中文一起吞掉 +static CSS_BLOCK: LazyLock = + LazyLock::new(|| Regex::new(r"\.[A-Za-z_][A-Za-z0-9_-]*\s*\{[^}]*\}").expect("valid regex")); + +/// 招聘者名片:`韩璐浓 在线 字节跳动 · HR.招聘专员`,粘在正文末尾 +static BOSS_RECRUITER: LazyLock = LazyLock::new(|| { + Regex::new(r"\s(\S{1,12})\s+(\S*(?:在线|活跃))\s+(\S.*?)\s+·\s+(\S.*?)\s*$") + .expect("valid regex") +}); + +static BOSS_WORKPLACE: LazyLock = + LazyLock::new(|| Regex::new(r"工作地址\s*(.+?)\s*点击查看地图").expect("valid regex")); + +/// 行首编号:`1、` `2.` `3)` `①` `- ` 等,剥掉后交给列表渲染 +static LEADING_BULLET: LazyLock = LazyLock::new(|| { + Regex::new(r"^\s*(?:[((]?[0-9]+[))、.::]|[-•·*]|[①-⑳])\s*").expect("valid regex") +}); + +/// 正文结束的位置:这之后全是 App 引导、工作地址和「查看更多」 +const BOSS_TAIL_MARKER: &str = "去App与BOSS随时沟通"; +/// 抓下来的文本一定以它开头,后面跟着页面上的技能标签 +const BOSS_HEAD_NOISE: &str = "举报微信扫码分享"; + +const LIEPIN: &str = "liepin"; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct JobSection { + pub title: String, + pub items: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct Recruiter { + pub name: String, + /// 「在线」「2周内活跃」这类活跃度描述 + pub status: String, + pub company: String, + pub role: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] +pub struct ParsedJobDescription { + /// 结构化后的正文小节;没识别出标题时会有一个标题为空的兜底小节 + pub sections: Vec, + /// 学历、经验、公司规模这类短标签,主要来自猎聘卡片 + pub highlights: Vec, + pub workplace: Option, + pub recruiter: Option, + /// 清洗后的正文全文。喂模型、做关键词匹配、前端「查看原文」都用它 + pub clean_text: String, + /// 洗完没剩下有效内容——页面结构变了或本就没抓到 JD + pub empty: bool, +} + +/// 去掉行首编号;整行只有编号时返回空串,由调用方丢弃 +fn strip_bullet(line: &str) -> String { + LEADING_BULLET.replace(line, "").trim().to_string() +} + +/// 命中的小节标题,允许尾随冒号 +fn match_section_title(line: &str) -> Option<&'static str> { + let normalized = line.trim_end_matches([':', ':', ' ']).trim(); + SECTION_TITLES + .iter() + .find(|title| **title == normalized) + .copied() +} + +/// 第一个独占一行的小节标题所在的字节偏移。 +/// +/// 两条抓取链路的噪声形态不同:岗位列表用 `textContent`,页面控件文本全糊在 +/// 第一行;已沟通列表用 `innerText`,「举报」「微信扫码分享」会各占一行。 +/// 后者靠摘第一行摘不干净,只能认准「BOSS 的 JD 必然从某个小节标题起头」这一点。 +fn first_section_title_offset(text: &str) -> Option { + let mut offset = 0; + for line in text.split_inclusive('\n') { + if match_section_title(line.trim()).is_some() { + return Some(offset); + } + offset += line.len(); + } + None +} + +/// 按小节标题把正文切开。标题之前的内容归到一个无标题小节, +/// 这样即使一个标题都没识别出来,正文也不会凭空消失 +fn split_sections(text: &str) -> Vec { + let mut sections = Vec::new(); + let mut current = JobSection::default(); + + for raw_line in text.lines() { + let line = raw_line.trim(); + if line.is_empty() { + continue; + } + + if let Some(title) = match_section_title(line) { + if !current.items.is_empty() { + sections.push(std::mem::take(&mut current)); + } + current = JobSection { + title: title.to_string(), + items: Vec::new(), + }; + continue; + } + + let item = strip_bullet(line); + if !item.is_empty() { + current.items.push(item); + } + } + + if !current.items.is_empty() { + sections.push(current); + } + sections +} + +fn parse_boss(detail: &str) -> ParsedJobDescription { + let workplace = BOSS_WORKPLACE + .captures(detail) + .map(|caps| caps[1].trim().to_string()) + .filter(|value| !value.is_empty()); + + // 尾巴先砍:App 引导之后没有任何 JD 内容,留着只会干扰招聘者名片的匹配 + let trimmed = match detail.find(BOSS_TAIL_MARKER) { + Some(index) => &detail[..index], + None => detail, + }; + let without_css = CSS_BLOCK.replace_all(trimmed, ""); + let mut body = BOSS_NOISE.replace_all(&without_css, "").into_owned(); + + // 招聘者名片没有换行,和正文最后一行粘在一起,只能从末尾反着认 + let recruiter = BOSS_RECRUITER.captures(&body).map(|caps| { + let cut = caps.get(0).expect("whole match").start(); + let recruiter = Recruiter { + name: caps[1].to_string(), + status: caps[2].to_string(), + company: caps[3].to_string(), + role: caps[4].to_string(), + }; + (recruiter, cut) + }); + if let Some((_, cut)) = &recruiter { + body.truncate(*cut); + } + let recruiter = recruiter.map(|(value, _)| value); + + // 第一行是「举报微信扫码分享 + 页面标题 + 粘成一坨的技能标签」, + // 技能标签之间没有分隔符,切不出来也没必要留——正文里本就写着要求。 + // 但行尾那个小节标题是正文的一部分,得摘回来 + if let Some(break_at) = body.find('\n').filter(|index| *index > 0) { + let (head, rest) = body.split_at(break_at); + body = match SECTION_TITLES.iter().find(|title| head.ends_with(**title)) { + Some(anchor) => format!("{anchor}{rest}"), + // 认不出标题就只摘掉固定前缀:宁可留点噪声,也不能把正文首行一起丢了 + None => format!( + "{}{rest}", + head.strip_prefix(BOSS_HEAD_NOISE).unwrap_or(head) + ), + }; + } + + // 一个小节标题都认不出来时不截:那多半是格式特殊的 JD, + // 宁可留点控件文本,也不能把整段正文当噪声丢了 + if let Some(start) = first_section_title_offset(&body) { + body.drain(..start); + } + + let clean_text = body.trim().to_string(); + ParsedJobDescription { + sections: split_sections(&clean_text), + highlights: Vec::new(), + workplace, + recruiter, + empty: clean_text.is_empty(), + clean_text, + } +} + +static LIEPIN_EXPERIENCE: LazyLock = LazyLock::new(|| { + Regex::new(r"应届|经验不限|[0-9]+-[0-9]+年|[0-9]+年以[上下]").expect("valid regex") +}); +static LIEPIN_EDUCATION: LazyLock = LazyLock::new(|| { + // 长的写在前面:交替是最左优先的,`本科` 排在 `统招本科` 前会把前缀吃掉 + Regex::new(r"统招本科|本科|大专|硕士|博士|中专|高中|学历不限|EMBA|MBA").expect("valid regex") +}); +static LIEPIN_SCALE: LazyLock = LazyLock::new(|| { + Regex::new(r"少于[0-9]+人|[0-9]+-[0-9]+人|[0-9]+人以[上下]|[0-9]+人").expect("valid regex") +}); +/// 卡片末尾的活跃度,如 `1天前在线`、`23分钟前在线` +static LIEPIN_ACTIVE: LazyLock = + LazyLock::new(|| Regex::new(r"(\S*(?:在线|活跃))\s*$").expect("valid regex")); +/// 招聘者:`杨女士·人事专员` +static LIEPIN_RECRUITER: LazyLock = + LazyLock::new(|| Regex::new(r"(\S+?)·(\S+)").expect("valid regex")); + +/// 猎聘存的是列表卡片文本,字段之间只有空格: +/// `AI开发工程师 【 广州-黄埔区 】 8-15k 1-3年 本科 西麦科技 计算机软件新三板上市100-499人 杨女士·人事专员 1天前在线` +/// +/// 这里只挑岗位表格里没有的字段(经验、学历、公司规模、招聘者), +/// 标题薪资地点已经是独立字段,重复展示没意义 +fn parse_liepin(detail: &str) -> ParsedJobDescription { + let text = detail.trim(); + if text.is_empty() { + return ParsedJobDescription { + empty: true, + ..Default::default() + }; + } + + let mut highlights: Vec = Vec::new(); + for pattern in [&*LIEPIN_EXPERIENCE, &*LIEPIN_EDUCATION, &*LIEPIN_SCALE] { + if let Some(found) = pattern.find(text) { + let value = found.as_str().to_string(); + if !highlights.contains(&value) { + highlights.push(value); + } + } + } + + let recruiter = LIEPIN_RECRUITER.captures(text).map(|caps| Recruiter { + name: caps[1].to_string(), + role: caps[2].to_string(), + status: LIEPIN_ACTIVE + .captures(text) + .map(|active| active[1].to_string()) + .unwrap_or_default(), + company: String::new(), + }); + + ParsedJobDescription { + sections: Vec::new(), + empty: highlights.is_empty() && recruiter.is_none(), + highlights, + workplace: None, + recruiter, + clean_text: text.to_string(), + } +} + +/// 把抓下来的岗位描述原文洗成可渲染、可喂模型的结构。 +/// +/// 平台没标注时按 BOSS 处理:存量数据里 BOSS 占九成,且 BOSS 的清洗规则 +/// 对普通文本是幂等的,误判的代价只是少洗掉几处噪声。 +pub fn parse(detail: &str, platform: &str) -> ParsedJobDescription { + if detail.trim().is_empty() { + return ParsedJobDescription { + empty: true, + ..Default::default() + }; + } + if platform == LIEPIN { + parse_liepin(detail) + } else { + parse_boss(detail) + } +} + +/// 只要清洗后的正文。喂 prompt、做关键词匹配都用这个出口, +/// 免得每个调用方各自决定「要不要洗」「洗到什么程度」 +pub fn clean_text(detail: &str, platform: &str) -> String { + parse(detail, platform).clean_text +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 结构取自真实抓取结果,保留了反爬注入的原样 + const BOSS_RAW: &str = concat!( + "举报微信扫码分享职位描述GolangJavaC++CMySQLAI agentSpringPython", + ".HsDyPBbi{display:inline-block;width:0.1px;height:0.1px;overflow:hidden;visibility: hidden;}", + ".nfJJHrefm{display:none!important;}", + "来自BOSS直聘职位描述\n", + "1、深入理解来自BOSS直聘客户业务场景,快速开BOSS直聘发AI应用原型验BOSS直聘证,kanzhun不断迭代推进至生产级部署的Agent系统;\n", + "2、基于AI和云设计架构,并通过AI辅助开发在客户环境中快速实现;\n", + "职位要求\n", + "1、本科及以上学历,计算机、工程、数学等相关专业或同等实践经验;\n", + "2、3年以上研发工作经验;\n", + "\n", + "加分项\n", + "1、有复杂Agent系统的开发经验。 韩璐浓 在线 字节跳动 · HR.招聘专员 ", + "去App与BOSS随时沟通 前往App与BOSS随时沟通工作地址深圳南山区深圳景湖大厦", + "广东省深圳市南山区创业路1555号景湖大厦点击查看地图查看更多信息", + ); + + #[test] + fn strips_anti_scraping_noise_from_boss_detail() { + let parsed = parse(BOSS_RAW, "boss"); + + assert!(!parsed.clean_text.contains("display:inline-block")); + assert!(!parsed.clean_text.contains("来自BOSS直聘")); + assert!(!parsed.clean_text.contains("kanzhun")); + // 噪声词是插在句子中间的,抹掉后语义要接得上 + assert!(parsed + .clean_text + .contains("快速开发AI应用原型验证,不断迭代")); + } + + #[test] + fn drops_glued_skill_tags_but_keeps_the_section_title_behind_them() { + let parsed = parse(BOSS_RAW, "boss"); + + assert!(!parsed.clean_text.contains("举报微信扫码分享")); + assert!(!parsed.clean_text.contains("GolangJavaC++")); + assert_eq!(parsed.sections[0].title, "职位描述"); + } + + #[test] + fn splits_sections_and_strips_leading_numbers() { + let parsed = parse(BOSS_RAW, "boss"); + + let titles: Vec<&str> = parsed + .sections + .iter() + .map(|section| section.title.as_str()) + .collect(); + assert_eq!(titles, vec!["职位描述", "职位要求", "加分项"]); + assert_eq!(parsed.sections[0].items.len(), 2); + assert!(parsed.sections[0].items[0].starts_with("深入理解")); + assert_eq!( + parsed.sections[2].items, + vec!["有复杂Agent系统的开发经验。"] + ); + } + + #[test] + fn extracts_recruiter_card_glued_to_the_last_line() { + let parsed = parse(BOSS_RAW, "boss"); + + assert_eq!( + parsed.recruiter, + Some(Recruiter { + name: "韩璐浓".to_string(), + status: "在线".to_string(), + company: "字节跳动".to_string(), + role: "HR.招聘专员".to_string(), + }) + ); + // 名片摘走后不能残留在最后一条正文里 + assert!(!parsed.clean_text.contains("韩璐浓")); + } + + #[test] + fn extracts_workplace_without_dragging_app_banner_into_the_body() { + let parsed = parse(BOSS_RAW, "boss"); + + assert!(parsed.workplace.as_deref().unwrap().contains("景湖大厦")); + assert!(!parsed.clean_text.contains("去App与BOSS随时沟通")); + assert!(!parsed.clean_text.contains("查看更多信息")); + } + + #[test] + fn liepin_card_yields_conditions_and_recruiter() { + let parsed = parse( + "AI开发工程师 【 广州-黄埔区 】 8-15k 1-3年 本科 西麦科技 计算机软件新三板上市100-499人 杨女士·人事专员 1天前在线", + "liepin", + ); + + assert_eq!(parsed.highlights, vec!["1-3年", "本科", "100-499人"]); + assert_eq!( + parsed.recruiter, + Some(Recruiter { + name: "杨女士".to_string(), + role: "人事专员".to_string(), + status: "1天前在线".to_string(), + company: String::new(), + }) + ); + } + + #[test] + fn blank_detail_is_empty() { + for value in ["", " \n "] { + assert!(parse(value, "boss").empty); + assert!(parse(value, "liepin").empty); + } + } + + #[test] + fn body_survives_when_no_section_title_is_recognized() { + let parsed = parse("负责后端服务开发\n参与架构设计", "boss"); + + assert!(!parsed.empty); + assert_eq!(parsed.sections.len(), 1); + assert_eq!(parsed.sections[0].title, ""); + assert_eq!( + parsed.sections[0].items, + vec!["负责后端服务开发", "参与架构设计"] + ); + } + + /// 已沟通列表走 innerText,「举报」「微信扫码分享」会各占一行落在 JD 前面 + #[test] + fn drops_page_controls_stacked_above_the_first_section_title() { + let parsed = parse( + "微信扫码分享\n举\n报\n职位描述\n负责推荐系统后端开发。\n要求熟悉 Rust。", + "boss", + ); + + assert_eq!(parsed.sections.len(), 1); + assert_eq!(parsed.sections[0].title, "职位描述"); + assert_eq!( + parsed.sections[0].items, + vec!["负责推荐系统后端开发。", "要求熟悉 Rust。"] + ); + assert!(!parsed.clean_text.contains("微信扫码分享")); + } + + #[test] + fn missing_platform_falls_back_to_boss_rules() { + let parsed = parse("岗位职责\n1. 写代码", ""); + + assert_eq!(parsed.sections[0].title, "岗位职责"); + assert_eq!(parsed.sections[0].items, vec!["写代码"]); + } + + #[test] + fn clean_text_is_the_single_exit_for_prompt_and_matching() { + // 出口只有一个:prompt 与关键词匹配拿到的必须是同一份文本 + assert_eq!(clean_text(BOSS_RAW, "boss"), parse(BOSS_RAW, "boss").clean_text); + assert!(!clean_text(BOSS_RAW, "boss").contains("kanzhun")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cbad5bb..e58bc41 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,6 +6,7 @@ pub mod config; pub mod credential; pub mod dao; pub mod error; +pub mod job_description; pub mod llm; pub mod logger; pub mod rpa; @@ -64,6 +65,7 @@ pub fn run() { command::job::job_query, command::job::job_analyze, command::job::job_analyze_batch, + command::job::job_description_view, command::job::chat_messages_by_job, command::communicated_jobs::job_collect_communicated, command::manual_review::manual_review_list, diff --git a/src-tauri/src/rpa/boss/handler/mod.rs b/src-tauri/src/rpa/boss/handler/mod.rs index 5c4ba1a..1603470 100644 --- a/src-tauri/src/rpa/boss/handler/mod.rs +++ b/src-tauri/src/rpa/boss/handler/mod.rs @@ -11,7 +11,7 @@ mod sync_chat_history; pub use login::login; pub use login_check::login_check; pub use position_say_hello::{position_say_hello, position_say_hello_on_page}; -pub(crate) use reply_unread::{parse_chat_messages, parse_encrypt_job_id}; +pub(crate) use reply_unread::{parse_chat_messages, parse_encrypt_job_id, MessageDirection}; pub use reply_unread::{reply_unread, reply_unread_on_page}; pub use send_message::send_messages; pub use send_resume::send_resume; diff --git a/src-tauri/src/rpa/boss/handler/reply_unread.rs b/src-tauri/src/rpa/boss/handler/reply_unread.rs index eed3c70..59fc80c 100644 --- a/src-tauri/src/rpa/boss/handler/reply_unread.rs +++ b/src-tauri/src/rpa/boss/handler/reply_unread.rs @@ -57,6 +57,8 @@ pub async fn reply_unread_on_page( // 后面的才会补上来。handled 只是最后一道防线,兜住「处理完仍赖在列表里」的 // 会话,免得同一张卡片被无限重试 let mut handled: HashSet = HashSet::new(); + // 我方 uid 一轮内不变,跨会话攒着用,见 [`MessageDirection`] + let mut direction = MessageDirection::default(); // 未读堆积时不能一口气全处理完:单轮耗时一旦超过轮询间隔, // 后面每一轮都在追赶上一轮的尾巴,节奏彻底乱掉。剩下的留给下一轮 let max_per_round = app_runtime_config @@ -140,7 +142,7 @@ pub async fn reply_unread_on_page( continue; }; let body_str = String::from_utf8(body_bytes).context("historyMsg 响应非 UTF-8 编码")?; - let fresh = parse_chat_messages(&body_str)?; + let fresh = parse_chat_messages(&body_str, &mut direction)?; // 处理这一个会话时出的错不该让整轮未读中断:后面还有别的会话等着 if let Err(error) = @@ -403,12 +405,57 @@ fn describe_action(action: ReplyAction) -> &'static str { } } +/// 跨会话累积的收发方判定线索。 +/// +/// 招聘者 uid 只挂在 `body.type == 8` 的岗位卡片消息上,而这条消息未必落在本次 +/// 拉取的分页里。缺了它就无从区分收发,旧实现一律当成对方发来,整段会话在沟通 +/// 记录里会全部挤到左侧——实测 467 个会话里有 6 个这样,且都是聊得最久的。 +/// +/// 但「我方 uid」在一轮任务里是不变的:只要有任意一个会话拿到了招聘者 uid, +/// 剩下的 uid 必然是自己(BOSS 会话都是一对一)。把它记下来往后传,缺岗位卡片 +/// 的会话就能反着判。 +#[derive(Debug, Default, Clone, Copy)] +pub(crate) struct MessageDirection { + self_uid: Option, +} + +impl MessageDirection { + /// 已知招聘者 uid 时,同一会话里另一个 uid 就是自己。 + /// + /// 收发两侧都要看:只有招聘者单方发言的会话,`from` 里挑不出自己, + /// 但每条消息的 `to` 都指向自己。 + fn learn(&mut self, boss_uid: i64, counterpart: Option) { + if self.self_uid.is_some() { + return; + } + if let Some(uid) = counterpart.filter(|uid| *uid != boss_uid) { + self.self_uid = Some(uid); + } + } + + /// `true` 表示招聘者发来的消息,`false` 表示自己发出的。 + fn is_received(&self, boss_uid: Option, from_uid: Option) -> bool { + match (boss_uid, from_uid, self.self_uid) { + (Some(boss), Some(from), _) => from == boss, + // 缺岗位卡片时退回「我方 uid」反推 + (None, Some(from), Some(own)) => from != own, + // 两个线索都没有,只能沿用旧兜底:宁可当成对方发来,也不要把 + // 招聘者的话认成自己说的 + _ => true, + } + } +} + // 从 historyMsg 接口响应中解析消息列表 // // 发送方判断逻辑: // 第一条 body.type == 8 的消息携带 body.jobDesc.boss.uid(招聘者 uid)。 // 后续每条消息:from.uid == boss_uid 则 received=true(招聘者发来),否则 received=false(自己发送)。 -pub(crate) fn parse_chat_messages(body: &str) -> Result, anyhow::Error> { +// 本次响应里没有岗位卡片消息时,改用 `direction` 中跨会话攒下的我方 uid 反推。 +pub(crate) fn parse_chat_messages( + body: &str, + direction: &mut MessageDirection, +) -> Result, anyhow::Error> { let root: serde_json::Value = serde_json::from_str(body).context("historyMsg JSON 解析失败")?; let code = root.get("code").and_then(|v| v.as_i64()).unwrap_or(-1); @@ -438,6 +485,15 @@ pub(crate) fn parse_chat_messages(body: &str) -> Result, anyhow } }); + // 这个会话认出了招聘者,顺手把我方 uid 记下来给后面缺岗位卡片的会话用 + if let Some(boss) = boss_uid { + for msg in messages { + for path in ["/from/uid", "/to/uid"] { + direction.learn(boss, msg.pointer(path).and_then(|v| v.as_i64())); + } + } + } + fn first_attachment_name(value: &serde_json::Value) -> Option { match value { serde_json::Value::Object(map) => { @@ -509,10 +565,7 @@ pub(crate) fn parse_chat_messages(body: &str) -> Result, anyhow let mid = msg.get("mid").and_then(|v| v.as_i64())?; let from_uid = msg.pointer("/from/uid").and_then(|v| v.as_i64()); // received=true 表示招聘者(boss)发来的消息,false 表示自己发送的 - let received = match (boss_uid, from_uid) { - (Some(boss), Some(from)) => from == boss, - _ => true, // 无法判断时默认视为对方发来 - }; + let received = direction.is_received(boss_uid, from_uid); let time = msg.get("time").and_then(|v| v.as_i64()).unwrap_or(0); let from_name = msg .pointer("/from/name") @@ -589,6 +642,7 @@ mod tests { ] } }"#, + &mut MessageDirection::default(), ) .expect("简历附件消息应能解析"); @@ -597,6 +651,78 @@ mod tests { assert!(!messages[0].received); } + /// 缺岗位卡片的会话,靠上一个会话攒下的我方 uid 反推收发方。 + /// 没有这条兜底时整段会话会被判成对方发来,沟通记录里全挤在左侧。 + #[test] + fn falls_back_to_learned_self_uid_when_job_card_missing() { + let mut direction = MessageDirection::default(); + + // 第一个会话带岗位卡片,从中认出招聘者 10,反推出我方是 20 + parse_chat_messages( + r#"{ + "code": 0, + "zpData": { + "messages": [ + { + "mid": 1, "time": 1000, + "from": {"uid": 10, "name": "招聘者"}, "to": {"uid": 20}, + "body": {"type": 8, "jobDesc": {"boss": {"uid": 10}}} + } + ] + } + }"#, + &mut direction, + ) + .expect("首个会话应能解析"); + + // 第二个会话分页里没有岗位卡片,招聘者换成了 30 + let messages = parse_chat_messages( + r#"{ + "code": 0, + "zpData": { + "messages": [ + { + "mid": 2, "time": 2000, + "from": {"uid": 30, "name": "曾女士"}, "to": {"uid": 20}, + "body": {"type": 1, "text": "方便聊聊吗"} + }, + { + "mid": 3, "time": 3000, + "from": {"uid": 20, "name": "我"}, "to": {"uid": 30}, + "body": {"type": 1, "text": "方便的"} + } + ] + } + }"#, + &mut direction, + ) + .expect("缺岗位卡片的会话也应能解析"); + + assert_eq!(messages.len(), 2); + assert!(messages[0].received, "招聘者发来的"); + assert!(!messages[1].received, "自己发出的"); + } + + /// 两个线索都没有时沿用旧兜底:宁可当成对方发来, + /// 也不能把招聘者的话认成自己说的 + #[test] + fn defaults_to_received_without_any_uid_hint() { + let messages = parse_chat_messages( + r#"{ + "code": 0, + "zpData": { + "messages": [ + {"mid": 1, "time": 1000, "from": {"uid": 30}, "body": {"type": 1, "text": "在吗"}} + ] + } + }"#, + &mut MessageDirection::default(), + ) + .expect("无 uid 线索时也应能解析"); + + assert!(messages[0].received); + } + #[test] fn extracts_encrypt_job_id_only_on_success_code() { assert_eq!( diff --git a/src-tauri/src/rpa/boss/handler/sync_chat_history.rs b/src-tauri/src/rpa/boss/handler/sync_chat_history.rs index 6a849b2..1735cc9 100644 --- a/src-tauri/src/rpa/boss/handler/sync_chat_history.rs +++ b/src-tauri/src/rpa/boss/handler/sync_chat_history.rs @@ -14,7 +14,7 @@ use crate::{ boss::{ handler::{ chat_list::{self, conversation_key, ListState}, - parse_chat_messages, parse_encrypt_job_id, + parse_chat_messages, parse_encrypt_job_id, MessageDirection, }, model::ChatMessage, BOSS_CHAT_URL, @@ -385,6 +385,8 @@ pub async fn sync_chat_history_on_page( let mut result = ChatHistorySyncResult::default(); let mut seen = HashSet::new(); let mut stagnant_rounds = 0; + // 我方 uid 一轮内不变,跨会话攒着用,见 [`MessageDirection`] + let mut direction = MessageDirection::default(); // 会话列表为虚拟滚动列表:不断处理当前已渲染项并向下滚动, // 连续三轮没有新会话时认为到达底部。 @@ -444,8 +446,8 @@ pub async fn sync_chat_history_on_page( continue; }; - let messages = - parse_chat_messages(&history_body).context("解析周期间歇历史对话失败")?; + let messages = parse_chat_messages(&history_body, &mut direction) + .context("解析周期间歇历史对话失败")?; let dom_snapshot = extract_job_snapshot_from_dom(page); let api_snapshot = merge_snapshot( boss_data_body @@ -607,6 +609,7 @@ mod tests { ] } }"#, + &mut MessageDirection::default(), ) .expect("简历附件消息应能解析"); diff --git a/src-tauri/src/rpa/run_flow.rs b/src-tauri/src/rpa/run_flow.rs index 3533aa6..d71af53 100644 --- a/src-tauri/src/rpa/run_flow.rs +++ b/src-tauri/src/rpa/run_flow.rs @@ -82,6 +82,14 @@ pub enum PlatformKind { } impl PlatformKind { + /// 落库与解析规则里用的平台标识,与 `JobDetail.platform` 保持一致 + pub fn as_str(self) -> &'static str { + match self { + PlatformKind::Boss => "boss", + PlatformKind::Liepin => "liepin", + } + } + pub fn login_message(self) -> &'static str { match self { PlatformKind::Boss => "请使用 BOSS 直聘 App 扫码登录", diff --git a/src-tauri/src/verify.rs b/src-tauri/src/verify.rs index 5de8893..3d7cb85 100644 --- a/src-tauri/src/verify.rs +++ b/src-tauri/src/verify.rs @@ -42,12 +42,26 @@ fn first_keyword_match(text: &str, keywords: &[String]) -> Option { .find(|keyword| text.contains(keyword)) } +/// 参与匹配的岗位描述。 +/// +/// 用清洗后的正文而不是抓取原文:反爬注入的随机类名(`.HsDyPBbi{...}`)和 +/// 开头粘成一坨的技能标签都会参与 `contains`,让「排除 Java」这类规则 +/// 命中一堆不相干的岗位。规则匹配的应该是人看到的那份文本 +fn description_text(job: &RpaJob) -> String { + crate::job_description::clean_text(&job.detail, job.platform.as_str()) +} + fn regex_target_text(job: &RpaJob, target: &MatchTarget) -> String { match target { MatchTarget::Title => job.title.clone(), MatchTarget::Company => job.company_name.clone(), - MatchTarget::Description => job.detail.clone(), - MatchTarget::All => format!("{}\n{}\n{}", job.title, job.company_name, job.detail), + MatchTarget::Description => description_text(job), + MatchTarget::All => format!( + "{}\n{}\n{}", + job.title, + job.company_name, + description_text(job) + ), } } diff --git a/src/lib/job-description.ts b/src/lib/job-description.ts new file mode 100644 index 0000000..0f39429 --- /dev/null +++ b/src/lib/job-description.ts @@ -0,0 +1,47 @@ +/** + * 岗位描述的前端取数出口。 + * + * 抓下来的 JD 原文混着反爬注入的样式代码、噪声词和页面控件文本,清洗与结构化 + * 统一在 Rust 侧的 `job_description` 模块里完成——前端再写一套必然和喂给模型的 + * 那份漂移。所有要展示或引用岗位描述的地方都从这里取,别直接读 `JobDetail.detail`。 + */ + +import { invoke } from "@tauri-apps/api/core"; +import type { CommandResult } from "../types/command"; +import type { ParsedJobDescription } from "../types/job-detail"; + +const EMPTY: ParsedJobDescription = { + sections: [], + highlights: [], + workplace: null, + recruiter: null, + clean_text: "", + empty: true, +}; + +/** + * 取岗位描述的结构化视图。 + * + * 取不到就返回空结构而不是抛错:调用方展示的是岗位详情, + * 描述缺失只该让正文区空着,不该把整页拖成错误态。 + */ +export async function fetchJobDescription( + jobId: string, +): Promise { + try { + const result = await invoke>( + "job_description_view", + { jobId }, + ); + return result.success && result.data ? result.data : EMPTY; + } catch { + return EMPTY; + } +} + +/** 只要清洗后的正文——拼提示词上下文时用 */ +export async function fetchJobDescriptionText(jobId: string): Promise { + return (await fetchJobDescription(jobId)).clean_text; +} + +export { EMPTY as EMPTY_JOB_DESCRIPTION }; diff --git a/src/types/job-detail.ts b/src/types/job-detail.ts index 01e808c..f662523 100644 --- a/src/types/job-detail.ts +++ b/src/types/job-detail.ts @@ -21,6 +21,38 @@ export interface JobListItem extends JobDetail { communication_status: CommunicationStatus; } +/* ── 岗位描述的结构化视图 ── + * 与 Rust 侧 `job_description::ParsedJobDescription` 对应。清洗规则跟着平台 + * 页面结构走,只在后端维护一份:前端另写一套必然和喂给模型的那份漂移。 + * 取数走 `job_description_view` 命令。 + */ + +export interface JobSection { + title: string; + items: string[]; +} + +export interface Recruiter { + name: string; + /** 「在线」「2周内活跃」这类活跃度描述 */ + status: string; + company: string; + role: string; +} + +export interface ParsedJobDescription { + /** 结构化后的正文小节;没识别出标题时会有一个标题为空的兜底小节 */ + sections: JobSection[]; + /** 学历、经验、公司规模这类短标签,主要来自猎聘卡片 */ + highlights: string[]; + workplace: string | null; + recruiter: Recruiter | null; + /** 清洗后的正文全文,供「查看原文」兜底 */ + clean_text: string; + /** 洗完没剩下有效内容——页面结构变了或本就没抓到 JD */ + empty: boolean; +} + export interface ChatMessageRecord { id: string; job_id: string; diff --git a/src/view/job-data/AnalysisReport.tsx b/src/view/job-data/AnalysisReport.tsx index 98b0c64..ba2a12a 100644 --- a/src/view/job-data/AnalysisReport.tsx +++ b/src/view/job-data/AnalysisReport.tsx @@ -142,10 +142,10 @@ const AnalysisReport = ({ job, onBack, aiConfigured, onConfigureAi, onAnalyzed } )} - {/* Job Info Card */} - -
- + {/* 岗位基本信息由「岗位详情」页签负责,这里只留触发分析的入口 */} + {onBack ? ( + + {job.company_name} {job.salary || "-"} {job.location || "-"} @@ -153,9 +153,12 @@ const AnalysisReport = ({ job, onBack, aiConfigured, onConfigureAi, onAnalyzed } {job.is_send_resume ? 已投递 : 未投递} - {!onBack && analyzeButton} + + ) : ( +
+ {analyzeButton}
- + )} {/* Analysis Content */} {analysisChecking ? ( diff --git a/src/view/job-data/ChatThread.tsx b/src/view/job-data/ChatThread.tsx new file mode 100644 index 0000000..84ecdcf --- /dev/null +++ b/src/view/job-data/ChatThread.tsx @@ -0,0 +1,176 @@ +import { useEffect, useMemo, useState } from "react"; +import { Empty, Modal, Spin, Tag } from "antd"; +import { invoke } from "@tauri-apps/api/core"; +import type { CommandResult } from "../../types/command"; +import type { ChatMessageRecord, JobDetail } from "../../types/job-detail"; + +/** 平台回执,不是双方说的话,居中弱化显示而不是塞进某一侧的气泡 */ +const SYSTEM_MESSAGE_PREFIXES = [ + "简历文件:", + "简历文件:", + "对方已查看", + "对方已同意", + "您的附件简历", + "对方拒绝", + "对方已拒绝", +]; + +const isSystemMessage = (text: string): boolean => { + const normalized = text.trim(); + return SYSTEM_MESSAGE_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +}; + +const formatDay = (time: number): string => { + const date = new Date(time); + const today = new Date(); + const sameYear = date.getFullYear() === today.getFullYear(); + const label = date.toLocaleDateString("zh-CN", { + ...(sameYear ? {} : { year: "numeric" }), + month: "long", + day: "numeric", + }); + const dayDiff = Math.round( + (new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() - + new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime()) / + 86400000, + ); + if (dayDiff === 0) return `今天 ${label}`; + if (dayDiff === 1) return `昨天 ${label}`; + return label; +}; + +const formatClock = (time: number): string => + new Date(time).toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); + +type ThreadItem = + | { kind: "day"; key: string; label: string } + | { kind: "system"; key: string; text: string } + | { kind: "message"; key: string; message: ChatMessageRecord }; + +/// 按时间铺开消息,跨天时插入日期分隔条。日期只在变化时出现, +/// 同一天连着聊几十条不会被分隔条切碎 +function buildThread(messages: ChatMessageRecord[]): ThreadItem[] { + const items: ThreadItem[] = []; + let lastDay = ""; + + for (const message of messages) { + const day = new Date(message.time).toDateString(); + if (day !== lastDay) { + lastDay = day; + items.push({ kind: "day", key: `day-${day}`, label: formatDay(message.time) }); + } + items.push( + isSystemMessage(message.text) + ? { kind: "system", key: message.id, text: message.text.trim() } + : { kind: "message", key: message.id, message }, + ); + } + return items; +} + +/** 对方头像取姓名首字;姓名缺失时退回一个中性占位 */ +const avatarText = (name: string): string => name.trim().charAt(0) || "对"; + +const ChatBubble = ({ message }: { message: ChatMessageRecord }) => { + const mine = !message.received; + return ( +
+
+ {mine ? "我" : avatarText(message.from_name)} +
+
+
+ {mine ? "我" : message.from_name || "对方"} + {formatClock(message.time)} +
+
{message.text}
+
+
+ ); +}; + +const ChatThreadModal = ({ + job, + open, + onClose, +}: { + job: JobDetail; + open: boolean; + onClose: () => void; +}) => { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!open) return; + setLoading(true); + invoke>("chat_messages_by_job", { + jobId: job.id, + }) + .then((result) => { + if (result.success && result.data) { + setMessages([...result.data].sort((a, b) => a.time - b.time)); + } else { + setMessages([]); + } + }) + .catch(() => setMessages([])) + .finally(() => setLoading(false)); + }, [job.id, open]); + + const thread = useMemo(() => buildThread(messages), [messages]); + const peerCount = messages.filter((message) => message.received).length; + const peerName = + messages.find((message) => message.received)?.from_name.trim() || "对方"; + + return ( + + {loading ? ( +
+ +
+ ) : messages.length === 0 ? ( +
+ +
+ ) : ( + <> + {/* 左右分栏靠气泡位置区分,图例把这个约定说明白 */} +
+ 左:{peerName}({peerCount}) + 右:我({messages.length - peerCount}) + {job.company_name} +
+
+ {thread.map((item) => + item.kind === "day" ? ( +
+ {item.label} +
+ ) : item.kind === "system" ? ( +
+ {item.text} +
+ ) : ( + + ), + )} +
+ + )} +
+ ); +}; + +export default ChatThreadModal; +export { buildThread, isSystemMessage }; diff --git a/src/view/job-data/JobBrief.tsx b/src/view/job-data/JobBrief.tsx new file mode 100644 index 0000000..0bc1b7c --- /dev/null +++ b/src/view/job-data/JobBrief.tsx @@ -0,0 +1,193 @@ +import { useEffect, useState } from "react"; +import { Button, Empty, Skeleton, Tag, Typography } from "antd"; +import { + EnvironmentOutlined, + FileTextOutlined, + UserOutlined, +} from "@ant-design/icons"; +import { fetchJobDescription } from "../../lib/job-description"; +import type { JobDetail, ParsedJobDescription } from "../../types/job-detail"; +import type { InterviewJobAnalysis } from "../../types/analysis"; + +/** 小节的语义配色:职责说「做什么」,要求说「要什么」,加分项是锦上添花 */ +const sectionTone = (title: string): "duty" | "require" | "bonus" | "plain" => { + if (/职责|描述|详情|内容/.test(title)) return "duty"; + if (/要求|资格|能力/.test(title)) return "require"; + if (/加分|福利|待遇|我们提供/.test(title)) return "bonus"; + return "plain"; +}; + +const matchTone = (score: number): string => + score >= 80 ? "green" : score >= 60 ? "gold" : "red"; + +/** 岗位表格里的时间是 `2026-08-06 16:08:27`,详情里只到分钟就够 */ +const shortTime = (value?: string | null): string => + value ? value.slice(0, 16) : "-"; + +function MetaItem({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +/** + * 岗位原始信息的可视化。 + * + * 抓到的 JD 原文噪声很重,清洗与结构化统一在 Rust 侧的 `job_description` + * 里完成——和喂给模型的是同一份文本,前端不再自己洗一遍。这里只负责把 + * 小节铺开,并保证不出现「明明抓到了数据,页面上什么都没有」。 + */ +const JobBrief = ({ + job, + analysis, +}: { + job: JobDetail; + analysis?: InterviewJobAnalysis; +}) => { + const [showRaw, setShowRaw] = useState(false); + const [parsed, setParsed] = useState(null); + const platform = job.platform === "liepin" ? "猎聘" : "BOSS 直聘"; + + useEffect(() => { + let stale = false; + setParsed(null); + void fetchJobDescription(job.id).then((result) => { + if (!stale) setParsed(result); + }); + return () => { + stale = true; + }; + }, [job.id]); + + return ( +
+ {/* ── 头部:一眼看清是什么岗位、什么状态 ── */} +
+
+ + {job.title} + +
+ {job.company_name || "未知公司"} + {job.location && ·} + {job.location && {job.location.trim()}} +
+
+
+ {job.salary &&
{job.salary}
} +
+ {platform} + {job.is_send_resume ? 已投递 : 未投递} + {job.is_reply && 已回复} + {analysis && !analysis.parse_error && ( + + 匹配 {analysis.match_score} 分 + + )} +
+
+
+ + {/* ── 时间线与来源,都是表格里看不全的字段 ── */} +
+ + + + {parsed && parsed.highlights.length > 0 && ( + + {parsed.highlights.map((item) => ( + + {item} + + ))} + + } + /> + )} +
+ + {/* ── JD 正文。岗位头部本地就有,只有正文要等后端解析 ── */} + {!parsed ? ( + + ) : parsed.empty ? ( + + ) : parsed.sections.length > 0 ? ( +
+ {parsed.sections.map((section, index) => ( +
+

+ + {section.title || "岗位描述"} +

+
    + {section.items.map((item, itemIndex) => ( +
  1. {item}
  2. + ))} +
+
+ ))} +
+ ) : ( + // 猎聘存的是列表卡片文本,本就没有 JD 正文。有效字段已经提到上面的 + // 「岗位条件」里,原文留给下面的折叠区,避免同样的内容铺两遍 + + )} + + {/* ── 附加信息 ── */} + {parsed && (parsed.workplace || parsed.recruiter) && ( +
+ {parsed.workplace && ( +
+ + {parsed.workplace} +
+ )} + {parsed.recruiter && ( +
+ + + {parsed.recruiter.name} + {parsed.recruiter.role && ` · ${parsed.recruiter.role}`} + {parsed.recruiter.company && + parsed.recruiter.company !== job.company_name && + ` · ${parsed.recruiter.company}`} + + {parsed.recruiter.status && ( + {parsed.recruiter.status} + )} +
+ )} +
+ )} + + {/* 解析规则跟着平台页面结构走,页面改版时留个看原文的出口 */} + {parsed && !parsed.empty && ( +
+ + {showRaw &&
{parsed.clean_text}
} +
+ )} +
+ ); +}; + +export default JobBrief; diff --git a/src/view/job-data/chat-thread.test.tsx b/src/view/job-data/chat-thread.test.tsx new file mode 100644 index 0000000..b9a199a --- /dev/null +++ b/src/view/job-data/chat-thread.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import ChatThreadModal, { buildThread, isSystemMessage } from "./ChatThread"; +import type { ChatMessageRecord, JobDetail } from "../../types/job-detail"; + +// vitest 没开 globals,testing-library 的自动清理不会注册 +afterEach(cleanup); + +const DAY = 86400000; +const BASE = new Date("2026-08-12T09:00:00").getTime(); + +function message( + id: string, + received: boolean, + text: string, + time: number, +): ChatMessageRecord { + return { + id, + job_id: "demo", + mid: Number(id), + received, + text, + time, + from_name: received ? "张明" : "我", + }; +} + +const job: JobDetail = { + id: "demo", + platform: "boss", + title: "AI 应用架构师", + company_name: "示例科技", + detail: "", + salary: "30-55K", + location: "深圳", + is_reply: true, + is_send_resume: true, + created_at: "2026-08-06 16:08:27", + resume_sent_at: null, + updated_at: "2026-08-12 09:30:11", +}; + +describe("buildThread", () => { + it("跨天才插日期分隔条,同一天连着聊不会被切碎", () => { + const thread = buildThread([ + message("1", true, "你好", BASE - DAY), + message("2", false, "你好", BASE - DAY + 60000), + message("3", true, "明天面试", BASE), + ]); + + expect(thread.filter((item) => item.kind === "day")).toHaveLength(2); + expect(thread[0].kind).toBe("day"); + expect(thread[3].kind).toBe("day"); + }); + + it("平台回执单独成条,不混进任何一侧的气泡", () => { + const thread = buildThread([ + message("1", true, "对方已查看了您的附件简历", BASE), + message("2", false, "简历文件:示例.pdf", BASE + 1000), + message("3", true, "明天方便吗", BASE + 2000), + ]); + + const kinds = thread.filter((item) => item.kind !== "day").map((item) => item.kind); + expect(kinds).toEqual(["system", "system", "message"]); + }); + + it("认得出平台回执,普通对话不受影响", () => { + expect(isSystemMessage("对方已同意,您的附件简历已发送给对方")).toBe(true); + expect(isSystemMessage("简历文件:已交换简历")).toBe(true); + expect(isSystemMessage("对方已查看了您的附件简历")).toBe(true); + expect(isSystemMessage("你好,方便聊聊这个岗位吗")).toBe(false); + }); +}); + +describe("ChatThreadModal", () => { + it("按收发方分列渲染:对方在左,我方在右", async () => { + vi.mocked(invoke).mockResolvedValue({ + success: true, + data: [ + message("1", false, "您好,想应聘这个岗位", BASE), + message("2", true, "方便聊聊吗", BASE + 60000), + ], + error: null, + }); + + render( {}} />); + + await waitFor(() => + expect(screen.getByText("您好,想应聘这个岗位")).toBeInTheDocument(), + ); + + // Modal 渲染在 body 上的 portal 里,不在 render 返回的容器中 + const rows = document.querySelectorAll(".chat-row"); + expect(rows).toHaveLength(2); + expect(rows[0].className).toContain("is-mine"); + expect(rows[1].className).toContain("is-peer"); + }); + + it("没有沟通记录时给出空态而不是空白", async () => { + vi.mocked(invoke).mockResolvedValue({ success: true, data: [], error: null }); + render( {}} />); + await waitFor(() => + expect(screen.getByText("暂无沟通记录")).toBeInTheDocument(), + ); + }); +}); diff --git a/src/view/job-data/index.tsx b/src/view/job-data/index.tsx index af66872..39d2e1b 100644 --- a/src/view/job-data/index.tsx +++ b/src/view/job-data/index.tsx @@ -8,6 +8,7 @@ import { Select, Space, Table, + Tabs, Tag, Typography, message, @@ -26,7 +27,6 @@ import type { ColumnsType } from "antd/es/table"; import type { CommandResult } from "../../types/command"; import { commandErrorMessage } from "../../types/command"; import type { - ChatMessageRecord, CommunicationStatus, JobDetail, JobListItem, @@ -34,6 +34,8 @@ import type { import type { InterviewJobAnalysis } from "../../types/analysis"; import { DEFAULT_HIGH_MATCH_SCORE } from "../../types/app-config"; import AnalysisReport from "./AnalysisReport"; +import ChatThreadModal from "./ChatThread"; +import JobBrief from "./JobBrief"; import "./style.css"; /** 与 Rust 侧 BatchAnalysisResult 对应 */ @@ -84,119 +86,6 @@ const renderCommunicationStatus = (status: CommunicationStatus) => { return {meta.label}; }; -/* ────────── Chat messages modal ────────── */ - -const ChatMessagesModal = ({ - job, - open, - onClose, -}: { - job: JobDetail; - open: boolean; - onClose: () => void; -}) => { - const [messages, setMessages] = useState([]); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (!open) return; - setLoading(true); - invoke>("chat_messages_by_job", { - jobId: job.id, - }) - .then((result) => { - if (result.success && result.data) { - setMessages( - [...result.data].sort((a, b) => a.time - b.time), - ); - } else { - setMessages([]); - } - }) - .catch(() => setMessages([])) - .finally(() => setLoading(false)); - }, [job.id, open]); - - return ( - - {loading ? ( -
- 加载中... -
- ) : messages.length === 0 ? ( -
- 暂无沟通记录 -
- ) : ( -
- {messages.map((msg) => { - const isMine = !msg.received; - const time = new Date(msg.time).toLocaleString("zh-CN", { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - }); - return ( -
-
-
- {msg.text} -
- - {msg.from_name} · {time} - -
-
- ); - })} -
- )} -
- ); -}; - /* ────────── Kanban lane config ────────── */ interface KanbanLane { @@ -279,7 +168,7 @@ function JobKanbanCard({ className="job-card" role="button" tabIndex={0} - title="查看面试分析报告" + title="查看岗位详情与分析报告" onClick={() => onView(job)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { @@ -810,36 +699,54 @@ const JobDataPage = ({ aiConfigured, onConfigureAi, focusJobId, onFocusHandled,
)} - {/* ── analysis modal ── */} + {/* ── job detail modal ── */} {currentJob && ( setCurrentJob(null)} footer={null} width="min(1180px, 94vw)" centered destroyOnHidden - styles={{ body: { height: "min(74vh, 860px)", overflowY: "auto", padding: "16px 24px" } }} + styles={{ body: { height: "min(74vh, 860px)", overflowY: "auto", padding: "8px 24px 16px" } }} > - - setAnalyses((current) => ({ ...current, [analysis.job_id]: analysis })) - } + {/* 岗位原文在前、AI 分析在后:先看清岗位本身,再看对它的判断 */} + + ), + }, + { + key: "analysis", + label: "面试分析报告", + children: ( + + setAnalyses((current) => ({ + ...current, + [analysis.job_id]: analysis, + })) + } + /> + ), + }, + ]} /> )} {/* ── chat modal ── */} {chatJob && ( - setChatJob(null)} - /> + setChatJob(null)} /> )} ); diff --git a/src/view/job-data/job-brief.test.tsx b/src/view/job-data/job-brief.test.tsx new file mode 100644 index 0000000..a8c5292 --- /dev/null +++ b/src/view/job-data/job-brief.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import JobBrief from "./JobBrief"; +import type { JobDetail, ParsedJobDescription } from "../../types/job-detail"; + +// vitest 没开 globals,testing-library 的自动清理不会注册, +// 上一个用例的 DOM 会留到下一个用例里造成重复匹配 +afterEach(cleanup); + +const baseJob: JobDetail = { + id: "demo", + platform: "boss", + title: "AI 应用架构师", + company_name: "示例科技", + salary: "30-55K·16薪", + location: " 深圳·南山区 ", + detail: "原文留在库里,页面渲染的是后端清洗后的结果", + is_reply: false, + is_send_resume: false, + created_at: "2026-08-06 16:08:27", + resume_sent_at: null, + updated_at: "2026-08-12 09:30:11", +}; + +/** 与 Rust 侧 `job_description::parse` 的返回结构一致 */ +function described(overrides: Partial = {}): ParsedJobDescription { + return { + sections: [], + highlights: [], + workplace: null, + recruiter: null, + clean_text: "", + empty: true, + ...overrides, + }; +} + +const mockDescription = (value: ParsedJobDescription) => + vi.mocked(invoke).mockResolvedValue({ success: true, data: value, error: null }); + +describe("JobBrief", () => { + it("把后端切好的小节铺开", async () => { + mockDescription( + described({ + empty: false, + clean_text: "职位描述\n负责 Agent 系统的架构设计;\n任职要求\n三年以上后端研发经验。", + sections: [ + { title: "职位描述", items: ["负责 Agent 系统的架构设计;"] }, + { title: "任职要求", items: ["三年以上后端研发经验。"] }, + ], + workplace: "深圳南山区示例园区 3 栋", + recruiter: { + name: "张明", + status: "在线", + company: "示例科技", + role: "HR.招聘专员", + }, + }), + ); + + render(); + + await waitFor(() => expect(screen.getByText("职位描述")).toBeInTheDocument()); + expect(screen.getByText("任职要求")).toBeInTheDocument(); + expect(screen.getByText("负责 Agent 系统的架构设计;")).toBeInTheDocument(); + expect(screen.getByText(/示例园区 3 栋/)).toBeInTheDocument(); + expect(screen.getByText(/张明/)).toBeInTheDocument(); + }); + + it("岗位头部不等后端,本地字段直接渲染", () => { + mockDescription(described()); + render(); + + // 描述还在路上时,标题公司薪资就该看得见 + expect(screen.getByText("AI 应用架构师")).toBeInTheDocument(); + expect(screen.getByText("示例科技")).toBeInTheDocument(); + expect(screen.getByText("30-55K·16薪")).toBeInTheDocument(); + }); + + it("没抓到岗位描述时给出明确提示,而不是空白一片", async () => { + mockDescription(described()); + render(); + + await waitFor(() => + expect(screen.getByText("这个岗位还没抓到岗位描述")).toBeInTheDocument(), + ); + }); + + it("猎聘没有 JD 正文,只提条件标签与招聘者,原文收进折叠区", async () => { + mockDescription( + described({ + empty: false, + highlights: ["1-3年", "本科", "100-499人"], + clean_text: "AI开发工程师 【 广州-黄埔区 】 8-15k 1-3年 本科 西麦科技 计算机软件新三板上市100-499人 杨女士·人事专员 1天前在线", + recruiter: { name: "杨女士", role: "人事专员", status: "1天前在线", company: "" }, + }), + ); + + render(); + + await waitFor(() => expect(screen.getByText("1-3年")).toBeInTheDocument()); + expect(screen.getByText("本科")).toBeInTheDocument(); + expect(screen.getByText("100-499人")).toBeInTheDocument(); + expect(screen.getByText(/杨女士/)).toBeInTheDocument(); + // 原文默认收起,同样的内容不铺两遍 + expect(screen.getByText("查看抓取原文")).toBeInTheDocument(); + expect(screen.queryByText(/新三板上市/)).not.toBeInTheDocument(); + }); + + it("解析取不到时退回空态,不把整页拖成错误", async () => { + vi.mocked(invoke).mockRejectedValue(new Error("命令不可用")); + render(); + + await waitFor(() => + expect(screen.getByText("这个岗位还没抓到岗位描述")).toBeInTheDocument(), + ); + expect(screen.getByText("AI 应用架构师")).toBeInTheDocument(); + }); +}); diff --git a/src/view/job-data/style.css b/src/view/job-data/style.css index a5a4bc2..1ec7c32 100644 --- a/src/view/job-data/style.css +++ b/src/view/job-data/style.css @@ -204,3 +204,362 @@ opacity: 1; } } + +/* ── 岗位详情 ── */ +.job-brief { + display: flex; + flex-direction: column; + gap: 16px; + --jd-border: #e8ecf2; + --jd-muted: #64748b; + --jd-ink: #0f172a; +} + +.brief-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding-bottom: 14px; + border-bottom: 1px solid var(--jd-border); +} + +.brief-head-main { + min-width: 0; +} + +.job-brief .brief-title { + margin: 0 0 6px; + color: var(--jd-ink); + line-height: 1.35; +} + +.brief-subtitle { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + color: var(--jd-muted); + font-size: 13px; +} + +.brief-company { + color: #334155; + font-weight: 600; +} + +.brief-dot { + color: #cbd5e1; +} + +.brief-head-side { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} + +.brief-salary { + color: #f97316; + font-size: 19px; + font-weight: 700; + white-space: nowrap; +} + +.brief-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; +} + +.brief-badges .ant-tag { + margin: 0; +} + +/* 时间线这类字段横排铺开,比 Descriptions 的两列更省纵向空间 */ +.brief-meta { + display: flex; + flex-wrap: wrap; + gap: 10px 28px; +} + +.brief-meta-item { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 12.5px; +} + +.brief-meta-label { + color: #94a3b8; +} + +.brief-meta-value { + color: #475569; + font-variant-numeric: tabular-nums; +} + +.brief-chips { + display: inline-flex; + flex-wrap: wrap; + gap: 4px; +} + +.brief-chip { + padding: 1px 8px; + border-radius: 6px; + background: #f1f5f9; + color: #475569; + font-size: 11.5px; +} + +.brief-sections { + display: flex; + flex-direction: column; + gap: 14px; +} + +/* 左侧色条按小节语义变色,长 JD 里能快速定位到「要求」那一段 */ +.brief-section { + padding: 12px 14px 12px 16px; + border-left: 3px solid #cbd5e1; + border-radius: 0 12px 12px 0; + background: #f8fafc; +} + +.brief-section.is-duty { + border-left-color: #1677ff; +} + +.brief-section.is-require { + border-left-color: #f97316; +} + +.brief-section.is-bonus { + border-left-color: #10b981; +} + +.brief-section-title { + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 8px; + color: var(--jd-ink); + font-size: 13.5px; + font-weight: 650; +} + +.brief-section.is-duty .brief-section-title .anticon { + color: #1677ff; +} + +.brief-section.is-require .brief-section-title .anticon { + color: #f97316; +} + +.brief-section.is-bonus .brief-section-title .anticon { + color: #10b981; +} + +.brief-section-list { + margin: 0; + padding-left: 20px; + color: #334155; + font-size: 13px; + line-height: 1.9; +} + +.brief-section-list li::marker { + color: #94a3b8; + font-variant-numeric: tabular-nums; +} + +.brief-empty { + padding: 32px 0; +} + +.brief-extra { + display: flex; + flex-wrap: wrap; + gap: 8px 24px; + padding-top: 12px; + border-top: 1px dashed var(--jd-border); +} + +.brief-extra-item { + display: flex; + align-items: center; + gap: 6px; + color: var(--jd-muted); + font-size: 12.5px; +} + +.brief-extra-item .anticon { + color: #94a3b8; +} + +.brief-status { + margin: 0; + font-size: 11px; +} + +.brief-raw-text { + max-height: 320px; + margin: 4px 0 0; + padding: 12px; + border-radius: 10px; + background: #f8fafc; + color: #64748b; + font-size: 12px; + line-height: 1.8; + overflow: auto; + white-space: pre-wrap; +} + +/* ── 沟通记录 ── */ +.chat-placeholder { + display: grid; + place-items: center; + padding: 60px 0; +} + +.chat-legend { + display: flex; + align-items: center; + gap: 6px; + padding-bottom: 10px; + margin-bottom: 10px; + border-bottom: 1px solid #f1f5f9; +} + +.chat-legend .ant-tag { + margin: 0; + font-size: 11px; +} + +.chat-legend-company { + margin-left: auto; + overflow: hidden; + color: #94a3b8; + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chat-thread { + display: flex; + flex-direction: column; + gap: 12px; + max-height: 56vh; + padding-right: 4px; + overflow-y: auto; +} + +.chat-day { + display: flex; + align-items: center; + gap: 10px; + color: #94a3b8; + font-size: 11px; +} + +/* 日期两侧各拉一条细线,把长会话按天切开 */ +.chat-day::before, +.chat-day::after { + flex: 1; + height: 1px; + background: #eef2f7; + content: ""; +} + +.chat-system { + align-self: center; + max-width: 80%; + padding: 3px 12px; + border-radius: 999px; + background: #f1f5f9; + color: #94a3b8; + font-size: 11.5px; + text-align: center; +} + +/* 对方在左、我方在右:只靠 row-reverse 翻转,两侧共用同一套结构 */ +.chat-row { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.chat-row.is-mine { + flex-direction: row-reverse; +} + +.chat-avatar { + display: grid; + width: 30px; + height: 30px; + flex: 0 0 auto; + border-radius: 50%; + color: #ffffff; + font-size: 12px; + font-weight: 600; + place-items: center; +} + +.chat-row.is-peer .chat-avatar { + background: #94a3b8; +} + +.chat-row.is-mine .chat-avatar { + background: #1677ff; +} + +.chat-bubble-wrap { + display: flex; + max-width: 74%; + flex-direction: column; + gap: 3px; +} + +.chat-row.is-mine .chat-bubble-wrap { + align-items: flex-end; +} + +.chat-sender { + display: flex; + align-items: baseline; + gap: 6px; + color: #94a3b8; + font-size: 11px; +} + +.chat-row.is-mine .chat-sender { + flex-direction: row-reverse; +} + +.chat-time { + font-variant-numeric: tabular-nums; +} + +.chat-bubble { + padding: 8px 12px; + border-radius: 12px; + font-size: 13px; + line-height: 1.6; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.chat-row.is-peer .chat-bubble { + border-bottom-left-radius: 4px; + background: #f1f5f9; + color: #334155; +} + +.chat-row.is-mine .chat-bubble { + border-bottom-right-radius: 4px; + background: #1677ff; + color: #ffffff; +} diff --git a/src/view/resume-optimizer/MockInterviewSetup.test.ts b/src/view/resume-optimizer/MockInterviewSetup.test.ts index 5c3b1d2..685b59d 100644 --- a/src/view/resume-optimizer/MockInterviewSetup.test.ts +++ b/src/view/resume-optimizer/MockInterviewSetup.test.ts @@ -34,8 +34,15 @@ describe("mock interview job selection", () => { }); it("builds interview context from the selected job", () => { - expect(buildInterviewJobContext(job())).toContain( + // JD 由调用方从后端取清洗后的正文传进来,不再直接读 job.detail + expect(buildInterviewJobContext(job(), "负责 Agent 平台研发")).toContain( "岗位:Agent 开发工程师\n公司:示例科技\n薪资:20-30K\n地点:南京\nJD:\n负责 Agent 平台研发", ); }); + + it("没取到岗位描述时只拼元信息,不留空的 JD 段", () => { + const context = buildInterviewJobContext(job(), " "); + expect(context).toContain("岗位:Agent 开发工程师"); + expect(context).not.toContain("JD:"); + }); }); diff --git a/src/view/resume-optimizer/MockInterviewSetup.tsx b/src/view/resume-optimizer/MockInterviewSetup.tsx index 6143b8e..3c9bf50 100644 --- a/src/view/resume-optimizer/MockInterviewSetup.tsx +++ b/src/view/resume-optimizer/MockInterviewSetup.tsx @@ -21,6 +21,7 @@ import { SearchOutlined, } from "@ant-design/icons"; import { invoke } from "@tauri-apps/api/core"; +import { fetchJobDescriptionText } from "@/lib/job-description"; import type { CommandResult } from "@/types/command"; import type { JobDetail } from "@/types/job-detail"; import { @@ -64,14 +65,21 @@ export function isSelectableInterviewJob(job: JobDetail): boolean { return title.length > 0 && !PLACEHOLDER_JOB_TITLES.has(title) && !PLACEHOLDER_COMPANY_NAMES.has(company); } -export function buildInterviewJobContext(job: JobDetail): string { +/** + * 拼给模型的岗位上下文。 + * + * `description` 由调用方从 [`fetchJobDescriptionText`] 取——抓下来的 + * `job.detail` 混着反爬注入的样式代码和噪声词,原样喂进去既占额度又干扰判断。 + * 这里保持纯函数,取数留在调用方 + */ +export function buildInterviewJobContext(job: JobDetail, description: string): string { const metadata = [ `岗位:${job.title.trim()}`, job.company_name.trim() ? `公司:${job.company_name.trim()}` : "", job.salary.trim() ? `薪资:${job.salary.trim()}` : "", job.location?.trim() ? `地点:${job.location.trim()}` : "", ].filter(Boolean); - const detail = job.detail.trim(); + const detail = description.trim(); return [...metadata, detail ? `JD:\n${detail}` : ""].filter(Boolean).join("\n").slice(0, 6000); } @@ -86,6 +94,8 @@ export function MockInterviewSetup({ value, onChange, disabled }: MockInterviewS const [jobsLoading, setJobsLoading] = useState(false); const [jobsError, setJobsError] = useState(""); const [customJobOpen, setCustomJobOpen] = useState(false); + // 清洗后的 JD,展示和拼上下文共用同一份,不再各读一次 job.detail + const [description, setDescription] = useState(""); const patch = (next: Partial) => onChange({ ...value, ...next }); const loadJobs = () => { @@ -118,19 +128,22 @@ export function MockInterviewSetup({ value, onChange, disabled }: MockInterviewS ); const selectedJob = jobs.find((item) => item.id === value.selectedJobId); - const selectJob = (jobId?: string) => { + const selectJob = async (jobId?: string) => { if (!jobId) { + setDescription(""); patch({ selectedJobId: undefined, jobTitle: "", companyName: "", jobContext: "" }); return; } const job = jobs.find((item) => item.id === jobId); if (!job) return; setCustomJobOpen(false); + const detail = await fetchJobDescriptionText(job.id); + setDescription(detail); patch({ selectedJobId: job.id, jobTitle: job.title.trim(), companyName: job.company_name.trim(), - jobContext: buildInterviewJobContext(job), + jobContext: buildInterviewJobContext(job, detail), }); }; @@ -163,7 +176,7 @@ export function MockInterviewSetup({ value, onChange, disabled }: MockInterviewS placeholder="搜索公司或岗位名称" notFoundContent={jobsLoading ? "正在加载岗位…" : "暂无可用的真实岗位数据"} options={jobOptions} - onChange={selectJob} + onChange={(jobId) => void selectJob(jobId)} className="mi-job-select" /> {jobsError && ( @@ -186,11 +199,11 @@ export function MockInterviewSetup({ value, onChange, disabled }: MockInterviewS {selectedJob.salary && {selectedJob.salary}} - {selectedJob.detail && ( + {description && ( {selectedJob.detail} }]} + items={[{ key: "jd", label: "查看完整岗位描述", children:
{description}
}]} /> )}
diff --git a/src/view/resume-optimizer/index.tsx b/src/view/resume-optimizer/index.tsx index 9f1b478..e2e8e51 100644 --- a/src/view/resume-optimizer/index.tsx +++ b/src/view/resume-optimizer/index.tsx @@ -5,6 +5,7 @@ import type { MockInterviewQuestionReview } from "@/types/analysis"; import type { JobDetail } from "@/types/job-detail"; import { MockInterviewHome } from "./MockInterviewHome"; import { buildInterviewJobContext } from "./MockInterviewSetup"; +import { fetchJobDescriptionText } from "@/lib/job-description"; import { MockInterviewPanel } from "./MockInterviewPanel"; import { MockInterviewReportPage } from "./MockInterviewReportPage"; import { MockInterviewSetupPage } from "./MockInterviewSetupPage"; @@ -85,13 +86,18 @@ function ResumeOptimizerPage({ config, onOpenLlmConfig, pendingInterviewJob, onP // 从岗位管理带岗位过来时直接进配置页,岗位信息已经填好,用户只需要挑面试参数 useEffect(() => { if (!pendingInterviewJob) return; - setSettings({ - ...DEFAULT_INTERVIEW_SETTINGS, - selectedJobId: pendingInterviewJob.id, - jobTitle: pendingInterviewJob.title.trim(), - companyName: pendingInterviewJob.company_name.trim(), - jobContext: buildInterviewJobContext(pendingInterviewJob), - }); + // JD 要先经后端清洗,取数是异步的;页面切换不等它, + // 岗位标题公司这些本地就有,用户可以立刻开始挑面试参数 + const job = pendingInterviewJob; + void fetchJobDescriptionText(job.id).then((detail) => + setSettings({ + ...DEFAULT_INTERVIEW_SETTINGS, + selectedJobId: job.id, + jobTitle: job.title.trim(), + companyName: job.company_name.trim(), + jobContext: buildInterviewJobContext(job, detail), + }), + ); setSetupFromJob(true); setPage({ name: "setup" }); onPendingInterviewHandled?.(); From ac32f0348febf70034408dbf5f23539583e9383b Mon Sep 17 00:00:00 2001 From: patricLee Date: Thu, 20 Aug 2026 10:58:14 +0800 Subject: [PATCH 2/7] =?UTF-8?q?feat:=E5=91=A8=E6=9C=9F=E6=8A=95=E9=80=92?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E6=AE=B5=E6=8A=95=E9=80=92=E6=97=B6?= =?UTF-8?q?=E6=AE=B5=E4=B8=8E=E5=AE=9A=E6=97=B6=E7=BB=93=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 定时能力: - 每日投递时段支持多段,能空出午休(09-12 / 14-18),用 24 小时格子拖选 - 可设「跑满 N 小时自动结束」,提交任务时换算成绝对时刻 - 时段外暂停投递但继续回复未读,到点自动恢复 - 老配置的 window_start_minute/window_end_minute 自动并入多段列表, 缺这条迁移会静默把用户的夜间时段重置成 09:00-18:00 修「一直在投递、回复轮不上」,三条独立成因: - 一轮投递没有上界,关键词稍宽就能跑几小时,等待期根本轮不到。 加 RoundBudget(最多 N 条 / M 分钟 / 连续失败 5 次熔断),只对周期投递默认生效 - 等待期短于轮询间隔时一次都不回复,改成进入空闲期先回一轮再睡 - 回复耗时不计入周期,把间隔撑成「间隔 + N×回复耗时」,改用绝对 deadline 计时 另外:单轮投递失败不再直接终止长驻任务(连续 3 轮才终止), sync_chat_history 挪进空闲期内不再额外占用周期预算 界面: - 启动弹窗改成紧凑列表,周期参数收进右侧抽屉,主弹窗只留一行摘要 - 配置中心「岗位筛选」新增周期投递块,存每次启动的初值 --- src-tauri/src/command/rpa/run_flow.rs | 30 +- src-tauri/src/config.rs | 209 +++++ .../rpa/boss/handler/position_say_hello.rs | 112 ++- .../rpa/liepin/handler/position_say_hello.rs | 67 +- src-tauri/src/rpa/mod.rs | 1 + src-tauri/src/rpa/run_flow.rs | 311 ++++--- src-tauri/src/rpa/schedule.rs | 857 ++++++++++++++++++ src-tauri/src/task/mod.rs | 34 +- src/App.tsx | 5 +- src/components/HourGrid.test.tsx | 105 +++ src/components/HourGrid.tsx | 100 ++ src/types/app-config.ts | 143 +++ src/types/rpa.test.ts | 246 +++++ src/types/rpa.ts | 116 +++ src/view/config/PeriodicDeliverySection.tsx | 204 +++++ src/view/config/index.tsx | 12 + .../config/periodic-delivery-section.test.tsx | 201 ++++ src/view/workspace/index.tsx | 324 +++++-- 18 files changed, 2845 insertions(+), 232 deletions(-) create mode 100644 src-tauri/src/rpa/schedule.rs create mode 100644 src/components/HourGrid.test.tsx create mode 100644 src/components/HourGrid.tsx create mode 100644 src/view/config/PeriodicDeliverySection.tsx create mode 100644 src/view/config/periodic-delivery-section.test.tsx diff --git a/src-tauri/src/command/rpa/run_flow.rs b/src-tauri/src/command/rpa/run_flow.rs index 2665c6b..bfc7dd7 100644 --- a/src-tauri/src/command/rpa/run_flow.rs +++ b/src-tauri/src/command/rpa/run_flow.rs @@ -3,7 +3,10 @@ use crate::{ config::{load_app_config, resolve_job_profile}, dao::{model::JobProfileSnapshot, profile_snapshot_dao}, logger, - rpa::run_flow::{self, EnvCheckResult, FlowMode, PlatformKind, ReadinessReport}, + rpa::{ + run_flow::{self, EnvCheckResult, FlowMode, PlatformKind, ReadinessReport}, + schedule::{self, PeriodicPlan}, + }, task::{JobTaskInfo, JobTaskOverview, JobTaskProfile, JOB_TASK_MANAGER}, }; @@ -105,9 +108,9 @@ pub async fn preflight_job_task( pub async fn boss_flow( app_handle: tauri::AppHandle, mode: FlowMode, - interval_minutes: Option, + plan: Option, ) -> CommandResult<()> { - rpa_flow(app_handle, PlatformKind::Boss, mode, interval_minutes).await + rpa_flow(app_handle, PlatformKind::Boss, mode, plan).await } #[tauri::command] @@ -115,7 +118,7 @@ pub async fn rpa_flow( app_handle: tauri::AppHandle, platform: PlatformKind, mode: FlowMode, - interval_minutes: Option, + plan: Option, ) -> CommandResult<()> { let _running_guard = match run_flow::try_start_job_task() { Ok(guard) => guard, @@ -163,7 +166,7 @@ pub async fn rpa_flow( Ok(runtime) => runtime.block_on(run_flow::execute_rpa_flow( platform, mode, - interval_minutes, + plan, &app_runtime_config, )), Err(e) => Err(anyhow::anyhow!("{e}")), @@ -192,12 +195,19 @@ pub fn start_job_task( app_handle: tauri::AppHandle, platform: PlatformKind, mode: FlowMode, - interval_minutes: Option, + plan: Option, profile_id: Option, ) -> CommandResult { - if mode == FlowMode::PeriodicJobHunting && interval_minutes.is_none_or(|minutes| minutes == 0) { - return CommandResult::err("周期性投递间隔必须大于 0 分钟"); - } + // 计划在入队时就校验并规整一次,落进任务里的是最终形态。等到 worker 起来才发现 + // 「结束时间已经过了」,用户看到的是任务一闪而过,什么都没发生 + let plan = if mode == FlowMode::PeriodicJobHunting { + match schedule::resolve_plan(plan, chrono::Local::now()) { + Ok(plan) => Some(plan), + Err(error) => return CommandResult::err(error), + } + } else { + None + }; let base_config = match crate::config::load_app_config_inner(app_handle) { Ok(config) => config, @@ -251,7 +261,7 @@ pub fn start_job_task( return CommandResult::err(format!("任务准备未完成:{missing}")); } - match JOB_TASK_MANAGER.submit(platform, mode, interval_minutes, config, task_profile) { + match JOB_TASK_MANAGER.submit(platform, mode, plan, config, task_profile) { Ok(task) => CommandResult::ok(task), Err(error) => CommandResult::err(error), } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index b479259..004b55c 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -101,6 +101,7 @@ pub fn default_app_config() -> AppRuntimeConfig { replay_config, analysis_config: AnalysisConfig::default(), reply_polling_config: ReplyPollingConfig::default(), + periodic_delivery_config: PeriodicDeliveryConfig::default(), browser_config: BrowserConfig { user_data_dir: "".to_string(), chrome_exe_path: None, @@ -396,6 +397,7 @@ pub fn validate_and_normalize(config: &mut AppRuntimeConfig) -> Result<(), Strin normalize_llm_retry_config(&mut config.llm_retry_config); normalize_llm_fallbacks(&mut config.llm_fallbacks)?; normalize_analysis_config(&mut config.analysis_config); + config.periodic_delivery_config.migrate_legacy_window(); normalize_job_profiles(config)?; config.browser_config.max_parallel_tasks = config .browser_config @@ -609,6 +611,10 @@ pub struct AppRuntimeConfig { #[serde(default)] pub reply_polling_config: ReplyPollingConfig, + /// 周期投递的默认参数。启动任务时把它当初值带进弹窗,改动只对那次任务生效 + #[serde(default)] + pub periodic_delivery_config: PeriodicDeliveryConfig, + /// 浏览器运行配置 pub browser_config: BrowserConfig, @@ -1295,6 +1301,129 @@ fn default_humanize_delay_max_seconds() -> u64 { 120 } +// ================================ +// 周期投递配置 +// +// 这里存的是启动弹窗的初值,不是正在跑的任务的参数:任务一旦入队就带着自己的 +// 计划快照,之后改这里不会影响它。放在顶层而不是方案卡里,理由和轮询节奏一样 +// ——它是运行节奏,不是求职策略。 +// ================================ + +/// 一天的分钟数。窗口用「零点起的分钟数」表示,才装得下 09:30 这种半点边界 +pub const MINUTES_PER_DAY: u32 = 24 * 60; +/// 单轮打招呼上限的允许区间上界 +pub const MAX_GREETS_PER_ROUND: u32 = 200; +/// 单轮时长上限的允许区间上界(分钟) +pub const MAX_ROUND_MINUTES: u64 = 240; +/// 自动结束时长的允许区间上界(小时) +pub const MAX_RUN_HOURS: u64 = 72; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct PeriodicDeliveryConfig { + /// 两轮投递之间的间隔(分钟) + #[serde(default = "default_delivery_interval_minutes")] + pub interval_minutes: u64, + + /// 是否只在指定时段投递。关掉后全天可投 + #[serde(default)] + pub window_enabled: bool, + + /// 投递时段,可以有多段:上午一段、下午一段,中间的午休就空出来了。 + /// 每段以「零点起的分钟数」表示,左闭右开 + #[serde(default = "default_delivery_windows")] + pub windows: Vec, + + /// 单段时代的字段,只用于读入老配置,读完即并入 `windows` 并不再写回。 + /// + /// 缺了这条迁移的表现不是报错而是静默重置:老用户升级后时段回到 09:00-18:00, + /// 而他原本设的可能是夜间投递 + #[serde(default, skip_serializing)] + pub window_start_minute: Option, + + #[serde(default, skip_serializing)] + pub window_end_minute: Option, + + /// 启动后最多跑多少小时,0 表示不自动结束。 + /// + /// 存成时长而不是绝对时刻:配置是「下次也这么跑」的模板,存死某个钟点 + /// 隔天就过期了。提交任务时才换算成绝对的结束时刻 + #[serde(default)] + pub max_run_hours: u64, + + /// 单轮最多打招呼多少条,0 表示不限。 + /// + /// 这是「一直在投递、回复轮不上」的正解:岗位列表几乎是无限的, + /// 一轮不设上界就可能跑几个小时,两轮之间的空闲期自然永远轮不到 + #[serde(default = "default_max_greets_per_round")] + pub max_greets_per_round: u32, + + /// 单轮最长跑多少分钟,0 表示不限 + #[serde(default = "default_max_round_minutes")] + pub max_round_minutes: u64, +} + +/// 配置文件里的一段投递时段。与 `rpa::schedule::DailyWindow` 同形, +/// 但配置层不该反向依赖 RPA 模块,提交任务时再转换 +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeliveryWindow { + pub start_minute: u32, + pub end_minute: u32, +} + +impl PeriodicDeliveryConfig { + /// 把老配置的单段字段并进 `windows`。已经有多段数据时以多段为准 + fn migrate_legacy_window(&mut self) { + let legacy = self.window_start_minute.zip(self.window_end_minute); + if let Some((start, end)) = legacy { + if self.windows.is_empty() { + self.windows = vec![DeliveryWindow { + start_minute: start, + end_minute: end, + }]; + } + } + self.window_start_minute = None; + self.window_end_minute = None; + if self.windows.is_empty() { + self.windows = default_delivery_windows(); + } + } +} + +impl Default for PeriodicDeliveryConfig { + fn default() -> Self { + Self { + interval_minutes: default_delivery_interval_minutes(), + window_enabled: false, + windows: default_delivery_windows(), + window_start_minute: None, + window_end_minute: None, + max_run_hours: 0, + max_greets_per_round: default_max_greets_per_round(), + max_round_minutes: default_max_round_minutes(), + } + } +} + +fn default_delivery_interval_minutes() -> u64 { + 30 +} + +fn default_delivery_windows() -> Vec { + vec![DeliveryWindow { + start_minute: 9 * 60, + end_minute: 18 * 60, + }] +} + +fn default_max_greets_per_round() -> u32 { + 30 +} + +fn default_max_round_minutes() -> u64 { + 60 +} + // ================================ // 岗位分析配置 // @@ -1604,6 +1733,7 @@ mod tests { let mut value = serde_yaml::to_value(default_app_config()).unwrap(); let root = value.as_mapping_mut().unwrap(); root.remove(serde_yaml::Value::String("reply_polling_config".into())); + root.remove(serde_yaml::Value::String("periodic_delivery_config".into())); root.get_mut(serde_yaml::Value::String("replay_config".into())) .and_then(serde_yaml::Value::as_mapping_mut) .unwrap() @@ -1615,6 +1745,85 @@ mod tests { assert_eq!(config.replay_config.auto_reply_window_hours, 24); assert_eq!(config.reply_polling_config.interval_minutes, 5); assert_eq!(config.reply_polling_config.max_conversations_per_round, 10); + assert_eq!( + config.periodic_delivery_config, + PeriodicDeliveryConfig::default() + ); + assert_eq!(config.periodic_delivery_config.interval_minutes, 30); + assert_eq!(config.periodic_delivery_config.max_greets_per_round, 30); + } + + /// 老配置里投递时段是 `window_start_minute`/`window_end_minute` 两个标量。 + /// 缺了这条迁移,表现不是报错而是静默重置:老用户升级后时段回到 09:00-18:00, + /// 而他原本设的可能是夜间投递,任务于是在完全不该跑的时间开投 + #[test] + fn a_legacy_single_window_config_migrates_into_the_window_list() { + let mut config = default_app_config(); + config.periodic_delivery_config.windows = Vec::new(); + config.periodic_delivery_config.window_start_minute = Some(22 * 60); + config.periodic_delivery_config.window_end_minute = Some(6 * 60); + + validate_and_normalize(&mut config).unwrap(); + + assert_eq!( + config.periodic_delivery_config.windows, + vec![DeliveryWindow { + start_minute: 22 * 60, + end_minute: 6 * 60, + }] + ); + // 迁移完就把老字段清空,写回配置文件时不再出现 + assert_eq!(config.periodic_delivery_config.window_start_minute, None); + assert_eq!(config.periodic_delivery_config.window_end_minute, None); + } + + /// 已经有多段数据时不能被老字段覆盖,否则每次存盘都会退回单段 + #[test] + fn an_existing_window_list_wins_over_the_legacy_fields() { + let mut config = default_app_config(); + config.periodic_delivery_config.windows = vec![ + DeliveryWindow { start_minute: 9 * 60, end_minute: 12 * 60 }, + DeliveryWindow { start_minute: 14 * 60, end_minute: 18 * 60 }, + ]; + config.periodic_delivery_config.window_start_minute = Some(0); + config.periodic_delivery_config.window_end_minute = Some(60); + + validate_and_normalize(&mut config).unwrap(); + + assert_eq!(config.periodic_delivery_config.windows.len(), 2); + assert_eq!( + config.periodic_delivery_config.windows[0].start_minute, + 9 * 60 + ); + } + + /// 时段列表被清空时兜回默认,而不是留一个「开了时段限制却一段都没有」的空壳 + #[test] + fn an_empty_window_list_falls_back_to_the_default_window() { + let mut config = default_app_config(); + config.periodic_delivery_config.windows = Vec::new(); + + validate_and_normalize(&mut config).unwrap(); + + assert_eq!( + config.periodic_delivery_config.windows, + default_delivery_windows() + ); + } + + /// 单轮上限的默认值不是随便填的:它是「一直在投递、回复轮不上」的正解。 + /// 哪天被改回 0(不限),周期投递会退回到一轮跑几个小时、空闲期永远轮不到的状态, + /// 而配置读取、任务启动全都正常,症状只在挂了半天之后才显出来 + #[test] + fn periodic_delivery_defaults_bound_a_single_round() { + let config = PeriodicDeliveryConfig::default(); + + assert!(config.max_greets_per_round > 0); + assert!(config.max_round_minutes > 0); + assert!(config.interval_minutes > 0); + // 时段与自动结束默认不启用:这两项是可选约束,不该在用户没设过时突然生效 + assert!(!config.window_enabled); + assert_eq!(config.max_run_hours, 0); } #[test] diff --git a/src-tauri/src/rpa/boss/handler/position_say_hello.rs b/src-tauri/src/rpa/boss/handler/position_say_hello.rs index 40bad8e..884442b 100644 --- a/src-tauri/src/rpa/boss/handler/position_say_hello.rs +++ b/src-tauri/src/rpa/boss/handler/position_say_hello.rs @@ -1,5 +1,5 @@ use std::collections::HashSet; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::{ auto_analysis, @@ -13,6 +13,7 @@ use crate::{ greet::build_greet_resources, run_flow::is_job_task_stop_requested, run_flow::PlatformKind, + schedule::{BudgetVerdict, RoundBudget}, }, utils::salary::decode_salary, verify, @@ -25,11 +26,13 @@ use urlencoding::encode; // 岗位打招呼轰炸 pub async fn position_say_hello( app_runtime_config: &AppRuntimeConfig, + budget: RoundBudget, ) -> Result<(), anyhow::Error> { let app_runtime_config = app_runtime_config.clone(); browser::with_browser(|connection| { Box::pin(async move { - position_say_hello_on_page(connection, connection.tab(), &app_runtime_config).await + position_say_hello_on_page(connection, connection.tab(), &app_runtime_config, budget) + .await }) }) .await @@ -40,8 +43,10 @@ pub async fn position_say_hello_on_page( connection: &ChromiumPage, page: &Page, app_runtime_config: &AppRuntimeConfig, + budget: RoundBudget, ) -> Result<(), anyhow::Error> { let app_runtime_config = app_runtime_config.clone(); + let round_started = Instant::now(); let search_url = build_job_search_url(&app_runtime_config.job_filter_config); // 加载本地已处理的岗位ID,用于去重 let mut processed_job_ids: HashSet = job_detail_dao::list() @@ -74,11 +79,19 @@ pub async fn position_say_hello_on_page( } let mut no_new_count = 0u32; let mut stats = RoundStats::default(); + // 撞上平台每日沟通上限之后打招呼会一条接一条地失败,而列表还能一直往下滚。 + // 这个计数是那种「零产出却停不下来」的唯一出口 + let mut consecutive_greet_failures = 0u32; let stop_reason = 'outer: loop { if is_job_task_stop_requested() { break 'outer StopReason::UserStopped; } + if let Some(reason) = + budget_stop_reason(&budget, &stats, round_started, consecutive_greet_failures) + { + break 'outer reason; + } let jeb_card_area_eles = page.eles(".card-area")?; logger::info(format!("页面加载到{}条岗位卡片", jeb_card_area_eles.len()))?; @@ -93,6 +106,11 @@ pub async fn position_say_hello_on_page( if is_job_task_stop_requested() { break 'outer StopReason::UserStopped; } + if let Some(reason) = + budget_stop_reason(&budget, &stats, round_started, consecutive_greet_failures) + { + break 'outer reason; + } stats.scanned += 1; let greet_job = match read_job_card(page, &job_card_area_ele, &processed_job_ids) { Ok(CardOutcome::Ready(greet_job)) => *greet_job, @@ -155,9 +173,10 @@ pub async fn position_say_hello_on_page( &app_runtime_config, ); match handle_greet(connection, greet_job.clone(), app_runtime_config.clone()).await { - Ok(()) => {} + Ok(()) => consecutive_greet_failures = 0, Err(error) => { stats.greet_failed += 1; + consecutive_greet_failures += 1; logger::warning(greet_failure_message( &greet_job.title, &greet_job.company_name, @@ -382,6 +401,12 @@ enum StopReason { EmptyList, /// 打招呼失败且策略要求立即终止 GreetFailureAborted, + /// 本轮打招呼条数达到设定上限 + GreetQuotaReached, + /// 本轮耗时达到设定上限 + TimeLimitReached, + /// 连续多次打招呼失败,本轮熔断 + TooManyGreetFailures, } impl StopReason { @@ -389,6 +414,15 @@ impl StopReason { match self { StopReason::ReachedBottom => "岗位列表已触底,该搜索条件下的岗位已全部浏览完毕", StopReason::NoMoreFromApi => "接口返回无更多岗位,该搜索条件下的岗位已全部浏览完毕", + StopReason::GreetQuotaReached => { + "本轮打招呼条数已达设定上限,提前结束本轮;周期投递会在下一轮继续" + } + StopReason::TimeLimitReached => { + "本轮运行时长已达设定上限,提前结束本轮;周期投递会在下一轮继续" + } + StopReason::TooManyGreetFailures => { + "连续多次打招呼失败,可能已达平台每日沟通上限或触发了安全验证,本轮提前结束" + } StopReason::NoNewJobs => { "连续多次滚动均未加载到新岗位,判定已到列表末尾,这是正常结束、不是程序崩溃;可更换岗位关键词/城市,或改用周期投递持续跟进" } @@ -402,6 +436,25 @@ impl StopReason { } } +/// 把预算判定翻译成本轮的结束原因。预算还够时返回 None +fn budget_stop_reason( + budget: &RoundBudget, + stats: &RoundStats, + round_started: Instant, + consecutive_greet_failures: u32, +) -> Option { + match budget.check( + stats.greet_success, + round_started.elapsed(), + consecutive_greet_failures, + ) { + BudgetVerdict::Continue => None, + BudgetVerdict::GreetLimit => Some(StopReason::GreetQuotaReached), + BudgetVerdict::TimeLimit => Some(StopReason::TimeLimitReached), + BudgetVerdict::FailureLimit => Some(StopReason::TooManyGreetFailures), + } +} + /// 跳过计数达到步长时才打进度日志,避免刷屏 fn should_log_skip_progress(skipped_in_round: u32) -> bool { skipped_in_round > 0 && skipped_in_round.is_multiple_of(SKIP_PROGRESS_STEP) @@ -1439,6 +1492,59 @@ mod tests { } } + /// 不设上界时行为必须与改造前完全一致,否则「单轮自动求职」会莫名其妙提前收工 + #[test] + fn an_unlimited_budget_never_ends_the_round_early() { + let stats = RoundStats { + greet_success: 500, + ..RoundStats::default() + }; + + assert_eq!( + budget_stop_reason(&RoundBudget::unlimited(), &stats, Instant::now(), 99), + None + ); + } + + #[test] + fn budget_limits_map_to_their_own_stop_reasons() { + let budget = RoundBudget { + max_greets: 3, + max_minutes: 0, + max_consecutive_greet_failures: 5, + }; + let fresh = RoundStats::default(); + let spent = RoundStats { + greet_success: 3, + ..RoundStats::default() + }; + + assert_eq!(budget_stop_reason(&budget, &fresh, Instant::now(), 0), None); + assert_eq!( + budget_stop_reason(&budget, &spent, Instant::now(), 0), + Some(StopReason::GreetQuotaReached) + ); + assert_eq!( + budget_stop_reason(&budget, &fresh, Instant::now(), 5), + Some(StopReason::TooManyGreetFailures) + ); + } + + /// 提前结束不是故障,文案必须说清楚「下一轮还会继续」, + /// 否则用户会以为周期投递挂了 + #[test] + fn budget_stop_reasons_read_as_normal_endings() { + assert!(StopReason::GreetQuotaReached + .describe() + .contains("下一轮继续")); + assert!(StopReason::TimeLimitReached + .describe() + .contains("下一轮继续")); + assert!(StopReason::TooManyGreetFailures + .describe() + .contains("每日沟通上限")); + } + #[test] fn generation_failure_with_no_explicit_fallback_never_calls_send() { let calls = std::cell::Cell::new(0); diff --git a/src-tauri/src/rpa/liepin/handler/position_say_hello.rs b/src-tauri/src/rpa/liepin/handler/position_say_hello.rs index 3643fd5..180455c 100644 --- a/src-tauri/src/rpa/liepin/handler/position_say_hello.rs +++ b/src-tauri/src/rpa/liepin/handler/position_say_hello.rs @@ -14,6 +14,7 @@ use crate::{ greet::build_greet_resources, liepin::LIEPIN_SITE_URL, run_flow::is_job_task_stop_requested, + schedule::{BudgetVerdict, RoundBudget}, }, utils::salary::decode_salary, verify, @@ -23,12 +24,15 @@ use rust_drission::{utils::sleep_random_ms, ChromiumPage, Page}; use serde::Deserialize; use urlencoding::encode; -pub async fn position_say_hello(config: &AppRuntimeConfig) -> Result<(), anyhow::Error> { +pub async fn position_say_hello( + config: &AppRuntimeConfig, + budget: RoundBudget, +) -> Result<(), anyhow::Error> { let config = config.clone(); browser::with_browser(|connection| { - Box::pin( - async move { position_say_hello_on_page(connection, connection.tab(), &config).await }, - ) + Box::pin(async move { + position_say_hello_on_page(connection, connection.tab(), &config, budget).await + }) }) .await } @@ -38,8 +42,13 @@ pub async fn position_say_hello_on_page( connection: &ChromiumPage, page: &Page, config: &AppRuntimeConfig, + budget: RoundBudget, ) -> Result<(), anyhow::Error> { let config = config.clone(); + let round_started = Instant::now(); + // 页级 RoundStats 每页都会重置,而预算按整轮算,所以另攒两个整轮计数 + let mut round_greeted = 0u32; + let mut consecutive_greet_failures = 0u32; let search_url = build_job_search_url(&config); let mut processed_job_ids: HashSet = job_detail_dao::list() .unwrap_or_default() @@ -64,6 +73,15 @@ pub async fn position_say_hello_on_page( logger::info("猎聘求职任务已结束")?; return Ok(()); } + if let Some(reason) = budget_stop_reason( + &budget, + round_greeted, + round_started, + consecutive_greet_failures, + ) { + logger::info(reason)?; + return Ok(()); + } let jobs = collect_jobs(page)?; if jobs.is_empty() { @@ -78,6 +96,16 @@ pub async fn position_say_hello_on_page( logger::info("猎聘求职任务已结束")?; return Ok(()); } + if let Some(reason) = budget_stop_reason( + &budget, + round_greeted, + round_started, + consecutive_greet_failures, + ) { + logger::info(stats.summary())?; + logger::info(reason)?; + return Ok(()); + } stats.scanned += 1; let db_id = format!("liepin:{}", job.platform_job_id); @@ -135,14 +163,18 @@ pub async fn position_say_hello_on_page( match greet_job(connection, job.clone(), config.clone()).await { Ok(false) => { stats.skipped_hold += 1; + consecutive_greet_failures = 0; } Ok(true) => { stats.greeted += 1; + round_greeted += 1; + consecutive_greet_failures = 0; processed_job_ids.insert(format!("liepin:{}", job.platform_job_id)); processed_job_ids.insert(job.platform_job_id.clone()); } Err(error) => { stats.greet_failed += 1; + consecutive_greet_failures += 1; logger::warning(greet_failure_message(&job.title, &job.company_name, &error))?; continue; } @@ -159,6 +191,33 @@ pub async fn position_say_hello_on_page( } } +/// 把预算判定翻译成本轮的结束语。预算还够时返回 None。 +/// +/// 提前结束不是故障,文案必须说清楚「下一轮还会继续」,否则用户会以为投递挂了 +fn budget_stop_reason( + budget: &RoundBudget, + round_greeted: u32, + round_started: Instant, + consecutive_greet_failures: u32, +) -> Option<&'static str> { + match budget.check( + round_greeted, + round_started.elapsed(), + consecutive_greet_failures, + ) { + BudgetVerdict::Continue => None, + BudgetVerdict::GreetLimit => { + Some("猎聘本轮打招呼条数已达设定上限,提前结束本轮;周期投递会在下一轮继续") + } + BudgetVerdict::TimeLimit => { + Some("猎聘本轮运行时长已达设定上限,提前结束本轮;周期投递会在下一轮继续") + } + BudgetVerdict::FailureLimit => { + Some("猎聘连续多次打招呼失败,可能已达平台每日沟通上限或触发了安全验证,本轮提前结束") + } + } +} + /// 本页岗位处理统计。跳过类逐条打日志会把有效信息淹掉,改为汇总一条。 #[derive(Debug, Clone, Default, PartialEq, Eq)] struct RoundStats { diff --git a/src-tauri/src/rpa/mod.rs b/src-tauri/src/rpa/mod.rs index 6541f43..f093721 100644 --- a/src-tauri/src/rpa/mod.rs +++ b/src-tauri/src/rpa/mod.rs @@ -6,3 +6,4 @@ pub mod liepin; pub mod polling; pub mod reply_effects; pub mod run_flow; +pub mod schedule; diff --git a/src-tauri/src/rpa/run_flow.rs b/src-tauri/src/rpa/run_flow.rs index d71af53..011f8fb 100644 --- a/src-tauri/src/rpa/run_flow.rs +++ b/src-tauri/src/rpa/run_flow.rs @@ -2,13 +2,17 @@ use std::{ cell::RefCell, sync::atomic::{AtomicBool, Ordering}, sync::Arc, - time::Duration, + time::{Duration, Instant}, }; +use chrono::{DateTime, Local}; use rust_drission::{ChromiumPage, Page}; use serde::{Deserialize, Serialize}; -use super::{boss, liepin, polling}; +use super::{ + boss, liepin, polling, + schedule::{self, PeriodicPlan, PeriodicState, RoundBudget}, +}; use crate::{config::AppRuntimeConfig, logger}; static JOB_TASK_RUNNING: AtomicBool = AtomicBool::new(false); @@ -486,29 +490,24 @@ pub async fn check_env(platform: PlatformKind) -> Result, + plan: Option, config: &AppRuntimeConfig, ) -> Result<(), anyhow::Error> { if has_managed_job_task_context() { let config = config.clone(); return crate::browser::with_task_browser(|connection, main_tab| { Box::pin(async move { - execute_rpa_flow_with_browser( - connection, - main_tab, - platform, - mode, - interval_minutes, - &config, - ) - .await + execute_rpa_flow_with_browser(connection, main_tab, platform, mode, plan, &config) + .await }) }) .await; } match mode { - FlowMode::JobHunting => execute_job_hunting(platform, config).await, + FlowMode::JobHunting => { + execute_job_hunting(platform, config, RoundBudget::unlimited()).await + } FlowMode::ReplyUnread => { execute_reply_unread(platform, config).await?; Ok(()) @@ -521,9 +520,9 @@ pub async fn execute_rpa_flow( Ok(()) } FlowMode::PeriodicJobHunting => { - let interval = resolve_periodic_interval_minutes(interval_minutes) - .map_err(|e| anyhow::anyhow!(e))?; - periodic_position_say_hello(platform, config, interval).await + let plan = + schedule::resolve_plan(plan, Local::now()).map_err(|e| anyhow::anyhow!(e))?; + periodic_job_hunting(&PeriodicTarget::NewTab(platform), config, &plan).await } FlowMode::PollingReply => polling_reply(&ReplyTarget::NewTab(platform), config).await, } @@ -535,12 +534,19 @@ pub async fn execute_rpa_flow_with_browser( main_tab: &Page, platform: PlatformKind, mode: FlowMode, - interval_minutes: Option, + plan: Option, config: &AppRuntimeConfig, ) -> Result<(), anyhow::Error> { match mode { FlowMode::JobHunting => { - execute_job_hunting_on_page(connection, main_tab, platform, config).await + execute_job_hunting_on_page( + connection, + main_tab, + platform, + config, + RoundBudget::unlimited(), + ) + .await } FlowMode::ReplyUnread => execute_reply_unread_on_page(main_tab, platform, config).await, FlowMode::SyncChatHistory => { @@ -551,10 +557,14 @@ pub async fn execute_rpa_flow_with_browser( Ok(()) } FlowMode::PeriodicJobHunting => { - let interval = resolve_periodic_interval_minutes(interval_minutes) - .map_err(|e| anyhow::anyhow!(e))?; - periodic_position_say_hello_on_page(connection, main_tab, platform, config, interval) - .await + let plan = + schedule::resolve_plan(plan, Local::now()).map_err(|e| anyhow::anyhow!(e))?; + periodic_job_hunting( + &PeriodicTarget::OwnedTab(connection, main_tab, platform), + config, + &plan, + ) + .await } FlowMode::PollingReply => { polling_reply(&ReplyTarget::OwnedTab(main_tab, platform), config).await @@ -564,19 +574,20 @@ pub async fn execute_rpa_flow_with_browser( pub async fn execute_boss_flow( mode: FlowMode, - interval_minutes: Option, + plan: Option, config: &AppRuntimeConfig, ) -> Result<(), anyhow::Error> { - execute_rpa_flow(PlatformKind::Boss, mode, interval_minutes, config).await + execute_rpa_flow(PlatformKind::Boss, mode, plan, config).await } async fn execute_job_hunting( platform: PlatformKind, config: &AppRuntimeConfig, + budget: RoundBudget, ) -> Result<(), anyhow::Error> { match platform { - PlatformKind::Boss => boss::handler::position_say_hello(config).await, - PlatformKind::Liepin => liepin::handler::position_say_hello(config).await, + PlatformKind::Boss => boss::handler::position_say_hello(config, budget).await, + PlatformKind::Liepin => liepin::handler::position_say_hello(config, budget).await, } } @@ -601,13 +612,14 @@ async fn execute_job_hunting_on_page( main_tab: &Page, platform: PlatformKind, config: &AppRuntimeConfig, + budget: RoundBudget, ) -> Result<(), anyhow::Error> { match platform { PlatformKind::Boss => { - boss::handler::position_say_hello_on_page(connection, main_tab, config).await + boss::handler::position_say_hello_on_page(connection, main_tab, config, budget).await } PlatformKind::Liepin => { - liepin::handler::position_say_hello_on_page(connection, main_tab, config).await + liepin::handler::position_say_hello_on_page(connection, main_tab, config, budget).await } } } @@ -694,119 +706,193 @@ async fn polling_reply( } } -fn resolve_periodic_interval_minutes(interval_minutes: Option) -> Result { - match interval_minutes { - Some(value) if value > 0 => Ok(value), - Some(_) => Err("周期性投递间隔必须大于 0 分钟".to_string()), - None => Err("周期性投递缺少执行间隔".to_string()), - } +/// 连续多少轮投递失败即终止整个周期任务。 +/// +/// 单轮失败不该拖垮长驻任务——岗位列表加载超时、平台偶发风控这类错误下一轮多半 +/// 就好了。但连着失败就不是偶发,多半是登录失效或触发了安全验证,再转下去只是空转 +const MAX_CONSECUTIVE_ROUND_FAILURES: u32 = 3; + +/// 一轮周期投递跑在哪里。 +/// +/// 队列任务持有自己的连接和主标签页,兼容入口则每次新开。投递、回复、同步历史 +/// 三件事在两条路径上的差异全都收在这里,主循环只有一份 +enum PeriodicTarget<'a> { + NewTab(PlatformKind), + OwnedTab(&'a ChromiumPage, &'a Page, PlatformKind), } -async fn periodic_position_say_hello( - platform: PlatformKind, - config: &AppRuntimeConfig, - interval_minutes: u64, -) -> Result<(), anyhow::Error> { - loop { - if is_job_task_stop_requested() { - logger::info("周期性投递任务已结束")?; - return Ok(()); +impl<'a> PeriodicTarget<'a> { + fn reply_target(&self) -> ReplyTarget<'a> { + match self { + Self::NewTab(platform) => ReplyTarget::NewTab(*platform), + Self::OwnedTab(_, main_tab, platform) => ReplyTarget::OwnedTab(main_tab, *platform), } + } - logger::info("开始执行本轮周期性投递")?; - execute_job_hunting(platform, config).await?; - - if is_job_task_stop_requested() { - logger::info("周期性投递任务已结束")?; - return Ok(()); + async fn deliver( + &self, + config: &AppRuntimeConfig, + budget: RoundBudget, + ) -> Result<(), anyhow::Error> { + match self { + Self::NewTab(platform) => execute_job_hunting(*platform, config, budget).await, + Self::OwnedTab(connection, main_tab, platform) => { + execute_job_hunting_on_page(connection, main_tab, *platform, config, budget).await + } } + } - logger::info(format!("本轮投递完成,等待{}分钟后继续", interval_minutes))?; - if platform == PlatformKind::Boss { - if let Err(error) = boss::handler::sync_chat_history().await { - logger::warning(format!( - "周期间歇同步 BOSS 历史对话失败,本轮继续等待: {error}" - ))?; + async fn sync_chat_history(&self) -> Result<(), anyhow::Error> { + match self { + Self::NewTab(PlatformKind::Boss) => { + boss::handler::sync_chat_history().await?; + Ok(()) + } + Self::OwnedTab(_, main_tab, PlatformKind::Boss) => { + boss::handler::sync_chat_history_on_page(main_tab).await?; + Ok(()) } + _ => Ok(()), } - wait_periodic_interval(&ReplyTarget::NewTab(platform), config, interval_minutes).await?; } } -async fn periodic_position_say_hello_on_page( - connection: &ChromiumPage, - main_tab: &Page, - platform: PlatformKind, +/// 周期投递主循环。 +/// +/// 每次转到循环顶部都重新问一遍计划「现在该干嘛」,而不是自己记状态:任务可能 +/// 一挂就是几天,跨过午夜、跨过投递时段边界,靠增量推算迟早会算歪 +async fn periodic_job_hunting( + target: &PeriodicTarget<'_>, config: &AppRuntimeConfig, - interval_minutes: u64, + plan: &PeriodicPlan, ) -> Result<(), anyhow::Error> { - loop { - if is_job_task_stop_requested() { - logger::info("周期性投递任务已结束")?; - return Ok(()); - } - - logger::info("开始执行本轮周期性投递")?; - execute_job_hunting_on_page(connection, main_tab, platform, config).await?; + let budget = RoundBudget::from_plan(plan); + let mut consecutive_failures = 0u32; + // 时段外每轮都播报一次「暂停中」会把日志刷满,只在刚进入暂停时说一次 + let mut pause_announced = false; + loop { if is_job_task_stop_requested() { logger::info("周期性投递任务已结束")?; return Ok(()); } - logger::info(format!("本轮投递完成,等待{}分钟后继续", interval_minutes))?; - if platform == PlatformKind::Boss { - if let Err(error) = boss::handler::sync_chat_history_on_page(main_tab).await { - logger::warning(format!( - "周期间歇同步 BOSS 历史对话失败,本轮继续等待: {error}" + match schedule::plan_state(plan, Local::now()) { + PeriodicState::Finished => { + logger::info("已到设定的结束时间,周期投递任务结束")?; + return Ok(()); + } + PeriodicState::Idle(open_at) => { + if !pause_announced { + logger::info(format!( + "当前不在投递时段({}),暂停投递,{} 恢复;期间继续自动回复未读", + schedule::describe_windows(&plan.windows), + open_at.format("%m-%d %H:%M"), + ))?; + pause_announced = true; + } + if !idle_until(target, config, open_at).await? { + logger::info("周期性投递任务已结束")?; + return Ok(()); + } + } + PeriodicState::Deliver => { + pause_announced = false; + logger::info("开始执行本轮周期性投递")?; + let started = Instant::now(); + match target.deliver(config, budget).await { + Ok(()) => consecutive_failures = 0, + Err(error) => { + consecutive_failures += 1; + if consecutive_failures >= MAX_CONSECUTIVE_ROUND_FAILURES { + return Err(error.context(format!( + "周期投递连续 {MAX_CONSECUTIVE_ROUND_FAILURES} 轮失败,已终止任务" + ))); + } + logger::warning(format!( + "本轮投递失败(连续第 {consecutive_failures} 次),等待下一轮重试:{error}" + ))?; + } + } + + if is_job_task_stop_requested() { + logger::info("周期性投递任务已结束")?; + return Ok(()); + } + + let next_at = schedule::next_delivery_at(plan, Local::now()); + logger::info(format!( + "本轮投递用时 {} 分钟,下一轮 {} 开始,其间按轮询节奏检查未读", + started.elapsed().as_secs() / 60, + next_at.format("%m-%d %H:%M"), ))?; + if !idle_until(target, config, next_at).await? { + logger::info("周期性投递任务已结束")?; + return Ok(()); + } } } - wait_periodic_interval( - &ReplyTarget::OwnedTab(main_tab, platform), - config, - interval_minutes, - ) - .await?; } } -/// 周期投递两轮之间的等待期。 +/// 周期投递的空闲期:两轮之间的等待,以及投递时段之外的暂停。 /// /// 这段时间本来是纯空闲——投递间隔通常半小时起步,而调度器对每个平台只放行一个任务, -/// 另起一个轮询任务只会跟投递互抢浏览器。所以把等待期切成若干个轮询间隔, -/// 间隙里顺手把未读回掉,一个任务就同时具备投递和回复两条能力 -async fn wait_periodic_interval( - target: &ReplyTarget<'_>, +/// 另起一个轮询任务只会跟投递互抢浏览器。所以把空闲期交给自动回复,一个任务就同时 +/// 具备投递和回复两条能力。 +/// +/// 用绝对的 deadline 而不是倒扣剩余秒数:回复本身要花时间(默认单轮最多 10 个会话, +/// 每个还带拟人延迟),倒扣的写法把这段耗时算在周期之外,实际周期会被撑成 +/// 「间隔 + N×回复耗时」,投递节奏跟着漂。返回 false 表示中途收到停止请求 +async fn idle_until( + target: &PeriodicTarget<'_>, config: &AppRuntimeConfig, - interval_minutes: u64, -) -> Result<(), anyhow::Error> { - let mut remaining_seconds = interval_minutes.saturating_mul(60); + deadline: DateTime, +) -> Result { + // 同步历史对话放在空闲期之内而不是之外:它一样要占用浏览器,摆在外面等于 + // 每轮都在周期预算之上再加一笔,投递间隔就名不副实了 + if let Err(error) = target.sync_chat_history().await { + logger::warning(format!("周期间歇同步历史对话失败,本轮继续:{error}"))?; + } - while remaining_seconds > 0 { + let mut was_active = true; + loop { if is_job_task_stop_requested() { - logger::info("周期性投递任务已结束")?; - return Ok(()); + return Ok(false); + } + let mut remaining = schedule::seconds_until(deadline, Local::now()); + if remaining == 0 { + return Ok(true); + } + + // 先回一轮再睡。刚投完正是对方可能已经回消息的时候;更要紧的是空闲期短于 + // 轮询间隔时(投递间隔 10 分钟撞上默认 5 分钟轮询加 120 秒抖动就会发生), + // 先睡的写法会让这一整段空闲期一次都不回复 + if polling::is_active_now(&config.reply_polling_config) { + if let Err(error) = run_reply_round(&target.reply_target(), config).await { + logger::warning(format!("周期间歇自动回复失败,稍后重试:{error}"))?; + } + remaining = schedule::seconds_until(deadline, Local::now()); + if remaining == 0 { + return Ok(true); + } } let wake = polling::next_wake_now(&config.reply_polling_config); - let sleep_seconds = wake.seconds().min(remaining_seconds); - if !sleep_interruptible(sleep_seconds).await { - logger::info("周期性投递任务已结束")?; - return Ok(()); + let active = matches!(wake, polling::NextWake::Sleep(_)); + if was_active != active { + logger::info(if active { + "已进入自动回复时段,空闲期恢复回复未读" + } else { + "当前不在自动回复时段,空闲期暂停回复" + })?; + was_active = active; } - remaining_seconds -= sleep_seconds; - // 等待期已经走完就别再插一轮回复,否则下一轮投递被平白推迟。 - // 不在活跃时段时这次醒来只是重新判断时段,此时开口等于半夜秒回 HR - if remaining_seconds > 0 && matches!(wake, polling::NextWake::Sleep(_)) { - if let Err(error) = run_reply_round(target, config).await { - logger::warning(format!("周期间歇自动回复失败,本轮继续等待: {error}"))?; - } + if !sleep_interruptible(wake.seconds().min(remaining)).await { + return Ok(false); } } - - Ok(()) } /// 可被停止请求打断的睡眠。返回 true 表示睡满了,false 表示中途收到停止请求。 @@ -1057,11 +1143,20 @@ mod tests { assert_eq!(liepin, PlatformKind::Liepin); } + /// 周期投递的计划校验发生在真正开跑之前。这里只钉住「run_flow 确实走了校验」, + /// 各种边界由 `schedule` 的单测覆盖 #[test] - fn resolve_periodic_interval_minutes_requires_positive_minutes() { - assert_eq!(resolve_periodic_interval_minutes(Some(10)).unwrap(), 10); - assert!(resolve_periodic_interval_minutes(Some(0)).is_err()); - assert!(resolve_periodic_interval_minutes(None).is_err()); + fn periodic_plan_is_validated_before_the_loop_starts() { + let now = Local::now(); + + assert_eq!( + schedule::resolve_plan(Some(PeriodicPlan::every(10)), now) + .unwrap() + .interval_minutes, + 10 + ); + assert!(schedule::resolve_plan(Some(PeriodicPlan::every(0)), now).is_err()); + assert!(schedule::resolve_plan(None, now).is_err()); } #[test] diff --git a/src-tauri/src/rpa/schedule.rs b/src-tauri/src/rpa/schedule.rs new file mode 100644 index 0000000..060b885 --- /dev/null +++ b/src-tauri/src/rpa/schedule.rs @@ -0,0 +1,857 @@ +//! 周期投递的定时计划。 +//! +//! 和 [`super::polling`] 一样全是纯函数,当前时刻一律由调用方传入。定时这件事 +//! 一旦算错,症状是「到点没开投」或者「该收工了还在投」——两者都要等上几个小时 +//! 才看得出来,集成测试根本覆盖不到,只能靠单测把边界钉死。 + +use chrono::{DateTime, Duration as ChronoDuration, Local, NaiveTime, TimeZone, Timelike}; +use serde::{Deserialize, Serialize}; + +/// 一天里的分钟数上限。窗口用「零点起的分钟数」表示而不是小时, +/// 是为了支持 09:30 这种半点边界 +pub const MINUTES_PER_DAY: u32 = 24 * 60; + +/// 每日投递时段,左闭右开。 +/// +/// 起点等于终点视为全天投递,而不是「一分钟都不投」——用户把两头调成一样时 +/// 想表达的显然是不限制,让投递彻底停摆只会被当成故障。这条语义与 +/// [`super::polling::is_active_hour`] 保持一致 +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +pub struct DailyWindow { + pub start_minute: u32, + pub end_minute: u32, +} + +impl DailyWindow { + pub fn new(start_minute: u32, end_minute: u32) -> Self { + Self { + start_minute: start_minute.min(MINUTES_PER_DAY), + end_minute: end_minute.min(MINUTES_PER_DAY), + } + } + + /// 起止相同 = 全天,此时窗口不构成任何约束 + fn is_all_day(&self) -> bool { + self.start_minute == self.end_minute + } +} + +/// 一次周期投递任务的完整计划。 +/// +/// 全字段 `serde(default)`:前端只传 `interval_minutes` 时构造出来的计划 +/// 等价于改造前的行为——无时段限制、不自动结束、单轮不设上界 +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct PeriodicPlan { + /// 两轮投递之间的间隔(分钟) + pub interval_minutes: u64, + + /// 每日投递时段,可以有多段(例如上午一段、下午一段)。空表示全天可投。 + /// + /// 一律经 [`normalize_windows`] 规整后再存进来:重叠与首尾相接的段已合并, + /// 跨零点的段已拆成两截,因此这里的每一段都满足 `start < end` + #[serde(default)] + pub windows: Vec, + + /// 整个任务的结束时刻。None 表示手动停止前一直跑 + #[serde(default)] + pub run_until: Option>, + + /// 单轮最多打招呼多少条,0 表示不限 + #[serde(default)] + pub max_greets_per_round: u32, + + /// 单轮最长跑多少分钟,0 表示不限 + #[serde(default)] + pub max_round_minutes: u64, +} + +impl PeriodicPlan { + /// 只有间隔、其余全不限的计划。改造前的周期投递就是这个形态 + pub fn every(interval_minutes: u64) -> Self { + Self { + interval_minutes, + windows: Vec::new(), + run_until: None, + max_greets_per_round: 0, + max_round_minutes: 0, + } + } +} + +/// 某一时刻周期任务该处于的状态 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PeriodicState { + /// 现在就该投递 + Deliver, + /// 暂停投递,空闲到这个时刻。空闲期照常自动回复未读 + Idle(DateTime), + /// 已过设定的结束时刻,整个任务收工 + Finished, +} + +/// 校验并规整一份计划。 +/// +/// 间隔为 0 会让周期退化成忙循环,结束时刻早于当前时刻则任务一启动就结束—— +/// 两者都该在提交任务时就说清楚,而不是等跑起来才发现什么都没发生 +pub fn resolve_plan( + plan: Option, + now: DateTime, +) -> Result { + let Some(mut plan) = plan else { + return Err("周期性投递缺少执行计划".to_string()); + }; + if plan.interval_minutes == 0 { + return Err("周期性投递间隔必须大于 0 分钟".to_string()); + } + if plan + .windows + .iter() + .any(|window| window.start_minute > MINUTES_PER_DAY || window.end_minute > MINUTES_PER_DAY) + { + return Err("投递时段必须落在 00:00 - 24:00 之间".to_string()); + } + plan.windows = normalize_windows(&plan.windows); + if let Some(run_until) = plan.run_until { + if run_until <= now { + return Err("自动结束时间必须晚于当前时间".to_string()); + } + } + Ok(plan) +} + +/// 规整一组投递时段:拆开跨零点的段、排序、合并重叠与首尾相接的段。 +/// +/// 覆盖全天时返回空——空在下游一律读作「不限制」,留着一个 00:00-24:00 的段 +/// 只会让后面每一步都多判一次。 +/// +/// 相接的段也要合并:09:00-12:00 与 12:00-14:00 之间并没有真实的空档, +/// 分成两段会让「当前时段何时关闭」算出一个 12:00 的假边界,投递到点白停一次 +pub fn normalize_windows(windows: &[DailyWindow]) -> Vec { + let mut spans: Vec<(u32, u32)> = Vec::new(); + for window in windows { + let start = window.start_minute.min(MINUTES_PER_DAY); + let end = window.end_minute.min(MINUTES_PER_DAY); + if start == end { + // 单段「起止相同 = 全天」的语义在多段里同样成立:整组坍缩成不限制 + return Vec::new(); + } + if start < end { + spans.push((start, end)); + } else { + // 跨零点的段拆成两截,之后就只剩普通区间要处理 + spans.push((start, MINUTES_PER_DAY)); + spans.push((0, end)); + } + } + + spans.sort_unstable(); + let mut merged: Vec<(u32, u32)> = Vec::new(); + for (start, end) in spans { + match merged.last_mut() { + Some(last) if start <= last.1 => last.1 = last.1.max(end), + _ => merged.push((start, end)), + } + } + + if merged.as_slice() == [(0, MINUTES_PER_DAY)] { + return Vec::new(); + } + merged + .into_iter() + .map(|(start, end)| DailyWindow { + start_minute: start, + end_minute: end, + }) + .collect() +} + +/// 某个「零点起的分钟数」是否落在任意一段投递时段内。空表示全天可投 +pub fn is_within_windows(windows: &[DailyWindow], minute_of_day: u32) -> bool { + windows.is_empty() + || windows + .iter() + .any(|window| is_within_window(window, minute_of_day)) +} + +/// 距离下一段投递时段开启的时刻,取所有段里最近的一个 +pub fn next_windows_open(windows: &[DailyWindow], now: DateTime) -> DateTime { + windows + .iter() + .map(|window| next_window_open(window, now)) + .min() + .unwrap_or(now) +} + +/// 当前所处那段时段的关闭时刻。不在任何一段内时返回 None +pub fn current_windows_close( + windows: &[DailyWindow], + now: DateTime, +) -> Option> { + windows + .iter() + .find_map(|window| current_window_close(window, now)) +} + +/// 多段时段的可读描述,用于日志 +pub fn describe_windows(windows: &[DailyWindow]) -> String { + if windows.is_empty() { + return "全天".to_string(); + } + windows + .iter() + .map(describe_window) + .collect::>() + .join("、") +} + +/// 判断某个「零点起的分钟数」是否落在投递时段内 +pub fn is_within_window(window: &DailyWindow, minute_of_day: u32) -> bool { + if window.is_all_day() { + return true; + } + if window.start_minute < window.end_minute { + // 常规时段,例如 09:00-18:00 + minute_of_day >= window.start_minute && minute_of_day < window.end_minute + } else { + // 跨零点时段,例如 22:00-06:00 + minute_of_day >= window.start_minute || minute_of_day < window.end_minute + } +} + +/// 距离下一次窗口开启的时刻。已经在窗口内时返回当前时刻 +pub fn next_window_open(window: &DailyWindow, now: DateTime) -> DateTime { + if is_within_window(window, minute_of_day(now)) { + return now; + } + at_minute_of_day(now, window.start_minute) + .filter(|candidate| *candidate > now) + .unwrap_or_else(|| { + at_minute_of_day(now + ChronoDuration::days(1), window.start_minute) + .unwrap_or(now + ChronoDuration::hours(1)) + }) +} + +/// 当前窗口的关闭时刻。不在窗口内时返回 None +pub fn current_window_close(window: &DailyWindow, now: DateTime) -> Option> { + if !is_within_window(window, minute_of_day(now)) { + return None; + } + let today_close = at_minute_of_day(now, window.end_minute)?; + if today_close > now { + Some(today_close) + } else { + // 跨零点窗口里,终点落在「明天」那一侧 + at_minute_of_day(now + ChronoDuration::days(1), window.end_minute) + } +} + +/// 算出此刻该做什么 +pub fn plan_state(plan: &PeriodicPlan, now: DateTime) -> PeriodicState { + if plan.run_until.is_some_and(|deadline| now >= deadline) { + return PeriodicState::Finished; + } + if is_within_windows(&plan.windows, minute_of_day(now)) { + return PeriodicState::Deliver; + } + let open_at = next_windows_open(&plan.windows, now); + // 窗口在结束时刻之后才开,说明这一觉睡过去就没有下一轮了,直接收工 + match plan.run_until { + Some(deadline) if open_at >= deadline => PeriodicState::Finished, + _ => PeriodicState::Idle(open_at), + } +} + +/// 一轮投递结束后,下一轮应该在什么时候开始。 +/// +/// 取「间隔到期」「窗口关闭」「任务结束」三者中最早的一个:窗口一关就该停下, +/// 哪怕间隔还没走完;结束时刻同理 +pub fn next_delivery_at(plan: &PeriodicPlan, now: DateTime) -> DateTime { + let mut target = + now + ChronoDuration::minutes(plan.interval_minutes.min(i64::MAX as u64) as i64); + if let Some(close_at) = current_windows_close(&plan.windows, now) { + target = target.min(close_at); + } + if let Some(deadline) = plan.run_until { + target = target.min(deadline); + } + target +} + +/// 距离目标时刻还有多少秒,已经过点时返回 0 +pub fn seconds_until(target: DateTime, now: DateTime) -> u64 { + (target - now).num_seconds().max(0) as u64 +} + +/// 把「零点起的分钟数」格式化成 `HH:MM`,用于日志 +pub fn format_minute_of_day(minute: u32) -> String { + let minute = minute.min(MINUTES_PER_DAY); + format!("{:02}:{:02}", minute / 60, minute % 60) +} + +/// 投递时段的可读描述,用于日志 +pub fn describe_window(window: &DailyWindow) -> String { + format!( + "{}-{}", + format_minute_of_day(window.start_minute), + format_minute_of_day(window.end_minute) + ) +} + +/// 撞上平台每日沟通上限之后,打招呼会一条接一条地失败,而岗位列表还能一直往下滚。 +/// 没有这个熔断,本轮就会在毫无产出的情况下把整个投递窗口耗光 +pub const DEFAULT_MAX_CONSECUTIVE_GREET_FAILURES: u32 = 5; + +/// 一轮投递的预算。 +/// +/// 这是「一直在投递、回复轮不上」的正解:岗位列表几乎是无限的,一轮不设上界 +/// 就可能跑几个小时,两轮之间的空闲期自然永远轮不到。字段取 0 一律表示不限, +/// 「单轮自动求职」走 [`RoundBudget::unlimited`],行为与改造前一致 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RoundBudget { + /// 本轮最多打招呼多少条,0 表示不限 + pub max_greets: u32, + /// 本轮最长跑多少分钟,0 表示不限 + pub max_minutes: u64, + /// 连续多少次打招呼失败即熔断,0 表示不熔断 + pub max_consecutive_greet_failures: u32, +} + +/// 预算检查的结论 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BudgetVerdict { + /// 预算还够,继续处理下一个岗位 + Continue, + /// 打招呼条数达到上限 + GreetLimit, + /// 本轮耗时达到上限 + TimeLimit, + /// 连续打招呼失败达到熔断阈值 + FailureLimit, +} + +impl BudgetVerdict { + pub fn is_exhausted(self) -> bool { + !matches!(self, Self::Continue) + } +} + +impl RoundBudget { + /// 不设任何上界。改造前的单轮投递就是这个形态 + pub fn unlimited() -> Self { + Self { + max_greets: 0, + max_minutes: 0, + max_consecutive_greet_failures: 0, + } + } + + pub fn from_plan(plan: &PeriodicPlan) -> Self { + Self { + max_greets: plan.max_greets_per_round, + max_minutes: plan.max_round_minutes, + max_consecutive_greet_failures: DEFAULT_MAX_CONSECUTIVE_GREET_FAILURES, + } + } + + /// 本轮是否还能继续。 + /// + /// 时间由调用方以「本轮已跑了多久」的形式传入,保持这里可测 + pub fn check( + &self, + greeted: u32, + elapsed: std::time::Duration, + consecutive_failures: u32, + ) -> BudgetVerdict { + if self.max_greets > 0 && greeted >= self.max_greets { + return BudgetVerdict::GreetLimit; + } + if self.max_minutes > 0 && elapsed.as_secs() >= self.max_minutes.saturating_mul(60) { + return BudgetVerdict::TimeLimit; + } + if self.max_consecutive_greet_failures > 0 + && consecutive_failures >= self.max_consecutive_greet_failures + { + return BudgetVerdict::FailureLimit; + } + BudgetVerdict::Continue + } +} + +fn minute_of_day(moment: DateTime) -> u32 { + moment.hour() * 60 + moment.minute() +} + +/// 把某一天的日期与「零点起的分钟数」拼成具体时刻。 +/// +/// 夏令时切换那天可能不存在对应的本地时刻,此时返回 None 交由调用方兜底 +fn at_minute_of_day(day: DateTime, minute: u32) -> Option> { + // 24:00 指的是次日零点,NaiveTime 表示不了,换算成「明天 00:00」 + if minute >= MINUTES_PER_DAY { + return at_minute_of_day(day + ChronoDuration::days(1), 0); + } + let time = NaiveTime::from_hms_opt(minute / 60, minute % 60, 0)?; + Local + .from_local_datetime(&day.date_naive().and_time(time)) + .earliest() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(text: &str) -> DateTime { + DateTime::parse_from_rfc3339(text) + .unwrap() + .with_timezone(&Local) + } + + fn local(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> DateTime { + Local + .with_ymd_and_hms(year, month, day, hour, minute, 0) + .unwrap() + } + + fn window(start: u32, end: u32) -> DailyWindow { + DailyWindow::new(start, end) + } + + #[test] + fn regular_window_covers_only_its_own_range() { + let daytime = window(9 * 60, 18 * 60); + + assert!(is_within_window(&daytime, 9 * 60)); + assert!(is_within_window(&daytime, 17 * 60 + 59)); + assert!(!is_within_window(&daytime, 18 * 60)); + assert!(!is_within_window(&daytime, 8 * 60 + 59)); + } + + /// 起止相同表达的是「不限制」。当成空区间会让投递彻底停摆, + /// 用户看到的是任务在跑却什么都不做,比任何报错都难排查 + #[test] + fn identical_bounds_mean_all_day_rather_than_never() { + let all_day = window(9 * 60, 9 * 60); + + assert!(is_within_window(&all_day, 3 * 60)); + assert!(is_within_window(&all_day, 15 * 60)); + } + + #[test] + fn window_crossing_midnight_wraps_correctly() { + let night = window(22 * 60, 6 * 60); + + assert!(is_within_window(&night, 23 * 60)); + assert!(is_within_window(&night, 2 * 60)); + assert!(!is_within_window(&night, 12 * 60)); + } + + #[test] + fn half_hour_boundaries_are_representable() { + let window = window(9 * 60 + 30, 18 * 60 + 30); + + assert!(!is_within_window(&window, 9 * 60 + 29)); + assert!(is_within_window(&window, 9 * 60 + 30)); + assert!(is_within_window(&window, 18 * 60 + 29)); + assert!(!is_within_window(&window, 18 * 60 + 30)); + } + + #[test] + fn next_window_open_returns_now_when_already_inside() { + let now = local(2026, 8, 19, 10, 0); + + assert_eq!(next_window_open(&window(9 * 60, 18 * 60), now), now); + } + + #[test] + fn next_window_open_picks_today_when_the_window_has_not_started() { + let now = local(2026, 8, 19, 7, 0); + + assert_eq!( + next_window_open(&window(9 * 60, 18 * 60), now), + local(2026, 8, 19, 9, 0) + ); + } + + #[test] + fn next_window_open_rolls_over_to_tomorrow_after_the_window_closed() { + let now = local(2026, 8, 19, 20, 0); + + assert_eq!( + next_window_open(&window(9 * 60, 18 * 60), now), + local(2026, 8, 20, 9, 0) + ); + } + + #[test] + fn current_window_close_is_none_outside_the_window() { + let now = local(2026, 8, 19, 20, 0); + + assert_eq!(current_window_close(&window(9 * 60, 18 * 60), now), None); + } + + /// 跨零点窗口的终点落在「明天」那一侧,按当天日期拼出来的时刻已经过去了 + #[test] + fn current_window_close_of_a_midnight_crossing_window_lands_on_the_next_day() { + let now = local(2026, 8, 19, 23, 0); + + assert_eq!( + current_window_close(&window(22 * 60, 6 * 60), now), + Some(local(2026, 8, 20, 6, 0)) + ); + } + + /// 午休那两小时正是多段存在的理由:09-12 与 14-18 中间必须真的停下来 + #[test] + fn two_windows_leave_the_gap_between_them_uncovered() { + let split = normalize_windows(&[window(9 * 60, 12 * 60), window(14 * 60, 18 * 60)]); + + assert_eq!(split.len(), 2); + assert!(is_within_windows(&split, 11 * 60)); + assert!(!is_within_windows(&split, 13 * 60)); + assert!(is_within_windows(&split, 15 * 60)); + assert!(!is_within_windows(&split, 20 * 60)); + } + + #[test] + fn windows_are_sorted_regardless_of_input_order() { + let sorted = normalize_windows(&[window(14 * 60, 18 * 60), window(9 * 60, 12 * 60)]); + + assert_eq!(sorted[0].start_minute, 9 * 60); + assert_eq!(sorted[1].start_minute, 14 * 60); + } + + /// 相接的两段之间没有真实的空档。留成两段会让「当前时段何时关闭」 + /// 算出一个 12:00 的假边界,投递到点白停一次 + #[test] + fn touching_windows_are_merged_into_one() { + let merged = normalize_windows(&[window(9 * 60, 12 * 60), window(12 * 60, 14 * 60)]); + + assert_eq!(merged, vec![window(9 * 60, 14 * 60)]); + } + + #[test] + fn overlapping_windows_are_merged_into_one() { + let merged = normalize_windows(&[window(9 * 60, 13 * 60), window(11 * 60, 18 * 60)]); + + assert_eq!(merged, vec![window(9 * 60, 18 * 60)]); + } + + /// 跨零点的段拆成两截之后,下游就只剩 start < end 的普通区间要处理 + #[test] + fn a_midnight_crossing_window_is_split_into_two_spans() { + let split = normalize_windows(&[window(22 * 60, 6 * 60)]); + + assert_eq!(split, vec![window(0, 6 * 60), window(22 * 60, MINUTES_PER_DAY)]); + assert!(is_within_windows(&split, 23 * 60)); + assert!(is_within_windows(&split, 2 * 60)); + assert!(!is_within_windows(&split, 12 * 60)); + } + + /// 覆盖满一整天等于没有限制,留着一个 00:00-24:00 的段只会让下游每步多判一次 + #[test] + fn windows_covering_the_whole_day_collapse_to_unrestricted() { + assert!(normalize_windows(&[window(0, MINUTES_PER_DAY)]).is_empty()); + assert!(normalize_windows(&[window(0, 12 * 60), window(12 * 60, MINUTES_PER_DAY)]).is_empty()); + assert!(normalize_windows(&[window(9 * 60, 9 * 60)]).is_empty()); + assert!(normalize_windows(&[]).is_empty()); + } + + /// 处在前一段里时,下一轮的边界是这一段的终点,不是最后一段的终点 + #[test] + fn next_delivery_is_capped_by_the_window_the_moment_falls_in() { + let plan = PeriodicPlan { + windows: normalize_windows(&[window(9 * 60, 12 * 60), window(14 * 60, 18 * 60)]), + ..PeriodicPlan::every(120) + }; + + assert_eq!( + next_delivery_at(&plan, local(2026, 8, 19, 11, 0)), + local(2026, 8, 19, 12, 0) + ); + assert_eq!( + next_delivery_at(&plan, local(2026, 8, 19, 17, 0)), + local(2026, 8, 19, 18, 0) + ); + } + + /// 午休期间要醒在 14:00,而不是睡到次日 09:00 + #[test] + fn the_gap_between_windows_wakes_at_the_next_window_not_tomorrow() { + let plan = PeriodicPlan { + windows: normalize_windows(&[window(9 * 60, 12 * 60), window(14 * 60, 18 * 60)]), + ..PeriodicPlan::every(30) + }; + + assert_eq!( + plan_state(&plan, local(2026, 8, 19, 13, 0)), + PeriodicState::Idle(local(2026, 8, 19, 14, 0)) + ); + assert_eq!( + plan_state(&plan, local(2026, 8, 19, 20, 0)), + PeriodicState::Idle(local(2026, 8, 20, 9, 0)) + ); + } + + #[test] + fn multiple_windows_are_described_in_wall_clock_time() { + assert_eq!(describe_windows(&[]), "全天"); + assert_eq!( + describe_windows(&[window(9 * 60, 12 * 60), window(14 * 60, 18 * 60)]), + "09:00-12:00、14:00-18:00" + ); + } + + #[test] + fn a_plan_without_a_window_always_delivers() { + let plan = PeriodicPlan::every(30); + + assert_eq!( + plan_state(&plan, local(2026, 8, 19, 3, 0)), + PeriodicState::Deliver + ); + } + + #[test] + fn a_plan_outside_its_window_idles_until_the_window_opens() { + let plan = PeriodicPlan { + windows: vec![window(9 * 60, 18 * 60)], + ..PeriodicPlan::every(30) + }; + + assert_eq!( + plan_state(&plan, local(2026, 8, 19, 20, 0)), + PeriodicState::Idle(local(2026, 8, 20, 9, 0)) + ); + } + + #[test] + fn a_plan_past_its_deadline_is_finished() { + let plan = PeriodicPlan { + run_until: Some(local(2026, 8, 19, 18, 0)), + ..PeriodicPlan::every(30) + }; + + assert_eq!( + plan_state(&plan, local(2026, 8, 19, 18, 0)), + PeriodicState::Finished + ); + assert_eq!( + plan_state(&plan, local(2026, 8, 19, 17, 59)), + PeriodicState::Deliver + ); + } + + /// 下一次窗口开启已经在结束时刻之后,再睡过去也没有下一轮可投, + /// 与其让任务空等一夜再退出,不如当场收工 + #[test] + fn a_plan_whose_window_reopens_after_the_deadline_finishes_instead_of_idling() { + let plan = PeriodicPlan { + windows: vec![window(9 * 60, 18 * 60)], + run_until: Some(local(2026, 8, 20, 2, 0)), + ..PeriodicPlan::every(30) + }; + + assert_eq!( + plan_state(&plan, local(2026, 8, 19, 20, 0)), + PeriodicState::Finished + ); + } + + #[test] + fn next_delivery_follows_the_interval_when_nothing_else_binds() { + let plan = PeriodicPlan::every(30); + let now = local(2026, 8, 19, 10, 0); + + assert_eq!(next_delivery_at(&plan, now), local(2026, 8, 19, 10, 30)); + } + + /// 窗口一关就该停下,哪怕间隔还没走完——否则最后一轮会溢出到时段之外 + #[test] + fn next_delivery_is_capped_by_the_window_close() { + let plan = PeriodicPlan { + windows: vec![window(9 * 60, 18 * 60)], + ..PeriodicPlan::every(120) + }; + let now = local(2026, 8, 19, 17, 0); + + assert_eq!(next_delivery_at(&plan, now), local(2026, 8, 19, 18, 0)); + } + + #[test] + fn next_delivery_is_capped_by_the_deadline() { + let plan = PeriodicPlan { + run_until: Some(local(2026, 8, 19, 10, 10)), + ..PeriodicPlan::every(120) + }; + let now = local(2026, 8, 19, 10, 0); + + assert_eq!(next_delivery_at(&plan, now), local(2026, 8, 19, 10, 10)); + } + + #[test] + fn resolve_plan_rejects_a_missing_or_degenerate_plan() { + let now = local(2026, 8, 19, 10, 0); + + assert!(resolve_plan(None, now).is_err()); + assert!(resolve_plan(Some(PeriodicPlan::every(0)), now).is_err()); + } + + #[test] + fn resolve_plan_rejects_a_deadline_in_the_past() { + let now = local(2026, 8, 19, 10, 0); + let plan = PeriodicPlan { + run_until: Some(local(2026, 8, 19, 9, 0)), + ..PeriodicPlan::every(30) + }; + + assert!(resolve_plan(Some(plan), now).is_err()); + } + + /// 全天窗口留在计划里只会让后面每一步都多判一次,规整阶段直接摘掉 + #[test] + fn resolve_plan_drops_an_all_day_window() { + let now = local(2026, 8, 19, 10, 0); + let plan = PeriodicPlan { + windows: vec![window(9 * 60, 9 * 60)], + ..PeriodicPlan::every(30) + }; + + assert!(resolve_plan(Some(plan), now).unwrap().windows.is_empty()); + } + + #[test] + fn seconds_until_never_goes_negative() { + let now = local(2026, 8, 19, 10, 0); + + assert_eq!(seconds_until(local(2026, 8, 19, 10, 1), now), 60); + assert_eq!(seconds_until(local(2026, 8, 19, 9, 0), now), 0); + } + + #[test] + fn window_is_described_in_wall_clock_time() { + assert_eq!(format_minute_of_day(9 * 60 + 30), "09:30"); + assert_eq!(format_minute_of_day(0), "00:00"); + assert_eq!(format_minute_of_day(MINUTES_PER_DAY), "24:00"); + assert_eq!(describe_window(&window(9 * 60, 18 * 60)), "09:00-18:00"); + } + + /// 前端只传间隔时,反序列化出来的计划必须等价于改造前的行为 + #[test] + fn a_plan_json_with_only_an_interval_deserializes_to_the_legacy_behaviour() { + let plan: PeriodicPlan = serde_json::from_str(r#"{"interval_minutes":30}"#).unwrap(); + + assert_eq!(plan, PeriodicPlan::every(30)); + } + + #[test] + fn an_unlimited_budget_never_stops_a_round() { + let budget = RoundBudget::unlimited(); + + assert_eq!( + budget.check(10_000, std::time::Duration::from_secs(86_400), 999), + BudgetVerdict::Continue + ); + } + + #[test] + fn a_budget_stops_the_round_once_the_greet_quota_is_spent() { + let budget = RoundBudget { + max_greets: 30, + ..RoundBudget::unlimited() + }; + + assert_eq!( + budget.check(29, std::time::Duration::ZERO, 0), + BudgetVerdict::Continue + ); + assert_eq!( + budget.check(30, std::time::Duration::ZERO, 0), + BudgetVerdict::GreetLimit + ); + } + + #[test] + fn a_budget_stops_the_round_once_it_runs_too_long() { + let budget = RoundBudget { + max_minutes: 60, + ..RoundBudget::unlimited() + }; + + assert_eq!( + budget.check(0, std::time::Duration::from_secs(3599), 0), + BudgetVerdict::Continue + ); + assert_eq!( + budget.check(0, std::time::Duration::from_secs(3600), 0), + BudgetVerdict::TimeLimit + ); + } + + /// 撞上平台每日沟通上限后打招呼会连续失败,而列表还能一直往下滚—— + /// 没有这道熔断,本轮就会在零产出的情况下把整个投递窗口耗光 + #[test] + fn a_budget_trips_on_consecutive_greet_failures() { + let budget = RoundBudget::from_plan(&PeriodicPlan::every(30)); + + assert_eq!( + budget.check(0, std::time::Duration::ZERO, 4), + BudgetVerdict::Continue + ); + assert_eq!( + budget.check(0, std::time::Duration::ZERO, 5), + BudgetVerdict::FailureLimit + ); + } + + #[test] + fn a_budget_built_from_a_plan_carries_its_round_limits() { + let plan = PeriodicPlan { + max_greets_per_round: 12, + max_round_minutes: 45, + ..PeriodicPlan::every(30) + }; + + let budget = RoundBudget::from_plan(&plan); + + assert_eq!(budget.max_greets, 12); + assert_eq!(budget.max_minutes, 45); + assert!(BudgetVerdict::GreetLimit.is_exhausted()); + assert!(!BudgetVerdict::Continue.is_exhausted()); + } + + /// 前端用 `Date.toISOString()` 拼结束时刻,发过来的是 `Z` 结尾的 UTC 串, + /// 而计划里存的是本地时刻。这条链路断掉的表现是任务提交时报「自动结束时间 + /// 必须晚于当前时间」——而用户明明选的是几小时之后 + #[test] + fn a_utc_deadline_from_the_frontend_is_read_as_the_same_instant() { + let plan: PeriodicPlan = serde_json::from_str( + r#"{"interval_minutes":30,"run_until":"2026-08-19T10:00:00.000Z"}"#, + ) + .unwrap(); + + assert_eq!(plan.run_until, Some(at("2026-08-19T10:00:00+00:00"))); + assert_eq!( + plan_state(&plan, at("2026-08-19T09:59:00+00:00")), + PeriodicState::Deliver + ); + assert_eq!( + plan_state(&plan, at("2026-08-19T10:00:00+00:00")), + PeriodicState::Finished + ); + } + + #[test] + fn a_plan_round_trips_through_json_with_its_deadline() { + let plan = PeriodicPlan { + windows: vec![window(9 * 60, 18 * 60)], + run_until: Some(at("2026-08-21T02:00:00+08:00")), + max_greets_per_round: 30, + max_round_minutes: 60, + ..PeriodicPlan::every(30) + }; + + let encoded = serde_json::to_string(&plan).unwrap(); + let decoded: PeriodicPlan = serde_json::from_str(&encoded).unwrap(); + + assert_eq!(decoded, plan); + } +} diff --git a/src-tauri/src/task/mod.rs b/src-tauri/src/task/mod.rs index 79d60c6..dcba07b 100644 --- a/src-tauri/src/task/mod.rs +++ b/src-tauri/src/task/mod.rs @@ -21,7 +21,10 @@ use uuid::Uuid; use crate::{ config::AppRuntimeConfig, - rpa::run_flow::{self, FlowMode, PlatformKind}, + rpa::{ + run_flow::{self, FlowMode, PlatformKind}, + schedule::PeriodicPlan, + }, }; const MAX_QUEUED_TASKS: usize = 32; @@ -61,6 +64,10 @@ pub struct JobTaskInfo { pub profile_name: Option, #[serde(default)] pub profile_snapshot_id: Option, + /// 周期投递任务提交时固定下来的计划,其他模式为空。 + /// 交给前端格式化而不是在这里拼字符串——启动弹窗本来就要预览同一份摘要 + #[serde(default)] + pub plan: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] @@ -73,7 +80,7 @@ pub struct JobTaskOverview { struct TaskEntry { info: JobTaskInfo, - interval_minutes: Option, + plan: Option, config: Arc, cancelled: Arc, } @@ -101,7 +108,7 @@ impl SchedulerState { &mut self, platform: PlatformKind, mode: FlowMode, - interval_minutes: Option, + plan: Option, config: Arc, profile: Option, ) -> Result { @@ -155,12 +162,13 @@ impl SchedulerState { .as_ref() .and_then(|value| value.profile_name.clone()), profile_snapshot_id: profile.and_then(|value| value.profile_snapshot_id), + plan: plan.clone(), }; self.tasks.insert( task_id.clone(), TaskEntry { info: info.clone(), - interval_minutes, + plan, config, cancelled: Arc::new(AtomicBool::new(false)), }, @@ -195,7 +203,7 @@ impl SchedulerState { task_id, platform: entry.info.platform, mode: entry.info.mode, - interval_minutes: entry.interval_minutes, + plan: entry.plan.clone(), config: Arc::clone(&entry.config), cancelled: Arc::clone(&entry.cancelled), }) @@ -305,7 +313,7 @@ struct WorkerInput { task_id: String, platform: PlatformKind, mode: FlowMode, - interval_minutes: Option, + plan: Option, config: Arc, cancelled: Arc, } @@ -333,14 +341,14 @@ impl TaskManager { &self, platform: PlatformKind, mode: FlowMode, - interval_minutes: Option, + plan: Option, config: AppRuntimeConfig, profile: Option, ) -> Result { let max_parallel_tasks = config.browser_config.max_parallel_tasks; let mut state = self.inner.state.lock().unwrap_or_else(|e| e.into_inner()); state.max_parallel_tasks = normalize_parallelism(max_parallel_tasks); - let info = state.enqueue(platform, mode, interval_minutes, Arc::new(config), profile)?; + let info = state.enqueue(platform, mode, plan, Arc::new(config), profile)?; self.inner.wake_scheduler.notify_one(); Ok(info) } @@ -421,7 +429,7 @@ fn spawn_worker(inner: Arc, worker: WorkerInput) { Ok(runtime) => runtime.block_on(run_flow::execute_rpa_flow( worker.platform, worker.mode, - worker.interval_minutes, + worker.plan.clone(), &worker.config, )), Err(error) => Err(anyhow::anyhow!("创建任务运行时失败: {error}")), @@ -546,7 +554,7 @@ mod tests { .enqueue( PlatformKind::Boss, FlowMode::PeriodicJobHunting, - Some(30), + Some(PeriodicPlan::every(30)), Arc::new(default_app_config()), None, ) @@ -556,7 +564,7 @@ mod tests { .enqueue( PlatformKind::Boss, FlowMode::PeriodicJobHunting, - Some(60), + Some(PeriodicPlan::every(60)), Arc::new(default_app_config()), None, ) @@ -603,7 +611,7 @@ mod tests { .enqueue( PlatformKind::Boss, FlowMode::PeriodicJobHunting, - Some(30), + Some(PeriodicPlan::every(30)), Arc::new(default_app_config()), None, ) @@ -622,7 +630,7 @@ mod tests { .enqueue( PlatformKind::Boss, FlowMode::PeriodicJobHunting, - Some(30), + Some(PeriodicPlan::every(30)), Arc::new(default_app_config()), None, ) diff --git a/src/App.tsx b/src/App.tsx index b200994..626e0c8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { Alert, Button, ConfigProvider, Spin, Tabs, Typography } from "antd"; import { RocketOutlined } from "@ant-design/icons"; import "./App.css"; import { useAppConfig } from "@/hooks/useAppConfig"; -import { copyJobProfile, DEFAULT_REGEX_RULE_LIMIT, getAnalysisConfig, getDefaultJobProfile, getJobProfiles, getReplyPollingConfig, selectProfileAfterRemoval, type AnalysisConfig, type AppRuntimeConfig, type BrowserConfig, type GreetConfig, type GreetResource, type JobFilterConfig, type JobProfile, type RegexRule, type ReplayConfig, type ReplyPollingConfig, type ReplyResource, type ReplyTemplate, type ResumeConfig } from "@/types/app-config"; +import { copyJobProfile, DEFAULT_REGEX_RULE_LIMIT, getAnalysisConfig, getDefaultJobProfile, getJobProfiles, getReplyPollingConfig, getPeriodicDeliveryConfig, selectProfileAfterRemoval, type AnalysisConfig, type AppRuntimeConfig, type BrowserConfig, type GreetConfig, type GreetResource, type JobFilterConfig, type JobProfile, type PeriodicDeliveryConfig, type RegexRule, type ReplayConfig, type ReplyPollingConfig, type ReplyResource, type ReplyTemplate, type ResumeConfig } from "@/types/app-config"; import type { JobDetail } from "@/types/job-detail"; import { Onboarding } from "@/view/onboarding"; import { ConfigPage } from "@/view/config"; @@ -88,6 +88,8 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, // 轮询节奏是顶层配置,但同样可能整块缺失,不能直接走 merge const updatePolling = (next: Partial) => update((c) => ({ ...c, reply_polling_config: { ...getReplyPollingConfig(c), ...next } })); + const updatePeriodicDelivery = (next: Partial) => + update((c) => ({ ...c, periodic_delivery_config: { ...getPeriodicDeliveryConfig(c), ...next } })); const updateProfiles = (nextProfiles: JobProfile[], defaultId = config.default_job_profile_id) => update((c) => ({ ...c, job_profiles: nextProfiles, @@ -137,6 +139,7 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, removeReplyResource={(ti: number, ri: number) => updateActiveProfile((p) => ({ ...p, replay_config: { ...p.replay_config, templates: p.replay_config.templates.map((t, i) => i === ti ? { ...t, content: t.content.filter((_, x) => x !== ri) } : t) } }))} updateAnalysis={updateAnalysis} updatePolling={updatePolling} + updatePeriodicDelivery={updatePeriodicDelivery} updateBrowser={(v: Partial) => merge("browser_config", v)} updateResume={(v: Partial) => updateProfileSection("resume_config", v)} updateRule={(i: number, v: Partial) => updateActiveProfile((p) => ({ ...p, job_filter_config: { ...p.job_filter_config, regex_rules: updateAt(p.job_filter_config.regex_rules, i, v) } }))} addRule={() => updateActiveProfile((p) => ({ ...p, job_filter_config: { ...p.job_filter_config, regex_rules: [...p.job_filter_config.regex_rules, { name: "", pattern: "", target: "All", mode: "ACCEPT" }] } }))} diff --git a/src/components/HourGrid.test.tsx b/src/components/HourGrid.test.tsx new file mode 100644 index 0000000..6464fd4 --- /dev/null +++ b/src/components/HourGrid.test.tsx @@ -0,0 +1,105 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import HourGrid, { applyDrag } from "./HourGrid"; +import { HOURS_PER_DAY } from "@/types/app-config"; + +afterEach(cleanup); + +const hoursOf = (...ranges: Array<[number, number]>) => + Array.from({ length: HOURS_PER_DAY }, (_, hour) => + ranges.some(([from, to]) => hour >= from && hour < to), + ); + +const cells = () => screen.getAllByRole("button"); + +/** 模拟一次真实的按下—横扫—松开,包括浏览器在 mouseup 之后补发的 click */ +function dragAcross(from: number, to: number) { + const grid = cells(); + fireEvent.mouseDown(grid[from]); + for (let hour = from; hour !== to + Math.sign(to - from); hour += Math.sign(to - from) || 1) { + fireEvent.mouseEnter(grid[hour]); + if (hour === to) break; + } + fireEvent.mouseUp(window); + fireEvent.click(grid[to]); +} + +describe("applyDrag", () => { + it("把一整段涂成同一个状态,与拖动方向无关", () => { + const blank = hoursOf(); + + expect(applyDrag(blank, { from: 9, to: 11, paint: true })).toEqual(hoursOf([9, 12])); + expect(applyDrag(blank, { from: 11, to: 9, paint: true })).toEqual(hoursOf([9, 12])); + }); + + it("从已选格起笔时是擦除", () => { + expect(applyDrag(hoursOf([9, 18]), { from: 12, to: 13, paint: false })).toEqual( + hoursOf([9, 12], [14, 18]), + ); + }); +}); + +describe("HourGrid", () => { + it("渲染 24 个格子并反映选中状态", () => { + render(); + + const grid = cells(); + expect(grid).toHaveLength(HOURS_PER_DAY); + expect(grid[9].getAttribute("aria-pressed")).toBe("true"); + expect(grid[11].getAttribute("aria-pressed")).toBe("true"); + expect(grid[12].getAttribute("aria-pressed")).toBe("false"); + }); + + /** + * 浏览器在 mouseup 之后还会补发一次 click。少了这道判断,单击一格会被 + * mouseup 和 click 各切一次,最终回到原样——用户看到的是「点不动」 + */ + it("单击一格只切换一次", () => { + const onChange = vi.fn(); + render(); + + dragAcross(9, 9); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(hoursOf([9, 10])); + }); + + it("横扫连选一整段", () => { + const onChange = vi.fn(); + render(); + + dragAcross(9, 11); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(hoursOf([9, 12])); + }); + + // 午休那两小时正是多段存在的理由:在已选区间中间擦掉一截就得到两段 + it("在已选区间中间擦出缺口", () => { + const onChange = vi.fn(); + render(); + + dragAcross(12, 13); + + expect(onChange).toHaveBeenCalledWith(hoursOf([9, 12], [14, 18])); + }); + + // 键盘走的是 click 而没有 mousedown,那条「跳过补发 click」的判断不能误伤它 + it("键盘触发的 click 照常切换", () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(cells()[3]); + + expect(onChange).toHaveBeenCalledWith(hoursOf([3, 4])); + }); + + it("禁用时不响应", () => { + const onChange = vi.fn(); + render(); + + fireEvent.click(cells()[3]); + + expect(onChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/HourGrid.tsx b/src/components/HourGrid.tsx new file mode 100644 index 0000000..8dc26f6 --- /dev/null +++ b/src/components/HourGrid.tsx @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from "react"; +import { HOURS_PER_DAY } from "@/types/app-config"; + +interface DragState { + from: number; + to: number; + /** 这一笔是在涂上还是在擦掉,由按下那一格的当前状态决定 */ + paint: boolean; +} + +interface Props { + /** 24 个小时格的选中状态,第 h 项代表 `[h:00, h+1:00)` */ + value: boolean[]; + onChange: (next: boolean[]) => void; + disabled?: boolean; + /** 无障碍标签前缀,用于拼每一格的 aria-label */ + label?: string; +} + +/** 把一笔涂抹应用到格子上 */ +export function applyDrag(hours: boolean[], drag: DragState): boolean[] { + const from = Math.min(drag.from, drag.to); + const to = Math.max(drag.from, drag.to); + return hours.map((on, hour) => (hour >= from && hour <= to ? drag.paint : on)); +} + +/** + * 24 小时选择格。 + * + * 一天摊成 24 格,点一格切换、按住横扫连选。相比两个时间下拉,它的价值在于 + * 一眼看得出全天的分布——尤其是「上午投、午休停、下午再投」这种带缺口的安排, + * 用起止时间描述要读两遍才明白,摊成格子是一目了然的。 + * + * 代价是粒度只到整点,09:30 这种半点边界表达不了。 + */ +export default function HourGrid({ value, onChange, disabled, label = "投递时段" }: Props) { + const [drag, setDrag] = useState(null); + // 鼠标松开之后浏览器还会补发一次 click。不记下这一笔的来路, + // 单击一格就会被 mouseup 和 click 各切一次,最终回到原样、看着像点不动 + const mouseDriven = useRef(false); + const preview = drag ? applyDrag(value, drag) : value; + + // 拖到格子外面松手同样要结算,否则那一笔会一直挂着, + // 鼠标再移回来会接着涂——看起来就像控件自己在动 + useEffect(() => { + if (!drag) return; + const commit = () => { + onChange(applyDrag(value, drag)); + setDrag(null); + }; + window.addEventListener("mouseup", commit); + return () => window.removeEventListener("mouseup", commit); + }, [drag, onChange, value]); + + return ( +
+
+ {Array.from({ length: HOURS_PER_DAY }, (_, hour) => { + const on = preview[hour] === true; + return ( +
+
+ 00 + 06 + 12 + 18 + 24 +
+
+ ); +} diff --git a/src/types/app-config.ts b/src/types/app-config.ts index a388d1d..44af7ac 100644 --- a/src/types/app-config.ts +++ b/src/types/app-config.ts @@ -218,6 +218,105 @@ export const DEFAULT_REPLY_POLLING_CONFIG: ReplyPollingConfig = { humanize_delay_max_seconds: 120, }; +/** 一天的分钟数。投递时段用「零点起的分钟数」表示,才装得下 09:30 这种半点边界 */ +export const MINUTES_PER_DAY = 24 * 60; +export const MAX_GREETS_PER_ROUND = 200; +export const MAX_ROUND_MINUTES = 240; +export const MAX_RUN_HOURS = 72; + +/** 一段投递时段,以「零点起的分钟数」表示,左闭右开 */ +export interface DailyWindow { + start_minute: number; + end_minute: number; +} + +/** + * 周期投递的默认参数。 + * + * 存的是启动弹窗的初值,不是正在跑的任务的参数——任务一旦入队就带着自己的计划 + * 快照,之后改这里不影响它。和轮询节奏一样放在顶层:这是运行节奏,不是求职策略。 + */ +export interface PeriodicDeliveryConfig { + /** 两轮投递之间的间隔 */ + interval_minutes: number; + /** 是否只在指定时段投递,关掉后全天可投 */ + window_enabled: boolean; + /** 投递时段,可以有多段:上午一段、下午一段,中间的午休就空出来了 */ + windows: DailyWindow[]; + /** 启动后最多跑多少小时,0 表示不自动结束 */ + max_run_hours: number; + /** 单轮最多打招呼多少条,0 表示不限。这是「一直在投递、回复轮不上」的正解 */ + max_greets_per_round: number; + /** 单轮最长跑多少分钟,0 表示不限 */ + max_round_minutes: number; +} + +/** 与 Rust 侧 PeriodicDeliveryConfig 的 serde 默认值保持一致 */ +export const DEFAULT_PERIODIC_DELIVERY_CONFIG: PeriodicDeliveryConfig = { + interval_minutes: 30, + window_enabled: false, + windows: [{ start_minute: 9 * 60, end_minute: 18 * 60 }], + max_run_hours: 0, + max_greets_per_round: 30, + max_round_minutes: 60, +}; + +/** 时段格子的粒度:一格一小时 */ +export const HOURS_PER_DAY = 24; + +/** + * 把时段列表摊成 24 个小时格的选中状态,供格子控件渲染。 + * + * 一格代表 `[h:00, h+1:00)`,只要这一小时里有任何一分钟落在时段内就算选中。 + * 半点边界(09:30)因此会被向外取整——格子的粒度只到整点,这是它的取舍 + */ +export function windowsToHours(windows: DailyWindow[]): boolean[] { + const hours = Array.from({ length: HOURS_PER_DAY }, () => false); + for (const window of windows) { + const start = Math.max(0, Math.min(MINUTES_PER_DAY, window.start_minute)); + const end = Math.max(0, Math.min(MINUTES_PER_DAY, window.end_minute)); + // 跨零点的段(22:00-06:00)在格子上就是两头都亮,不需要单独的表示法 + const ranges = + start < end + ? [[start, end]] + : start > end + ? [ + [start, MINUTES_PER_DAY], + [0, end], + ] + : [[0, MINUTES_PER_DAY]]; // 起止相同 = 全天 + for (const [from, to] of ranges) { + for (let hour = Math.floor(from / 60); hour < Math.ceil(to / 60); hour += 1) { + if (hour >= 0 && hour < HOURS_PER_DAY) hours[hour] = true; + } + } + } + return hours; +} + +/** + * 把 24 个小时格合并回时段列表:连续亮着的格子并成一段。 + * + * 全选返回空数组——空在后端一律读作「不限制」,塞一个 00:00-24:00 进去 + * 只会让每一步判断都多绕一次 + */ +export function hoursToWindows(hours: boolean[]): DailyWindow[] { + const windows: DailyWindow[] = []; + let start: number | null = null; + for (let hour = 0; hour <= HOURS_PER_DAY; hour += 1) { + const on = hour < HOURS_PER_DAY && hours[hour] === true; + if (on && start === null) start = hour; + if (!on && start !== null) { + windows.push({ start_minute: start * 60, end_minute: hour * 60 }); + start = null; + } + } + if (windows.length === 1 && windows[0].start_minute === 0 && windows[0].end_minute === MINUTES_PER_DAY) { + return []; + } + return windows; +} + export interface BrowserConfig { user_data_dir: string; chrome_exe_path: string | null; @@ -261,6 +360,8 @@ export interface AppRuntimeConfig { analysis_config?: AnalysisConfig; /** 旧配置没有这块,读取时请使用 getReplyPollingConfig 兜底 */ reply_polling_config?: ReplyPollingConfig; + /** 旧配置没有这块,读取时请使用 getPeriodicDeliveryConfig 兜底 */ + periodic_delivery_config?: PeriodicDeliveryConfig; browser_config: BrowserConfig; resume_config: ResumeConfig; /** 旧配置/测试 mock 可能暂时不包含这两个字段,读取时请使用 getJobProfiles。 */ @@ -280,6 +381,48 @@ export function getReplyPollingConfig(config: Pick, +): PeriodicDeliveryConfig { + return { + ...DEFAULT_PERIODIC_DELIVERY_CONFIG, + ...(config.periodic_delivery_config ?? {}), + }; +} + +/** + * 两份周期投递配置是否等价。 + * + * 「恢复默认」按钮靠它决定该不该置灰。逐字段比而不是 JSON.stringify:后者依赖 + * 键顺序,两份内容相同、来源不同的对象会被判成不等,按钮于是永远亮着。 + * `windows` 是数组,必须按内容比——引用比较对它永远返回 false + */ +export function isSamePeriodicDelivery( + left: PeriodicDeliveryConfig, + right: PeriodicDeliveryConfig, +): boolean { + return ( + left.interval_minutes === right.interval_minutes && + left.window_enabled === right.window_enabled && + left.max_run_hours === right.max_run_hours && + left.max_greets_per_round === right.max_greets_per_round && + left.max_round_minutes === right.max_round_minutes && + isSameWindows(left.windows, right.windows) + ); +} + +function isSameWindows(left: DailyWindow[], right: DailyWindow[]): boolean { + return ( + left.length === right.length && + left.every( + (window, index) => + window.start_minute === right[index].start_minute && + window.end_minute === right[index].end_minute, + ) + ); +} + /** 把旧版顶层求职配置投影为默认方案,供迁移期 UI 安全读取。 */ export function getJobProfiles(config: AppRuntimeConfig): JobProfile[] { if (config.job_profiles?.length) return config.job_profiles; diff --git a/src/types/rpa.test.ts b/src/types/rpa.test.ts index 2805a33..c759904 100644 --- a/src/types/rpa.test.ts +++ b/src/types/rpa.test.ts @@ -1,11 +1,23 @@ import { describe, expect, it } from "vitest"; import type { JobTaskInfo, JobTaskState, PlatformKind } from "./rpa"; import { + buildPeriodicPlan, countJobTasks, + describePeriodicDelivery, + describePeriodicPlan, + formatMinuteOfDay, getActiveTaskForPlatform, isActiveJobTask, isLongRunningMode, } from "./rpa"; +import { + DEFAULT_PERIODIC_DELIVERY_CONFIG, + HOURS_PER_DAY, + hoursToWindows, + isSamePeriodicDelivery, + windowsToHours, + type PeriodicDeliveryConfig, +} from "./app-config"; function task( taskId: string, @@ -69,3 +81,237 @@ describe("long running modes", () => { (mode) => expect(isLongRunningMode(mode)).toBe(false), ); }); + +describe("periodic delivery plan", () => { + it("renders minute-of-day as wall clock time", () => { + expect(formatMinuteOfDay(0)).toBe("00:00"); + expect(formatMinuteOfDay(9 * 60 + 30)).toBe("09:30"); + expect(formatMinuteOfDay(24 * 60)).toBe("24:00"); + }); + + // 关掉的开关必须落成 null / 0,后端才会按「不限制」处理。塞一个装作没启用的 + // 值进去,任务跑起来就会莫名其妙受一个用户没设过的时段约束 + it("omits the window and deadline when neither is enabled", () => { + const plan = buildPeriodicPlan(DEFAULT_PERIODIC_DELIVERY_CONFIG); + + expect(plan.windows).toEqual([]); + expect(plan.run_until).toBeNull(); + expect(plan.interval_minutes).toBe(30); + expect(plan.max_greets_per_round).toBe(30); + }); + + // 午休那两小时正是多段存在的理由,得原样送到后端 + it("carries every window through when the schedule is enabled", () => { + const plan = buildPeriodicPlan({ + ...DEFAULT_PERIODIC_DELIVERY_CONFIG, + window_enabled: true, + windows: [ + { start_minute: 9 * 60, end_minute: 12 * 60 }, + { start_minute: 14 * 60, end_minute: 18 * 60 }, + ], + }); + + expect(plan.windows).toEqual([ + { start_minute: 540, end_minute: 720 }, + { start_minute: 840, end_minute: 1080 }, + ]); + }); + + // 表单存的是「跑 N 小时」,而任务判断该不该收工只能靠绝对时刻。 + // 换算基准是点下确认的那一刻,所以这里把 now 显式传进去钉死 + it("converts the run duration into an absolute deadline", () => { + const now = new Date("2026-08-19T10:00:00+08:00"); + + const plan = buildPeriodicPlan( + { ...DEFAULT_PERIODIC_DELIVERY_CONFIG, max_run_hours: 8 }, + now, + ); + + expect(plan.run_until).toBe(new Date("2026-08-19T18:00:00+08:00").toISOString()); + }); + + // 摘要只说约束,不说默认。把「全天 · 不自动结束 · 不限条数」一并铺出来, + // 真正被限制住的那几项反而看不见了 + it("summarises only the constraints that are actually set", () => { + const summary = describePeriodicPlan({ + interval_minutes: 30, + windows: [], + run_until: null, + max_greets_per_round: 0, + max_round_minutes: 0, + }); + + expect(summary).toBe("每 30 分钟一轮"); + }); + + it("summarises the window, deadline and round limits together", () => { + const summary = describePeriodicPlan({ + interval_minutes: 30, + windows: [ + { start_minute: 9 * 60, end_minute: 12 * 60 }, + { start_minute: 14 * 60, end_minute: 18 * 60 }, + ], + run_until: new Date(2026, 7, 21, 2, 0).toISOString(), + max_greets_per_round: 30, + max_round_minutes: 60, + }); + + expect(summary).toContain("每 30 分钟一轮"); + expect(summary).toContain("09:00-12:00、14:00-18:00 投递"); + expect(summary).toContain("至 08-21 02:00 结束"); + expect(summary).toContain("单轮至多 30 条"); + expect(summary).toContain("单轮至多 60 分钟"); + }); + + // 后端只带间隔的旧计划照样要能显示,不能因为缺字段就渲染出 undefined + it("summarises a plan that only carries an interval", () => { + expect(describePeriodicPlan({ interval_minutes: 45 })).toBe("每 45 分钟一轮"); + }); +}); + +describe("periodic delivery form summary", () => { + // 折叠起来时这行摘要是配置的唯一出口,默认值也必须说清楚单轮护栏还在 + it("summarises the default form without inventing constraints", () => { + expect(describePeriodicDelivery(DEFAULT_PERIODIC_DELIVERY_CONFIG)).toBe( + "每 30 分钟一轮 · 单轮至多 30 条 · 单轮至多 60 分钟", + ); + }); + + // 表单形态还没启动,说「8 小时后结束」才对得上刚拖的那个滑块; + // 换算成绝对时刻是 buildPeriodicPlan 在提交那一刻的事 + it("states the run limit as a duration rather than a wall clock time", () => { + const summary = describePeriodicDelivery({ + ...DEFAULT_PERIODIC_DELIVERY_CONFIG, + window_enabled: true, + max_run_hours: 8, + }); + + expect(summary).toContain("09:00-18:00 投递"); + expect(summary).toContain("8 小时后结束"); + }); + + it("drops every constraint that is switched off", () => { + expect( + describePeriodicDelivery({ + interval_minutes: 20, + window_enabled: false, + windows: [{ start_minute: 9 * 60, end_minute: 18 * 60 }], + max_run_hours: 0, + max_greets_per_round: 0, + max_round_minutes: 0, + }), + ).toBe("每 20 分钟一轮"); + }); +}); + +describe("小时格与时段列表互转", () => { + const hoursOf = (...ranges: Array<[number, number]>) => + Array.from({ length: HOURS_PER_DAY }, (_, hour) => + ranges.some(([from, to]) => hour >= from && hour < to), + ); + + // 这是这次需求的核心:09-12 与 14-18 之间的午休必须真的留空 + it("把带缺口的格子合并成两段", () => { + expect(hoursToWindows(hoursOf([9, 12], [14, 18]))).toEqual([ + { start_minute: 9 * 60, end_minute: 12 * 60 }, + { start_minute: 14 * 60, end_minute: 18 * 60 }, + ]); + }); + + it("连续的格子只合并成一段", () => { + expect(hoursToWindows(hoursOf([9, 18]))).toEqual([ + { start_minute: 9 * 60, end_minute: 18 * 60 }, + ]); + }); + + // 全选等于不限制。塞一个 00:00-24:00 给后端只会让每一步判断都多绕一次 + it("全选折叠成空数组,全不选也是空数组", () => { + expect(hoursToWindows(hoursOf([0, 24]))).toEqual([]); + expect(hoursToWindows(hoursOf())).toEqual([]); + }); + + it("末尾那格收在 24:00 而不是溢出到次日", () => { + expect(hoursToWindows(hoursOf([22, 24]))).toEqual([ + { start_minute: 22 * 60, end_minute: 24 * 60 }, + ]); + }); + + it("时段列表摊回格子", () => { + expect( + windowsToHours([ + { start_minute: 9 * 60, end_minute: 12 * 60 }, + { start_minute: 14 * 60, end_minute: 18 * 60 }, + ]), + ).toEqual(hoursOf([9, 12], [14, 18])); + }); + + // 跨零点的段在格子上就是两头都亮,不需要单独的表示法 + it("跨零点的段摊成首尾两截", () => { + expect(windowsToHours([{ start_minute: 22 * 60, end_minute: 6 * 60 }])).toEqual( + hoursOf([0, 6], [22, 24]), + ); + }); + + // 半点边界只能向外取整——这是格子粒度的取舍,得钉住免得日后被当成 bug 改坏 + it("半点边界向外取整到整点格", () => { + expect(windowsToHours([{ start_minute: 9 * 60 + 30, end_minute: 17 * 60 + 30 }])).toEqual( + hoursOf([9, 18]), + ); + }); + + it("空列表摊成一格都不亮", () => { + expect(windowsToHours([])).toEqual(hoursOf()); + }); + + it("多段经一轮往返后保持不变", () => { + const windows = [ + { start_minute: 8 * 60, end_minute: 11 * 60 }, + { start_minute: 13 * 60, end_minute: 17 * 60 }, + { start_minute: 20 * 60, end_minute: 22 * 60 }, + ]; + + expect(hoursToWindows(windowsToHours(windows))).toEqual(windows); + }); +}); + +describe("periodic delivery equality", () => { + // 「恢复默认」按钮靠它置灰。用 JSON.stringify 比会依赖键顺序, + // 两份内容相同、来源不同的对象被判成不等,按钮就永远亮着 + it("ignores key order when comparing two equivalent configs", () => { + const reordered = { + max_round_minutes: DEFAULT_PERIODIC_DELIVERY_CONFIG.max_round_minutes, + interval_minutes: DEFAULT_PERIODIC_DELIVERY_CONFIG.interval_minutes, + max_greets_per_round: DEFAULT_PERIODIC_DELIVERY_CONFIG.max_greets_per_round, + max_run_hours: DEFAULT_PERIODIC_DELIVERY_CONFIG.max_run_hours, + window_enabled: DEFAULT_PERIODIC_DELIVERY_CONFIG.window_enabled, + // 内容相同但引用不同的数组,必须按内容判等 + windows: DEFAULT_PERIODIC_DELIVERY_CONFIG.windows.map((window) => ({ ...window })), + }; + + expect(isSamePeriodicDelivery(DEFAULT_PERIODIC_DELIVERY_CONFIG, reordered)).toBe(true); + }); + + const changes: Array<[string, Partial]> = [ + ["interval_minutes", { interval_minutes: 45 }], + ["window_enabled", { window_enabled: true }], + ["windows", { windows: [{ start_minute: 10 * 60, end_minute: 12 * 60 }] }], + ["windows 段数", { + windows: [ + { start_minute: 9 * 60, end_minute: 12 * 60 }, + { start_minute: 14 * 60, end_minute: 18 * 60 }, + ], + }], + ["max_run_hours", { max_run_hours: 8 }], + ["max_greets_per_round", { max_greets_per_round: 10 }], + ["max_round_minutes", { max_round_minutes: 90 }], + ]; + + it.each(changes)("detects a change to %s", (_field, patch) => { + expect( + isSamePeriodicDelivery(DEFAULT_PERIODIC_DELIVERY_CONFIG, { + ...DEFAULT_PERIODIC_DELIVERY_CONFIG, + ...patch, + }), + ).toBe(false); + }); +}); diff --git a/src/types/rpa.ts b/src/types/rpa.ts index d327254..8a521a5 100644 --- a/src/types/rpa.ts +++ b/src/types/rpa.ts @@ -1,3 +1,7 @@ +import type { DailyWindow, PeriodicDeliveryConfig } from "./app-config"; + +export type { DailyWindow }; + export type PlatformKind = "boss" | "liepin"; export type EnvCheckStep = "browser" | "platform_login" | "completed"; @@ -32,6 +36,24 @@ export type JobTaskState = | "failed" | "cancelled"; +/** + * 一次周期投递任务的计划。任务入队时固定下来,之后改配置不影响它。 + * + * 除 `interval_minutes` 外全部可选,只带间隔时等价于改造前的行为: + * 无时段限制、不自动结束、单轮不设上界。 + */ +export interface PeriodicPlan { + interval_minutes: number; + /** 每日投递时段,可以有多段。空或缺省表示全天可投 */ + windows?: DailyWindow[]; + /** RFC3339 本地时刻,到点整个任务收工 */ + run_until?: string | null; + /** 单轮最多打招呼多少条,0 表示不限 */ + max_greets_per_round?: number; + /** 单轮最长跑多少分钟,0 表示不限 */ + max_round_minutes?: number; +} + export interface JobTaskInfo { task_id: string; platform: PlatformKind; @@ -45,6 +67,100 @@ export interface JobTaskInfo { profile_id?: string | null; profile_name?: string | null; profile_snapshot_id?: string | null; + /** 周期投递任务的计划快照;其他模式为空。 */ + plan?: PeriodicPlan | null; +} + +/** + * 把表单里的一组数值拧成提交给后端的计划。 + * + * 「最长运行 N 小时」在这里换算成绝对的结束时刻:表单存的是「下次也这么跑」的 + * 模板,存死某个钟点隔天就过期了;而任务一旦跑起来,判断该不该收工只能靠绝对时刻。 + * 关掉的开关一律落成 null / 0,让后端按「不限制」处理,而不是塞一个装作没启用的值 + */ +export function buildPeriodicPlan( + config: PeriodicDeliveryConfig, + now: Date = new Date(), +): PeriodicPlan { + return { + interval_minutes: config.interval_minutes, + windows: config.window_enabled ? config.windows : [], + run_until: + config.max_run_hours > 0 + ? new Date(now.getTime() + config.max_run_hours * 3_600_000).toISOString() + : null, + max_greets_per_round: config.max_greets_per_round, + max_round_minutes: config.max_round_minutes, + }; +} + +/** 把「零点起的分钟数」格式化成 HH:MM */ +export function formatMinuteOfDay(minute: number): string { + const clamped = Math.max(0, Math.min(24 * 60, Math.round(minute))); + return `${String(Math.floor(clamped / 60)).padStart(2, "0")}:${String(clamped % 60).padStart(2, "0")}`; +} + +/** 多段时段的可读描述,例如 `09:00-12:00、14:00-18:00` */ +export function describeWindows(windows: DailyWindow[]): string { + if (windows.length === 0) return "全天"; + return windows + .map((window) => `${formatMinuteOfDay(window.start_minute)}-${formatMinuteOfDay(window.end_minute)}`) + .join("、"); +} + +/** + * 表单形态的一行摘要,配置折叠起来时替它说话。 + * + * 与 [describePeriodicPlan] 的差别只在自动结束:这里还没启动,说「8 小时后结束」 + * 才对得上用户刚拖的那个滑块;换算成绝对时刻要等任务真的入队。 + */ +export function describePeriodicDelivery(config: PeriodicDeliveryConfig): string { + const parts = [`每 ${config.interval_minutes} 分钟一轮`]; + + if (config.window_enabled && config.windows.length > 0) { + parts.push(`${describeWindows(config.windows)} 投递`); + } + if (config.max_run_hours > 0) { + parts.push(`${config.max_run_hours} 小时后结束`); + } + if (config.max_greets_per_round) { + parts.push(`单轮至多 ${config.max_greets_per_round} 条`); + } + if (config.max_round_minutes) { + parts.push(`单轮至多 ${config.max_round_minutes} 分钟`); + } + + return parts.join(" · "); +} + +/** + * 周期投递计划的一行摘要,任务卡片用。 + * + * 只说约束,不说默认:没设时段就不提时段。把「全天 · 不自动结束 · 不限条数」 + * 一并铺出来,真正被限制住的那几项反而看不见了。 + */ +export function describePeriodicPlan(plan: PeriodicPlan): string { + const parts = [`每 ${plan.interval_minutes} 分钟一轮`]; + + if (plan.windows && plan.windows.length > 0) { + parts.push(`${describeWindows(plan.windows)} 投递`); + } + if (plan.run_until) { + const end = new Date(plan.run_until); + if (!Number.isNaN(end.getTime())) { + parts.push( + `至 ${String(end.getMonth() + 1).padStart(2, "0")}-${String(end.getDate()).padStart(2, "0")} ${String(end.getHours()).padStart(2, "0")}:${String(end.getMinutes()).padStart(2, "0")} 结束`, + ); + } + } + if (plan.max_greets_per_round) { + parts.push(`单轮至多 ${plan.max_greets_per_round} 条`); + } + if (plan.max_round_minutes) { + parts.push(`单轮至多 ${plan.max_round_minutes} 分钟`); + } + + return parts.join(" · "); } export interface JobTaskOverview { diff --git a/src/view/config/PeriodicDeliverySection.tsx b/src/view/config/PeriodicDeliverySection.tsx new file mode 100644 index 0000000..0a35b6c --- /dev/null +++ b/src/view/config/PeriodicDeliverySection.tsx @@ -0,0 +1,204 @@ +import { Button, Typography } from "antd"; +import { + ClockCircleOutlined, + FieldTimeOutlined, + HourglassOutlined, + PoweroffOutlined, + SendOutlined, + UndoOutlined, +} from "@ant-design/icons"; +import { + DEFAULT_PERIODIC_DELIVERY_CONFIG, + hoursToWindows, + isSamePeriodicDelivery, + MAX_GREETS_PER_ROUND, + MAX_ROUND_MINUTES, + MAX_RUN_HOURS, + windowsToHours, + type DailyWindow, + type PeriodicDeliveryConfig, +} from "../../types/app-config"; +import { describeWindows } from "../../types/rpa"; +import HourGrid from "@/components/HourGrid"; +import { SettingGroup, SettingSlider, SettingToggle } from "@/components/SettingField"; + +const { Text } = Typography; + +/** 常用时段组合。一格一格点出「上午投、午休停、下午再投」很费事,预设兜住高频场景 */ +const WINDOW_PRESETS: Array<{ label: string; windows: DailyWindow[] }> = [ + { label: "工作时间 9-18", windows: [{ start_minute: 9 * 60, end_minute: 18 * 60 }] }, + { + label: "上下午 9-12 / 14-18", + windows: [ + { start_minute: 9 * 60, end_minute: 12 * 60 }, + { start_minute: 14 * 60, end_minute: 18 * 60 }, + ], + }, + { label: "夜间 20-24", windows: [{ start_minute: 20 * 60, end_minute: 24 * 60 }] }, +]; + +interface Props { + config: PeriodicDeliveryConfig; + onChange: (next: Partial) => void; + /** + * `card` 是配置页里的独立卡片,自带标题与「恢复默认」; + * `plain` 只出字段,外框和按钮交给调用方——启动弹窗的参数抽屉用这个, + * 它自己的标题栏和 footer 已经承担了同样的职责,再套一层就是两层壳 + */ + variant?: "card" | "plain"; + /** + * 「恢复默认」还原成的那份配置,与当前值相同时按钮置灰。仅 `card` 形态渲染。 + * + * 两处的「默认」不是同一件事:配置页恢复的是出厂值,启动弹窗恢复的是用户 + * 自己在配置页存下的那套。所以基准由调用方给,组件不自作主张 + */ + resetTo?: PeriodicDeliveryConfig; + resetLabel?: string; +} + +/** 当前值是否已经等于基准,用来决定「恢复默认」该不该置灰 */ +export function canResetPeriodicDelivery( + config: PeriodicDeliveryConfig, + resetTo: PeriodicDeliveryConfig, +): boolean { + return !isSamePeriodicDelivery(config, resetTo); +} + +/** + * 周期投递的运行节奏。 + * + * 配置页和启动弹窗的参数抽屉共用同一套控件:抽屉里改的是那一次任务,配置页改的 + * 是下次的初值。两处若各写一份表单,迟早会出现「抽屉有这项、配置页没有」的漂移。 + * + * 「单轮上限」这两项不是可有可无的调优旋钮——岗位列表几乎是无限的,一轮不设上界 + * 就可能跑几个小时,两轮之间的空闲期永远轮不到,表现就是它一直在投递、从不回复。 + */ +export default function PeriodicDeliverySection({ + config, + onChange, + variant = "card", + resetTo, + resetLabel = "恢复默认", +}: Props) { + const fields = ( +
+ + } + title="投递间隔" + description="上一轮结束到下一轮开始之间的间隔,这段时间用来自动回复未读" + min={1} + max={1440} + sliderMax={180} + step={5} + fallback={DEFAULT_PERIODIC_DELIVERY_CONFIG.interval_minutes} + value={config.interval_minutes} + unit="分钟" + onChange={(value) => onChange({ interval_minutes: value })} + /> + } + title="单轮最多打招呼" + description="到顶就结束本轮、进入空闲期,下一轮继续。不设上限时一轮能跑几个小时,中间的未读消息全得排队等着" + min={0} + max={MAX_GREETS_PER_ROUND} + fallback={DEFAULT_PERIODIC_DELIVERY_CONFIG.max_greets_per_round} + value={config.max_greets_per_round} + unit="条" + valueLabel={(value) => (value === 0 ? "不限制" : null)} + onChange={(value) => onChange({ max_greets_per_round: value })} + /> + } + title="单轮最长运行" + description="另一道保险:岗位再多,本轮跑满这么久也会收尾" + min={0} + max={MAX_ROUND_MINUTES} + step={5} + fallback={DEFAULT_PERIODIC_DELIVERY_CONFIG.max_round_minutes} + value={config.max_round_minutes} + unit="分钟" + valueLabel={(value) => (value === 0 ? "不限制" : null)} + onChange={(value) => onChange({ max_round_minutes: value })} + /> + + + + } + title="只在指定时段投递" + description="时段外不投递,但仍然继续自动回复未读;到点自动恢复投递" + checked={config.window_enabled} + onChange={(checked) => onChange({ window_enabled: checked })} + > + {config.window_enabled && ( +
+ onChange({ windows: hoursToWindows(hours) })} + /> +
+ 快捷 + {WINDOW_PRESETS.map((preset) => ( + + ))} +
+
+ {config.windows.length === 0 + ? "一格都没选,任务不会投递;点上方格子选出可投的小时" + : `当前:${describeWindows(config.windows)}`} +
+
+ )} +
+ 0 ? : } + title="自动结束" + description="从任务启动开始计时,到点整个任务收工。不设则一直跑到你手动停止" + min={0} + max={MAX_RUN_HOURS} + fallback={DEFAULT_PERIODIC_DELIVERY_CONFIG.max_run_hours} + value={config.max_run_hours} + unit="小时后" + valueLabel={(value) => (value === 0 ? "不自动结束" : null)} + onChange={(value) => onChange({ max_run_hours: value })} + /> +
+
+ ); + + if (variant === "plain") { + return fields; + } + + return ( +
+
+
+ 周期投递 + + 这里改的是启动任务时的初值,每次启动仍可单独调整;已在跑的任务不受影响 + +
+ {resetTo && ( + + )} +
+ {fields} +
+ ); +} diff --git a/src/view/config/index.tsx b/src/view/config/index.tsx index 0fc51fc..f28513c 100644 --- a/src/view/config/index.tsx +++ b/src/view/config/index.tsx @@ -41,9 +41,12 @@ import { AnalysisConfig, AnalysisTrigger, ReplyPollingConfig, + PeriodicDeliveryConfig, + DEFAULT_PERIODIC_DELIVERY_CONFIG, getAnalysisConfig, getJobProfiles, getReplyPollingConfig, + getPeriodicDeliveryConfig, DEFAULT_AUTO_REPLY_WINDOW_HOURS, DEFAULT_MAX_AUTO_REPLIES, DEFAULT_MAX_REPLY_CHARS, @@ -61,6 +64,7 @@ import { SettingToggle, } from "@/components/SettingField"; import ReplyPollingSection from "./ReplyPollingSection"; +import PeriodicDeliverySection from "./PeriodicDeliverySection"; import { jobTypeOptions, salaryOptions, @@ -268,6 +272,7 @@ export interface ConfigPageProps { removeReplyResource: (templateIndex: number, resourceIndex: number) => void; updateAnalysis: (next: Partial) => void; updatePolling: (next: Partial) => void; + updatePeriodicDelivery: (next: Partial) => void; updateBrowser: (next: Partial) => void; updateResume: (next: Partial) => void; updateRule: (index: number, next: Partial) => void; @@ -1101,6 +1106,13 @@ export function ConfigPage(props: ConfigPageProps) { }, ]} /> + + ); case "greet": diff --git a/src/view/config/periodic-delivery-section.test.tsx b/src/view/config/periodic-delivery-section.test.tsx new file mode 100644 index 0000000..f308662 --- /dev/null +++ b/src/view/config/periodic-delivery-section.test.tsx @@ -0,0 +1,201 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import PeriodicDeliverySection, { + canResetPeriodicDelivery, +} from "./PeriodicDeliverySection"; +import { + DEFAULT_PERIODIC_DELIVERY_CONFIG, + type PeriodicDeliveryConfig, +} from "../../types/app-config"; + +// vitest 没开 globals,testing-library 的自动清理不会注册, +// 上一个用例的 DOM 会留到下一个用例里造成重复匹配 +afterEach(cleanup); + +function config( + overrides: Partial = {}, +): PeriodicDeliveryConfig { + return { ...DEFAULT_PERIODIC_DELIVERY_CONFIG, ...overrides }; +} + +describe("PeriodicDeliverySection 字段", () => { + it.each([ + "投递间隔", + "单轮最多打招呼", + "单轮最长运行", + "只在指定时段投递", + "自动结束", + ])("渲染出「%s」", (label) => { + render(); + + expect(screen.getByText(label)).toBeTruthy(); + }); + + // 时段格子只在开关打开时才有意义,关着还占一片会让人以为它在生效 + it("时段格子跟随开关出现", () => { + const { rerender } = render( + , + ); + expect(screen.queryByLabelText(/投递时段 09:00/)).toBeNull(); + + rerender( + , + ); + + expect(screen.getByLabelText(/投递时段 09:00/)).toBeTruthy(); + }); + + // 09-12 与 14-18 之间的午休正是多段存在的理由,摘要必须把缺口说出来 + it("摘要写出全部时段而不是只写首尾", () => { + render( + , + ); + + expect(screen.getByText("当前:09:00-12:00、14:00-18:00")).toBeTruthy(); + }); + + /** + * 「开了时段限制却一格没选」是个安静的陷阱:任务能入队、能跑, + * 但永远等不到可投的时刻,日志上只有一句「暂停投递」然后再无下文 + */ + it("一格都没选时给出警告而不是静默通过", () => { + render( + , + ); + + expect(screen.getByText(/一格都没选,任务不会投递/)).toBeTruthy(); + }); + + it("点预设一次填好带缺口的两段", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "上下午 9-12 / 14-18" })); + + expect(onChange).toHaveBeenCalledWith({ + windows: [ + { start_minute: 9 * 60, end_minute: 12 * 60 }, + { start_minute: 14 * 60, end_minute: 18 * 60 }, + ], + }); + }); +}); + +describe("PeriodicDeliverySection 形态", () => { + /** + * 抽屉自己的标题栏和 footer 已经承担了标题与「恢复默认」的职责, + * plain 形态再出一份就是两层壳 + */ + it("plain 形态不带外框标题与恢复按钮", () => { + render( + , + ); + + expect(screen.queryByText("周期投递")).toBeNull(); + expect(screen.queryByRole("button", { name: /恢复/ })).toBeNull(); + // 字段本身照常渲染 + expect(screen.getByText("投递间隔")).toBeTruthy(); + }); + + it("card 形态带标题,没给基准时仍不渲染恢复按钮", () => { + render(); + + expect(screen.getByText("周期投递")).toBeTruthy(); + expect(screen.queryByRole("button", { name: /恢复/ })).toBeNull(); + }); +}); + +describe("PeriodicDeliverySection 恢复默认", () => { + it("当前值就是基准时按钮禁用", () => { + render( + , + ); + + expect( + screen.getByRole("button", { name: /恢复默认/ }).hasAttribute("disabled"), + ).toBe(true); + }); + + /** + * 一次性还原整份配置,而不是逐字段发若干次 onChange: + * 调用方普遍用 `{...current, ...next}` 合并,分批发会在中间态上重渲染 + */ + it("改动过之后一次性还原整份配置", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /恢复默认/ })); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith(DEFAULT_PERIODIC_DELIVERY_CONFIG); + }); + + /** + * 两处的「默认」不是同一件事:配置页恢复出厂值,启动弹窗的抽屉恢复的是用户 + * 自己在配置页存下的那套。基准由调用方给,组件不能自作主张回落到出厂值 + */ + it("基准由调用方决定,不写死出厂默认", () => { + const onChange = vi.fn(); + const mine = config({ interval_minutes: 60, max_greets_per_round: 15 }); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: /恢复我的默认/ })); + + expect(onChange).toHaveBeenCalledWith(mine); + }); + + // 抽屉的 footer 自己渲染按钮,得靠同一个判定决定置灰,不然两处会给出不同答案 + it("导出的判定与按钮禁用状态是同一套", () => { + expect( + canResetPeriodicDelivery(config(), DEFAULT_PERIODIC_DELIVERY_CONFIG), + ).toBe(false); + expect( + canResetPeriodicDelivery( + config({ interval_minutes: 90 }), + DEFAULT_PERIODIC_DELIVERY_CONFIG, + ), + ).toBe(true); + }); +}); diff --git a/src/view/workspace/index.tsx b/src/view/workspace/index.tsx index e13e7ce..772e708 100644 --- a/src/view/workspace/index.tsx +++ b/src/view/workspace/index.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Card, + Drawer, Image, Modal, Radio, @@ -26,6 +27,8 @@ import { PlayCircleOutlined, RocketOutlined, SendOutlined, + SlidersOutlined, + UndoOutlined, WarningOutlined, } from "@ant-design/icons"; import { invoke } from "@tauri-apps/api/core"; @@ -41,18 +44,26 @@ import type { PlatformKind, } from "../../types/rpa"; import { + buildPeriodicPlan, countJobTasks, + describePeriodicDelivery, + describePeriodicPlan, isActiveJobTask, } from "../../types/rpa"; +import type { PeriodicPlan } from "../../types/rpa"; import type { JobDetail } from "../../types/job-detail"; import ManualReviewDrawer, { useManualReview } from "./manual-review-drawer"; import { getDefaultJobProfile, getJobProfiles, + getPeriodicDeliveryConfig, getReplyPollingConfig, type AppRuntimeConfig, + type PeriodicDeliveryConfig, } from "../../types/app-config"; -import { NumberField } from "../../components/NumberField"; +import PeriodicDeliverySection, { + canResetPeriodicDelivery, +} from "../config/PeriodicDeliverySection"; type CheckPhase = "idle" | "checking" | "done"; @@ -210,10 +221,6 @@ const TASK_STATE_ORDER: Record = { const PLATFORM_ORDER: PlatformKind[] = ["boss", "liepin"]; -const DEFAULT_INTERVAL_MINUTES = 30; -const MIN_INTERVAL_MINUTES = 1; -const MAX_INTERVAL_MINUTES = 1440; - export function sortTasksForQueue(tasks: JobTaskInfo[]): JobTaskInfo[] { return [...tasks].sort((left, right) => { const stateOrder = TASK_STATE_ORDER[left.status] - TASK_STATE_ORDER[right.status]; @@ -264,6 +271,22 @@ function taskStateColor(status: JobTaskInfo["status"]): string { }[status]; } +/** + * 周期投递里挡住启动的配置问题,没有问题时返回 null。 + * + * 「开了时段限制却一格都没选」是个安静的陷阱:任务能入队、能跑,但永远等不到 + * 可投的时刻,日志上只会看到一句「暂停投递」然后再无下文。挡在启动之前才说得清 + */ +export function describePeriodicBlocker( + config: Pick, +): string | null { + if (config.interval_minutes <= 0) return "投递间隔必须大于 0 分钟"; + if (config.window_enabled && config.windows.length === 0) { + return "投递时段一格都没选,任务不会投递"; + } + return null; +} + export function requestStopJobTask(taskId: string): Promise> { return invoke>("stop_job_task", { taskId }); } @@ -306,7 +329,11 @@ const WorkspacePage = ({ const [logContent, setLogContent] = useState(""); const [startModalOpen, setStartModalOpen] = useState(false); const [selectedMode, setSelectedMode] = useState("job_hunting"); - const [intervalMinutes, setIntervalMinutes] = useState(DEFAULT_INTERVAL_MINUTES); + // 弹窗里的周期参数是本次任务的,改动不回写配置——配置页存的只是初值 + const [periodicDraft, setPeriodicDraft] = useState(() => + getPeriodicDeliveryConfig(config), + ); + const [paramsDrawerOpen, setParamsDrawerOpen] = useState(false); const [selectedProfileId, setSelectedProfileId] = useState(() => getDefaultJobProfile(config).id); const [platform, setPlatform] = useState("boss"); const [reviewDrawerOpen, setReviewDrawerOpen] = useState(false); @@ -441,7 +468,7 @@ const WorkspacePage = ({ async ( startedPlatform: PlatformKind, mode: FlowMode, - intervalMinutes?: number, + plan?: PeriodicPlan, profileId?: string, ) => { setPendingStartPlatforms((current) => ({ @@ -452,7 +479,7 @@ const WorkspacePage = ({ const result = await invoke>("start_job_task", { platform: startedPlatform, mode, - intervalMinutes: mode === "periodic_job_hunting" ? intervalMinutes : undefined, + plan: mode === "periodic_job_hunting" ? plan : undefined, profileId: mode === "job_hunting" || mode === "periodic_job_hunting" ? profileId : undefined, }); if (!result.success || !result.data) { @@ -482,11 +509,18 @@ const WorkspacePage = ({ setModalPlatform(targetPlatform); setSelectedMode("job_hunting"); setSelectedProfileId(getDefaultJobProfile(config).id); + // 每次打开都从配置重新取初值,上一次弹窗里的临时改动不该粘在这一次上 + setPeriodicDraft(getPeriodicDeliveryConfig(config)); + setParamsDrawerOpen(false); setStartModalOpen(true); }, [config]); + const closeParamsDrawer = useCallback(() => setParamsDrawerOpen(false), []); + const closeStartModal = useCallback(() => { setStartModalOpen(false); + // 抽屉挂在弹窗之上,弹窗关了它还浮着就成了没有来路的孤儿面板 + setParamsDrawerOpen(false); }, []); const handleStartConfirm = useCallback(async () => { @@ -494,14 +528,15 @@ const WorkspacePage = ({ await handleRpaFlow( modalPlatform, selectedMode, - selectedMode === "periodic_job_hunting" ? intervalMinutes : undefined, + // 「最长运行 N 小时」在这里才换算成绝对结束时刻,基准是点下确认的这一刻 + selectedMode === "periodic_job_hunting" ? buildPeriodicPlan(periodicDraft) : undefined, selectedMode === "job_hunting" || selectedMode === "periodic_job_hunting" ? selectedProfileId : undefined, ); }, [ closeStartModal, handleRpaFlow, - intervalMinutes, modalPlatform, + periodicDraft, selectedMode, selectedProfileId, ]); @@ -563,6 +598,98 @@ const WorkspacePage = ({ const runningModeLabel = `${taskCounts.running} / ${taskOverview.max_parallel_tasks}`; const pendingReviewCount = manualReview.records.length; + /** 摘要区的一行:左侧窄标签、右侧内容,两行之间靠这个网格对齐 */ + const summaryRow = (label: string, content: React.ReactNode) => ( + <> + {label} +
{content}
+ + ); + + const profileRow = summaryRow( + "求职方案", + <> + !profile.archived).map((profile) => ({ - value: profile.id, - label: `${profile.name}${profile.id === (config.default_job_profile_id || getDefaultJobProfile(config).id) ? "(默认)" : ""}`, - }))} - /> - - 任务加入队列后会固定使用该方案快照,之后修改配置不会影响本次任务。 - - - ) : selectedMode === "reply_unread" ? ( - - ) : null} - + {modeSummary} + + )} + {/* ── 周期投递参数抽屉 ── */} + + + + + } + > + + setPeriodicDraft((current) => ({ ...current, ...next }))} + /> + + {/* ── Collapsible log terminal ── */}
Date: Thu, 20 Aug 2026 13:06:10 +0800 Subject: [PATCH 3/7] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E6=8B=9F=E4=BA=BA?= =?UTF-8?q?=E5=8C=96=EF=BC=8C=E6=8A=95=E9=80=92=E8=8A=82=E5=A5=8F=E4=B8=8E?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=8A=A8=E4=BD=9C=E6=8C=89=E5=BD=93=E6=97=A5?= =?UTF-8?q?=E4=BA=BA=E6=A0=BC=E9=9A=8F=E6=9C=BA=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 平台风控看的不是单次动作像不像人,而是长期模式:每条投递都隔 4 秒、每轮都 正好 30 条,单看每一步都合法,连起来是一条没有呼吸的直线。 刻意不新开一套节奏参数,而是给既有的「单轮上限 / 投递间隔 / 岗位间停顿」蒙上 一层扰动:用户设的 30 条仍是那个量级,但今天可能 26 条、明天 33 条,中途还会 停下来歇几分钟。配置上只多了一个开关和轻度/标准/谨慎三档。 人格种子存在配置里长期不变,每天再由「种子 + 日期」派生出当天的具体策略, 于是同一天内手速与休息习惯自洽,跨天自动换一套,不同用户之间也各不相同。 全程确定性推导,可单测。 - humanize.rs:人格派生与参数塑形,纯函数 - human_pace.rs:微休息、走神、随机跳过,可被停止请求秒级打断 - human_input.rs:贝塞尔鼠标轨迹、逐字符打字、分段滚动,走 CDP Input 事件; 取不到坐标或 CDP 拒绝时一律回退到原有写法,绝不因此让投递失败 随机跳过必须发生在点开卡片之前:BOSS 会给点开过的卡片打 is-seen,放在之后 跳过等于把岗位永久放弃,而本意只是「这次先不投」。已加测试钉住这个顺序。 顺带修复保存配置不回读后端规整结果的问题。人格种子由后端生成,而前端一直把 提交的那份当已保存快照,种子于是每保存一次就换一个,稳定随机直接失效;夹取类 字段同样存在显示漂移。save_app_config 改为返回落盘后的配置。 --- src-tauri/src/command/config.rs | 8 +- src-tauri/src/config.rs | 189 ++++- .../rpa/boss/handler/position_say_hello.rs | 106 ++- .../src/rpa/boss/handler/send_message.rs | 60 +- src-tauri/src/rpa/human_input.rs | 530 +++++++++++++ src-tauri/src/rpa/human_pace.rs | 297 ++++++++ src-tauri/src/rpa/humanize.rs | 694 ++++++++++++++++++ .../rpa/liepin/handler/position_say_hello.rs | 34 +- src-tauri/src/rpa/mod.rs | 3 + src-tauri/src/rpa/reply_effects.rs | 16 +- src-tauri/src/rpa/run_flow.rs | 25 +- src-tauri/src/rpa/schedule.rs | 35 +- src/App.tsx | 6 +- src/hooks/useAppConfig.test.tsx | 35 +- src/hooks/useAppConfig.ts | 11 +- src/lib/tauriConfig.ts | 10 +- src/types/app-config.ts | 43 ++ src/types/command.ts | 4 +- src/view/config/HumanizeSection.tsx | 128 ++++ src/view/config/index.tsx | 10 + 20 files changed, 2195 insertions(+), 49 deletions(-) create mode 100644 src-tauri/src/rpa/human_input.rs create mode 100644 src-tauri/src/rpa/human_pace.rs create mode 100644 src-tauri/src/rpa/humanize.rs create mode 100644 src/view/config/HumanizeSection.tsx diff --git a/src-tauri/src/command/config.rs b/src-tauri/src/command/config.rs index af261a4..61a2161 100644 --- a/src-tauri/src/command/config.rs +++ b/src-tauri/src/command/config.rs @@ -10,13 +10,17 @@ pub fn load_app_config(app_handle: tauri::AppHandle) -> CommandResult CommandResult<()> { +) -> CommandResult { match config::save_app_config_inner(app_handle, config) { - Ok(()) => CommandResult::ok(()), + Ok(saved) => CommandResult::ok(saved), Err(err) => CommandResult::err(err), } } diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 004b55c..f6c4543 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -102,6 +102,7 @@ pub fn default_app_config() -> AppRuntimeConfig { analysis_config: AnalysisConfig::default(), reply_polling_config: ReplyPollingConfig::default(), periodic_delivery_config: PeriodicDeliveryConfig::default(), + humanize_config: HumanizeConfig::default(), browser_config: BrowserConfig { user_data_dir: "".to_string(), chrome_exe_path: None, @@ -193,25 +194,33 @@ pub fn load_app_config_inner(app_handle: tauri::AppHandle) -> Result Result<(), AppError> { +) -> Result { let _permit = read_lock(); save_app_config_unlocked(app_handle, config) } +/// 保存并返回**落盘后的**配置。 +/// +/// 返回规整结果而不是 `()`:`validate_and_normalize` 会做迁移、夹取上下界、 +/// 补生成人格种子,落盘的内容和调用方提交的那份并不相同。不把它交回去, +/// 前端手里就一直是提交前的旧值,下次保存又原样提交一遍——对夹取类字段只是 +/// 显示不同步,对人格种子则是每保存一次就换一套人格,「稳定随机」直接失效 pub(crate) fn save_app_config_unlocked( app_handle: tauri::AppHandle, mut config: AppRuntimeConfig, -) -> Result<(), AppError> { +) -> Result { let path = config_path(&app_handle)?; validate_and_normalize(&mut config).map_err(AppError::validation)?; config.schema_version = CURRENT_SCHEMA_VERSION; let content = serde_yaml::to_string(&config).map_err(|error| { AppError::configuration("无法序列化应用配置").with_detail(error.to_string()) })?; - atomic_write(&path, content.as_bytes()) + atomic_write(&path, content.as_bytes())?; + Ok(config) } fn read_config_file(path: &Path) -> Result { @@ -398,6 +407,7 @@ pub fn validate_and_normalize(config: &mut AppRuntimeConfig) -> Result<(), Strin normalize_llm_fallbacks(&mut config.llm_fallbacks)?; normalize_analysis_config(&mut config.analysis_config); config.periodic_delivery_config.migrate_legacy_window(); + config.humanize_config.ensure_seed(); normalize_job_profiles(config)?; config.browser_config.max_parallel_tasks = config .browser_config @@ -465,6 +475,10 @@ fn normalize_llm_retry_config(retry: &mut LlmRetryConfig) { retry.retry_base_delay_ms = retry .retry_base_delay_ms .clamp(MIN_RETRY_BASE_DELAY_MS, MAX_RETRY_BASE_DELAY_MS); + retry.request_timeout_seconds = retry.request_timeout_seconds.clamp( + MIN_LLM_REQUEST_TIMEOUT_SECONDS, + MAX_LLM_REQUEST_TIMEOUT_SECONDS, + ); } /// 标识只允许字母、数字、下划线和连字符:它会被拼进 keyring 条目名, @@ -615,6 +629,10 @@ pub struct AppRuntimeConfig { #[serde(default)] pub periodic_delivery_config: PeriodicDeliveryConfig, + /// 拟人化。和轮询节奏一样是全局运行行为,不随求职方案变化 + #[serde(default)] + pub humanize_config: HumanizeConfig, + /// 浏览器运行配置 pub browser_config: BrowserConfig, @@ -809,6 +827,10 @@ pub struct LlmRetryConfig { /// 首次重试前的等待毫秒数,之后按指数退避 #[serde(default = "default_retry_base_delay_ms")] pub retry_base_delay_ms: u64, + + /// 单次大模型请求的超时时间(秒) + #[serde(default = "default_request_timeout_seconds")] + pub request_timeout_seconds: u64, } fn default_network_retry_attempts() -> u32 { @@ -819,11 +841,16 @@ fn default_retry_base_delay_ms() -> u64 { 500 } +fn default_request_timeout_seconds() -> u64 { + 120 +} + impl Default for LlmRetryConfig { fn default() -> Self { Self { network_retry_attempts: default_network_retry_attempts(), retry_base_delay_ms: default_retry_base_delay_ms(), + request_timeout_seconds: default_request_timeout_seconds(), } } } @@ -833,6 +860,9 @@ pub const MAX_NETWORK_RETRY_ATTEMPTS: u32 = 5; /// 重试等待时长的允许区间(毫秒) pub const MIN_RETRY_BASE_DELAY_MS: u64 = 100; pub const MAX_RETRY_BASE_DELAY_MS: u64 = 10_000; +/// 单次大模型请求超时的允许区间(秒) +pub const MIN_LLM_REQUEST_TIMEOUT_SECONDS: u64 = 1; +pub const MAX_LLM_REQUEST_TIMEOUT_SECONDS: u64 = 600; /// 降级链中的一环,屏蔽「主用配置」与「备用条目」之间的结构差异。 /// 调用方只需按顺序遍历,不必关心某一环来自哪张表。 @@ -1424,6 +1454,79 @@ fn default_max_round_minutes() -> u64 { 60 } +// ================================ +// 拟人化 +// +// 平台风控看的不是单次动作像不像人,而是长期模式:每条投递都隔 4 秒、每轮都正好 +// 30 条、每天投满同样的量——单看每一步都合法,连起来是一条没有呼吸的直线。 +// +// 所以这里刻意不新开一套节奏参数,而是给既有的「单轮上限 / 投递间隔 / 轮询抖动」 +// 蒙上一层扰动:用户设的 30 条仍是那个量级,但今天可能 26 条、明天 33 条,中途 +// 还会停下来歇几分钟。用户调的是意图,拟人化调的是把意图落成动作的方式 +// ================================ + +/// 拟人化强度。只决定扰动幅度,不引入新的数值参数—— +/// 具体的休息阈值、停顿长度、打字速度全部由当日人格从既有配置派生 +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum HumanizeIntensity { + /// 轻度:只在既有节奏上小幅抖动,几乎不牺牲产出 + Light, + /// 标准:投几十条歇一会儿、偶尔跳过一个岗位,产出降一到两成 + #[default] + Standard, + /// 谨慎:休息更频繁更久、跳过更多、动作更慢,产出明显下降 + Cautious, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct HumanizeConfig { + /// 总开关。关掉后所有节奏与输入行为与改造前完全一致 + #[serde(default)] + pub enabled: bool, + + #[serde(default)] + pub intensity: HumanizeIntensity, + + /// 人格种子,0 表示还没生成过。 + /// + /// 首次启用时随机生成一次就固定下来,之后每天再由它派生出当天的具体策略。 + /// 每次启动都重新掷一次的话,「这台机器有自己的操作习惯」这件事就不成立了—— + /// 而真人的手速、休息习惯是长期稳定、日间微调的 + #[serde(default)] + pub persona_seed: u64, +} + +impl HumanizeConfig { + /// 启用却还没有种子时补一个。 + /// + /// 关闭状态下不生成:种子一旦落盘就代表一个确定的人格,用户只是没开功能, + /// 不该在配置文件里先留下一个将来会被沿用的身份 + fn ensure_seed(&mut self) { + if !self.enabled { + return; + } + // 超界的种子只能来自手改配置或旧数据。留着它等于让 JSON 往返去改写它, + // 那时人格会在某次保存后毫无征兆地整个换掉 + if self.persona_seed == 0 || self.persona_seed >= PERSONA_SEED_LIMIT { + self.persona_seed = new_persona_seed(); + } + } +} + +/// 人格种子的取值上界(不含)。 +/// +/// 卡在 2^53 是因为配置要经 JSON 往返到前端再存回来,而 JS 的 Number 装不下 +/// 超过 2^53 的整数——超出部分会被静默改写。种子一旦被改写,人格每次保存配置 +/// 就换一套,「稳定随机」这件事直接失效,而且没有任何报错 +const PERSONA_SEED_LIMIT: u64 = 1 << 53; + +/// 掷一个非零的人格种子。0 是「未生成」的哨兵值,必须避开 +fn new_persona_seed() -> u64 { + use rand::Rng; + rand::thread_rng().gen_range(1..PERSONA_SEED_LIMIT) +} + // ================================ // 岗位分析配置 // @@ -1753,6 +1856,81 @@ mod tests { assert_eq!(config.periodic_delivery_config.max_greets_per_round, 30); } + /// 拟人化默认关着,且关着的时候不该在配置文件里留下一个人格身份 + #[test] + fn humanize_is_off_by_default_and_stays_seedless() { + let mut config = default_app_config(); + + validate_and_normalize(&mut config).unwrap(); + + assert!(!config.humanize_config.enabled); + assert_eq!(config.humanize_config.persona_seed, 0); + assert_eq!(config.humanize_config.intensity, HumanizeIntensity::Standard); + } + + /// 开启时补一个种子,之后每次保存都必须原样保留——种子变了,人格就换了 + #[test] + fn enabling_humanize_mints_a_seed_once_and_then_keeps_it() { + let mut config = default_app_config(); + config.humanize_config.enabled = true; + + validate_and_normalize(&mut config).unwrap(); + let minted = config.humanize_config.persona_seed; + + assert_ne!(minted, 0); + validate_and_normalize(&mut config).unwrap(); + assert_eq!(config.humanize_config.persona_seed, minted); + } + + /// 种子要经 JSON 往返到前端再存回来,超过 2^53 会被 JS 的 Number 静默改写。 + /// 那意味着人格在某次保存配置后毫无征兆地整个换掉 + #[test] + fn a_minted_seed_survives_a_json_round_trip_intact() { + for _ in 0..64 { + let mut config = default_app_config(); + config.humanize_config.enabled = true; + validate_and_normalize(&mut config).unwrap(); + let minted = config.humanize_config.persona_seed; + + let encoded = serde_json::to_string(&config.humanize_config).unwrap(); + let decoded: HumanizeConfig = serde_json::from_str(&encoded).unwrap(); + // JS 侧的 Number 往返:超界的值会在这一步被改写 + let through_js = encoded.parse::().ok(); + + assert_eq!(decoded.persona_seed, minted); + assert!(minted < (1u64 << 53), "种子超界:{minted}"); + assert!(through_js.is_none() || minted as f64 as u64 == minted); + } + } + + /// 手改配置或旧数据可能留下超界的种子,必须当场换掉而不是带着它跑 + #[test] + fn an_out_of_range_seed_is_replaced_instead_of_kept() { + let mut config = default_app_config(); + config.humanize_config.enabled = true; + config.humanize_config.persona_seed = u64::MAX; + + validate_and_normalize(&mut config).unwrap(); + + assert!(config.humanize_config.persona_seed < (1u64 << 53)); + assert_ne!(config.humanize_config.persona_seed, 0); + } + + /// 旧配置整块没有 humanize_config,读出来必须是「关闭」而不是解析失败 + #[test] + fn a_config_without_the_humanize_block_falls_back_to_disabled() { + let mut value = serde_yaml::to_value(default_app_config()).unwrap(); + value + .as_mapping_mut() + .unwrap() + .remove(serde_yaml::Value::String("humanize_config".into())); + + let config = parse_config_content(&serde_yaml::to_string(&value).unwrap()).unwrap(); + + assert_eq!(config.humanize_config, HumanizeConfig::default()); + assert!(!config.humanize_config.enabled); + } + /// 老配置里投递时段是 `window_start_minute`/`window_end_minute` 两个标量。 /// 缺了这条迁移,表现不是报错而是静默重置:老用户升级后时段回到 09:00-18:00, /// 而他原本设的可能是夜间投递,任务于是在完全不该跑的时间开投 @@ -2570,6 +2748,7 @@ job_profiles: [] config.llm_retry_config = LlmRetryConfig { network_retry_attempts: 99, retry_base_delay_ms: 1, + request_timeout_seconds: 9_999, }; validate_and_normalize(&mut config).unwrap(); @@ -2582,6 +2761,10 @@ job_profiles: [] config.llm_retry_config.retry_base_delay_ms, MIN_RETRY_BASE_DELAY_MS ); + assert_eq!( + config.llm_retry_config.request_timeout_seconds, + MAX_LLM_REQUEST_TIMEOUT_SECONDS + ); config.llm_retry_config.retry_base_delay_ms = 999_999; validate_and_normalize(&mut config).unwrap(); diff --git a/src-tauri/src/rpa/boss/handler/position_say_hello.rs b/src-tauri/src/rpa/boss/handler/position_say_hello.rs index 884442b..7e57cac 100644 --- a/src-tauri/src/rpa/boss/handler/position_say_hello.rs +++ b/src-tauri/src/rpa/boss/handler/position_say_hello.rs @@ -11,6 +11,8 @@ use crate::{ boss::{handler::send_messages, model::GreetJob}, conversation::SendVerdict, greet::build_greet_resources, + human_input, + human_pace::GreetPacer, run_flow::is_job_task_stop_requested, run_flow::PlatformKind, schedule::{BudgetVerdict, RoundBudget}, @@ -79,6 +81,9 @@ pub async fn position_say_hello_on_page( } let mut no_new_count = 0u32; let mut stats = RoundStats::default(); + // 休息节奏从用户设的单轮上限派生。拟人化关着时它是个空壳, + // 停顿仍是改造前那段 3-5 秒 + let mut pacer = GreetPacer::new(&app_runtime_config.humanize_config, budget.max_greets); // 撞上平台每日沟通上限之后打招呼会一条接一条地失败,而列表还能一直往下滚。 // 这个计数是那种「零产出却停不下来」的唯一出口 let mut consecutive_greet_failures = 0u32; @@ -112,7 +117,8 @@ pub async fn position_say_hello_on_page( break 'outer reason; } stats.scanned += 1; - let greet_job = match read_job_card(page, &job_card_area_ele, &processed_job_ids) { + let greet_job = + match read_job_card(page, &job_card_area_ele, &processed_job_ids, &mut pacer) { Ok(CardOutcome::Ready(greet_job)) => *greet_job, Ok(CardOutcome::Skipped(reason)) => { stats.record_skip(reason); @@ -197,7 +203,11 @@ pub async fn position_say_hello_on_page( stats.greet_success += 1; logger::info(format!("{} 初次沟通成功", greet_job.title))?; processed_job_ids.insert(greet_job.platform_job_id.clone()); - sleep_random_ms(3000, 5000); + // 停顿、以及连投若干条之后的休息都在这里面。收到停止请求时立即收尾, + // 不能让用户等完一段十几分钟的休息 + if !pacer.after_greet(true).await { + break 'outer StopReason::UserStopped; + } } // 本页处理结果汇总:一条都没进入打招呼流程时给出聚合提示,避免界面长时间无输出 @@ -271,6 +281,9 @@ pub async fn position_say_hello_on_page( stop_reason.describe(), stats.total_summary() ))?; + if let Some(summary) = pacer.summary() { + logger::info(summary)?; + } Ok(()) } @@ -289,6 +302,8 @@ enum SkipReason { AlreadyViewed, /// 岗位 ID 已存在于本地库,之前已投递 AlreadyProcessed, + /// 拟人化随机跳过:扫一眼标题就划过去了,没点开 + Humanized, } /// 读取一张岗位卡片的结果 @@ -311,6 +326,8 @@ struct RoundStats { skipped_rule: u32, /// 因 AI 语义复核未通过或失败而跳过 skipped_ai: u32, + /// 被拟人化随机跳过(「只看不投」) + skipped_humanize: u32, /// 卡片读取失败 read_failed: u32, /// 打招呼成功 @@ -324,6 +341,7 @@ impl RoundStats { match reason { SkipReason::AlreadyViewed => self.skipped_viewed += 1, SkipReason::AlreadyProcessed => self.skipped_processed += 1, + SkipReason::Humanized => self.skipped_humanize += 1, } } @@ -347,6 +365,7 @@ impl RoundStats { .saturating_sub(base.skipped_processed), skipped_rule: self.skipped_rule.saturating_sub(base.skipped_rule), skipped_ai: self.skipped_ai.saturating_sub(base.skipped_ai), + skipped_humanize: self.skipped_humanize.saturating_sub(base.skipped_humanize), read_failed: self.read_failed.saturating_sub(base.read_failed), greet_success: self.greet_success.saturating_sub(base.greet_success), greet_failed: self.greet_failed.saturating_sub(base.greet_failed), @@ -355,12 +374,13 @@ impl RoundStats { fn summary(&self) -> String { format!( - "共扫描 {} 条岗位,已浏览跳过 {} 条,已投递跳过 {} 条,规则过滤跳过 {} 条,AI 复核跳过 {} 条,读取失败 {} 条,打招呼成功 {} 条,打招呼失败 {} 条", + "共扫描 {} 条岗位,已浏览跳过 {} 条,已投递跳过 {} 条,规则过滤跳过 {} 条,AI 复核跳过 {} 条,拟人化跳过 {} 条,读取失败 {} 条,打招呼成功 {} 条,打招呼失败 {} 条", self.scanned, self.skipped_viewed, self.skipped_processed, self.skipped_rule, self.skipped_ai, + self.skipped_humanize, self.read_failed, self.greet_success, self.greet_failed, @@ -372,11 +392,12 @@ impl RoundStats { /// 否则「共扫描 90 条岗位」会被误读成真的看过 90 个不同岗位。 fn total_summary(&self) -> String { format!( - "打招呼成功 {} 条,失败 {} 条;规则过滤跳过 {} 条,AI 复核跳过 {} 条,读取失败 {} 条;累计扫描岗位卡片 {} 次(含滚动后的重复扫描),其中因已浏览或已投递而跳过 {} 次", + "打招呼成功 {} 条,失败 {} 条;规则过滤跳过 {} 条,AI 复核跳过 {} 条,拟人化跳过 {} 条,读取失败 {} 条;累计扫描岗位卡片 {} 次(含滚动后的重复扫描),其中因已浏览或已投递而跳过 {} 次", self.greet_success, self.greet_failed, self.skipped_rule, self.skipped_ai, + self.skipped_humanize, self.read_failed, self.scanned, self.skipped_known(), @@ -513,6 +534,7 @@ fn read_job_card( page: &Page, job_card_area_ele: &Element, processed_job_ids: &HashSet, + pacer: &mut GreetPacer, ) -> Result { if job_card_area_ele.attr("class")?.contains("is-seen") { return Ok(CardOutcome::Skipped(SkipReason::AlreadyViewed)); @@ -536,7 +558,16 @@ fn read_job_card( } } - job_card_ele.click()?; + // 「每个岗位都点开看」是人做不到的事:真人扫列表时就会凭标题划过去一些。 + // + // 这一判断必须赶在点击之前。BOSS 会给点开过的卡片打上 is-seen,下一轮扫描 + // 直接跳过——放在点开之后跳过,等于把这个岗位永久放弃掉,而拟人化的本意 + // 只是「这次先不投」 + if pacer.should_skim() { + return Ok(CardOutcome::Skipped(SkipReason::Humanized)); + } + + human_input::click(page, &job_card_ele)?; sleep_random_ms(800, 1200); let job_detail_text = page @@ -911,7 +942,7 @@ async fn handle_greet_on_work_tab( } // 2. BOSS 的建联按钮使用站内 JavaScript 路由,会将当前工作标签切换到聊天页。 - btn.click()?; + human_input::click(work_page, &btn)?; if is_job_task_stop_requested() { logger::info("求职任务已结束")?; return Ok(()); @@ -1139,7 +1170,11 @@ fn scroll_bottom_probe(page: &Page) -> Result { let height_before = scroll_height_from_value(&nudged).unwrap_or_default(); sleep_random_ms(200, 400); - page.run_js_await(SCROLL_BOTTOM_SCRIPT)?; + // 拟人化开着时分几段滚下去:一脚从当前位置踩到 scrollHeight 是滚轮和触控板 + // 都做不出来的位移。没开时仍走原来那一下 + if human_input::scroll_to_bottom(page)?.is_none() { + page.run_js_await(SCROLL_BOTTOM_SCRIPT)?; + } // 懒加载是异步追加 DOM 的,稍等一下再量高度才有意义 sleep_random_ms(500, 800); @@ -1398,6 +1433,7 @@ mod tests { skipped_processed: 15, skipped_rule: 1, skipped_ai: 1, + skipped_humanize: 2, read_failed: 0, greet_success: 1, greet_failed: 0, @@ -1410,10 +1446,30 @@ mod tests { assert!(summary.contains("已投递跳过 15 条")); assert!(summary.contains("规则过滤跳过 1 条")); assert!(summary.contains("AI 复核跳过 1 条")); + assert!(summary.contains("拟人化跳过 2 条")); assert!(summary.contains("打招呼成功 1 条")); assert!(summary.contains("打招呼失败 0 条")); } + /// 拟人化跳过要和「规则不匹配」分开记:前者是这轮先不投,后者是压根不该投。 + /// 混在一起的话,用户会以为自己的筛选条件写错了 + #[test] + fn humanized_skips_are_counted_separately_from_rule_skips() { + let stats = RoundStats { + scanned: 10, + skipped_rule: 3, + skipped_humanize: 2, + greet_success: 5, + ..RoundStats::default() + }; + + let summary = stats.total_summary(); + + assert!(summary.contains("规则过滤跳过 3 条")); + assert!(summary.contains("拟人化跳过 2 条")); + assert_eq!(stats.engaged(), 5); + } + #[test] fn total_summary_marks_rescans_instead_of_claiming_distinct_jobs() { // 三轮滚动重扫同一页 30 张卡片:累计值不能被读成「看过 90 个不同岗位」 @@ -1421,11 +1477,7 @@ mod tests { scanned: 90, skipped_viewed: 87, skipped_processed: 3, - skipped_rule: 0, - skipped_ai: 0, - read_failed: 0, - greet_success: 0, - greet_failed: 0, + ..RoundStats::default() }; let summary = stats.total_summary(); @@ -1436,6 +1488,36 @@ mod tests { assert!(!summary.contains("共扫描 90 条岗位")); } + /// 拟人化跳过必须发生在点开卡片之前。 + /// + /// BOSS 会给点开过的卡片打上 is-seen,下一轮扫描直接跳过——顺序反了, + /// 「这次先不投」就变成了「永久放弃这个岗位」。这是纯粹的位置关系, + /// 编译器和运行时都不会报错,只会静默地少投一批岗位 + #[test] + fn the_humanized_skip_happens_before_the_card_is_opened() { + let source = include_str!("position_say_hello.rs"); + + let skim = source.find("pacer.should_skim()").expect("跳过判断已被移除"); + let open = source + .find("human_input::click(page, &job_card_ele)") + .expect("卡片点击已被改写"); + + assert!(skim < open, "拟人化跳过跑到了点开卡片之后"); + } + + /// 拟人化跳过不该混进「已浏览/已投递」那两个桶:那两个是「以前处理过」, + /// 而这个是「这次故意没看」,混在一起进度日志会虚报 + #[test] + fn a_humanized_skip_is_counted_on_its_own() { + let mut stats = RoundStats::default(); + stats.record_skip(SkipReason::Humanized); + stats.record_skip(SkipReason::AlreadyViewed); + + assert_eq!(stats.skipped_humanize, 1); + assert_eq!(stats.skipped_known(), 1); + assert_eq!(stats.engaged(), 0); + } + #[test] fn round_stats_distinguishes_viewed_and_processed_skips() { let mut stats = RoundStats::default(); diff --git a/src-tauri/src/rpa/boss/handler/send_message.rs b/src-tauri/src/rpa/boss/handler/send_message.rs index f3d3a33..58610bc 100644 --- a/src-tauri/src/rpa/boss/handler/send_message.rs +++ b/src-tauri/src/rpa/boss/handler/send_message.rs @@ -6,27 +6,69 @@ use crate::{ config::{ReplayResourceType, ReplyResource}, logger, rpa::common::upload_image_to_file_input, + rpa::human_input, + rpa::run_flow::is_job_task_stop_requested, }; +const CHAT_INPUT_SELECTOR: &str = "#chat-input"; +const SEND_BUTTON_SELECTOR: &str = ".chat-op .btn-send"; + // 发送文本消息 pub fn send_text_message(page: &Page, greeting: &str) -> Result { - let greeting_js = serde_json::to_string(greeting).map_err(|e| anyhow!("{}", e))?; - page.wait(".chat-op .btn-send", Duration::from_secs(10))?; - page.run_js(&format!( - "document.querySelector('#chat-input').textContent = {};", - greeting_js - ))?; + page.wait(SEND_BUTTON_SELECTOR, Duration::from_secs(10))?; + if !type_greeting(page, greeting)? { + return Ok(false); + } + // 逐字输入一条长招呼语要几十秒,这中间用户完全可能点了停止。 + // 内容已经填进输入框了,但按不按发送是另一回事 + if is_job_task_stop_requested() { + logger::info("任务已停止,本条消息未发送")?; + return Ok(false); + } // input-area sleep_random_ms(900, 1500); - let send_btn_selector = ".chat-op .btn-send"; - let send_btn_ele = page.ele(send_btn_selector)?; + let send_btn_ele = page.ele(SEND_BUTTON_SELECTOR)?; if let Some(send_btn_ele) = send_btn_ele { - send_btn_ele.click()?; + human_input::click(page, &send_btn_ele)?; return Ok(true); } Ok(false) } +/// 把招呼语填进聊天输入框。 +/// +/// 拟人化开着时逐字符敲进去——CDP 的 `Input.insertText` 触发的是真实的 +/// `beforeinput` / `input`,而直接给 `textContent` 赋值这一路,站点侧看到的是 +/// 内容凭空出现、没有任何输入事件。 +/// +/// 打字这条路只要没走通就整段回退到赋值:残缺的半句话发给 HR 比机器特征糟得多, +/// 所以回退前先把输入框清空,绝不在已有内容上接着写 +fn type_greeting(page: &Page, greeting: &str) -> Result { + if let Some(input) = page.ele(CHAT_INPUT_SELECTOR)? { + match human_input::type_text(page, &input, greeting) { + Ok(true) => return Ok(true), + Ok(false) => clear_chat_input(page)?, + Err(error) => { + logger::warning(format!("逐字输入失败,改用整段填入:{error}"))?; + clear_chat_input(page)?; + } + } + } + + let greeting_js = serde_json::to_string(greeting).map_err(|e| anyhow!("{}", e))?; + page.run_js(&format!( + "document.querySelector('{CHAT_INPUT_SELECTOR}').textContent = {greeting_js};" + ))?; + Ok(true) +} + +fn clear_chat_input(page: &Page) -> Result<(), anyhow::Error> { + page.run_js(&format!( + "(() => {{ const el = document.querySelector('{CHAT_INPUT_SELECTOR}'); if (el) el.textContent = ''; }})();" + ))?; + Ok(()) +} + // 不加 `input[type=file]` 裸兜底:Boss 聊天页还有简历上传框,误命中会把图片塞错地方 const BOSS_IMAGE_INPUT_SELECTORS: &[&str] = &["input[type='file'][accept*='image']"]; diff --git a/src-tauri/src/rpa/human_input.rs b/src-tauri/src/rpa/human_input.rs new file mode 100644 index 0000000..d57747f --- /dev/null +++ b/src-tauri/src/rpa/human_input.rs @@ -0,0 +1,530 @@ +//! 拟人化的输入动作:鼠标轨迹、逐字符打字、分段滚动。 +//! +//! 这一层的收益和风险都很直接。收益是 `element.click()` 走的是 JS 派发, +//! `isTrusted` 为 false,而这里发的是 CDP `Input` 事件,浏览器眼里就是真实输入; +//! 风险是真实点击会打在坐标上——元素被遮挡、不在视口、rect 拿不到,点出去就是 +//! 一次误触。 +//! +//! 所以每个函数都遵守同一条纪律:**能拟人则拟人,拿不准就原样回退**。 +//! 拟人化是为了少被风控盯上,不是为了把投递本身搞挂——一次误点的代价(给错误的 +//! BOSS 发招呼、点进无关页面)远大于一次 `isTrusted: false`。 + +use std::time::Duration; + +use anyhow::anyhow; +use rust_drission::{Element, Page}; +use serde_json::{json, Value}; + +use crate::rpa::humanize::{current_persona, roll, Persona}; +use crate::rpa::run_flow::is_job_task_stop_requested; + +/// 元素小于这个尺寸就不做坐标点击了。 +/// +/// 几像素的目标上,轨迹抖动很容易把落点甩到边界外,得不偿失 +const MIN_CLICKABLE_SIZE: f64 = 8.0; + +/// 一次点击里按下与抬起之间的停顿区间(毫秒)。真人按不出 0 毫秒的键程 +const CLICK_HOLD_MS: (u64, u64) = (45, 130); + +/// 元素在视口里的位置与尺寸 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// 视口尺寸 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Viewport { + pub width: f64, + pub height: f64, +} + +impl Rect { + /// 这个矩形值不值得走坐标点击。 + /// + /// 完全在视口之外、或者小得抖一下就脱靶的目标一律交回 JS 点击 + pub fn is_clickable_within(&self, viewport: Viewport) -> bool { + self.width >= MIN_CLICKABLE_SIZE + && self.height >= MIN_CLICKABLE_SIZE + && self.x >= 0.0 + && self.y >= 0.0 + && self.x + self.width <= viewport.width + && self.y + self.height <= viewport.height + } + + /// 落点:中心附近的一个随机位置,但不贴边。 + /// + /// 每次都精确点在几何中心是比 `isTrusted: false` 更刺眼的特征—— + /// 真人的落点是一片云,不是一个点。`roll_x` / `roll_y` 取 0..=1 + pub fn click_point(&self, roll_x: f64, roll_y: f64) -> (f64, f64) { + // 只在中间 60% 的范围里取点,留出边距扛住轨迹抖动 + let spread_x = self.width * 0.3; + let spread_y = self.height * 0.3; + let center_x = self.x + self.width / 2.0; + let center_y = self.y + self.height / 2.0; + ( + center_x + (roll_x.clamp(0.0, 1.0) * 2.0 - 1.0) * spread_x, + center_y + (roll_y.clamp(0.0, 1.0) * 2.0 - 1.0) * spread_y, + ) + } +} + +/// 从落点倒推一条进场轨迹。 +/// +/// 真人的鼠标不会瞬移,也不会走直线。这里用一条二次贝塞尔:起点在目标外侧 +/// 一段距离,控制点偏到一边制造弧度,落点即目标。`steps` 越大越细腻, +/// 代价是每一步一次 CDP 往返 +pub fn approach_path(target: (f64, f64), steps: u32, rolls: (f64, f64, f64)) -> Vec<(f64, f64)> { + let steps = steps.clamp(2, 40); + let (roll_angle, roll_distance, roll_bend) = rolls; + + // 起点落在目标周围 120-360 像素的一个方向上 + let angle = roll_angle.clamp(0.0, 1.0) * std::f64::consts::TAU; + let distance = 120.0 + roll_distance.clamp(0.0, 1.0) * 240.0; + let start = ( + target.0 + angle.cos() * distance, + target.1 + angle.sin() * distance, + ); + + // 控制点垂直于起点-终点连线偏出去,弧度左右不定 + let bend = (roll_bend.clamp(0.0, 1.0) * 2.0 - 1.0) * distance * 0.4; + let mid = ((start.0 + target.0) / 2.0, (start.1 + target.1) / 2.0); + let direction = (target.0 - start.0, target.1 - start.1); + let length = (direction.0 * direction.0 + direction.1 * direction.1).sqrt().max(1.0); + let normal = (-direction.1 / length, direction.0 / length); + let control = (mid.0 + normal.0 * bend, mid.1 + normal.1 * bend); + + (1..=steps) + .map(|step| { + // 末段放慢:ease-out 让轨迹在接近目标时变密,和真人收手的样子一致 + let linear = step as f64 / steps as f64; + let t = 1.0 - (1.0 - linear).powi(2); + let inverse = 1.0 - t; + ( + inverse * inverse * start.0 + 2.0 * inverse * t * control.0 + t * t * target.0, + inverse * inverse * start.1 + 2.0 * inverse * t * control.1 + t * t * target.1, + ) + }) + .collect() +} + +/// 点击一个元素。 +/// +/// 装了人格就走真实鼠标事件,没装、或者坐标条件不满足就回退到 JS 点击。 +/// 无论走哪条路,「点击这个元素」这件事一定会发生——回退不是失败 +pub fn click(page: &Page, element: &Element) -> Result<(), anyhow::Error> { + let Some(persona) = current_persona() else { + return Ok(element.click()?); + }; + match trusted_click(page, element, &persona) { + Ok(()) => Ok(()), + Err(_) => { + // 拿不到坐标、元素被遮挡、CDP 拒绝——都不值得让整条投递流程失败 + element.click()?; + Ok(()) + } + } +} + +/// 真实鼠标点击。任何一步不满足条件都返回 Err,交给调用方回退 +fn trusted_click(page: &Page, element: &Element, persona: &Persona) -> Result<(), anyhow::Error> { + let viewport = read_viewport(page)?; + let rect = read_rect(element)?; + if !rect.is_clickable_within(viewport) { + return Err(anyhow!("元素不在可安全点击的范围内")); + } + + let target = rect.click_point(roll(), roll()); + let steps = persona.mouse_steps(roll()); + for point in approach_path(target, steps, (roll(), roll(), roll())) { + page.dispatch_mouse_event("mouseMoved", point.0, point.1, None, None)?; + // 每步之间的微停顿,凑出一条有速度变化的轨迹 + sleep_ms(2 + (roll() * 12.0) as u64); + } + + // 停在目标上短暂悬停再按下:真人的手要先停稳 + sleep_ms(40 + (roll() * 160.0) as u64); + page.dispatch_mouse_event("mousePressed", target.0, target.1, Some("left"), Some(1))?; + sleep_ms(CLICK_HOLD_MS.0 + (roll() * (CLICK_HOLD_MS.1 - CLICK_HOLD_MS.0) as f64) as u64); + page.dispatch_mouse_event("mouseReleased", target.0, target.1, Some("left"), Some(1))?; + Ok(()) +} + +/// 逐字符敲进目标元素。返回 false 表示这条路走不通,调用方应当回退到原有写法。 +/// +/// 只负责把字打进去,不负责发送——发送按钮的可用状态由站点自己的输入事件驱动, +/// 而 CDP `Input.insertText` 触发的正是真实的 `beforeinput` / `input` +pub fn type_text(page: &Page, element: &Element, text: &str) -> Result { + let Some(persona) = current_persona() else { + return Ok(false); + }; + if text.is_empty() { + return Ok(false); + } + if element.focus().is_err() { + return Ok(false); + } + sleep_ms(120 + (roll() * 380.0) as u64); + + for (index, character) in text.chars().enumerate() { + if is_job_task_stop_requested() { + // 半截消息比不发更糟,但这里已经打进去的内容不会被发送—— + // 发送动作在调用方,且停止请求同样会拦住它 + return Ok(false); + } + if page + .run_cdp( + "Input.insertText", + Some(json!({ "text": character.to_string() })), + ) + .is_err() + { + // 第一个字符就失败说明这条路不通,交给调用方原样回退; + // 打到一半才失败则已经产生了残缺内容,必须让调用方清干净重来 + return Ok(false); + } + sleep_ms(keystroke_delay_ms(&persona, character, index, roll(), roll())); + } + + sleep_ms(persona.review_before_send_ms(roll())); + Ok(true) +} + +/// 敲下一个字符之后停多久。 +/// +/// 标点后面停得久一点——真人在这里换气、想下一句怎么写;开头几个字也慢, +/// 手还没热起来 +pub fn keystroke_delay_ms( + persona: &Persona, + character: char, + index: usize, + speed_roll: f64, + pause_roll: f64, +) -> u64 { + let mut delay = persona.typing_delay_ms(speed_roll); + if index < 3 { + delay = (delay as f64 * 1.4).round() as u64; + } + if matches!( + character, + ',' | '。' | '!' | '?' | ';' | ':' | ',' | '.' | '!' | '?' | ';' | ':' | '\n' + ) { + // 三成的句读会真的停顿,全停就成了另一种规律 + if pause_roll.clamp(0.0, 1.0) < 0.3 { + delay = delay.saturating_add(240 + (pause_roll * 2_000.0) as u64); + } + } + delay +} + +/// 分几段滚到底,而不是一脚踩到 `scrollHeight`。 +/// +/// 瞬移到底部这个动作真人做不出来——滚轮一次转不了半个页面,触控板也不行。 +/// 返回滚动结束后的页面高度;没装人格时返回 None,调用方走原有的滚动路径 +pub fn scroll_to_bottom(page: &Page) -> Result, anyhow::Error> { + let Some(persona) = current_persona() else { + return Ok(None); + }; + let steps = 3 + (roll() * 4.0) as u32; + for _ in 0..steps { + if is_job_task_stop_requested() { + break; + } + // 一次滚一屏上下,带随机余量 + let delta = 320.0 + roll() * 520.0; + page.run_js_await(&format!( + r#" +(() => {{ + const html = document.documentElement; + const body = document.body; + const container = html.scrollHeight > html.clientHeight ? html : body; + container.scrollTop = container.scrollTop + {delta}; + window.dispatchEvent(new Event('scroll')); + return container.scrollTop; +}})(); +"# + ))?; + // 先按浮点算完再取整:pace 是 1.2 这种小数,先转 u64 会被截成 1,缩放就没了 + sleep_ms(((180.0 + roll() * 420.0) * persona.pace.clamp(0.5, 3.0)).round() as u64); + } + + // 最后一段补到底,保证懒加载观察器一定被唤醒 + let settled = page.run_js_await( + r#" +(() => { + const html = document.documentElement; + const body = document.body; + const container = html.scrollHeight > html.clientHeight ? html : body; + container.scrollTop = container.scrollHeight; + window.dispatchEvent(new Event('scroll')); + return { scrollHeight: container.scrollHeight, scrollTop: container.scrollTop }; +})(); +"#, + )?; + Ok(Some(scroll_height_of(&settled).unwrap_or_default())) +} + +fn read_viewport(page: &Page) -> Result { + let value = page.run_js_await("({ width: window.innerWidth, height: window.innerHeight })")?; + let raw = value.get("value").unwrap_or(&value); + let width = raw.get("width").and_then(Value::as_f64).unwrap_or(0.0); + let height = raw.get("height").and_then(Value::as_f64).unwrap_or(0.0); + if width <= 0.0 || height <= 0.0 { + return Err(anyhow!("读不到视口尺寸")); + } + Ok(Viewport { width, height }) +} + +fn read_rect(element: &Element) -> Result { + let value = element.rect()?; + let raw = value.get("value").unwrap_or(&value); + Ok(Rect { + x: raw.get("x").and_then(Value::as_f64).ok_or_else(|| anyhow!("元素缺少 x 坐标"))?, + y: raw.get("y").and_then(Value::as_f64).ok_or_else(|| anyhow!("元素缺少 y 坐标"))?, + width: raw + .get("width") + .and_then(Value::as_f64) + .ok_or_else(|| anyhow!("元素缺少宽度"))?, + height: raw + .get("height") + .and_then(Value::as_f64) + .ok_or_else(|| anyhow!("元素缺少高度"))?, + }) +} + +/// 从 JS 返回值里取 scrollHeight,兼容 `{value: {...}}` 包装 +fn scroll_height_of(value: &Value) -> Option { + let raw = value.get("value").unwrap_or(value); + raw.get("scrollHeight").and_then(Value::as_f64) +} + +/// 输入动作全程跑在 rust_drission 的同步 API 上,这里跟着用同步睡眠 +fn sleep_ms(millis: u64) { + if millis > 0 { + std::thread::sleep(Duration::from_millis(millis)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{HumanizeConfig, HumanizeIntensity}; + use chrono::NaiveDate; + + fn persona() -> Persona { + Persona::derive( + &HumanizeConfig { + enabled: true, + intensity: HumanizeIntensity::Standard, + persona_seed: 0x0BAD_C0FF_EE00_1234, + }, + NaiveDate::from_ymd_opt(2026, 8, 20).unwrap(), + 30, + ) + .unwrap() + } + + fn viewport() -> Viewport { + Viewport { + width: 1440.0, + height: 900.0, + } + } + + #[test] + fn a_normal_button_inside_the_viewport_is_clickable() { + let rect = Rect { + x: 400.0, + y: 300.0, + width: 120.0, + height: 36.0, + }; + + assert!(rect.is_clickable_within(viewport())); + } + + /// 滚出视口的元素坐标是负的,照着点会打在别的东西上 + #[test] + fn an_element_scrolled_out_of_view_is_not_clickable() { + let above = Rect { + x: 400.0, + y: -60.0, + width: 120.0, + height: 36.0, + }; + let below = Rect { + x: 400.0, + y: 880.0, + width: 120.0, + height: 36.0, + }; + + assert!(!above.is_clickable_within(viewport())); + assert!(!below.is_clickable_within(viewport())); + } + + /// 几像素的目标上轨迹抖动会把落点甩出去,不如交回 JS 点击 + #[test] + fn a_tiny_element_is_not_worth_a_coordinate_click() { + let rect = Rect { + x: 400.0, + y: 300.0, + width: 4.0, + height: 4.0, + }; + + assert!(!rect.is_clickable_within(viewport())); + } + + /// 落点必须散开,每次都精确命中几何中心比 isTrusted 更刺眼 + #[test] + fn click_points_scatter_around_the_center() { + let rect = Rect { + x: 100.0, + y: 100.0, + width: 200.0, + height: 50.0, + }; + + let center = rect.click_point(0.5, 0.5); + let corner = rect.click_point(0.0, 1.0); + + assert_eq!(center, (200.0, 125.0)); + assert_ne!(corner, center); + } + + /// 散开归散开,落点绝不能跑到元素外面去 + #[test] + fn click_points_never_leave_the_element() { + let rect = Rect { + x: 100.0, + y: 100.0, + width: 200.0, + height: 50.0, + }; + + for roll_x in [0.0, 0.25, 0.5, 0.75, 1.0] { + for roll_y in [0.0, 0.5, 1.0] { + let (x, y) = rect.click_point(roll_x, roll_y); + assert!(x >= rect.x && x <= rect.x + rect.width, "x={x}"); + assert!(y >= rect.y && y <= rect.y + rect.height, "y={y}"); + } + } + } + + /// 越界的随机源不该把落点甩出元素 + #[test] + fn out_of_range_rolls_keep_the_click_point_inside() { + let rect = Rect { + x: 0.0, + y: 0.0, + width: 100.0, + height: 100.0, + }; + + let (x, y) = rect.click_point(-9.0, 42.0); + + assert!((0.0..=100.0).contains(&x)); + assert!((0.0..=100.0).contains(&y)); + } + + /// 轨迹的终点必须正好落在目标上,否则点击就偏了 + #[test] + fn the_approach_path_ends_exactly_on_the_target() { + let path = approach_path((640.0, 480.0), 10, (0.3, 0.7, 0.2)); + + let last = *path.last().unwrap(); + assert!((last.0 - 640.0).abs() < 0.001, "{last:?}"); + assert!((last.1 - 480.0).abs() < 0.001, "{last:?}"); + assert_eq!(path.len(), 10); + } + + /// 直线轨迹本身就是特征,路径中段必须偏离起终点连线 + #[test] + fn the_approach_path_curves_instead_of_running_straight() { + let target = (640.0, 480.0); + let path = approach_path(target, 12, (0.0, 0.5, 1.0)); + let start = path[0]; + + let midpoint = path[path.len() / 2]; + let straight_x = start.0 + (target.0 - start.0) * 0.5; + let straight_y = start.1 + (target.1 - start.1) * 0.5; + let drift = ((midpoint.0 - straight_x).powi(2) + (midpoint.1 - straight_y).powi(2)).sqrt(); + + assert!(drift > 1.0, "轨迹几乎是直线,drift={drift}"); + } + + /// 收手时步子变密,和真人接近目标时减速一致 + #[test] + fn the_approach_path_decelerates_towards_the_target() { + let path = approach_path((640.0, 480.0), 12, (0.25, 0.5, 0.0)); + + let first_leg = distance(path[0], path[1]); + let last_leg = distance(path[path.len() - 2], path[path.len() - 1]); + + assert!(last_leg < first_leg, "first={first_leg} last={last_leg}"); + } + + /// 步数被夹在合理区间,免得 CDP 往返把一次点击拖成几秒 + #[test] + fn the_step_count_is_clamped_to_a_sane_range() { + assert_eq!(approach_path((10.0, 10.0), 0, (0.5, 0.5, 0.5)).len(), 2); + assert_eq!(approach_path((10.0, 10.0), 999, (0.5, 0.5, 0.5)).len(), 40); + } + + /// 开头几个字慢一些,手还没热起来 + #[test] + fn the_first_keystrokes_are_slower_than_the_rest() { + let persona = persona(); + + let opening = keystroke_delay_ms(&persona, '你', 0, 0.5, 0.9); + let settled = keystroke_delay_ms(&persona, '你', 9, 0.5, 0.9); + + assert!(opening > settled); + } + + /// 句读处会换气,但不能每个逗号都停——那是另一种规律 + #[test] + fn punctuation_sometimes_pauses_and_sometimes_does_not() { + let persona = persona(); + + let paused = keystroke_delay_ms(&persona, ',', 9, 0.5, 0.0); + let flowed = keystroke_delay_ms(&persona, ',', 9, 0.5, 0.9); + + assert!(paused > flowed); + assert_eq!(flowed, keystroke_delay_ms(&persona, '字', 9, 0.5, 0.9)); + } + + /// 一条几十字的招呼语打完要花多久,量级上必须还是「人在打字」而不是「人在发呆」 + #[test] + fn typing_a_greeting_takes_a_human_amount_of_time() { + let persona = persona(); + let text = "您好,我看到贵司在招聘后端工程师,我有五年相关经验,方便聊聊吗?"; + + let total: u64 = text + .chars() + .enumerate() + .map(|(index, character)| keystroke_delay_ms(&persona, character, index, 0.5, 0.5)) + .sum(); + + assert!(total > 4_000, "打得太快:{total}ms"); + assert!(total < 90_000, "打得太慢:{total}ms"); + } + + #[test] + fn scroll_height_is_read_from_wrapped_and_plain_results() { + assert_eq!( + scroll_height_of(&json!({"value": {"scrollHeight": 4200}})), + Some(4200.0) + ); + assert_eq!(scroll_height_of(&json!({"scrollHeight": 4200.5})), Some(4200.5)); + assert_eq!(scroll_height_of(&json!({"foo": 1})), None); + } + + fn distance(from: (f64, f64), to: (f64, f64)) -> f64 { + ((to.0 - from.0).powi(2) + (to.1 - from.1).powi(2)).sqrt() + } +} diff --git a/src-tauri/src/rpa/human_pace.rs b/src-tauri/src/rpa/human_pace.rs new file mode 100644 index 0000000..9e0a5a4 --- /dev/null +++ b/src-tauri/src/rpa/human_pace.rs @@ -0,0 +1,297 @@ +//! 投递循环里的拟人化节奏执行器。 +//! +//! 和 [`super::humanize`] 分开放,是因为那边刻意保持纯判断——人格推导与参数塑形 +//! 全靠单测兜着,掺进睡眠和日志就没法跑。这边则相反,每个方法都在等时钟或写日志, +//! 但它们同样是平台无关的:BOSS 和猎聘的投递循环必须共用同一套节奏,否则两个 +//! 平台会各自演化出一套「像人」的定义,而用户在配置页只调了一个开关。 + +use std::time::Duration; + +use crate::config::HumanizeConfig; +use crate::logger; +use crate::rpa::humanize::{roll, scoped_persona, Persona, PersonaGuard}; +use crate::rpa::run_flow::is_job_task_stop_requested; + +/// 一轮投递期间的节奏控制。 +/// +/// 拟人化关闭时 `persona` 为 None,所有方法退化成改造前的行为:固定 3-5 秒停顿、 +/// 不休息、不跳过。这条退路必须一直留着——功能默认关闭,绝大多数用户跑的是它 +pub struct GreetPacer { + persona: Option, + /// 距上次休息已经投出去多少条 + greets_since_break: u32, + /// 本轮一共歇了几次,只用于日志 + breaks_taken: u32, + /// 本轮跳过了几个岗位,只用于日志 + skimmed: u32, + /// 把人格装进线程上下文,鼠标与打字动作在调用链深处据此决定要不要拟人。 + /// 节奏器一销毁,人格随之摘下——本轮的一切拟人行为都以它为界 + _persona_guard: PersonaGuard, +} + +impl GreetPacer { + /// `base_greets` 传用户设的单轮上限,休息节奏由它派生 + pub fn new(config: &HumanizeConfig, base_greets: u32) -> Self { + let persona = Persona::today(config, base_greets); + if let Some(persona) = persona.as_ref() { + let _ = logger::info(format!("拟人化已启用,今日节奏:{}", persona.describe())); + } + Self { + persona, + greets_since_break: 0, + breaks_taken: 0, + skimmed: 0, + _persona_guard: scoped_persona(persona), + } + } + + /// 拟人化没启用时的空档器,供不需要节奏控制的调用方使用 + pub fn disabled() -> Self { + Self { + persona: None, + greets_since_break: 0, + breaks_taken: 0, + skimmed: 0, + _persona_guard: scoped_persona(None), + } + } + + pub fn persona(&self) -> Option<&Persona> { + self.persona.as_ref() + } + + /// 这个岗位要不要「只看不投」。 + /// + /// 跳过的岗位不入库,下一轮还会再遇到它——这是「今天先不投」而不是「永远不投」 + pub fn should_skim(&mut self) -> bool { + let Some(persona) = self.persona.as_ref() else { + return false; + }; + if !persona.should_skim(roll()) { + return false; + } + self.skimmed += 1; + true + } + + /// 处理完一个岗位之后的停顿,必要时转成一次完整的休息。 + /// + /// `greeted` 表示招呼是否真的发出去了。没发出去(被闸门拦下、发送失败) + /// 同样要停顿——那一段浏览、点击、等待页面的动作是实实在在发生过的—— + /// 但不该计进休息节奏:休息的依据是「连着投了多少条」,不是「连着看了多少个」。 + /// + /// 返回 false 表示等待期间收到了停止请求,调用方应当立刻结束本轮 + pub async fn after_greet(&mut self, greeted: bool) -> bool { + let pause = self.plan_pause(greeted); + if let Pause::Break(millis) = pause { + let seconds = millis / 1_000; + let _ = logger::info(format!( + "已连续投递 {} 条,休息 {} 分 {} 秒后继续", + self.persona.map(|p| p.break_after_greets).unwrap_or_default(), + seconds / 60, + seconds % 60, + )); + } + sleep_ms_interruptible(pause.millis()).await + } + + /// 算出这一步之后该等多久,并推进休息计数。 + /// + /// 与真正的等待分开:等待动辄十几分钟,混在一起就没法在单测里验证 + /// 「什么时候该歇」——而那恰恰是最容易算错、又最难在集成测试里看出来的部分 + fn plan_pause(&mut self, greeted: bool) -> Pause { + let Some(persona) = self.persona else { + // 改造前就是这么等的,关掉拟人化必须原样退回这里 + return Pause::Gap(pick_legacy_gap_ms()); + }; + + if greeted { + self.greets_since_break += 1; + if persona.should_break(self.greets_since_break) { + self.greets_since_break = 0; + self.breaks_taken += 1; + return Pause::Break(persona.break_seconds(roll()).saturating_mul(1_000)); + } + } + + Pause::Gap(persona.gap_after_greet_ms(roll(), roll(), roll())) + } + + /// 本轮的拟人化行为摘要,没有任何行为时返回 None + pub fn summary(&self) -> Option { + if self.persona.is_none() || (self.breaks_taken == 0 && self.skimmed == 0) { + return None; + } + Some(format!( + "拟人化本轮休息 {} 次,随机跳过 {} 个岗位", + self.breaks_taken, self.skimmed + )) + } +} + +/// 一步之后要等多久,以及这段等待是普通停顿还是一次完整的休息 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Pause { + Gap(u64), + Break(u64), +} + +impl Pause { + fn millis(self) -> u64 { + match self { + Self::Gap(millis) | Self::Break(millis) => millis, + } + } +} + +/// 改造前投递循环里那段固定的 3-5 秒停顿 +fn pick_legacy_gap_ms() -> u64 { + 3_000 + (roll() * 2_000.0).round() as u64 +} + +/// 可被停止请求打断的睡眠。返回 false 表示中途收到停止请求。 +/// +/// 按秒切片而不是一觉睡到底:休息可能长达二十分钟,用户点了停止不该干等着。 +/// 不足一秒的尾巴直接睡掉,它不值得多绕一次判断 +async fn sleep_ms_interruptible(millis: u64) -> bool { + let whole_seconds = millis / 1_000; + for _ in 0..whole_seconds { + if is_job_task_stop_requested() { + return false; + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + if is_job_task_stop_requested() { + return false; + } + let remainder = millis % 1_000; + if remainder > 0 { + tokio::time::sleep(Duration::from_millis(remainder)).await; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::HumanizeIntensity; + + fn enabled_config() -> HumanizeConfig { + HumanizeConfig { + enabled: true, + intensity: HumanizeIntensity::Standard, + persona_seed: 0x1234_5678_9ABC_DEF0, + } + } + + /// 功能默认关闭,绝大多数用户跑的是这条路径:一个人格都不该被推出来 + #[test] + fn a_disabled_config_produces_a_pacer_without_a_persona() { + let pacer = GreetPacer::new(&HumanizeConfig::default(), 30); + + assert!(pacer.persona().is_none()); + assert!(pacer.summary().is_none()); + } + + #[test] + fn an_enabled_config_produces_a_pacer_with_a_persona() { + let pacer = GreetPacer::new(&enabled_config(), 30); + + assert!(pacer.persona().is_some()); + } + + /// 没有人格就一个岗位都不跳,拟人化关着时产出不该有任何损失 + #[test] + fn a_pacer_without_a_persona_never_skims() { + let mut pacer = GreetPacer::disabled(); + + for _ in 0..50 { + assert!(!pacer.should_skim()); + } + } + + /// 没发生过休息或跳过时不出摘要,免得每轮日志尾巴都挂一句「休息 0 次」 + #[test] + fn a_pacer_with_nothing_to_report_stays_silent() { + let pacer = GreetPacer::new(&enabled_config(), 30); + + assert!(pacer.summary().is_none()); + } + + /// 休息的依据是「连着投了多少条」。把没投成的也算进去,休息会来得偏早, + /// 极端情况下(连续发送失败)变成投 0 条歇一次 + #[test] + fn only_delivered_greetings_count_towards_the_next_break() { + let mut pacer = GreetPacer::new(&enabled_config(), 30); + let threshold = pacer.persona().unwrap().break_after_greets; + + for _ in 0..threshold * 2 { + assert!(matches!(pacer.plan_pause(false), Pause::Gap(_))); + } + + assert_eq!(pacer.greets_since_break, 0); + assert_eq!(pacer.breaks_taken, 0); + } + + /// 没投成也得停一下:那一段浏览、点击、等页面的动作是真发生过的, + /// 不停顿反而比正常投递还快 + #[test] + fn a_failed_greeting_still_pauses() { + let mut pacer = GreetPacer::new(&enabled_config(), 30); + + assert!(pacer.plan_pause(false).millis() > 0); + } + + /// 投满阈值就歇,歇完计数归零,下一段重新数起 + #[test] + fn a_break_arrives_on_the_threshold_and_resets_the_counter() { + let mut pacer = GreetPacer::new(&enabled_config(), 30); + let threshold = pacer.persona().unwrap().break_after_greets; + + for _ in 0..threshold - 1 { + assert!(matches!(pacer.plan_pause(true), Pause::Gap(_))); + } + let pause = pacer.plan_pause(true); + + assert!(matches!(pause, Pause::Break(_)), "{pause:?}"); + // 群里提的「投 30 条歇 5-10 分钟」正是这个量级 + assert!(pause.millis() >= 240_000, "休息太短:{}ms", pause.millis()); + assert_eq!(pacer.greets_since_break, 0); + assert_eq!(pacer.breaks_taken, 1); + assert!(matches!(pacer.plan_pause(true), Pause::Gap(_))); + } + + /// 拟人化关着时永远不休息,停顿也还是改造前那段 3-5 秒 + #[test] + fn a_pacer_without_a_persona_never_breaks() { + let mut pacer = GreetPacer::disabled(); + + for _ in 0..200 { + let pause = pacer.plan_pause(true); + assert!(matches!(pause, Pause::Gap(_))); + assert!((3_000..=5_000).contains(&pause.millis())); + } + assert_eq!(pacer.breaks_taken, 0); + } + + #[test] + fn the_summary_counts_breaks_and_skims() { + let mut pacer = GreetPacer::new(&enabled_config(), 30); + pacer.breaks_taken = 2; + pacer.skimmed = 3; + + let summary = pacer.summary().unwrap(); + + assert!(summary.contains("休息 2 次")); + assert!(summary.contains("跳过 3 个岗位")); + } + + /// 关掉拟人化时的停顿必须还是改造前那段 3-5 秒 + #[test] + fn the_legacy_gap_stays_between_three_and_five_seconds() { + for _ in 0..32 { + let gap = pick_legacy_gap_ms(); + assert!((3_000..=5_000).contains(&gap), "{gap}"); + } + } +} diff --git a/src-tauri/src/rpa/humanize.rs b/src-tauri/src/rpa/humanize.rs new file mode 100644 index 0000000..c546e72 --- /dev/null +++ b/src-tauri/src/rpa/humanize.rs @@ -0,0 +1,694 @@ +//! 拟人化:把既有的节奏参数换算成「今天这个人」的操作习惯。 +//! +//! 和 [`super::polling`]、[`super::schedule`] 一样全是纯函数,随机源与当前日期 +//! 一律由调用方传入。这里算错不会崩,只会让机器特征重新暴露出来——每条投递 +//! 都隔 4 秒、每轮都正好 30 条、连投两小时不喘气——而这些症状在集成测试里 +//! 根本看不出来,只能靠单测把边界钉死。 +//! +//! # 为什么不新开一套参数 +//! +//! 用户在配置页设的「单轮 30 条、间隔 30 分钟」表达的是投递意图,不是动作节拍。 +//! 拟人化要改的是后者:意图仍是 30 条那个量级,但今天可能 26 条、明天 33 条, +//! 中途还会停下来歇几分钟。所以这里没有「休息阈值」「休息时长」这类新旋钮, +//! 全部由 [`Persona`] 从既有配置派生。 +//! +//! # 稳定随机 +//! +//! 人格种子存在配置里长期不变,每天再由 `(种子, 日期)` 派生出当天的具体策略。 +//! 于是同一天内行为自洽——手速、休息习惯是一以贯之的;换一天自动换一套; +//! 不同用户之间也各不相同。全程确定性推导,给定种子和日期就能复现,可单测。 + +use chrono::{Datelike, NaiveDate}; + +use crate::config::{HumanizeConfig, HumanizeIntensity}; +use crate::rpa::schedule::RoundBudget; + +/// 单轮上限设成「不限」时,休息节奏所依据的基准条数。 +/// +/// 不限不等于「一口气投到天亮」——那恰恰是最该拦住的模式。没有用户给的量级时 +/// 按这个数推导休息节奏 +pub const FALLBACK_BASE_GREETS: u32 = 30; + +/// 当日人格:一套当天固定、跨天自动更换的操作习惯。 +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Persona { + pub intensity: HumanizeIntensity, + /// 今日整体节奏系数。小于 1 手快,大于 1 手慢 + pub pace: f64, + /// 连投多少条之后歇一会儿 + pub break_after_greets: u32, + /// 每次休息的时长区间(秒) + pub break_seconds: (u64, u64), + /// 两条投递之间的基础停顿区间(毫秒) + pub greet_gap_ms: (u64, u64), + /// 停顿时走神的概率,以及走神时额外拖长的秒数区间 + pub distraction_chance: f64, + pub distraction_seconds: (u64, u64), + /// 岗位通过筛选后仍然「只看不投」的概率 + pub skim_rate: f64, + /// 打字速度(字符/分钟) + pub typing_cpm: u32, + /// 单轮预算与投递间隔的抖动幅度(±比例) + pub budget_jitter: f64, +} + +/// 一个强度档位对应的扰动配方。各区间的下界即该档位最轻的表现 +#[derive(Debug, Clone, Copy)] +struct IntensitySpec { + pace: (f64, f64), + /// 休息阈值相对基准条数的比例区间 + break_after_ratio: (f64, f64), + break_seconds: (u64, u64), + /// 基础停顿相对既有 3-5 秒节奏的倍率区间 + gap_scale: (f64, f64), + distraction_chance: (f64, f64), + distraction_seconds: (u64, u64), + skim_rate: (f64, f64), + typing_cpm: (u32, u32), + budget_jitter: f64, +} + +/// 改造前投递循环里那段固定的 3-5 秒停顿。拟人化在它之上缩放, +/// 而不是另起一套毫秒数——关掉拟人化时行为要能原样退回这里 +const BASE_GREET_GAP_MS: (u64, u64) = (3_000, 5_000); + +impl HumanizeIntensity { + fn spec(self) -> IntensitySpec { + match self { + // 只把直线掰弯一点,产出基本不受影响 + HumanizeIntensity::Light => IntensitySpec { + pace: (0.9, 1.15), + break_after_ratio: (0.75, 1.0), + break_seconds: (60, 180), + gap_scale: (0.9, 1.4), + distraction_chance: (0.02, 0.05), + distraction_seconds: (8, 25), + skim_rate: (0.0, 0.03), + typing_cpm: (260, 400), + budget_jitter: 0.10, + }, + // 群里说的「投 30 条歇 5-10 分钟」就是这一档 + HumanizeIntensity::Standard => IntensitySpec { + pace: (1.0, 1.5), + break_after_ratio: (0.4, 0.75), + break_seconds: (300, 600), + gap_scale: (1.1, 2.2), + distraction_chance: (0.05, 0.1), + distraction_seconds: (15, 60), + skim_rate: (0.04, 0.12), + typing_cpm: (160, 260), + budget_jitter: 0.25, + }, + // 已经被限制过、或者账号很重要时用,宁可少投 + HumanizeIntensity::Cautious => IntensitySpec { + pace: (1.3, 2.2), + break_after_ratio: (0.25, 0.5), + break_seconds: (600, 1_500), + gap_scale: (1.8, 3.6), + distraction_chance: (0.1, 0.2), + distraction_seconds: (30, 120), + skim_rate: (0.12, 0.25), + typing_cpm: (100, 180), + budget_jitter: 0.35, + }, + } + } +} + +impl Persona { + /// 从种子和日期推出当天的人格。关闭拟人化或没有种子时返回 None。 + /// + /// `base_greets` 是用户设的单轮上限,取 0(不限)时回落到 + /// [`FALLBACK_BASE_GREETS`]——休息节奏总得有个量级可依 + pub fn derive(config: &HumanizeConfig, day: NaiveDate, base_greets: u32) -> Option { + if !config.enabled || config.persona_seed == 0 { + return None; + } + let spec = config.intensity.spec(); + let base = if base_greets == 0 { + FALLBACK_BASE_GREETS + } else { + base_greets + }; + + // 种子与日期揉在一起:同一天恒定同一套,跨天自动翻新 + let mut rng = Rng::new(config.persona_seed ^ day_key(day)); + let pace = rng.range(spec.pace.0, spec.pace.1); + let break_ratio = rng.range(spec.break_after_ratio.0, spec.break_after_ratio.1); + let gap_scale = rng.range(spec.gap_scale.0, spec.gap_scale.1); + + Some(Self { + intensity: config.intensity, + pace, + // 至少 3 条一歇:再密就成了「投一条歇一次」,那不是人在用,是程序在装模作样 + break_after_greets: ((base as f64 * break_ratio).round() as u32).max(3), + break_seconds: scale_span(spec.break_seconds, pace), + greet_gap_ms: scale_span(BASE_GREET_GAP_MS, gap_scale), + distraction_chance: rng.range(spec.distraction_chance.0, spec.distraction_chance.1), + distraction_seconds: spec.distraction_seconds, + skim_rate: rng.range(spec.skim_rate.0, spec.skim_rate.1), + typing_cpm: rng + .range(spec.typing_cpm.0 as f64, spec.typing_cpm.1 as f64) + .round() + .max(30.0) as u32, + budget_jitter: spec.budget_jitter, + }) + } + + /// 读当天日期推出人格,运行期用这个。 + pub fn today(config: &HumanizeConfig, base_greets: u32) -> Option { + Self::derive(config, chrono::Local::now().date_naive(), base_greets) + } + + /// 给本轮预算蒙一层抖动。 + /// + /// 用户设的 30 条是量级不是配额——每轮都精确停在第 30 条,这个「精确」 + /// 本身就是特征。`roll` 取 0..=1 + pub fn shape_budget(&self, budget: RoundBudget, roll: f64) -> RoundBudget { + RoundBudget { + max_greets: jitter_u32(budget.max_greets, self.budget_jitter, roll).max( + // 抖到 0 会被下游读成「不限」,本轮就彻底没有上界了 + if budget.max_greets > 0 { 1 } else { 0 }, + ), + max_minutes: jitter_u64(budget.max_minutes, self.budget_jitter, 1.0 - roll), + max_consecutive_greet_failures: budget.max_consecutive_greet_failures, + } + } + + /// 给两轮之间的间隔蒙一层抖动,并按今日节奏整体拉长。 + /// + /// 间隔只向上抖:把用户设的 30 分钟抖成 22 分钟等于比他要求的更频繁, + /// 拟人化没有资格替他提高投递密度 + pub fn shape_interval_minutes(&self, minutes: u64, roll: f64) -> u64 { + if minutes == 0 { + return 0; + } + let clamped = roll.clamp(0.0, 1.0); + let factor = 1.0 + self.budget_jitter * clamped; + ((minutes as f64 * factor).round() as u64).max(minutes) + } + + /// 两条投递之间停多久(毫秒)。`gap_roll` 取区间内的位置, + /// `distraction_roll` 决定这次要不要走神,`length_roll` 决定走神多久 + pub fn gap_after_greet_ms( + &self, + gap_roll: f64, + distraction_roll: f64, + length_roll: f64, + ) -> u64 { + let base = pick_u64(self.greet_gap_ms, gap_roll); + if distraction_roll.clamp(0.0, 1.0) >= self.distraction_chance { + return base; + } + // 真人会突然去回个消息、倒杯水,这种离群的长停顿正是固定节律做不出来的 + base.saturating_add(pick_u64(self.distraction_seconds, length_roll).saturating_mul(1_000)) + } + + /// 距上次休息已经投了这么多条,该歇了吗 + pub fn should_break(&self, greets_since_break: u32) -> bool { + greets_since_break >= self.break_after_greets + } + + /// 这次休息多久(秒) + pub fn break_seconds(&self, roll: f64) -> u64 { + pick_u64(self.break_seconds, roll) + } + + /// 这个岗位要不要「只看不投」。 + /// + /// 每个符合条件的岗位都投,本身就是人做不到的事——真人会挑、会犹豫、 + /// 会看完描述觉得不合适就走。跳过的岗位不入库,下一轮还会再遇到 + pub fn should_skim(&self, roll: f64) -> bool { + roll.clamp(0.0, 1.0) < self.skim_rate + } + + /// 敲一个字符要多久(毫秒)。`roll` 制造快慢不匀的手感 + pub fn typing_delay_ms(&self, roll: f64) -> u64 { + let mean = 60_000.0 / self.typing_cpm.max(1) as f64; + // 0.6~1.6 倍:匀速敲字比敲得慢更可疑 + let factor = 0.6 + roll.clamp(0.0, 1.0); + (mean * factor).round().max(1.0) as u64 + } + + /// 打完一段字之后、按下发送之前的停顿(毫秒)。真人会回读一遍 + pub fn review_before_send_ms(&self, roll: f64) -> u64 { + let span = (600, 2_600); + ((pick_u64(span, roll) as f64) * self.pace).round() as u64 + } + + /// 鼠标从当前位置移到目标要走几步。步数越多轨迹越细腻,代价是 CDP 往返 + pub fn mouse_steps(&self, roll: f64) -> u32 { + pick_u64((6, 14), roll) as u32 + } + + /// 一行摘要,给日志和配置页展示当前生效的策略用 + pub fn describe(&self) -> String { + format!( + "每投 {} 条歇 {}-{} 分钟,岗位间停顿 {:.1}-{:.1} 秒,跳过率 {:.0}%,打字 {} 字/分", + self.break_after_greets, + self.break_seconds.0 / 60, + self.break_seconds.1.div_ceil(60), + self.greet_gap_ms.0 as f64 / 1000.0, + self.greet_gap_ms.1 as f64 / 1000.0, + self.skim_rate * 100.0, + self.typing_cpm, + ) + } +} + +/// 把日期压成一个参与种子混合的整数 +fn day_key(day: NaiveDate) -> u64 { + day.num_days_from_ce() as u64 +} + +/// 按系数缩放一个区间,同时保证下界不越过上界 +fn scale_span(span: (u64, u64), factor: f64) -> (u64, u64) { + let low = ((span.0 as f64) * factor).round() as u64; + let high = ((span.1 as f64) * factor).round() as u64; + (low.min(high), high.max(low)) +} + +/// 用 0..=1 的随机源在区间内取值 +fn pick_u64(span: (u64, u64), roll: f64) -> u64 { + let (low, high) = if span.0 <= span.1 { + (span.0, span.1) + } else { + (span.1, span.0) + }; + low + ((high - low) as f64 * roll.clamp(0.0, 1.0)).round() as u64 +} + +/// 在 `value` 上下 `ratio` 比例内抖动。0 表示「不限」,抖了也还是不限 +fn jitter_u64(value: u64, ratio: f64, roll: f64) -> u64 { + if value == 0 { + return 0; + } + let offset = (roll.clamp(0.0, 1.0) * 2.0 - 1.0) * ratio; + ((value as f64) * (1.0 + offset)).round().max(0.0) as u64 +} + +fn jitter_u32(value: u32, ratio: f64, roll: f64) -> u32 { + jitter_u64(value as u64, ratio, roll).min(u32::MAX as u64) as u32 +} + +/// SplitMix64。选它是因为实现只有几行且与平台无关—— +/// 人格必须能在任何机器上由同一组种子复现,否则单测钉不住 +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Self { + Self(seed) + } + + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + fn unit(&mut self) -> f64 { + // 取高 53 位映射到 [0,1),与 f64 的尾数宽度对齐 + (self.next_u64() >> 11) as f64 / (1u64 << 53) as f64 + } + + fn range(&mut self, low: f64, high: f64) -> f64 { + low + (high - low) * self.unit() + } +} + +/// 运行期的随机源。人格是稳定的,落到每一次具体动作上仍要现掷 +pub fn roll() -> f64 { + use rand::Rng as _; + rand::thread_rng().gen_range(0.0..=1.0) +} + +thread_local! { + /// 当前任务的人格。 + /// + /// 鼠标轨迹、打字速度这些动作散落在整条调用链的最深处——岗位卡片点击、沟通 + /// 按钮、聊天输入框、滚动,每一处都在不同的函数里。为它们逐层加参数会把 + /// 「拟人化」这件事糊到几十个与之无关的签名上,而 RPA 全流程本来就跑在 + /// 一条专用线程上,和 [`crate::rpa::run_flow::is_job_task_stop_requested`] + /// 用的是同一套线程内上下文 + static CURRENT_PERSONA: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; +} + +/// 装上当前任务的人格,返回的守卫在离开作用域时自动摘下。 +/// +/// 必须用守卫而不是裸的 setter:任务提前返回、出错、被停止的路径有很多条, +/// 漏摘一次,下一个任务就会继承上一个任务的人格 +#[must_use = "人格在守卫被丢弃时即摘下,忽略返回值等于没装"] +pub fn scoped_persona(persona: Option) -> PersonaGuard { + let previous = CURRENT_PERSONA.with(|slot| slot.replace(persona)); + PersonaGuard { previous } +} + +pub struct PersonaGuard { + previous: Option, +} + +impl Drop for PersonaGuard { + fn drop(&mut self) { + let previous = self.previous.take(); + CURRENT_PERSONA.with(|slot| *slot.borrow_mut() = previous); + } +} + +/// 当前线程正在使用的人格。拟人化关闭时返回 None,调用方据此走原有路径 +pub fn current_persona() -> Option { + CURRENT_PERSONA.with(|slot| *slot.borrow()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(intensity: HumanizeIntensity) -> HumanizeConfig { + HumanizeConfig { + enabled: true, + intensity, + persona_seed: 0x5EED_1234_ABCD_0001, + } + } + + fn day(year: i32, month: u32, date: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(year, month, date).unwrap() + } + + fn persona(intensity: HumanizeIntensity) -> Persona { + Persona::derive(&config(intensity), day(2026, 8, 20), 30).unwrap() + } + + /// 关掉开关必须彻底没有人格,投递循环据此走回改造前的固定节奏 + #[test] + fn a_disabled_config_yields_no_persona() { + let mut disabled = config(HumanizeIntensity::Standard); + disabled.enabled = false; + + assert!(Persona::derive(&disabled, day(2026, 8, 20), 30).is_none()); + } + + /// 种子还没生成时同样没有人格,不能拿 0 当种子推出一套所有用户都一样的策略 + #[test] + fn a_missing_seed_yields_no_persona() { + let mut seedless = config(HumanizeIntensity::Standard); + seedless.persona_seed = 0; + + assert!(Persona::derive(&seedless, day(2026, 8, 20), 30).is_none()); + } + + /// 「稳定随机」的字面意思:同一天同一种子,推几次都是同一套 + #[test] + fn the_same_seed_and_day_always_derive_the_same_persona() { + let config = config(HumanizeIntensity::Standard); + + let first = Persona::derive(&config, day(2026, 8, 20), 30).unwrap(); + let second = Persona::derive(&config, day(2026, 8, 20), 30).unwrap(); + + assert_eq!(first, second); + } + + /// 跨天自动换一套。每天都一模一样的话,长期看仍是一条直线 + #[test] + fn a_new_day_derives_a_different_persona() { + let config = config(HumanizeIntensity::Standard); + + let today = Persona::derive(&config, day(2026, 8, 20), 30).unwrap(); + let tomorrow = Persona::derive(&config, day(2026, 8, 21), 30).unwrap(); + + assert_ne!(today, tomorrow); + } + + /// 两个用户同一天也该各有各的习惯 + #[test] + fn different_seeds_derive_different_personas() { + let mut other = config(HumanizeIntensity::Standard); + other.persona_seed = 0xA11C_E000_0000_0002; + + let mine = Persona::derive(&config(HumanizeIntensity::Standard), day(2026, 8, 20), 30); + let theirs = Persona::derive(&other, day(2026, 8, 20), 30); + + assert_ne!(mine.unwrap(), theirs.unwrap()); + } + + /// 休息阈值必须从用户设的单轮上限派生,而不是写死一个 30 + #[test] + fn the_break_threshold_scales_with_the_users_own_round_limit() { + let config = config(HumanizeIntensity::Standard); + + let small = Persona::derive(&config, day(2026, 8, 20), 20).unwrap(); + let large = Persona::derive(&config, day(2026, 8, 20), 100).unwrap(); + + assert!(large.break_after_greets > small.break_after_greets); + assert!(small.break_after_greets <= 20); + } + + /// 单轮不限时也得有个休息节奏,否则「不限」就成了通宵直投 + #[test] + fn an_unlimited_round_still_gets_a_break_rhythm() { + let persona = Persona::derive(&config(HumanizeIntensity::Standard), day(2026, 8, 20), 0); + + let persona = persona.unwrap(); + assert!(persona.break_after_greets >= 3); + assert!(persona.break_after_greets <= FALLBACK_BASE_GREETS); + } + + /// 阈值再小也不能小到「投一条歇一次」——那比匀速投递还反常 + #[test] + fn the_break_threshold_never_collapses_to_every_single_greet() { + for base in [1, 2, 3, 5] { + let persona = + Persona::derive(&config(HumanizeIntensity::Cautious), day(2026, 8, 20), base) + .unwrap(); + assert!(persona.break_after_greets >= 3, "base={base}"); + } + } + + /// 档位越谨慎,歇得越勤、停顿越长、跳过越多 + #[test] + fn a_more_cautious_intensity_slows_everything_down() { + let light = persona(HumanizeIntensity::Light); + let cautious = persona(HumanizeIntensity::Cautious); + + assert!(cautious.break_after_greets < light.break_after_greets); + assert!(cautious.greet_gap_ms.0 > light.greet_gap_ms.0); + assert!(cautious.skim_rate > light.skim_rate); + assert!(cautious.typing_cpm < light.typing_cpm); + } + + /// 单轮上限被抖动之后仍在同一量级,而不是被改成另一个数 + #[test] + fn the_round_budget_is_nudged_rather_than_replaced() { + let persona = persona(HumanizeIntensity::Standard); + let budget = RoundBudget { + max_greets: 30, + max_minutes: 60, + max_consecutive_greet_failures: 5, + }; + + let low = persona.shape_budget(budget, 0.0); + let high = persona.shape_budget(budget, 1.0); + + assert!(low.max_greets < 30 && low.max_greets >= 20); + assert!(high.max_greets > 30 && high.max_greets <= 40); + assert_eq!(low.max_consecutive_greet_failures, 5); + } + + /// 抖到 0 会被下游读成「不限」,本轮就彻底没有上界了——这是最坏的方向 + #[test] + fn a_capped_budget_never_jitters_down_into_unlimited() { + let mut persona = persona(HumanizeIntensity::Cautious); + persona.budget_jitter = 5.0; + let budget = RoundBudget { + max_greets: 1, + max_minutes: 1, + max_consecutive_greet_failures: 5, + }; + + let shaped = persona.shape_budget(budget, 0.0); + + assert!(shaped.max_greets >= 1); + } + + /// 本来就不限的预算抖完还是不限,不能凭空长出一个上界 + #[test] + fn an_unlimited_budget_stays_unlimited_after_shaping() { + let persona = persona(HumanizeIntensity::Standard); + + let shaped = persona.shape_budget(RoundBudget::unlimited(), 0.5); + + assert_eq!(shaped, RoundBudget::unlimited()); + } + + /// 间隔只向上抖:把 30 分钟抖成 22 分钟等于替用户提高了投递密度 + #[test] + fn the_delivery_interval_only_ever_stretches() { + let persona = persona(HumanizeIntensity::Standard); + + for roll in [0.0, 0.25, 0.5, 0.75, 1.0] { + assert!(persona.shape_interval_minutes(30, roll) >= 30, "roll={roll}"); + } + assert!(persona.shape_interval_minutes(30, 1.0) > 30); + assert_eq!(persona.shape_interval_minutes(0, 1.0), 0); + } + + /// 停顿落在人格给的区间内,走神时才会突破上界 + #[test] + fn the_gap_between_greets_stays_in_range_unless_distracted() { + let persona = persona(HumanizeIntensity::Standard); + + let calm = persona.gap_after_greet_ms(0.5, 1.0, 0.5); + assert!(calm >= persona.greet_gap_ms.0 && calm <= persona.greet_gap_ms.1); + + let distracted = persona.gap_after_greet_ms(0.5, 0.0, 1.0); + assert!(distracted > persona.greet_gap_ms.1); + } + + /// 走神概率为 0 时永远不该被拖长 + #[test] + fn a_zero_distraction_chance_never_stretches_the_gap() { + let mut persona = persona(HumanizeIntensity::Standard); + persona.distraction_chance = 0.0; + + let gap = persona.gap_after_greet_ms(0.0, 0.0, 1.0); + + assert_eq!(gap, persona.greet_gap_ms.0); + } + + #[test] + fn breaks_trigger_only_after_the_threshold_is_reached() { + let persona = persona(HumanizeIntensity::Standard); + let threshold = persona.break_after_greets; + + assert!(!persona.should_break(threshold - 1)); + assert!(persona.should_break(threshold)); + assert!(persona.should_break(threshold + 1)); + } + + #[test] + fn break_length_stays_within_the_personas_range() { + let persona = persona(HumanizeIntensity::Standard); + + assert_eq!(persona.break_seconds(0.0), persona.break_seconds.0); + assert_eq!(persona.break_seconds(1.0), persona.break_seconds.1); + // 群里提的「歇 5-10 分钟」正是标准档该给出的量级 + assert!(persona.break_seconds.0 >= 240); + assert!(persona.break_seconds.1 <= 1_200); + } + + /// 跳过率是概率,不是配额:随机源低于它才跳 + #[test] + fn skimming_follows_the_configured_rate() { + let mut persona = persona(HumanizeIntensity::Standard); + persona.skim_rate = 0.1; + + assert!(persona.should_skim(0.05)); + assert!(!persona.should_skim(0.1)); + assert!(!persona.should_skim(0.9)); + } + + /// 跳过率为 0 时一个都不该跳,轻度档不能悄悄吃掉产出 + #[test] + fn a_zero_skim_rate_never_skips() { + let mut persona = persona(HumanizeIntensity::Light); + persona.skim_rate = 0.0; + + for roll in [0.0, 0.001, 0.5, 1.0] { + assert!(!persona.should_skim(roll), "roll={roll}"); + } + } + + /// 匀速敲字比敲得慢更可疑,每个字符的耗时必须有波动 + #[test] + fn typing_delay_varies_around_the_configured_speed() { + let persona = persona(HumanizeIntensity::Standard); + + let fast = persona.typing_delay_ms(0.0); + let slow = persona.typing_delay_ms(1.0); + + assert!(fast > 0); + assert!(slow > fast); + } + + /// 越界的随机源不该把等待放大到离谱的量级,也不该 panic + #[test] + fn out_of_range_rolls_are_clamped_everywhere() { + let persona = persona(HumanizeIntensity::Standard); + + assert_eq!(persona.break_seconds(-3.0), persona.break_seconds.0); + assert_eq!(persona.break_seconds(9.0), persona.break_seconds.1); + assert_eq!( + persona.gap_after_greet_ms(-1.0, 1.0, 0.0), + persona.greet_gap_ms.0 + ); + assert!(persona.shape_interval_minutes(30, -2.0) >= 30); + assert!(persona.typing_delay_ms(42.0) > 0); + } + + #[test] + fn the_persona_summary_reads_as_a_concrete_strategy() { + let summary = persona(HumanizeIntensity::Standard).describe(); + + assert!(summary.contains("条歇")); + assert!(summary.contains("跳过率")); + assert!(summary.contains("字/分")); + } + + /// 随机源必须落在 0..=1,下游所有 clamp 都以此为前提 + #[test] + fn the_runtime_random_source_stays_in_the_unit_interval() { + for _ in 0..64 { + let value = roll(); + assert!((0.0..=1.0).contains(&value), "{value}"); + } + } + + /// 没装人格时深处的动作必须看得出来「现在不该拟人」 + #[test] + fn no_persona_is_installed_by_default() { + assert!(current_persona().is_none()); + } + + /// 守卫离开作用域就该摘干净,否则下一个任务会继承上一个任务的人格 + #[test] + fn the_persona_guard_restores_the_previous_value() { + let persona = persona(HumanizeIntensity::Standard); + + { + let _guard = scoped_persona(Some(persona)); + assert_eq!(current_persona(), Some(persona)); + + // 嵌套安装同样要能原样退回外层,而不是退成 None + let inner = Persona::derive(&config(HumanizeIntensity::Cautious), day(2026, 8, 21), 30); + { + let _inner_guard = scoped_persona(inner); + assert_eq!(current_persona(), inner); + } + assert_eq!(current_persona(), Some(persona)); + } + + assert!(current_persona().is_none()); + } + + #[test] + fn the_seeded_generator_spreads_over_the_unit_interval() { + let mut rng = Rng::new(7); + let mut low = 0; + let mut high = 0; + + for _ in 0..256 { + let value = rng.unit(); + assert!((0.0..1.0).contains(&value), "{value}"); + if value < 0.5 { + low += 1; + } else { + high += 1; + } + } + + assert!(low > 64 && high > 64, "low={low} high={high}"); + } +} diff --git a/src-tauri/src/rpa/liepin/handler/position_say_hello.rs b/src-tauri/src/rpa/liepin/handler/position_say_hello.rs index 180455c..70f17cb 100644 --- a/src-tauri/src/rpa/liepin/handler/position_say_hello.rs +++ b/src-tauri/src/rpa/liepin/handler/position_say_hello.rs @@ -12,6 +12,7 @@ use crate::{ common::{upload_image_to_file_input, RpaJob}, conversation::SendVerdict, greet::build_greet_resources, + human_pace::GreetPacer, liepin::LIEPIN_SITE_URL, run_flow::is_job_task_stop_requested, schedule::{BudgetVerdict, RoundBudget}, @@ -61,6 +62,9 @@ pub async fn position_say_hello_on_page( ))?; let mut seen_job_ids: HashSet = HashSet::new(); + // 休息节奏从用户设的单轮上限派生。拟人化关着时它是个空壳, + // 停顿仍是改造前那段固定间隔 + let mut pacer = GreetPacer::new(&config.humanize_config, budget.max_greets); logger::info(format!("正在打开猎聘职位搜索页: {}", search_url))?; page.get(&search_url)?; @@ -124,6 +128,13 @@ pub async fn position_say_hello_on_page( stats.skipped_rule += 1; continue; } + // 「每个符合条件的岗位都投」是人做不到的事。放在语义复核之前, + // 跳过的岗位不烧模型额度 + if pacer.should_skim() { + stats.skipped_humanize += 1; + logger::info(format!("拟人化跳过本岗位:{}", job.title))?; + continue; + } logger::info(format!( "猎聘处理岗位:{} - {}", @@ -160,10 +171,11 @@ pub async fn position_say_hello_on_page( AnalysisTrigger::FilterPassed, &config, ); - match greet_job(connection, job.clone(), config.clone()).await { + let greeted = match greet_job(connection, job.clone(), config.clone()).await { Ok(false) => { stats.skipped_hold += 1; consecutive_greet_failures = 0; + false } Ok(true) => { stats.greeted += 1; @@ -171,6 +183,7 @@ pub async fn position_say_hello_on_page( consecutive_greet_failures = 0; processed_job_ids.insert(format!("liepin:{}", job.platform_job_id)); processed_job_ids.insert(job.platform_job_id.clone()); + true } Err(error) => { stats.greet_failed += 1; @@ -178,11 +191,20 @@ pub async fn position_say_hello_on_page( logger::warning(greet_failure_message(&job.title, &job.company_name, &error))?; continue; } + }; + // 停顿、以及连投若干条之后的休息都在这里面。收到停止请求时立即收尾, + // 不能让用户等完一段十几分钟的休息 + if !pacer.after_greet(greeted).await { + logger::info(stats.summary())?; + logger::info("猎聘求职任务已结束")?; + return Ok(()); } - sleep_random_ms(2500, 4500); } logger::info(stats.summary())?; + if let Some(summary) = pacer.summary() { + logger::info(summary)?; + } if !scroll_next(page)? { logger::info("猎聘岗位列表已触底")?; @@ -228,6 +250,8 @@ struct RoundStats { skipped_rule: u32, /// AI 语义复核未通过或失败 skipped_ai: u32, + /// 被拟人化随机跳过(「只看不投」) + skipped_humanize: u32, /// 内容通过了复核,但发送前被闸门整轮拦下(例如模型判断不该投) skipped_hold: u32, greeted: u32, @@ -237,13 +261,14 @@ struct RoundStats { impl RoundStats { fn summary(&self) -> String { format!( - "猎聘本页 {} 条岗位:打招呼成功 {} 条,失败 {} 条;已沟通跳过 {} 条,规则过滤跳过 {} 条,AI 复核跳过 {} 条,发送闸门拦下 {} 条", + "猎聘本页 {} 条岗位:打招呼成功 {} 条,失败 {} 条;已沟通跳过 {} 条,规则过滤跳过 {} 条,AI 复核跳过 {} 条,拟人化跳过 {} 条,发送闸门拦下 {} 条", self.scanned, self.greeted, self.greet_failed, self.skipped_processed, self.skipped_rule, self.skipped_ai, + self.skipped_humanize, self.skipped_hold ) } @@ -1570,6 +1595,7 @@ mod tests { skipped_processed: 28, skipped_rule: 8, skipped_ai: 3, + skipped_humanize: 2, skipped_hold: 4, greeted: 2, greet_failed: 1, @@ -1583,6 +1609,8 @@ mod tests { assert!(summary.contains("已沟通跳过 28 条")); assert!(summary.contains("规则过滤跳过 8 条")); assert!(summary.contains("AI 复核跳过 3 条")); + // 拟人化跳过必须和「规则不匹配」分开记,否则用户会以为自己筛选条件写错了 + assert!(summary.contains("拟人化跳过 2 条")); assert!(summary.contains("发送闸门拦下 4 条")); } diff --git a/src-tauri/src/rpa/mod.rs b/src-tauri/src/rpa/mod.rs index f093721..e6eac77 100644 --- a/src-tauri/src/rpa/mod.rs +++ b/src-tauri/src/rpa/mod.rs @@ -2,6 +2,9 @@ pub mod boss; pub mod common; pub mod conversation; pub mod greet; +pub mod human_input; +pub mod human_pace; +pub mod humanize; pub mod liepin; pub mod polling; pub mod reply_effects; diff --git a/src-tauri/src/rpa/reply_effects.rs b/src-tauri/src/rpa/reply_effects.rs index 8948bfb..700d5cf 100644 --- a/src-tauri/src/rpa/reply_effects.rs +++ b/src-tauri/src/rpa/reply_effects.rs @@ -92,7 +92,10 @@ pub async fn wait_before_reply( let elapsed_ms = context .last_received() .map(|message| chrono::Local::now().timestamp_millis() - message.time); - let seconds = polling::humanize_delay_seconds_now(polling_config, elapsed_ms); + let seconds = stretch_by_persona(polling::humanize_delay_seconds_now( + polling_config, + elapsed_ms, + )); if seconds == 0 { return true; } @@ -107,3 +110,14 @@ pub async fn wait_before_reply( } true } + +/// 按当日人格的节奏系数拉长等待。 +/// +/// 这里刻意只放大、不缩短:用户在轮询配置里设的等待区间是他要求的下限, +/// 拟人化可以让今天的自己回得更慢一些,但没有资格替他回得更快 +fn stretch_by_persona(seconds: u64) -> u64 { + match crate::rpa::humanize::current_persona() { + Some(persona) => ((seconds as f64) * persona.pace.max(1.0)).round() as u64, + None => seconds, + } +} diff --git a/src-tauri/src/rpa/run_flow.rs b/src-tauri/src/rpa/run_flow.rs index 011f8fb..0588625 100644 --- a/src-tauri/src/rpa/run_flow.rs +++ b/src-tauri/src/rpa/run_flow.rs @@ -10,7 +10,7 @@ use rust_drission::{ChromiumPage, Page}; use serde::{Deserialize, Serialize}; use super::{ - boss, liepin, polling, + boss, humanize, liepin, polling, schedule::{self, PeriodicPlan, PeriodicState, RoundBudget}, }; use crate::{config::AppRuntimeConfig, logger}; @@ -653,6 +653,10 @@ async fn run_reply_round( target: &ReplyTarget<'_>, config: &AppRuntimeConfig, ) -> Result<(), anyhow::Error> { + // 回复同样要拟人:给 HR 打字这件事比投递更该像人,对面是个真人在看。 + // 这里自己装而不是沿用投递那份——空闲期的回复跑在投递的节奏器之外, + // 独立轮询任务更是压根没有投递循环 + let _persona = humanize::scoped_persona(humanize::Persona::today(&config.humanize_config, 0)); match target { ReplyTarget::NewTab(platform) => execute_reply_unread(*platform, config).await, ReplyTarget::OwnedTab(main_tab, platform) => { @@ -766,7 +770,7 @@ async fn periodic_job_hunting( config: &AppRuntimeConfig, plan: &PeriodicPlan, ) -> Result<(), anyhow::Error> { - let budget = RoundBudget::from_plan(plan); + let base_budget = RoundBudget::from_plan(plan); let mut consecutive_failures = 0u32; // 时段外每轮都播报一次「暂停中」会把日志刷满,只在刚进入暂停时说一次 let mut pause_announced = false; @@ -798,6 +802,14 @@ async fn periodic_job_hunting( } PeriodicState::Deliver => { pause_announced = false; + // 每轮重新掷一次:用户设的 30 条是量级不是配额,每轮都精确停在 + // 第 30 条、每次都隔整 30 分钟,这个「精确」本身就是机器特征。 + // 抖动只作用于本轮,计划快照始终保持用户提交时的样子 + let persona = humanize::Persona::today(&config.humanize_config, base_budget.max_greets); + let budget = match persona.as_ref() { + Some(persona) => persona.shape_budget(base_budget, humanize::roll()), + None => base_budget, + }; logger::info("开始执行本轮周期性投递")?; let started = Instant::now(); match target.deliver(config, budget).await { @@ -820,7 +832,14 @@ async fn periodic_job_hunting( return Ok(()); } - let next_at = schedule::next_delivery_at(plan, Local::now()); + // 间隔同样只向上抖:抖短了等于替用户提高投递密度,拟人化没这个资格 + let interval = match persona.as_ref() { + Some(persona) => { + persona.shape_interval_minutes(plan.interval_minutes, humanize::roll()) + } + None => plan.interval_minutes, + }; + let next_at = schedule::next_delivery_at_after(plan, Local::now(), interval); logger::info(format!( "本轮投递用时 {} 分钟,下一轮 {} 开始,其间按轮询节奏检查未读", started.elapsed().as_secs() / 60, diff --git a/src-tauri/src/rpa/schedule.rs b/src-tauri/src/rpa/schedule.rs index 060b885..c425298 100644 --- a/src-tauri/src/rpa/schedule.rs +++ b/src-tauri/src/rpa/schedule.rs @@ -266,8 +266,20 @@ pub fn plan_state(plan: &PeriodicPlan, now: DateTime) -> PeriodicState { /// 取「间隔到期」「窗口关闭」「任务结束」三者中最早的一个:窗口一关就该停下, /// 哪怕间隔还没走完;结束时刻同理 pub fn next_delivery_at(plan: &PeriodicPlan, now: DateTime) -> DateTime { - let mut target = - now + ChronoDuration::minutes(plan.interval_minutes.min(i64::MAX as u64) as i64); + next_delivery_at_after(plan, now, plan.interval_minutes) +} + +/// 同 [`next_delivery_at`],但间隔由调用方给。 +/// +/// 拟人化会把用户设的间隔往上抖一点,抖出来的值只对这一轮有效,不能写回计划—— +/// 计划是任务入队时固定下来的快照,改了它下一轮就会在抖过的值上再抖一次, +/// 几轮之后间隔会滚成一个离谱的数 +pub fn next_delivery_at_after( + plan: &PeriodicPlan, + now: DateTime, + interval_minutes: u64, +) -> DateTime { + let mut target = now + ChronoDuration::minutes(interval_minutes.min(i64::MAX as u64) as i64); if let Some(close_at) = current_windows_close(&plan.windows, now) { target = target.min(close_at); } @@ -676,6 +688,25 @@ mod tests { assert_eq!(next_delivery_at(&plan, now), local(2026, 8, 19, 18, 0)); } + /// 拟人化抖过的间隔只作用于这一轮,窗口与结束时刻照样把它压回来 + #[test] + fn an_overridden_interval_is_still_capped_by_the_window_and_deadline() { + let plan = PeriodicPlan { + windows: vec![window(9 * 60, 18 * 60)], + ..PeriodicPlan::every(30) + }; + let now = local(2026, 8, 19, 10, 0); + + assert_eq!( + next_delivery_at_after(&plan, now, 37), + local(2026, 8, 19, 10, 37) + ); + assert_eq!( + next_delivery_at_after(&plan, local(2026, 8, 19, 17, 50), 37), + local(2026, 8, 19, 18, 0) + ); + } + #[test] fn next_delivery_is_capped_by_the_deadline() { let plan = PeriodicPlan { diff --git a/src/App.tsx b/src/App.tsx index 626e0c8..ba57380 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { Alert, Button, ConfigProvider, Spin, Tabs, Typography } from "antd"; import { RocketOutlined } from "@ant-design/icons"; import "./App.css"; import { useAppConfig } from "@/hooks/useAppConfig"; -import { copyJobProfile, DEFAULT_REGEX_RULE_LIMIT, getAnalysisConfig, getDefaultJobProfile, getJobProfiles, getReplyPollingConfig, getPeriodicDeliveryConfig, selectProfileAfterRemoval, type AnalysisConfig, type AppRuntimeConfig, type BrowserConfig, type GreetConfig, type GreetResource, type JobFilterConfig, type JobProfile, type PeriodicDeliveryConfig, type RegexRule, type ReplayConfig, type ReplyPollingConfig, type ReplyResource, type ReplyTemplate, type ResumeConfig } from "@/types/app-config"; +import { copyJobProfile, DEFAULT_REGEX_RULE_LIMIT, getAnalysisConfig, getDefaultJobProfile, getJobProfiles, getReplyPollingConfig, getPeriodicDeliveryConfig, getHumanizeConfig, selectProfileAfterRemoval, type AnalysisConfig, type AppRuntimeConfig, type BrowserConfig, type GreetConfig, type GreetResource, type HumanizeConfig, type JobFilterConfig, type JobProfile, type PeriodicDeliveryConfig, type RegexRule, type ReplayConfig, type ReplyPollingConfig, type ReplyResource, type ReplyTemplate, type ResumeConfig } from "@/types/app-config"; import type { JobDetail } from "@/types/job-detail"; import { Onboarding } from "@/view/onboarding"; import { ConfigPage } from "@/view/config"; @@ -90,6 +90,9 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, update((c) => ({ ...c, reply_polling_config: { ...getReplyPollingConfig(c), ...next } })); const updatePeriodicDelivery = (next: Partial) => update((c) => ({ ...c, periodic_delivery_config: { ...getPeriodicDeliveryConfig(c), ...next } })); + // 拟人化同样是顶层配置,旧配置里整块缺失,不能直接走 merge + const updateHumanize = (next: Partial) => + update((c) => ({ ...c, humanize_config: { ...getHumanizeConfig(c), ...next } })); const updateProfiles = (nextProfiles: JobProfile[], defaultId = config.default_job_profile_id) => update((c) => ({ ...c, job_profiles: nextProfiles, @@ -140,6 +143,7 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, updateAnalysis={updateAnalysis} updatePolling={updatePolling} updatePeriodicDelivery={updatePeriodicDelivery} + updateHumanize={updateHumanize} updateBrowser={(v: Partial) => merge("browser_config", v)} updateResume={(v: Partial) => updateProfileSection("resume_config", v)} updateRule={(i: number, v: Partial) => updateActiveProfile((p) => ({ ...p, job_filter_config: { ...p.job_filter_config, regex_rules: updateAt(p.job_filter_config.regex_rules, i, v) } }))} addRule={() => updateActiveProfile((p) => ({ ...p, job_filter_config: { ...p.job_filter_config, regex_rules: [...p.job_filter_config.regex_rules, { name: "", pattern: "", target: "All", mode: "ACCEPT" }] } }))} diff --git a/src/hooks/useAppConfig.test.tsx b/src/hooks/useAppConfig.test.tsx index 5cd6458..b5d58d6 100644 --- a/src/hooks/useAppConfig.test.tsx +++ b/src/hooks/useAppConfig.test.tsx @@ -17,7 +17,7 @@ const config: AppRuntimeConfig = { onboarding_completed: false, llm_config: null, llm_fallbacks: [], - llm_retry_config: { network_retry_attempts: 2, retry_base_delay_ms: 500 }, + llm_retry_config: { network_retry_attempts: 2, retry_base_delay_ms: 500, request_timeout_seconds: 120 }, job_filter_config: { query: null, city: null, job_type: 0, salary: 0, experience: [], dgree: [], industry: [], scale: [], stage: [], keywords: [], exclude_keywords: [], company_keywords: [], company_exclude_keywords: [], enable_semantic_filter: false, semantic_filter_intent: null, regex_rules: [] }, platform_filter_config: { liepin: { dq: null, salary_code: null, pub_time: null, work_year_code: null, comp_tag: [] } }, greet_config: { enable_llm: false, reply_prompt: null, default_template: [] }, @@ -58,8 +58,8 @@ describe("useAppConfig", () => { it("preserves newer edits made while an earlier version is being saved", async () => { vi.mocked(api.loadAppConfig).mockResolvedValue(config); let finishSave: (() => void) | undefined; - vi.mocked(api.saveAppConfig).mockImplementation(() => new Promise((resolve) => { - finishSave = resolve; + vi.mocked(api.saveAppConfig).mockImplementation((submitted) => new Promise((resolve) => { + finishSave = () => resolve(submitted); })); const { result } = renderHook(() => useAppConfig()); await waitFor(() => expect(result.current.config).not.toBeNull()); @@ -88,7 +88,7 @@ describe("useAppConfig", () => { it("applies an explicitly saved onboarding config to the live state", async () => { vi.mocked(api.loadAppConfig).mockResolvedValue(config); - vi.mocked(api.saveAppConfig).mockResolvedValue(); + vi.mocked(api.saveAppConfig).mockImplementation(async (submitted) => submitted); const { result } = renderHook(() => useAppConfig()); await waitFor(() => expect(result.current.config).not.toBeNull()); @@ -101,7 +101,7 @@ describe("useAppConfig", () => { it("reports save success and failure", async () => { vi.mocked(api.loadAppConfig).mockResolvedValue(config); - vi.mocked(api.saveAppConfig).mockResolvedValue(); + vi.mocked(api.saveAppConfig).mockImplementation(async (submitted) => submitted); const { result } = renderHook(() => useAppConfig()); await waitFor(() => expect(result.current.config).not.toBeNull()); await act(() => result.current.save()); @@ -111,4 +111,29 @@ describe("useAppConfig", () => { expect(result.current.status).toBe("error"); expect(result.current.message).toBe("无法保存"); }); + + /** + * 后端在保存路径上会做迁移、夹取上下界、补生成拟人化的人格种子,落盘内容 + * 和提交内容并不相同。拿提交的那份当已保存快照的话,下次保存又原样提交一遍—— + * 对夹取类字段只是显示不同步,对人格种子则是每存一次换一套人格 + */ + it("adopts the normalized config the backend actually wrote", async () => { + vi.mocked(api.loadAppConfig).mockResolvedValue(config); + const normalized: AppRuntimeConfig = { + ...config, + humanize_config: { enabled: true, intensity: "standard", persona_seed: 8_123_456_789 }, + }; + vi.mocked(api.saveAppConfig).mockResolvedValue(normalized); + const { result } = renderHook(() => useAppConfig()); + await waitFor(() => expect(result.current.config).not.toBeNull()); + + await act(() => result.current.save({ + ...config, + humanize_config: { enabled: true, intensity: "standard", persona_seed: 0 }, + })); + + expect(result.current.config?.humanize_config?.persona_seed).toBe(8_123_456_789); + // 已保存快照也必须换成落盘的那份,否则界面立刻显示为「未保存」 + expect(result.current.dirty).toBe(false); + }); }); diff --git a/src/hooks/useAppConfig.ts b/src/hooks/useAppConfig.ts index 8b7a273..93f4ffc 100644 --- a/src/hooks/useAppConfig.ts +++ b/src/hooks/useAppConfig.ts @@ -39,10 +39,13 @@ export function useAppConfig() { const configAtSaveStart = config; setStatus("loading"); try { - await saveAppConfig(value); - const savedValue = JSON.stringify(value); - savedSnapshot.current = savedValue; - setConfig((current) => current === configAtSaveStart ? value : current); + // 用后端落盘后的那份而不是提交的那份:保存路径上会做迁移、夹取上下界、 + // 补生成拟人化的人格种子。拿旧值当已保存快照的话,下次保存又原样提交一遍, + // 种子于是每存一次换一个,拟人化的「当天节奏稳定」就不成立了 + const saved = await saveAppConfig(value); + savedSnapshot.current = JSON.stringify(saved); + // 保存期间用户可能又改了别处,这时不能拿回包覆盖他正在编辑的内容 + setConfig((current) => current === configAtSaveStart ? saved : current); setStatus("saved"); setMessage("配置已保存"); return true; diff --git a/src/lib/tauriConfig.ts b/src/lib/tauriConfig.ts index 836f16d..db65c62 100644 --- a/src/lib/tauriConfig.ts +++ b/src/lib/tauriConfig.ts @@ -11,8 +11,14 @@ export async function loadAppConfig(): Promise { return unwrap(await invoke>("load_app_config")); } -export async function saveAppConfig(config: AppRuntimeConfig): Promise { - unwrapVoid(await invoke>("save_app_config", { config }), "保存配置失败"); +/** + * 保存配置,返回**落盘后**的那份。 + * + * 后端在保存路径上会做迁移、夹取上下界、补生成拟人化的人格种子,落盘内容与 + * 提交内容并不相同。调用方应当用返回值替换手里的配置,否则会带着旧值继续编辑 + */ +export async function saveAppConfig(config: AppRuntimeConfig): Promise { + return unwrap(await invoke>("save_app_config", { config }), "保存配置失败"); } export async function importAppConfig(path: string): Promise { diff --git a/src/types/app-config.ts b/src/types/app-config.ts index 44af7ac..a16ef71 100644 --- a/src/types/app-config.ts +++ b/src/types/app-config.ts @@ -96,12 +96,17 @@ export interface LlmRetryConfig { network_retry_attempts: number; /** 首次重试前的等待毫秒数,之后按指数退避 */ retry_base_delay_ms: number; + /** 单次模型请求的超时时间(秒) */ + request_timeout_seconds: number; } /** 与 Rust 侧 validate_and_normalize 保持一致的取值区间 */ export const MAX_NETWORK_RETRY_ATTEMPTS = 5; export const MIN_RETRY_BASE_DELAY_MS = 100; export const MAX_RETRY_BASE_DELAY_MS = 10_000; +export const DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS = 120; +export const MIN_LLM_REQUEST_TIMEOUT_SECONDS = 1; +export const MAX_LLM_REQUEST_TIMEOUT_SECONDS = 600; export type ReplayResourceType = "Text" | "Image" | "LLM"; @@ -317,6 +322,42 @@ export function hoursToWindows(hours: boolean[]): DailyWindow[] { return windows; } +/** + * 拟人化强度。只决定扰动幅度,不引入新的数值参数—— + * 休息阈值、停顿长度、打字速度全部由后端从既有配置派生。 + */ +export type HumanizeIntensity = "light" | "standard" | "cautious"; + +/** + * 拟人化。 + * + * 平台风控看的不是单次动作像不像人,而是长期模式:每条投递都隔 4 秒、每轮都 + * 正好 30 条——单看每一步都合法,连起来是一条没有呼吸的直线。开启后后端会给 + * 既有的「单轮上限 / 投递间隔 / 停顿」蒙上一层扰动,用户设的量级不变。 + */ +export interface HumanizeConfig { + enabled: boolean; + intensity: HumanizeIntensity; + /** + * 人格种子,由后端首次启用时生成。界面只读不改:改了等于换一个人, + * 当天已经形成的节奏会整个变掉。 + * + * 后端刻意把它限制在 2^53 以内,这个字段才能安全地经 JSON 往返 + */ + persona_seed: number; +} + +export const DEFAULT_HUMANIZE_CONFIG: HumanizeConfig = { + enabled: false, + intensity: "standard", + persona_seed: 0, +}; + +/** 读取拟人化配置,旧配置缺这块时回落到「关闭」。 */ +export function getHumanizeConfig(config: Pick): HumanizeConfig { + return { ...DEFAULT_HUMANIZE_CONFIG, ...(config.humanize_config ?? {}) }; +} + export interface BrowserConfig { user_data_dir: string; chrome_exe_path: string | null; @@ -362,6 +403,8 @@ export interface AppRuntimeConfig { reply_polling_config?: ReplyPollingConfig; /** 旧配置没有这块,读取时请使用 getPeriodicDeliveryConfig 兜底 */ periodic_delivery_config?: PeriodicDeliveryConfig; + /** 旧配置没有这块,读取时请使用 getHumanizeConfig 兜底 */ + humanize_config?: HumanizeConfig; browser_config: BrowserConfig; resume_config: ResumeConfig; /** 旧配置/测试 mock 可能暂时不包含这两个字段,读取时请使用 getJobProfiles。 */ diff --git a/src/types/command.ts b/src/types/command.ts index 8ff1c6c..72cee1f 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -28,9 +28,9 @@ export function commandErrorMessage( return error?.message || fallback; } -export function unwrap(result: CommandResult): T { +export function unwrap(result: CommandResult, fallback?: string): T { if (!result.success || result.data === null) { - throw new Error(commandErrorMessage(result.error)); + throw new Error(commandErrorMessage(result.error, fallback)); } return result.data; } diff --git a/src/view/config/HumanizeSection.tsx b/src/view/config/HumanizeSection.tsx new file mode 100644 index 0000000..23477d4 --- /dev/null +++ b/src/view/config/HumanizeSection.tsx @@ -0,0 +1,128 @@ +import { Radio, Typography } from "antd"; +import { ExperimentOutlined, SafetyCertificateOutlined } from "@ant-design/icons"; +import { + type HumanizeConfig, + type HumanizeIntensity, +} from "../../types/app-config"; +import { SettingGroup, SettingToggle } from "@/components/SettingField"; + +const { Text } = Typography; + +/** + * 强度档位。 + * + * 每档只说「会发生什么」和「代价是什么」——具体数字由后端按当天的人格现算, + * 写死在这里迟早和实际行为对不上,而对不上的说明比没有说明更糟 + */ +const INTENSITY_OPTIONS: Array<{ + value: HumanizeIntensity; + label: string; + description: string; + cost: string; +}> = [ + { + value: "light", + label: "轻度", + description: "只在既有节奏上小幅抖动,投递量基本不变", + cost: "产出几乎无损失", + }, + { + value: "standard", + label: "标准", + description: "投一批歇几分钟,偶尔跳过一个岗位、停下来发会儿呆", + cost: "产出约降一到两成", + }, + { + value: "cautious", + label: "谨慎", + description: "休息更勤更久、跳过更多、动作更慢,适合已经被限制过的账号", + cost: "产出明显下降", + }, +]; + +interface Props { + config: HumanizeConfig; + onChange: (next: Partial) => void; +} + +/** + * 拟人化。 + * + * 这里刻意只有一个开关和三个档位:休息阈值、停顿长度、打字速度这些具体数字 + * 不做成旋钮,而是由系统按一个长期不变的「人格种子」每天现算一套。 + * 一组固定的数字——哪怕是用户自己填的——本身就是可被识别的模式。 + */ +export default function HumanizeSection({ config, onChange }: Props) { + return ( +
+
+ 拟人化 + + 让投递节奏和鼠标、键盘动作带上真人的不确定性。已在跑的任务不受影响 + +
+ + + } + title="启用拟人化" + description="平台看的不是单次动作像不像人,而是长期模式:每条都隔 4 秒、每轮都正好 30 条,连起来就是一条没有呼吸的直线" + checked={config.enabled} + onChange={(enabled) => onChange({ enabled })} + > + {config.enabled && ( +
+ onChange({ intensity: event.target.value as HumanizeIntensity })} + className="w-full" + > +
+ {INTENSITY_OPTIONS.map((option) => ( + +
+
{option.label}
+
+ {option.description} +
+
{option.cost}
+
+
+ ))} +
+
+ +
+ + {describePersona(config)} +
+
+ )} +
+
+
+ ); +} + +/** + * 说明当前这套策略从哪来。 + * + * 不展示具体数字:界面上的数字是渲染那一刻算的,而真正生效的是任务启动时 + * 后端按当天日期算的那套,两者对不上时用户只会怀疑功能坏了 + */ +export function describePersona(config: HumanizeConfig): string { + if (!config.enabled) { + return "关闭时投递节奏与改造前完全一致。"; + } + if (!config.persona_seed) { + return "启用后系统会生成一套专属的操作习惯,保存配置即生效。"; + } + return ( + "系统已按你的专属编号生成一套操作习惯:手速、歇多久、什么时候跳过一个岗位," + + "当天固定不变,每天自动换一套。具体节奏会写在任务日志里。" + ); +} diff --git a/src/view/config/index.tsx b/src/view/config/index.tsx index 15eb1b0..5b3785c 100644 --- a/src/view/config/index.tsx +++ b/src/view/config/index.tsx @@ -42,11 +42,13 @@ import { AnalysisTrigger, ReplyPollingConfig, PeriodicDeliveryConfig, + HumanizeConfig, DEFAULT_PERIODIC_DELIVERY_CONFIG, getAnalysisConfig, getJobProfiles, getReplyPollingConfig, getPeriodicDeliveryConfig, + getHumanizeConfig, DEFAULT_AUTO_REPLY_WINDOW_HOURS, DEFAULT_MAX_AUTO_REPLIES, DEFAULT_MAX_REPLY_CHARS, @@ -65,6 +67,7 @@ import { } from "@/components/SettingField"; import ReplyPollingSection from "./ReplyPollingSection"; import PeriodicDeliverySection from "./PeriodicDeliverySection"; +import HumanizeSection from "./HumanizeSection"; import { jobTypeOptions, salaryOptions, @@ -280,6 +283,7 @@ export interface ConfigPageProps { updateAnalysis: (next: Partial) => void; updatePolling: (next: Partial) => void; updatePeriodicDelivery: (next: Partial) => void; + updateHumanize: (next: Partial) => void; updateBrowser: (next: Partial) => void; updateResume: (next: Partial) => void; updateRule: (index: number, next: Partial) => void; @@ -1160,6 +1164,12 @@ export function ConfigPage(props: ConfigPageProps) { resetTo={DEFAULT_PERIODIC_DELIVERY_CONFIG} resetLabel="恢复出厂默认" /> + + {/* 紧跟周期投递:两者都在回答「怎么跑」,而不是「投什么」 */} + ); case "greet": From 03c630bdec60818dfaceaf6c8650ab550f9fcfad Mon Sep 17 00:00:00 2001 From: patricLee Date: Thu, 20 Aug 2026 15:46:00 +0800 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=E4=B8=8E=E8=87=AA=E5=8A=A8=E5=8C=96?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E7=95=8C=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/command/job.rs | 6 +- src-tauri/src/config.rs | 51 +- src-tauri/src/llm/service.rs | 162 ++-- src-tauri/src/rpa/run_flow.rs | 6 +- src/App.tsx | 21 +- src/components/AiFeatureGate.test.tsx | 14 +- src/components/AiFeatureGate.tsx | 26 +- src/types/app-config.ts | 20 + src/view/config/HumanizeSection.test.tsx | 81 ++ src/view/config/HumanizeSection.tsx | 156 ++-- src/view/config/LlmConfigPanel.test.tsx | 83 +- src/view/config/LlmConfigPanel.tsx | 617 ++++++++++----- src/view/config/ReplyPollingSection.test.tsx | 97 +++ src/view/config/ReplyPollingSection.tsx | 116 ++- src/view/config/index.tsx | 712 ++++++++++++------ src/view/conversation-debug/index.tsx | 4 +- src/view/job-data/AnalysisReport.tsx | 5 +- src/view/job-data/index.tsx | 4 +- .../MockInterviewSetupPage.tsx | 8 +- src/view/resume-optimizer/index.tsx | 10 +- src/view/workspace/index.tsx | 3 +- 21 files changed, 1558 insertions(+), 644 deletions(-) create mode 100644 src/view/config/HumanizeSection.test.tsx create mode 100644 src/view/config/ReplyPollingSection.test.tsx diff --git a/src-tauri/src/command/job.rs b/src-tauri/src/command/job.rs index f6e1bda..669b61e 100644 --- a/src-tauri/src/command/job.rs +++ b/src-tauri/src/command/job.rs @@ -765,7 +765,11 @@ pub async fn job_analyze_batch( Err(e) => return CommandResult::err(format!("加载配置失败: {}", e)), }; if app_config.llm_chain().is_empty() { - return CommandResult::err("请先配置大模型服务".to_string()); + return CommandResult::err(if app_config.llm_configured() { + "大模型已停用,请先启用模型服务".to_string() + } else { + "请先配置大模型服务".to_string() + }); } let skip_analyzed = skip_analyzed.unwrap_or(true); diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index f6c4543..fe2ef14 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -90,6 +90,7 @@ pub fn default_app_config() -> AppRuntimeConfig { schema_version: CURRENT_SCHEMA_VERSION, onboarding_completed: false, llm_config: None, + llm_enabled: None, llm_fallbacks: Vec::new(), llm_retry_config: LlmRetryConfig::default(), job_profiles: vec![default_profile], @@ -253,6 +254,10 @@ pub(crate) fn parse_config_content(content: &str) -> Result Result<(), Strin .max_parallel_tasks .clamp(MIN_PARALLEL_TASKS, MAX_PARALLEL_TASKS); + if config.llm_config.is_none() { + config.llm_enabled = None; + return Ok(()); + } + let Some(llm_config) = config.llm_config.as_mut() else { return Ok(()); }; @@ -584,6 +594,10 @@ pub struct AppRuntimeConfig { #[serde(default)] pub llm_config: Option, + /// 主用大模型是否启用。旧配置没有这块时默认按启用处理,停用时只改这个字段 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm_enabled: Option, + /// 主用服务不可用时按顺序尝试的备用服务 #[serde(default)] pub llm_fallbacks: Vec, @@ -891,6 +905,18 @@ impl LlmChainLink { } impl AppRuntimeConfig { + /// 主用大模型是否已经配置。 + pub fn llm_configured(&self) -> bool { + self.llm_config.is_some() + } + + /// 主用大模型是否当前可用。 + /// + /// 旧配置没有 `llm_enabled` 时默认视为启用;如果根本没配置主用服务,则始终不可用。 + pub fn llm_active(&self) -> bool { + self.llm_config.is_some() && self.llm_enabled.unwrap_or(true) + } + /// 按稳定标识查找方案;未传标识时使用默认方案。 pub fn job_profile(&self, profile_id: Option<&str>) -> Result<&JobProfile, String> { let profile_id = profile_id @@ -904,10 +930,14 @@ impl AppRuntimeConfig { } /// 按调用顺序返回大模型降级链:主用服务在前,其后是处于启用状态的备用服务。 - /// 未配置主用服务时返回空链,调用方据此报「请先配置大模型服务」。 + /// 未配置或已停用主用服务时返回空链,调用方据此阻止模型调用。 pub fn llm_chain(&self) -> Vec { - // 全局的 AI 功能门禁都以「是否配置了主用服务」为准, - // 这里必须保持一致:没有主用服务时整条链不可用,而不是退而使用备用服务。 + // 全局的 AI 功能门禁都以「主用服务是否可用」为准, + // 这里必须保持一致:没有主用服务或已停用时整条链都不可用,而不是退而使用备用服务。 + if !self.llm_active() { + return Vec::new(); + } + let Some(primary) = self.llm_config.as_ref() else { return Vec::new(); }; @@ -1793,6 +1823,7 @@ mod tests { assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION); assert!(!config.onboarding_completed); assert!(config.llm_config.is_none()); + assert!(config.llm_enabled.is_none()); } #[test] @@ -2723,6 +2754,20 @@ job_profiles: [] assert!(config.llm_chain().is_empty()); } + #[test] + fn llm_chain_is_empty_when_primary_is_disabled_but_kept() { + let mut config = default_app_config(); + config.llm_config = Some(LlmConfig { + provider: LlmProviderPreset::DeepSeek, + base_url: "https://api.deepseek.com".to_string(), + model: "deepseek-chat".to_string(), + }); + config.llm_enabled = Some(false); + + assert!(config.llm_chain().is_empty()); + assert!(config.llm_config.is_some()); + } + #[test] fn chain_link_display_name_prefers_label_then_model() { let mut config = default_app_config(); diff --git a/src-tauri/src/llm/service.rs b/src-tauri/src/llm/service.rs index 9c7b40b..d8505e7 100644 --- a/src-tauri/src/llm/service.rs +++ b/src-tauri/src/llm/service.rs @@ -11,8 +11,6 @@ use std::future::Future; use std::time::Duration; use tokio::time::timeout; -const LLM_REQUEST_TIMEOUT_SECONDS: u64 = 120; - #[derive(Clone, Debug)] pub struct LlmService { backend: LlmBackend, @@ -49,7 +47,13 @@ impl LlmService { .llm_chain() .into_iter() .next() - .ok_or_else(|| AppError::configuration("请先配置大模型服务"))?; + .ok_or_else(|| { + if config.llm_configured() && !config.llm_active() { + AppError::configuration("大模型已停用,请先启用模型服务") + } else { + AppError::configuration("请先配置大模型服务") + } + })?; Ok(Self::from_chain_link(&link, credential)?.with_retry(config.llm_retry_config.clone())) } @@ -359,107 +363,33 @@ impl LlmService { where F: FnMut(String) -> Result<(), AppError>, { - match &self.backend { - LlmBackend::Anthropic(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::DeepSeek(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::OpenAiCompatible(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::OpenAiResponses(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::MiniMax(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::Moonshot(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::Ollama(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::OpenRouter(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::XiaomiMimo(client) => { - stream_once( - client.completion_model(&self.model), - &self.model, - &prompt, - &self.provider, - &mut on_delta, - ) - .await - } - LlmBackend::ZAi(client) => { + let retry = &self.retry; + + macro_rules! stream { + ($client:expr) => { stream_once( - client.completion_model(&self.model), + $client.completion_model(&self.model), &self.model, &prompt, &self.provider, + retry, &mut on_delta, ) .await - } + }; + } + + match &self.backend { + LlmBackend::Anthropic(client) => stream!(client), + LlmBackend::DeepSeek(client) => stream!(client), + LlmBackend::OpenAiCompatible(client) => stream!(client), + LlmBackend::OpenAiResponses(client) => stream!(client), + LlmBackend::MiniMax(client) => stream!(client), + LlmBackend::Moonshot(client) => stream!(client), + LlmBackend::Ollama(client) => stream!(client), + LlmBackend::OpenRouter(client) => stream!(client), + LlmBackend::XiaomiMimo(client) => stream!(client), + LlmBackend::ZAi(client) => stream!(client), } } @@ -487,7 +417,11 @@ impl LlmChainService { pub fn from_runtime(config: &AppRuntimeConfig) -> Result { let links = config.llm_chain(); if links.is_empty() { - return Err(AppError::configuration("请先配置大模型服务")); + return Err(if config.llm_configured() && !config.llm_active() { + AppError::configuration("大模型已停用,请先启用模型服务") + } else { + AppError::configuration("请先配置大模型服务") + }); } Ok(Self { links, @@ -653,7 +587,7 @@ pub(crate) fn chain_exhausted_error(last_error: Option, attempted: usi } /// 单次请求的失败来源。**超时**与**请求返回错误**必须结构性分开: -/// 超时意味着 120 秒已经等满,重试只会成倍拖慢整轮求职; +/// 超时意味着配置的等待时间已经耗尽,重试只会成倍拖慢整轮求职; /// 只有「请求返回错误」里的网络层瞬时故障才值得重试。 #[derive(Debug)] pub(crate) enum AttemptFailure { @@ -691,7 +625,7 @@ pub(crate) fn retry_plan( return None; } match failure { - // 超时不重试:120 秒已经等满,重发只会让整轮求职成倍变慢 + // 超时不重试:请求已经等满,重发只会让整轮求职成倍变慢 AttemptFailure::Timeout => None, // 只对网络层瞬时故障重试;鉴权失败、额度受限、配置错误重试多少次都是同样结果 AttemptFailure::Completion(error) => (error.code == AppErrorCode::Network) @@ -719,7 +653,7 @@ where // 已完成的重试次数;`retry.network_retry_attempts` 为 0 时循环只会执行一遍 let mut attempt: u32 = 0; loop { - let outcome = timeout(Duration::from_secs(LLM_REQUEST_TIMEOUT_SECONDS), async { + let outcome = timeout(Duration::from_secs(retry.request_timeout_seconds), async { model.completion_request(prompt).send().await }) .await; @@ -772,13 +706,14 @@ async fn stream_once( model_name: &str, prompt: &str, provider: &LlmProviderPreset, + retry: &LlmRetryConfig, on_delta: &mut F, ) -> Result where M: CompletionModel + Send, F: FnMut(String) -> Result<(), AppError>, { - timeout(Duration::from_secs(LLM_REQUEST_TIMEOUT_SECONDS), async { + timeout(Duration::from_secs(retry.request_timeout_seconds), async { let request = model.completion_request(prompt).build(); let mut stream = model .stream(request) @@ -814,11 +749,12 @@ async fn stream_collect_attempt( model_name: &str, prompt: &str, provider: &LlmProviderPreset, + retry: &LlmRetryConfig, ) -> Result where M: CompletionModel + Send, { - let outcome = timeout(Duration::from_secs(LLM_REQUEST_TIMEOUT_SECONDS), async { + let outcome = timeout(Duration::from_secs(retry.request_timeout_seconds), async { let request = model.completion_request(prompt).build(); let mut stream = model .stream(request) @@ -867,7 +803,7 @@ where { let mut attempt: u32 = 0; loop { - let failure = match stream_collect_attempt(make_model(), model_name, prompt, provider).await + let failure = match stream_collect_attempt(make_model(), model_name, prompt, provider, retry).await { Ok(response) => return Ok(response), Err(failure) => failure, @@ -1320,6 +1256,7 @@ mod tests { LlmRetryConfig { network_retry_attempts: attempts, retry_base_delay_ms: base_delay_ms, + request_timeout_seconds: 120, } } @@ -1611,6 +1548,23 @@ mod tests { assert_eq!(error.message, "请先配置大模型服务"); } + #[test] + fn chain_service_reports_a_disabled_primary_without_losing_its_configuration() { + let mut config = default_app_config(); + config.llm_config = Some(LlmConfig { + provider: LlmProviderPreset::OpenAi, + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "primary-model".to_string(), + }); + config.llm_enabled = Some(false); + + let error = LlmChainService::from_runtime(&config).unwrap_err(); + + assert_eq!(error.code, AppErrorCode::Configuration); + assert_eq!(error.message, "大模型已停用,请先启用模型服务"); + assert!(config.llm_config.is_some()); + } + #[test] fn chain_service_keeps_primary_first_and_carries_retry_config() { let mut config = default_app_config(); diff --git a/src-tauri/src/rpa/run_flow.rs b/src-tauri/src/rpa/run_flow.rs index 0588625..4fab605 100644 --- a/src-tauri/src/rpa/run_flow.rs +++ b/src-tauri/src/rpa/run_flow.rs @@ -332,15 +332,17 @@ pub fn inspect_readiness( items.push(ReadinessItem { key: "llm".to_string(), label: "大模型".to_string(), - level: if !llm_needed || config.llm_config.is_some() { + level: if !llm_needed || config.llm_active() { ReadinessLevel::Ready } else { ReadinessLevel::Blocked }, message: if !llm_needed { "当前模式不依赖大模型".to_string() - } else if config.llm_config.is_some() { + } else if config.llm_active() { "大模型服务已配置".to_string() + } else if config.llm_configured() { + "大模型已停用,请先启用模型服务".to_string() } else { "当前功能使用了大模型,请先配置模型服务".to_string() }, diff --git a/src/App.tsx b/src/App.tsx index ba57380..80f0522 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,7 @@ import { Alert, Button, ConfigProvider, Spin, Tabs, Typography } from "antd"; import { RocketOutlined } from "@ant-design/icons"; import "./App.css"; import { useAppConfig } from "@/hooks/useAppConfig"; -import { copyJobProfile, DEFAULT_REGEX_RULE_LIMIT, getAnalysisConfig, getDefaultJobProfile, getJobProfiles, getReplyPollingConfig, getPeriodicDeliveryConfig, getHumanizeConfig, selectProfileAfterRemoval, type AnalysisConfig, type AppRuntimeConfig, type BrowserConfig, type GreetConfig, type GreetResource, type HumanizeConfig, type JobFilterConfig, type JobProfile, type PeriodicDeliveryConfig, type RegexRule, type ReplayConfig, type ReplyPollingConfig, type ReplyResource, type ReplyTemplate, type ResumeConfig } from "@/types/app-config"; +import { copyJobProfile, DEFAULT_REGEX_RULE_LIMIT, getAnalysisConfig, getDefaultJobProfile, getHumanizeConfig, getJobProfiles, getPeriodicDeliveryConfig, getReplyPollingConfig, isLlmActive, isLlmConfigured, selectProfileAfterRemoval, type AnalysisConfig, type AppRuntimeConfig, type BrowserConfig, type GreetConfig, type GreetResource, type HumanizeConfig, type JobFilterConfig, type JobProfile, type PeriodicDeliveryConfig, type RegexRule, type ReplayConfig, type ReplyPollingConfig, type ReplyResource, type ReplyTemplate, type ResumeConfig } from "@/types/app-config"; import type { JobDetail } from "@/types/job-detail"; import { Onboarding } from "@/view/onboarding"; import { ConfigPage } from "@/view/config"; @@ -39,9 +39,11 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, const [activeTab, setActiveTab] = useState("workspace"); const [focusJobId, setFocusJobId] = useState(); const [interviewJob, setInterviewJob] = useState(); - const [configGroup, setConfigGroup] = useState<"resume" | "llm" | "job" | "greet" | "reply" | "analysis" | "browser">("resume"); + const [configGroup, setConfigGroup] = useState<"resume" | "llm" | "job" | "greet" | "reply" | "analysis" | "browser">("job"); const [activeProfileId, setActiveProfileId] = useState(() => getDefaultJobProfile(config).id); const profiles = getJobProfiles(config); + const llmConfigured = isLlmConfigured(config); + const llmActive = isLlmActive(config); const activeProfile = profiles.find((profile) => profile.id === activeProfileId) ?? getDefaultJobProfile(config); const profileConfig = useMemo(() => ({ @@ -55,11 +57,19 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, replay_config: activeProfile.replay_config, analysis_config: getAnalysisConfig(activeProfile), }), [activeProfile, config, profiles]); + + // 大模型和备用服务允许先创建草稿,再通过「获取模型」补齐模型名。 + // 草稿不完整时暂不触发自动保存,避免后端的完整配置校验把配置页置为错误状态; + // 等地址和模型都齐全后,下一次编辑会正常落盘。 + const hasIncompleteLlmDraft = Boolean( + (config.llm_config && (!config.llm_config.base_url.trim() || !config.llm_config.model.trim())) + || config.llm_fallbacks.some((entry) => !entry.base_url.trim() || !entry.model.trim()), + ); useEffect(() => { - if (!dirty || status === "loading" || status === "error") return; + if (!dirty || status === "loading" || status === "error" || hasIncompleteLlmDraft) return; const timer = window.setTimeout(() => { void save(); }, 700); return () => window.clearTimeout(timer); - }, [dirty, save, status]); + }, [dirty, hasIncompleteLlmDraft, save, status]); const navigate = (next: AppTabKey) => setActiveTab(next); const openConversation = (jobId: string) => { setFocusJobId(jobId); setActiveTab("job-data"); }; @@ -124,6 +134,7 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, onOpenLlmConfig={openLlm} updateLlm={(llm_config) => update((c) => ({ ...c, llm_config }))} persistLlm={async (llm_config) => save({ ...config, llm_config })} + updateLlmEnabled={(llm_enabled) => update((c) => ({ ...c, llm_enabled }))} updateLlmFallbacks={(llm_fallbacks) => update((c) => ({ ...c, llm_fallbacks }))} updateLlmRetryConfig={(llm_retry_config) => update((c) => ({ ...c, llm_retry_config }))} persistConfig={() => save()} @@ -151,7 +162,7 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, removeRule={(i: number) => updateActiveProfile((p) => ({ ...p, job_filter_config: { ...p.job_filter_config, regex_rules: p.job_filter_config.regex_rules.filter((_, x) => x !== i) } }))} importConfig={importConfig} exportConfig={exportConfig} />; - const content = activeTab === "workspace" ? void navigate(tab)} onOpenConfig={openConfig} onOpenConversation={openConversation} /> : activeTab === "job-overview" ? target === "job-data" ? navigate("job-data") : openConfig(target)} onOpenConversation={openConversation} /> : activeTab === "job-data" ? : activeTab === "conversation-debug" ? : activeTab === "resume-optimizer" ? updateProfileSection("resume_config", { resume_content })} pendingInterviewJob={interviewJob} onPendingInterviewHandled={clearInterviewJob} /> : configPage; + const content = activeTab === "workspace" ? void navigate(tab)} onOpenConfig={openConfig} onOpenConversation={openConversation} /> : activeTab === "job-overview" ? target === "job-data" ? navigate("job-data") : openConfig(target)} onOpenConversation={openConversation} /> : activeTab === "job-data" ? : activeTab === "conversation-debug" ? : activeTab === "resume-optimizer" ? updateProfileSection("resume_config", { resume_content })} pendingInterviewJob={interviewJob} onPendingInterviewHandled={clearInterviewJob} /> : configPage; return (
diff --git a/src/components/AiFeatureGate.test.tsx b/src/components/AiFeatureGate.test.tsx index b1c7b6b..180ad1e 100644 --- a/src/components/AiFeatureGate.test.tsx +++ b/src/components/AiFeatureGate.test.tsx @@ -5,16 +5,24 @@ import { AiFeatureGate } from "./AiFeatureGate"; describe("AiFeatureGate", () => { afterEach(cleanup); it("renders the AI control when configured", () => { - render( {}}>); + render( {}}>); expect(screen.getByRole("button", { name: "开始分析" })).toBeEnabled(); }); - it("blocks the control and links to model configuration", () => { + it("blocks the control and links to model configuration when missing", () => { const onConfigure = vi.fn(); - render(); + render(); expect(screen.queryByRole("button", { name: "开始分析" })).not.toBeInTheDocument(); expect(screen.getByText(/AI 是可选功能/)).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "配置大模型" })); expect(onConfigure).toHaveBeenCalledOnce(); }); + + it("shows a disabled hint when the config exists but is switched off", () => { + const onConfigure = vi.fn(); + render(); + expect(screen.getByText("大模型已停用")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "去启用" })); + expect(onConfigure).toHaveBeenCalledOnce(); + }); }); diff --git a/src/components/AiFeatureGate.tsx b/src/components/AiFeatureGate.tsx index 22580ba..ecd6c05 100644 --- a/src/components/AiFeatureGate.tsx +++ b/src/components/AiFeatureGate.tsx @@ -1,14 +1,30 @@ import type { ReactNode } from "react"; import { Alert, Button, Space } from "antd"; -export function AiFeatureGate({ configured, onConfigure, children }: { configured: boolean; onConfigure: () => void; children: ReactNode }) { - if (configured) return <>{children}; +export function AiFeatureGate({ + active, + configured = active, + onConfigure, + children, +}: { + active: boolean; + configured?: boolean; + onConfigure: () => void; + children: ReactNode; +}) { + if (active) return <>{children}; return ( 其他本地功能仍可正常使用。} + message={configured ? "大模型已停用" : "AI 是可选功能,当前尚未配置大模型"} + description={ + + + + } /> ); } diff --git a/src/types/app-config.ts b/src/types/app-config.ts index a16ef71..736d9e9 100644 --- a/src/types/app-config.ts +++ b/src/types/app-config.ts @@ -394,6 +394,10 @@ export interface AppRuntimeConfig { job_filter_config: JobFilterConfig; platform_filter_config: PlatformFilterConfig; llm_config: LlmConfig | null; + /** + * 主用大模型是否启用;旧配置缺这块时默认视为启用,停用时只改这个字段,不清除 llm_config + */ + llm_enabled?: boolean; llm_fallbacks: LlmProviderEntry[]; llm_retry_config: LlmRetryConfig; greet_config: GreetConfig; @@ -419,6 +423,22 @@ export function getAnalysisConfig( return { ...DEFAULT_ANALYSIS_CONFIG, ...(source.analysis_config ?? {}) }; } +/** 是否已经保存过主用大模型配置。 */ +export function isLlmConfigured(config: Pick): boolean { + return config.llm_config !== null; +} + +/** + * 主用大模型是否处于可用状态。 + * + * 旧配置没有 `llm_enabled` 时默认按启用处理;如果根本没配置主用服务,则始终视为未启用。 + */ +export function isLlmActive( + config: Pick, +): boolean { + return config.llm_config !== null && config.llm_enabled !== false; +} + /** 读取轮询节奏,旧配置缺这块时回落到默认节奏。 */ export function getReplyPollingConfig(config: Pick): ReplyPollingConfig { return { ...DEFAULT_REPLY_POLLING_CONFIG, ...(config.reply_polling_config ?? {}) }; diff --git a/src/view/config/HumanizeSection.test.tsx b/src/view/config/HumanizeSection.test.tsx new file mode 100644 index 0000000..f1f9538 --- /dev/null +++ b/src/view/config/HumanizeSection.test.tsx @@ -0,0 +1,81 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import HumanizeSection, { describePersona } from "./HumanizeSection"; +import { + DEFAULT_HUMANIZE_CONFIG, + type HumanizeConfig, +} from "../../types/app-config"; + +afterEach(cleanup); + +function config(overrides: Partial = {}): HumanizeConfig { + return { ...DEFAULT_HUMANIZE_CONFIG, ...overrides }; +} + +describe("HumanizeSection", () => { + it("renders the three intensity choices when enabled", () => { + render( + , + ); + + expect(screen.queryByText("让 AI 操作更贴近真人行为,降低被识别风险,提升任务稳定性")).toBeNull(); + expect(screen.getByRole("radio", { name: "轻度" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "标准" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "谨慎" })).toBeTruthy(); + }); + + it("persists the enabled state through the change callback", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("switch", { name: "启用拟人化" })); + + expect(onChange).toHaveBeenCalledWith({ enabled: true }); + }); + + it("persists the selected intensity", () => { + const onChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("radio", { name: "谨慎" })); + + expect(onChange).toHaveBeenCalledWith({ intensity: "cautious" }); + }); + + it("folds the intensity choices while disabled", () => { + render(); + + expect(screen.queryByRole("radio", { name: "轻度" })).toBeNull(); + expect(screen.queryByRole("radio", { name: "标准" })).toBeNull(); + expect(screen.queryByRole("radio", { name: "谨慎" })).toBeNull(); + expect(screen.queryByText("拟人化已关闭,任务会按原有节奏执行。")).toBeNull(); + }); +}); + +describe("describePersona", () => { + it("describes disabled and not-yet-generated states", () => { + expect(describePersona(config())).toBe("拟人化已关闭,任务会按原有节奏执行。"); + expect(describePersona(config({ enabled: true }))).toBe( + "启用后系统会生成一套专属的操作习惯,保存配置即生效。", + ); + }); + + it("describes a generated persona without exposing its seed", () => { + const description = describePersona( + config({ enabled: true, persona_seed: 123456 }), + ); + + expect(description).toContain("系统已按你的专属编号生成一套操作习惯"); + expect(description).not.toContain("123456"); + }); +}); diff --git a/src/view/config/HumanizeSection.tsx b/src/view/config/HumanizeSection.tsx index 23477d4..3f52fe0 100644 --- a/src/view/config/HumanizeSection.tsx +++ b/src/view/config/HumanizeSection.tsx @@ -1,42 +1,59 @@ -import { Radio, Typography } from "antd"; -import { ExperimentOutlined, SafetyCertificateOutlined } from "@ant-design/icons"; +import { Radio } from "antd"; +import { + AimOutlined, + BarChartOutlined, + CoffeeOutlined, + InfoCircleOutlined, + LineChartOutlined, + SafetyCertificateOutlined, + UserOutlined, +} from "@ant-design/icons"; +import type { ReactNode } from "react"; import { type HumanizeConfig, type HumanizeIntensity, } from "../../types/app-config"; import { SettingGroup, SettingToggle } from "@/components/SettingField"; -const { Text } = Typography; - /** - * 强度档位。 - * - * 每档只说「会发生什么」和「代价是什么」——具体数字由后端按当天的人格现算, - * 写死在这里迟早和实际行为对不上,而对不上的说明比没有说明更糟 + * 强度档位只表达用户能感知到的行为差异,具体节奏仍由后端按人格种子推导。 + * 这样 UI 不会把某一组固定数字误认为是运行时的硬编码参数。 */ const INTENSITY_OPTIONS: Array<{ value: HumanizeIntensity; label: string; description: string; cost: string; + icon: ReactNode; + badge: string; + badgeIcon: ReactNode; }> = [ { value: "light", label: "轻度", - description: "只在既有节奏上小幅抖动,投递量基本不变", + description: "仅在既有节奏上小幅抖动,投递节奏基本不变", cost: "产出几乎无损失", + icon: , + badge: "稳定优先", + badgeIcon: , }, { value: "standard", label: "标准", description: "投一批歇几分钟,偶尔跳过一个岗位、停下来发会儿呆", cost: "产出约降一到两成", + icon: , + badge: "平衡推荐", + badgeIcon: , }, { value: "cautious", label: "谨慎", - description: "休息更勤更久、跳过更多、动作更慢,适合已经被限制过的账号", + description: "休息更勤更久、跳过更多、动作更慢,适合已被限制过的账号", cost: "产出明显下降", + icon: , + badge: "风控优先", + badgeIcon: , }, ]; @@ -46,83 +63,96 @@ interface Props { } /** - * 拟人化。 + * 拟人化设置。 * - * 这里刻意只有一个开关和三个档位:休息阈值、停顿长度、打字速度这些具体数字 - * 不做成旋钮,而是由系统按一个长期不变的「人格种子」每天现算一套。 - * 一组固定的数字——哪怕是用户自己填的——本身就是可被识别的模式。 + * 人格种子不是用户可编辑的参数:它由后端首次启用时生成,并在同一天内保持稳定。 + * 这里把重点放在「是否启用」和「行为倾向」上,避免把实现细节堆进配置表单。 */ export default function HumanizeSection({ config, onChange }: Props) { return ( -
-
- 拟人化 - - 让投递节奏和鼠标、键盘动作带上真人的不确定性。已在跑的任务不受影响 - -
- - - } - title="启用拟人化" - description="平台看的不是单次动作像不像人,而是长期模式:每条都隔 4 秒、每轮都正好 30 条,连起来就是一条没有呼吸的直线" - checked={config.enabled} - onChange={(enabled) => onChange({ enabled })} - > - {config.enabled && ( -
- onChange({ intensity: event.target.value as HumanizeIntensity })} - className="w-full" - > -
- {INTENSITY_OPTIONS.map((option) => ( + + } + title="启用拟人化" + description="模拟真人的操作节奏和行为模式,减少机械化特征" + checked={config.enabled} + onChange={(enabled) => onChange({ enabled })} + > + {config.enabled && ( +
+ onChange({ intensity: event.target.value as HumanizeIntensity })} + className="!block" + > +
+ {INTENSITY_OPTIONS.map((option) => { + const selected = config.intensity === option.value; + return ( -
-
{option.label}
-
- {option.description} +
+ + {option.icon} + +
+
+ + {option.label} + + + {option.description} + +
+ + {option.cost} +
-
{option.cost}
+ + {option.badgeIcon} + {option.badge} +
- ))} -
- - -
- - {describePersona(config)} + ); + })}
+ + +
+ + {describePersona(config)}
- )} - - -
+
+ )} + + ); } /** - * 说明当前这套策略从哪来。 - * - * 不展示具体数字:界面上的数字是渲染那一刻算的,而真正生效的是任务启动时 - * 后端按当天日期算的那套,两者对不上时用户只会怀疑功能坏了 + * 说明当前人格策略的来源,不展示运行时的具体随机数字。 + * 任务启动时后端会根据人格种子和当天日期推导实际节奏。 */ export function describePersona(config: HumanizeConfig): string { if (!config.enabled) { - return "关闭时投递节奏与改造前完全一致。"; + return "拟人化已关闭,任务会按原有节奏执行。"; } if (!config.persona_seed) { return "启用后系统会生成一套专属的操作习惯,保存配置即生效。"; } return ( - "系统已按你的专属编号生成一套操作习惯:手速、歇多久、什么时候跳过一个岗位," + - "当天固定不变,每天自动换一套。具体节奏会写在任务日志里。" + "同一账号在不同天的表现也会随机化而略有差异。" ); } diff --git a/src/view/config/LlmConfigPanel.test.tsx b/src/view/config/LlmConfigPanel.test.tsx index af59f8b..5d36c56 100644 --- a/src/view/config/LlmConfigPanel.test.tsx +++ b/src/view/config/LlmConfigPanel.test.tsx @@ -1,10 +1,14 @@ import { useState } from "react"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; import type { LlmConfig, LlmProviderEntry, LlmRetryConfig } from "@/types/app-config"; import { + DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS, MAX_NETWORK_RETRY_ATTEMPTS, + MAX_LLM_REQUEST_TIMEOUT_SECONDS, MAX_RETRY_BASE_DELAY_MS, + MIN_LLM_REQUEST_TIMEOUT_SECONDS, MIN_RETRY_BASE_DELAY_MS, PRIMARY_LLM_ENTRY_ID, } from "@/types/app-config"; @@ -24,6 +28,7 @@ import { LlmConfigPanel, clampRetryAttempts, clampRetryBaseDelay, + clampRequestTimeout, createFallbackEntry, createLlmEntryId, isValidLlmConfig, @@ -189,6 +194,13 @@ describe("重试策略取值区间", () => { expect(clampRetryBaseDelay(null)).toBe(100); expect(clampRetryBaseDelay(750)).toBe(750); }); + + it("模型请求超时被限制在 1 到 600 秒", () => { + expect(clampRequestTimeout(0)).toBe(MIN_LLM_REQUEST_TIMEOUT_SECONDS); + expect(clampRequestTimeout(999999)).toBe(MAX_LLM_REQUEST_TIMEOUT_SECONDS); + expect(clampRequestTimeout(null)).toBe(DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS); + expect(clampRequestTimeout(240)).toBe(240); + }); }); const primaryConfig: LlmConfig = { @@ -198,21 +210,30 @@ const primaryConfig: LlmConfig = { }; function Harness({ + initialConfig = primaryConfig, initialFallbacks = [], onFallbacks, onRetry, }: { + initialConfig?: LlmConfig; initialFallbacks?: LlmProviderEntry[]; onFallbacks?: (next: LlmProviderEntry[]) => void; onRetry?: (next: LlmRetryConfig) => void; }) { - const [config, setConfig] = useState(primaryConfig); + const [config, setConfig] = useState(initialConfig); + const [enabled, setEnabled] = useState(true); const [fallbacks, setFallbacks] = useState(initialFallbacks); - const [retry, setRetry] = useState({ network_retry_attempts: 2, retry_base_delay_ms: 500 }); + const [retry, setRetry] = useState({ + network_retry_attempts: 2, + retry_base_delay_ms: 500, + request_timeout_seconds: DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS, + }); return ( { onFallbacks?.(next); @@ -249,8 +270,50 @@ describe("LlmConfigPanel 降级链界面", () => { expect(screen.queryByRole("button", { name: "设为主用" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "上移备用 1" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "下移备用 1" })).not.toBeInTheDocument(); - // 主用标签只在存在备用服务时出现,单服务用户的观感与改动前一致 - expect(screen.queryByText("主用")).not.toBeInTheDocument(); + // 参考表单始终明确标出主用服务,且降级链中的第一行同步显示主用状态。 + expect(screen.getAllByText("主用").length).toBeGreaterThan(0); + }); + + it("首次获取模型时先保存当前输入的 API Key,再请求模型列表", async () => { + vi.mocked(invoke).mockImplementation((command: string) => { + if (command === "list_llm_credential_status") { + return Promise.resolve({ success: true, data: [], error: null }); + } + if (command === "set_llm_api_key") { + return Promise.resolve({ success: true, data: { configured: true, source: "keychain" }, error: null }); + } + if (command === "list_llm_models") { + return Promise.resolve({ success: true, data: ["gpt-first"], error: null }); + } + return Promise.resolve({ success: true, data: { configured: false, source: "none" }, error: null }); + }); + + render(); + fireEvent.change(await screen.findByLabelText("primary API Key"), { target: { value: "secret-key" } }); + fireEvent.click(screen.getByRole("button", { name: "刷新primary模型列表" })); + + await waitFor(() => expect(screen.getByLabelText("primary 模型")).toHaveValue("gpt-first")); + const commands = vi.mocked(invoke).mock.calls.map(([command]) => command); + expect(commands.indexOf("set_llm_api_key")).toBeGreaterThanOrEqual(0); + expect(commands.indexOf("list_llm_models")).toBeGreaterThan(commands.indexOf("set_llm_api_key")); + expect(vi.mocked(invoke)).toHaveBeenCalledWith("set_llm_api_key", { apiKey: "secret-key" }); + expect(vi.mocked(invoke)).toHaveBeenCalledWith("list_llm_models", { + provider: "openai", + baseUrl: "https://api.openai.com/v1", + }); + }); + + it("停用只切换状态,不清除主用配置", async () => { + render(); + + expect(await screen.findByLabelText("大模型启用开关")).toBeChecked(); + fireEvent.click(screen.getByLabelText("大模型启用开关")); + expect(screen.getByText("已停用")).toBeInTheDocument(); + expect(screen.getByText("配置已保留,启用大模型后展开编辑。")).toBeInTheDocument(); + expect(screen.queryByLabelText("primary 模型")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText("大模型启用开关")); + expect(screen.getByLabelText("primary 模型")).toHaveValue("gpt-test"); }); it("新增备用服务后列表出现新条目,其标识非空且不等于 primary", async () => { @@ -309,7 +372,7 @@ describe("LlmConfigPanel 降级链界面", () => { const lastCallArg = (mock: typeof onRetry) => mock.mock.calls[mock.mock.calls.length - 1]?.[0]; - const attempts = await screen.findByLabelText("网络故障重试次数"); + const attempts = await screen.findByLabelText("网络故障重试次数(输入数值)"); expect(attempts).toHaveAttribute("aria-valuemin", "0"); expect(attempts).toHaveAttribute("aria-valuemax", String(MAX_NETWORK_RETRY_ATTEMPTS)); fireEvent.change(attempts, { target: { value: "4" } }); @@ -317,11 +380,19 @@ describe("LlmConfigPanel 降级链界面", () => { expect(lastCallArg(onRetry).network_retry_attempts).toBe(4); onRetry.mockClear(); - const delay = screen.getByLabelText("首次重试等待"); + const delay = screen.getByLabelText("首次重试等待(输入数值)"); expect(delay).toHaveAttribute("aria-valuemin", String(MIN_RETRY_BASE_DELAY_MS)); expect(delay).toHaveAttribute("aria-valuemax", String(MAX_RETRY_BASE_DELAY_MS)); fireEvent.change(delay, { target: { value: "800" } }); await waitFor(() => expect(onRetry).toHaveBeenCalled()); expect(lastCallArg(onRetry).retry_base_delay_ms).toBe(800); + + onRetry.mockClear(); + const timeout = screen.getByLabelText("模型请求超时(输入数值)"); + expect(timeout).toHaveAttribute("aria-valuemin", String(MIN_LLM_REQUEST_TIMEOUT_SECONDS)); + expect(timeout).toHaveAttribute("aria-valuemax", String(MAX_LLM_REQUEST_TIMEOUT_SECONDS)); + fireEvent.change(timeout, { target: { value: "240" } }); + await waitFor(() => expect(onRetry).toHaveBeenCalled()); + expect(lastCallArg(onRetry).request_timeout_seconds).toBe(240); }); }); diff --git a/src/view/config/LlmConfigPanel.tsx b/src/view/config/LlmConfigPanel.tsx index ae9eff0..cb6908b 100644 --- a/src/view/config/LlmConfigPanel.tsx +++ b/src/view/config/LlmConfigPanel.tsx @@ -1,17 +1,31 @@ -import { useEffect, useMemo, useState } from "react"; -import { Alert, AutoComplete, Button, Card, Collapse, Divider, Form, Input, Modal, Select, Space, Switch, Tag, Typography } from "antd"; -import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { Alert, AutoComplete, Button, Card, Collapse, Form, Input, Modal, Select, Space, Switch, Tag, Typography } from "antd"; import { + ApiOutlined, + ArrowDownOutlined, + ArrowUpOutlined, + CheckCircleFilled, + ClockCircleOutlined, + DeleteOutlined, + FieldTimeOutlined, + PlusOutlined, + ReloadOutlined, + SafetyCertificateOutlined, +} from "@ant-design/icons"; +import { + DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS, MAX_NETWORK_RETRY_ATTEMPTS, + MAX_LLM_REQUEST_TIMEOUT_SECONDS, MAX_RETRY_BASE_DELAY_MS, MIN_RETRY_BASE_DELAY_MS, + MIN_LLM_REQUEST_TIMEOUT_SECONDS, PRIMARY_LLM_ENTRY_ID, type LlmConfig, type LlmProviderEntry, type LlmProviderPreset, type LlmRetryConfig, } from "@/types/app-config"; -import { NumberField } from "@/components/NumberField"; +import { SettingGroup, SettingSlider } from "@/components/SettingField"; import { clearLlmApiKey, clearLlmApiKeyFor, @@ -118,6 +132,11 @@ export const clampRetryAttempts = (value: number | null | undefined) => Math.min(Math.max(Math.round(value ?? 0), 0), MAX_NETWORK_RETRY_ATTEMPTS); export const clampRetryBaseDelay = (value: number | null | undefined) => Math.min(Math.max(Math.round(value ?? MIN_RETRY_BASE_DELAY_MS), MIN_RETRY_BASE_DELAY_MS), MAX_RETRY_BASE_DELAY_MS); +export const clampRequestTimeout = (value: number | null | undefined) => + Math.min( + Math.max(Math.round(value ?? DEFAULT_LLM_REQUEST_TIMEOUT_SECONDS), MIN_LLM_REQUEST_TIMEOUT_SECONDS), + MAX_LLM_REQUEST_TIMEOUT_SECONDS, + ); const fallbackTitle = (entry: LlmProviderEntry, index: number) => entry.label?.trim() || entry.model.trim() || `备用 ${index + 1}`; @@ -130,7 +149,9 @@ const credentialSourceText: Record = { export interface LlmConfigPanelProps { config: LlmConfig | null; + enabled?: boolean; onChange: (config: LlmConfig | null) => void; + onEnabledChange?: (enabled: boolean) => void; onPersist?: (config: LlmConfig | null) => Promise; onPendingApiKeyChange?: (apiKey: string) => void; compact?: boolean; @@ -147,7 +168,9 @@ export interface LlmConfigPanelProps { export function LlmConfigPanel({ config, + enabled, onChange, + onEnabledChange, onPersist, onPendingApiKeyChange, compact, @@ -158,13 +181,36 @@ export function LlmConfigPanel({ dirty, onPersistAll, }: LlmConfigPanelProps) { + const [modal, modalContextHolder] = Modal.useModal(); const [credentials, setCredentials] = useState>({}); const [apiKeys, setApiKeys] = useState>({}); const [models, setModels] = useState>({}); const [modelsLoadingId, setModelsLoadingId] = useState(null); const [testingId, setTestingId] = useState(null); const [activeKeys, setActiveKeys] = useState([]); - const [feedback, setFeedback] = useState<{ type: "success" | "error" | "info"; text: string } | null>(null); + const [connectionOk, setConnectionOk] = useState>({}); + // 旧配置没有 llm_enabled 时按启用处理;只有明确写入 false 才代表停用。 + const llmEnabled = enabled !== false; + const canToggle = Boolean(onEnabledChange && enabled !== undefined); + const statusLabel = config ? (llmEnabled ? "已启用" : "已停用") : "未配置"; + + const showFeedback = useCallback( + ( + type: "success" | "error" | "info" | "warning", + title: string, + content: ReactNode, + width = 520, + ) => { + modal[type]({ + title, + content, + centered: true, + width, + okText: "知道了", + }); + }, + [modal], + ); const chain = useMemo(() => fallbacks ?? [], [fallbacks]); const chainEnabled = Boolean(config && onFallbacksChange && !compact); @@ -180,9 +226,9 @@ export function LlmConfigPanel({ const single = await getLlmCredentialStatus(); if (cancelled) return; if (single.success && single.data) setCredentials({ [PRIMARY_LLM_ENTRY_ID]: single.data }); - else setFeedback({ type: "info", text: "无法读取凭据状态;可继续配置本地服务。" }); + else showFeedback("info", "凭据状态", "无法读取凭据状态;可继续配置本地服务。"); } catch { - if (!cancelled) setFeedback({ type: "info", text: "无法读取凭据状态;可继续配置本地服务。" }); + if (!cancelled) showFeedback("info", "凭据状态", "无法读取凭据状态;可继续配置本地服务。"); } }; // 一次性拉取整条链的凭据状态,避免每个服务各发一次请求 @@ -196,14 +242,18 @@ export function LlmConfigPanel({ }) .catch(() => (cancelled ? undefined : fallbackToPrimaryOnly())); return () => { cancelled = true; }; - }, [entryIdsKey]); + }, [entryIdsKey, showFeedback]); const options = useMemo(() => Object.entries(LLM_PRESETS).map(([value, item]) => ({ value, label: item.label })), []); const serviceOf = (entryId: string): LlmServiceFields | null => entryId === PRIMARY_LLM_ENTRY_ID ? config : chain.find((entry) => entry.id === entryId) ?? null; const clearModels = (entryId: string) => setModels((current) => ({ ...current, [entryId]: [] })); + const invalidateConnection = (entryId: string) => { + setConnectionOk((current) => current[entryId] ? { ...current, [entryId]: false } : current); + }; const patchEntry = (entryId: string, next: Partial) => { + invalidateConnection(entryId); if (next.provider || next.base_url !== undefined) clearModels(entryId); if (entryId === PRIMARY_LLM_ENTRY_ID) { if (config) onChange({ ...config, ...next }); @@ -215,20 +265,34 @@ export function LlmConfigPanel({ patchEntry(entryId, { provider, base_url: LLM_PRESETS[provider].baseUrl, model: "" }); const changeApiKey = (entryId: string, value: string) => { + invalidateConnection(entryId); setApiKeys((current) => ({ ...current, [entryId]: value })); if (entryId === PRIMARY_LLM_ENTRY_ID) onPendingApiKeyChange?.(value); }; - const storeKey = async (entryId: string) => { + const storeKey = async (entryId: string, showSuccess = true): Promise => { const draft = (apiKeys[entryId] ?? "").trim(); - if (!draft) { setFeedback({ type: "error", text: "请输入 API Key" }); return; } - const result = entryId === PRIMARY_LLM_ENTRY_ID ? await setLlmApiKey(draft) : await setLlmApiKeyFor(entryId, draft); - const status = result.data; - if (result.success && status) { - setCredentials((current) => ({ ...current, [entryId]: status })); - changeApiKey(entryId, ""); - setFeedback({ type: "success", text: "凭据已安全保存,可展开模型列表获取模型" }); - } else setFeedback({ type: "error", text: resultError(result.error, "保存凭据失败") }); + if (!draft) { + showFeedback("error", "保存凭据失败", "请输入 API Key"); + return false; + } + + try { + const result = entryId === PRIMARY_LLM_ENTRY_ID + ? await setLlmApiKey(draft) + : await setLlmApiKeyFor(entryId, draft); + const status = result.data; + if (result.success && status) { + setCredentials((current) => ({ ...current, [entryId]: status })); + changeApiKey(entryId, ""); + if (showSuccess) showFeedback("success", "凭据已保存", "凭据已安全保存,可展开模型列表获取模型"); + return true; + } + showFeedback("error", "保存凭据失败", resultError(result.error, "保存凭据失败")); + } catch (error) { + showFeedback("error", "保存凭据失败", error instanceof Error ? error.message : "保存凭据失败"); + } + return false; }; const clearKey = async (entryId: string) => { @@ -237,21 +301,38 @@ export function LlmConfigPanel({ if (result.success && status) { setCredentials((current) => ({ ...current, [entryId]: status })); changeApiKey(entryId, ""); - setFeedback({ type: "success", text: "凭据已清除" }); - } else setFeedback({ type: "error", text: resultError(result.error, "清除凭据失败") }); + showFeedback("success", "凭据已清除", "凭据已清除"); + } else showFeedback("error", "清除凭据失败", resultError(result.error, "清除凭据失败")); }; const fetchModels = async (entryId: string, showSuccess = false) => { const service = serviceOf(entryId); if (!service) return; - if (!service.base_url.trim()) { setFeedback({ type: "error", text: "请先填写服务地址" }); return; } - if (!shouldFetchLlmModels(service, Boolean(credentials[entryId]?.configured), true)) { - setFeedback({ type: "info", text: "请先保存 API Key,再展开模型列表获取模型" }); + if (!service.base_url.trim()) { showFeedback("error", "获取模型列表失败", "请先填写服务地址"); return; } + + // 首次配置时模型尚未落盘,但用户可能已经在当前表单输入了 API Key。 + // 先把这份草稿密钥保存到系统凭据库,再请求模型列表,避免「保存配置要模型、 + // 获取模型要密钥」互相等待。这里使用返回值而不是依赖异步更新后的 state。 + let credentialConfigured = Boolean(credentials[entryId]?.configured); + if (LLM_PRESETS[service.provider].requiresKey) { + const pendingKey = (apiKeys[entryId] ?? "").trim(); + if (pendingKey) { + // 即使已有环境变量或旧凭据,用户刚输入的替换 Key 也必须先落盘, + // 否则刷新模型列表仍会悄悄使用旧密钥。 + if (!await storeKey(entryId, false)) return; + credentialConfigured = true; + } else if (!credentialConfigured) { + showFeedback("info", "获取模型列表", "请先填写 API Key。填写后点击获取模型会自动保存并继续获取。"); + return; + } + } + if (!shouldFetchLlmModels(service, credentialConfigured, true)) { + showFeedback("info", "获取模型列表", "请先填写服务地址后再获取模型"); return; } // 备用服务的模型列表读的是已落盘的配置,草稿状态下拉取到的会是旧内容 if (entryId !== PRIMARY_LLM_ENTRY_ID && dirty) { - setFeedback({ type: "info", text: "备用服务的模型列表读取的是已保存的配置,请等待自动保存完成后再获取" }); + showFeedback("info", "获取模型列表", "备用服务的模型列表读取的是已保存的配置,请等待自动保存完成后再获取"); return; } setModelsLoadingId(entryId); @@ -263,14 +344,14 @@ export function LlmConfigPanel({ const list = result.data; setModels((current) => ({ ...current, [entryId]: list })); if (!service.model.trim() && list[0]) patchEntry(entryId, { model: list[0] }); - if (showSuccess) setFeedback({ type: "success", text: `已获取 ${list.length} 个模型` }); + if (showSuccess) showFeedback("success", "模型列表已更新", `已获取 ${list.length} 个模型`); } else { clearModels(entryId); - setFeedback({ type: "error", text: resultError(result.error, "获取模型列表失败") }); + showFeedback("error", "获取模型列表失败", resultError(result.error, "获取模型列表失败")); } } catch (error) { clearModels(entryId); - setFeedback({ type: "error", text: error instanceof Error ? error.message : "获取模型列表失败" }); + showFeedback("error", "获取模型列表失败", error instanceof Error ? error.message : "获取模型列表失败"); } finally { setModelsLoadingId(null); } @@ -281,23 +362,48 @@ export function LlmConfigPanel({ if (!service) return; setTestingId(entryId); try { - if (!isValidLlmConfig(service)) { setFeedback({ type: "error", text: "请检查服务地址和模型名称" }); return; } + if (!isValidLlmConfig(service)) { showFeedback("error", "短连接测试失败", "请检查服务地址和模型名称"); return; } if (LLM_PRESETS[service.provider].requiresKey && !credentials[entryId]?.configured) { - setFeedback({ type: "error", text: "该服务需要先配置 API Key" }); + showFeedback("error", "短连接测试失败", "该服务需要先配置 API Key"); return; } // 后端测试的是已落盘的配置,先保证磁盘上的内容与界面一致,避免测出误导性的结果 if (entryId === PRIMARY_LLM_ENTRY_ID) { if (onPersist && !await onPersist(config)) return; } else if (dirty) { - if (!onPersistAll) { setFeedback({ type: "error", text: "请先保存配置后再测试" }); return; } - if (!await onPersistAll()) { setFeedback({ type: "error", text: "配置保存失败,请修正后再测试" }); return; } + if (!onPersistAll) { showFeedback("error", "短连接测试失败", "请先保存配置后再测试"); return; } + if (!await onPersistAll()) { showFeedback("error", "短连接测试失败", "配置保存失败,请修正后再测试"); return; } } const result = entryId === PRIMARY_LLM_ENTRY_ID ? await testLlmConnection() : await testLlmEntryConnection(entryId); - if (result.success && result.data) setFeedback({ type: "success", text: `短连接测试成功 · 模型 ${result.data.model}${result.data.latency_ms ? ` · ${result.data.latency_ms}ms` : ""} · 响应: ${result.data.response}` }); - else setFeedback({ type: "error", text: resultError(result.error, "连接测试失败") }); + if (result.success && result.data) { + setConnectionOk((current) => ({ ...current, [entryId]: true })); + showFeedback( + "success", + "短连接测试成功", + ( + + + 模型:{result.data.model} + + {result.data.latency_ms !== undefined && ( + + 耗时:{result.data.latency_ms}ms + + )} + + 响应:{result.data.response} + + + ), + 560, + ); + } else { + invalidateConnection(entryId); + showFeedback("error", "连接测试失败", resultError(result.error, "连接测试失败")); + } } catch (error) { - setFeedback({ type: "error", text: error instanceof Error ? error.message : "连接测试失败" }); + invalidateConnection(entryId); + showFeedback("error", "连接测试失败", error instanceof Error ? error.message : "连接测试失败"); } finally { setTestingId(null); } @@ -321,7 +427,7 @@ export function LlmConfigPanel({ onOk: async () => { await clearLlmApiKeyFor(entry.id).catch(() => undefined); onFallbacksChange?.(chain.filter((_, i) => i !== index)); - setFeedback({ type: "success", text: `已删除「${fallbackTitle(entry, index)}」及其 API Key` }); + showFeedback("success", "备用服务已删除", `已删除「${fallbackTitle(entry, index)}」及其 API Key`); }, }); }; @@ -360,7 +466,7 @@ export function LlmConfigPanel({ .then((result) => (result.success && Array.isArray(result.data) ? result.data : null)) .catch(() => null); if (!statuses) { - setFeedback({ type: "error", text: "交换 API Key 失败,已取消本次调整,配置保持不变。" }); + showFeedback("error", "切换主用服务失败", "交换 API Key 失败,已取消本次调整,配置保持不变。"); return; } onChange(swapped.primary); @@ -375,169 +481,320 @@ export function LlmConfigPanel({ ), })); setModels({}); - setFeedback({ type: "success", text: "已交换主用与备用服务,API Key 已随服务一并对调。" }); + setConnectionOk({}); + showFeedback("success", "已切换主用服务", "已交换主用与备用服务,API Key 已随服务一并对调。"); }, }); }; - const renderServiceFields = (entryId: string, service: LlmServiceFields) => { - const credential = credentials[entryId] ?? null; + const renderModelField = (entryId: string, service: LlmServiceFields) => { const entryModels = models[entryId] ?? []; const loadingModels = modelsLoadingId === entryId; + return ( +
+ ({ value: model, label: model }))} + placeholder="自动获取或手动输入模型名称" + onChange={(model) => patchEntry(entryId, { model })} + onOpenChange={(open) => { + if (open && !loadingModels && entryModels.length === 0) void fetchModels(entryId); + }} + filterOption={(input, option) => String(option?.value ?? "").toLowerCase().includes(input.toLowerCase())} + /> +
+ ); + }; + + const renderServiceFields = (entryId: string, service: LlmServiceFields) => ( +
+ + patchEntry(entryId, { base_url: e.target.value })} + /> + + + {renderModelField(entryId, service)} + +
+ ); + + const renderCredentialFields = (entryId: string) => { + const credential = credentials[entryId] ?? null; + const tested = connectionOk[entryId] === true; return ( <> - - patchEntry(entryId, { base_url: e.target.value })} - /> - - - ({ value: model, label: model }))} - placeholder="自动获取或手动输入模型名称" - onChange={(model) => patchEntry(entryId, { model })} - onOpenChange={(open) => { - if (open && !loadingModels && entryModels.length === 0) void fetchModels(entryId); - }} - filterOption={(input, option) => String(option?.value ?? "").toLowerCase().includes(input.toLowerCase())} - style={{ width: "100%" }} - /> - - - - 凭据状态:{credential?.configured ? `已配置(${credentialSourceText[credential.source]})` : "未配置"}。应用不会读取或显示明文。 - - + +
changeApiKey(entryId, e.target.value)} /> - - - + +
+
+
+ + + 凭据状态:{credential?.configured ? `已配置(${credentialSourceText[credential.source]})` : "未配置"}。应用不会读取或显示明文。 + + + + + +
); }; - return -
- 服务预设{chainEnabled && chain.length > 0 && 主用}}> - onChange({ + provider, + base_url: LLM_PRESETS[provider].baseUrl, + model: "", + })} + /> + +
+ ) : ( + <> +
+
+
+ 基础配置 + + 配置主用模型服务,连接失败时会按下方顺序切换备用服务。 + +
+ 主用 +
+ +
+ + patchEntry(PRIMARY_LLM_ENTRY_ID, { base_url: e.target.value })} + /> + + {renderCredentialFields(PRIMARY_LLM_ENTRY_ID)} +
+ + {chainEnabled && ( +
+
+ 可用模型(降级链) + + 主用服务不可用时,按以下顺序自动切换备用服务,提升可用性与稳定性。 + +
+ +
+
+ 1 + + {config.model.trim() || "未选择模型"} + + 主用 +
+ + {chain.length > 0 && ( + setActiveKeys(Array.isArray(keys) ? keys : [keys])} + items={chain.map((entry, index) => ({ + key: entry.id, + label: ( +
+ {index + 2} + + {fallbackTitle(entry, index)} + + + {entry.enabled ? "备用" : "已停用"} + + 备用 {index + 1} +
+ ), + extra: ( + event.stopPropagation()}> + + + + + + { + e.preventDefault(); + }} + > +
+ {isProfileGroup(activeGroup) && ( +
+
+
+ + 当前求职方案 + +
+ setProfileDraft((draft) => ({ ...draft, name: event.target.value }))} - onPressEnter={saveProfileMeta} - /> -
-
- 方案说明 - setProfileDraft((draft) => ({ ...draft, description: event.target.value }))} - />
- {profileModalMode === "create" && ( - - )} - - -
+ + setProfileModalMode(null)} + onOk={saveProfileMeta} + destroyOnHidden + > + +
+ 方案名称 + + setProfileDraft((draft) => ({ + ...draft, + name: event.target.value, + })) + } + onPressEnter={saveProfileMeta} + /> +
+
+ 方案说明 + + setProfileDraft((draft) => ({ + ...draft, + description: event.target.value, + })) + } + /> +
+ {profileModalMode === "create" && ( + + )} +
+
+
+ ); } diff --git a/src/view/conversation-debug/index.tsx b/src/view/conversation-debug/index.tsx index f281aab..610ec4a 100644 --- a/src/view/conversation-debug/index.tsx +++ b/src/view/conversation-debug/index.tsx @@ -43,7 +43,7 @@ const emptyJob: JobInput = { location: "", }; -const ConversationDebugPage = ({ aiConfigured, onConfigureAi }: { aiConfigured: boolean; onConfigureAi: () => void }) => { +const ConversationDebugPage = ({ aiConfigured, llmConfigured, onConfigureAi }: { aiConfigured: boolean; llmConfigured: boolean; onConfigureAi: () => void }) => { const [job, setJob] = useState(emptyJob); const [bubbles, setBubbles] = useState([]); const [inputText, setInputText] = useState(""); @@ -147,7 +147,7 @@ const ConversationDebugPage = ({ aiConfigured, onConfigureAi }: { aiConfigured: return (
{contextHolder} - <> + <> {/* 左侧:岗位信息输入 */}
void; aiConfigured: boolean; + llmConfigured: boolean; onConfigureAi: () => void; /** 分析完成后通知外部刷新列表上的匹配度 */ onAnalyzed?: (analysis: InterviewJobAnalysis) => void; } -const AnalysisReport = ({ job, onBack, aiConfigured, onConfigureAi, onAnalyzed }: AnalysisReportProps) => { +const AnalysisReport = ({ job, onBack, aiConfigured, llmConfigured, onConfigureAi, onAnalyzed }: AnalysisReportProps) => { const [analysis, setAnalysis] = useState(null); const [analysisLoading, setAnalysisLoading] = useState(false); const [analysisChecking, setAnalysisChecking] = useState(false); @@ -105,7 +106,7 @@ const AnalysisReport = ({ job, onBack, aiConfigured, onConfigureAi, onAnalyzed } }, [aiConfigured, job.id, messageApi, onAnalyzed]); const analyzeButton = ( - + } + message={aiHint} + description={props.llmConfigured ? "配置会保留,启用后才能开始模拟面试。" : "完成模型配置后才能开始模拟面试。"} + action={} /> )} {!props.resumeReady && ( diff --git a/src/view/resume-optimizer/index.tsx b/src/view/resume-optimizer/index.tsx index e2e8e51..d54e18e 100644 --- a/src/view/resume-optimizer/index.tsx +++ b/src/view/resume-optimizer/index.tsx @@ -9,6 +9,7 @@ import { fetchJobDescriptionText } from "@/lib/job-description"; import { MockInterviewPanel } from "./MockInterviewPanel"; import { MockInterviewReportPage } from "./MockInterviewReportPage"; import { MockInterviewSetupPage } from "./MockInterviewSetupPage"; +import { isLlmActive } from "@/types/app-config"; import { deleteInterviewSession, generateInterviewReport, @@ -60,6 +61,7 @@ export function findSectionIndexByRenderedTitle(sections: ResumeMarkdownSection[ export interface ResumeOptimizerPageProps { config: AppRuntimeConfig; + llmConfigured: boolean; onOpenLlmConfig: () => void; onUpdateResume: (content: string) => void; /** 从岗位管理跳过来时要直接预填的岗位 */ @@ -73,14 +75,15 @@ type PageState = | { name: "session"; sessionId: string } | { name: "report"; sessionId: string; initialTab?: "summary" | "abilities" | "questions" | "transcript" }; -function ResumeOptimizerPage({ config, onOpenLlmConfig, pendingInterviewJob, onPendingInterviewHandled }: ResumeOptimizerPageProps) { +function ResumeOptimizerPage({ config, llmConfigured, onOpenLlmConfig, pendingInterviewJob, onPendingInterviewHandled }: ResumeOptimizerPageProps) { const [page, setPage] = useState({ name: "home" }); const [sessions, setSessions] = useState(listInterviewSessions); const [settings, setSettings] = useState({ ...DEFAULT_INTERVIEW_SETTINGS }); const [setupFromJob, setSetupFromJob] = useState(false); const [messageApi, contextHolder] = message.useMessage(); const resumeContent = (config.resume_config.resume_content ?? "").trim(); - const canStart = !!config.llm_config && !!resumeContent; + const aiReady = isLlmActive(config); + const canStart = aiReady && !!resumeContent; useEffect(() => subscribeInterviewSessions(() => setSessions(listInterviewSessions())), []); // 从岗位管理带岗位过来时直接进配置页,岗位信息已经填好,用户只需要挑面试参数 @@ -162,7 +165,8 @@ function ResumeOptimizerPage({ config, onOpenLlmConfig, pendingInterviewJob, onP setPage({ name: "home" })} diff --git a/src/view/workspace/index.tsx b/src/view/workspace/index.tsx index 772e708..13b05d9 100644 --- a/src/view/workspace/index.tsx +++ b/src/view/workspace/index.tsx @@ -819,7 +819,7 @@ const WorkspacePage = ({
{currentEnvironment.phase === "idle" ? ( - 尚未检查。需要确认登录状态时点击右侧按钮,本次结果会保留在当前平台卡片中。 + 尚未检查。需要确认登录状态时点击右侧按钮。 ) : ( ) : (
Date: Thu, 20 Aug 2026 16:22:57 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=E8=A7=A3=E9=99=A4=E5=A4=A7=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=E7=9A=84=E4=BF=9D=E5=AD=98=E6=AD=BB?= =?UTF-8?q?=E9=94=81=EF=BC=8C=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E5=8F=AA?= =?UTF-8?q?=E5=9C=A8=E7=82=B9=E5=87=BB=E6=97=B6=E6=8B=89=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 未填完的服务不再被拒绝保存:模型名要拉列表才知道,拉列表得先存好密钥 和地址,密钥又跟这份配置一起落盘——三者互为前提,新建的备用服务永远 填不完。校验放宽为「可以存」,能不能用改由 service_is_usable 判断, llm_active / llm_chain 据此把未填完的服务挡在调用之外。 前端 isLlmActive 同步收紧,并删掉 App 里拦截自动保存的 hasIncompleteLlmDraft。 list_llm_models_for 改为接受界面上的草稿参数,密钥仍按 entry_id 从凭据库读。 模型输入框的 onOpenChange 自动拉取一并删除:下拉在输入时反复开合, 每次都会打一次网络。改为只由「获取模型」按钮触发。 --- src-tauri/src/command/llm_provider.rs | 89 ++++++++++++++++++----- src-tauri/src/config.rs | 94 ++++++++++++++++++------- src/App.tsx | 14 ++-- src/lib/llmConfig.ts | 11 ++- src/types/app-config.ts | 19 ++++- src/view/config/LlmConfigPanel.test.tsx | 79 ++++++++++++++------- src/view/config/LlmConfigPanel.tsx | 91 ++++++++++++------------ 7 files changed, 269 insertions(+), 128 deletions(-) diff --git a/src-tauri/src/command/llm_provider.rs b/src-tauri/src/command/llm_provider.rs index b89199a..23c197a 100644 --- a/src-tauri/src/command/llm_provider.rs +++ b/src-tauri/src/command/llm_provider.rs @@ -129,8 +129,8 @@ where } /// 在降级链中按标识定位一个服务。 -/// 链由已保存的配置推导而来,所以「找不到」既可能是标识写错, -/// 也可能是用户刚在界面上填完还没保存,错误文案需要同时覆盖这两种情况。 +/// 链只收录填写完整且处于启用状态的服务,所以「找不到」通常是缺服务地址或模型名, +/// 其次才是该服务被停用了。 fn find_chain_link<'a>( chain: &'a [LlmChainLink], entry_id: &str, @@ -139,9 +139,7 @@ fn find_chain_link<'a>( .iter() .find(|link| link.id == entry_id) .ok_or_else(|| { - AppError::configuration( - "未找到该大模型服务,请先保存配置后再测试;未保存或已停用的服务无法测试", - ) + AppError::configuration("该大模型服务尚不可用,请补齐服务地址和模型名称并确认它已启用") }) } @@ -179,19 +177,43 @@ pub async fn test_llm_entry_connection( } } -/// 列出降级链中某个服务可用的模型 +/// 界面上正在编辑的服务参数。两项齐备才算数:只传一半无从判断该配哪个客户端, +/// 与其猜一个,不如退回已落盘的配置。 +fn draft_override( + provider: Option, + base_url: Option, +) -> Option<(LlmProviderPreset, String)> { + let base_url = base_url.map(|url| url.trim().to_string())?; + if base_url.is_empty() { + return None; + } + Some((provider?, base_url)) +} + +/// 列出降级链中某个服务可用的模型。 +/// +/// `provider` / `base_url` 是界面上正在编辑的草稿,传了就以草稿为准。 +/// 备用服务的模型名为空时整份配置根本保存不了,若这里坚持只读已落盘的配置, +/// 就成了「保存要模型、取模型要先保存」的死锁——新建的备用服务永远拿不到模型列表。 +/// 密钥始终按 `entry_id` 从凭据库读取,明文不经过前端。 #[tauri::command] pub async fn list_llm_models_for( app_handle: tauri::AppHandle, entry_id: String, + provider: Option, + base_url: Option, ) -> CommandResult> { - let (link, credential) = match resolve_chain_entry(app_handle, &entry_id) { - Ok(resolved) => resolved, - Err(error) => return CommandResult::err(error), + let (provider, base_url, credential) = match draft_override(provider, base_url) { + Some((provider, base_url)) => match credential::resolve_for_entry(&entry_id) { + Ok(credential) => (provider, base_url, credential), + Err(error) => return CommandResult::err(error), + }, + None => match resolve_chain_entry(app_handle, &entry_id) { + Ok((link, credential)) => (link.provider, link.base_url, credential), + Err(error) => return CommandResult::err(error), + }, }; - to_command_result( - fetch_model_list_with_credential(link.provider, &link.base_url, &credential).await, - ) + to_command_result(fetch_model_list_with_credential(provider, &base_url, &credential).await) } fn validate_llm_base_url(base_url: &str) -> Result<(), AppError> { @@ -463,15 +485,15 @@ mod tests { } #[test] - fn find_chain_link_reports_unsaved_service_clearly() { + fn find_chain_link_reports_an_unusable_service_clearly() { let chain = chain(); - // 界面上刚填完还没保存就点测试,是最常见的误用,错误文案必须点明这一点 - let error = find_chain_link(&chain, "backup-unsaved").unwrap_err(); + // 草稿也会落盘,所以链里没有它多半是缺地址或模型名,错误文案要直接指向该补什么 + let error = find_chain_link(&chain, "backup-incomplete").unwrap_err(); assert_eq!(error.code, AppErrorCode::Configuration); - assert!(error.message.contains("未找到该大模型服务")); - assert!(error.message.contains("保存")); + assert!(error.message.contains("尚不可用")); + assert!(error.message.contains("模型名称")); } #[test] @@ -479,7 +501,38 @@ mod tests { let error = find_chain_link(&[], PRIMARY_LLM_ENTRY_ID).unwrap_err(); assert_eq!(error.code, AppErrorCode::Configuration); - assert!(error.message.contains("未找到该大模型服务")); + assert!(error.message.contains("尚不可用")); + } + + #[test] + fn draft_override_takes_the_editing_values_and_trims_them() { + let draft = draft_override( + Some(LlmProviderPreset::DeepSeek), + Some(" https://api.deepseek.com ".to_string()), + ); + + assert_eq!( + draft, + Some(( + LlmProviderPreset::DeepSeek, + "https://api.deepseek.com".to_string() + )) + ); + } + + #[test] + fn draft_override_falls_back_to_saved_config_when_incomplete() { + // 只传一半、空串、全不传,都退回已落盘的配置,不去猜另一半 + assert_eq!(draft_override(Some(LlmProviderPreset::OpenAi), None), None); + assert_eq!( + draft_override(None, Some("https://api.openai.com/v1".to_string())), + None + ); + assert_eq!( + draft_override(Some(LlmProviderPreset::OpenAi), Some(" ".to_string())), + None + ); + assert_eq!(draft_override(None, None), None); } #[test] diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index fe2ef14..7e6c8eb 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -428,14 +428,11 @@ pub fn validate_and_normalize(config: &mut AppRuntimeConfig) -> Result<(), Strin return Ok(()); }; + // 只做规整,不因为「还没填完」拒绝落盘: + // 配置页要靠已保存的密钥去拉模型列表,拒绝保存会让用户永远填不完这份配置。 + // 未填完的服务由 `llm_active` / `llm_chain` 挡在调用之外。 llm_config.base_url = llm_config.base_url.trim().trim_end_matches('/').to_string(); llm_config.model = llm_config.model.trim().to_string(); - if llm_config.base_url.is_empty() { - return Err("大模型地址不能为空".to_string()); - } - if llm_config.model.is_empty() { - return Err("模型名称不能为空".to_string()); - } Ok(()) } @@ -513,10 +510,12 @@ fn normalize_llm_fallbacks(fallbacks: &mut Vec) -> Result<(), .map(str::to_string); } - // 界面上新增一行后还没来得及填写就保存,属于常见操作,静默丢弃即可; - // 填了一半才是真的配置错误,必须挡下来提醒用户。 + // 界面上新增一行后还没来得及填写就保存,属于常见操作,静默丢弃即可 fallbacks.retain(|entry| !(entry.base_url.is_empty() && entry.model.is_empty())); + // 只校验标识——它决定密钥存放在哪个 keyring 条目,错了会读写到别人的密钥。 + // 地址和模型名填了一半不算错误:那只是还没编辑完的草稿, + // 由 `LlmProviderEntry::is_usable` 决定它进不进降级链。 let mut seen_ids: HashSet<&str> = HashSet::new(); for entry in fallbacks.iter() { if !is_valid_entry_id(&entry.id) { @@ -530,12 +529,6 @@ fn normalize_llm_fallbacks(fallbacks: &mut Vec) -> Result<(), if !seen_ids.insert(entry.id.as_str()) { return Err(format!("备用大模型服务标识重复:{}", entry.id)); } - if entry.base_url.is_empty() { - return Err("备用大模型服务的地址不能为空".to_string()); - } - if entry.model.is_empty() { - return Err("备用大模型服务的模型名称不能为空".to_string()); - } } Ok(()) @@ -801,6 +794,21 @@ pub struct LlmConfig { pub model: String, } +/// 一个大模型服务是否填写完整、可以真正发起调用。 +/// +/// 配置页允许存在填了一半的服务:模型名要从服务端拉列表才知道,而拉列表得先存密钥, +/// 密钥又跟着这份配置一起落盘——若要求「填完才准保存」,这三者就会互相等待。 +/// 因此校验放宽为「可以存」,能不能用改由这里判断,未填完的服务不会进入降级链。 +fn service_is_usable(base_url: &str, model: &str) -> bool { + !base_url.trim().is_empty() && !model.trim().is_empty() +} + +impl LlmConfig { + pub fn is_usable(&self) -> bool { + service_is_usable(&self.base_url, &self.model) + } +} + /// 主用服务在降级链中的保留标识。它的 API Key 仍存放在旧的 keyring 条目里, /// 这样老用户升级后无需重新填写密钥。 pub const PRIMARY_LLM_ENTRY_ID: &str = "primary"; @@ -827,6 +835,12 @@ pub struct LlmProviderEntry { pub enabled: bool, } +impl LlmProviderEntry { + pub fn is_usable(&self) -> bool { + service_is_usable(&self.base_url, &self.model) + } +} + fn default_true() -> bool { true } @@ -912,9 +926,14 @@ impl AppRuntimeConfig { /// 主用大模型是否当前可用。 /// - /// 旧配置没有 `llm_enabled` 时默认视为启用;如果根本没配置主用服务,则始终不可用。 + /// 旧配置没有 `llm_enabled` 时默认视为启用;没配置主用服务、 + /// 或者服务还没填完(缺地址或模型名)时都不可用。 pub fn llm_active(&self) -> bool { - self.llm_config.is_some() && self.llm_enabled.unwrap_or(true) + self.llm_enabled.unwrap_or(true) + && self + .llm_config + .as_ref() + .is_some_and(|primary| primary.is_usable()) } /// 按稳定标识查找方案;未传标识时使用默认方案。 @@ -954,7 +973,8 @@ impl AppRuntimeConfig { chain.extend( self.llm_fallbacks .iter() - .filter(|entry| entry.enabled) + // 填了一半的备用服务只是草稿,允许保存但不参与调用 + .filter(|entry| entry.enabled && entry.is_usable()) .map(|entry| LlmChainLink { id: entry.id.clone(), label: entry.label.clone(), @@ -1838,8 +1858,12 @@ mod tests { assert_eq!(llm.model, "qwen3"); } + /// 没填完的主用服务能存下来,但不会被拿去调用。 + /// + /// 反过来做——拒绝保存——会把配置页锁死:模型名要拉列表才知道, + /// 拉列表得先存好密钥,密钥又跟这份配置一起落盘。 #[test] - fn invalid_non_null_llm_config_is_rejected() { + fn incomplete_primary_llm_config_is_saved_but_stays_inactive() { for (base_url, model) in [ ("", "qwen3"), (" ", "qwen3"), @@ -1853,8 +1877,11 @@ mod tests { model: model.to_string(), }); - assert!(validate_and_normalize(&mut config).is_err()); + validate_and_normalize(&mut config).unwrap(); + assert!(config.llm_config.is_some()); + assert!(!config.llm_active()); + assert!(config.llm_chain().is_empty()); } } @@ -2837,20 +2864,35 @@ job_profiles: [] assert_eq!(config.browser_config.max_parallel_tasks, MIN_PARALLEL_TASKS); } + /// 全空的行是「加了一行还没填」,直接丢弃;填了一半的是编辑到一半的草稿, + /// 要留住,但不能进降级链——否则运行时会拿着空模型名去发请求。 #[test] - fn blank_fallback_rows_are_dropped_but_half_filled_rows_are_rejected() { + fn blank_fallback_rows_are_dropped_and_half_filled_rows_are_kept_out_of_the_chain() { let mut config = default_app_config(); + config.llm_config = Some(LlmConfig { + provider: LlmProviderPreset::OpenAi, + base_url: "https://api.openai.com/v1".to_string(), + model: "gpt-4o".to_string(), + }); let mut blank = fallback_entry("backup-blank", ""); blank.base_url = " ".to_string(); - config.llm_fallbacks = vec![blank, fallback_entry("backup-a", "qwen-max")]; + config.llm_fallbacks = vec![ + blank, + fallback_entry("backup-half", ""), + fallback_entry("backup-a", "qwen-max"), + ]; validate_and_normalize(&mut config).unwrap(); - assert_eq!(config.llm_fallbacks.len(), 1); - assert_eq!(config.llm_fallbacks[0].id, "backup-a"); - config.llm_fallbacks = vec![fallback_entry("backup-a", "")]; - let error = validate_and_normalize(&mut config).unwrap_err(); - assert!(error.contains("模型名称不能为空")); + let ids: Vec<&str> = config + .llm_fallbacks + .iter() + .map(|entry| entry.id.as_str()) + .collect(); + assert_eq!(ids, vec!["backup-half", "backup-a"]); + + let chain_ids: Vec = config.llm_chain().into_iter().map(|link| link.id).collect(); + assert_eq!(chain_ids, vec![PRIMARY_LLM_ENTRY_ID, "backup-a"]); } #[test] diff --git a/src/App.tsx b/src/App.tsx index 80f0522..423ca6f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -58,18 +58,14 @@ function MainShell({ config, update, save, status, message, dirty, importConfig, analysis_config: getAnalysisConfig(activeProfile), }), [activeProfile, config, profiles]); - // 大模型和备用服务允许先创建草稿,再通过「获取模型」补齐模型名。 - // 草稿不完整时暂不触发自动保存,避免后端的完整配置校验把配置页置为错误状态; - // 等地址和模型都齐全后,下一次编辑会正常落盘。 - const hasIncompleteLlmDraft = Boolean( - (config.llm_config && (!config.llm_config.base_url.trim() || !config.llm_config.model.trim())) - || config.llm_fallbacks.some((entry) => !entry.base_url.trim() || !entry.model.trim()), - ); + // 填了一半的大模型服务也照常落盘:它只是草稿,后端不会拿它去发起调用。 + // 曾经这里要拦下不完整的草稿,是因为后端拒绝保存——那反过来卡死了配置页, + // 模型名要拉列表才知道,拉列表又得先把这份配置连同密钥存好。 useEffect(() => { - if (!dirty || status === "loading" || status === "error" || hasIncompleteLlmDraft) return; + if (!dirty || status === "loading" || status === "error") return; const timer = window.setTimeout(() => { void save(); }, 700); return () => window.clearTimeout(timer); - }, [dirty, hasIncompleteLlmDraft, save, status]); + }, [dirty, save, status]); const navigate = (next: AppTabKey) => setActiveTab(next); const openConversation = (jobId: string) => { setFocusJobId(jobId); setActiveTab("job-data"); }; diff --git a/src/lib/llmConfig.ts b/src/lib/llmConfig.ts index 15196b5..7966110 100644 --- a/src/lib/llmConfig.ts +++ b/src/lib/llmConfig.ts @@ -9,7 +9,7 @@ export const listLlmModels = (config: Pick) export const testLlmConnection = () => invoke>('test_llm_connection') // 以下命令按降级链条目标识读写:密钥独立存储,互不覆盖。 -// 注意 test_llm_entry_connection / list_llm_models_for 读的是已落盘的配置, +// 注意 test_llm_entry_connection 读的是已落盘的配置, // 界面上必须先保存再调用,否则会拿到与当前编辑内容不一致的结果。 export const getLlmCredentialStatusFor = (entryId: string) => invoke>('get_llm_credential_status_for', { entryId }) export const setLlmApiKeyFor = (entryId: string, apiKey: string) => invoke>('set_llm_api_key_for', { entryId, apiKey }) @@ -18,4 +18,11 @@ export const listLlmCredentialStatus = (entryIds: string[]) => invoke invoke>('swap_llm_credentials', { entryA, entryB }) export const testLlmEntryConnection = (entryId: string) => invoke>('test_llm_entry_connection', { entryId }) -export const listLlmModelsFor = (entryId: string) => invoke>('list_llm_models_for', { entryId }) +// 传入界面上正在编辑的 provider / base_url,未保存的备用服务也能取模型列表; +// 省略时后端退回读已落盘的配置。密钥始终由后端按 entryId 解析。 +export const listLlmModelsFor = (entryId: string, draft?: Pick) => + invoke>('list_llm_models_for', { + entryId, + provider: draft?.provider ?? null, + baseUrl: draft?.base_url ?? null, + }) diff --git a/src/types/app-config.ts b/src/types/app-config.ts index 736d9e9..87f29ae 100644 --- a/src/types/app-config.ts +++ b/src/types/app-config.ts @@ -423,7 +423,19 @@ export function getAnalysisConfig( return { ...DEFAULT_ANALYSIS_CONFIG, ...(source.analysis_config ?? {}) }; } -/** 是否已经保存过主用大模型配置。 */ +/** + * 一个大模型服务是否填写完整、可以真正发起调用。 + * + * 配置页允许保存填了一半的服务(模型名要拉列表才知道,拉列表又得先存好密钥), + * 所以「存下来了」不等于「能用」。与 Rust 侧的 `service_is_usable` 保持一致。 + */ +export function isLlmServiceUsable( + service: Pick | null | undefined, +): boolean { + return Boolean(service?.base_url.trim() && service.model.trim()); +} + +/** 是否已经保存过主用大模型配置。填了一半也算,界面据此显示「继续配置」而不是「去配置」。 */ export function isLlmConfigured(config: Pick): boolean { return config.llm_config !== null; } @@ -431,12 +443,13 @@ export function isLlmConfigured(config: Pick): b /** * 主用大模型是否处于可用状态。 * - * 旧配置没有 `llm_enabled` 时默认按启用处理;如果根本没配置主用服务,则始终视为未启用。 + * 旧配置没有 `llm_enabled` 时默认按启用处理;没配置主用服务、 + * 或者服务还没填完时都视为不可用。 */ export function isLlmActive( config: Pick, ): boolean { - return config.llm_config !== null && config.llm_enabled !== false; + return config.llm_enabled !== false && isLlmServiceUsable(config.llm_config); } /** 读取轮询节奏,旧配置缺这块时回落到默认节奏。 */ diff --git a/src/view/config/LlmConfigPanel.test.tsx b/src/view/config/LlmConfigPanel.test.tsx index 5d36c56..5bcb358 100644 --- a/src/view/config/LlmConfigPanel.test.tsx +++ b/src/view/config/LlmConfigPanel.test.tsx @@ -34,7 +34,6 @@ import { isValidLlmConfig, moveFallback, promoteFallbackToPrimary, - shouldFetchLlmModels, } from "./LlmConfigPanel"; describe("LLM presets", () => { @@ -72,30 +71,6 @@ describe("LLM config validation", () => { }); }); -describe("LLM model list loading", () => { - it("requires an explicit user action and saved key for key-based providers", () => { - const config = { - provider: "deepseek" as const, - base_url: "https://api.deepseek.com", - model: "", - }; - - expect(shouldFetchLlmModels(config, false, false)).toBe(false); - expect(shouldFetchLlmModels(config, true, false)).toBe(false); - expect(shouldFetchLlmModels(config, true, true)).toBe(true); - }); - - it("allows local Ollama model loading without a saved key", () => { - const config = { - provider: "ollama" as const, - base_url: "http://127.0.0.1:11434", - model: "", - }; - - expect(shouldFetchLlmModels(config, false, true)).toBe(true); - }); -}); - describe("降级链条目标识", () => { it("生成的标识非空、不等于主用保留标识,且只含 Rust 侧允许的字符", () => { for (let i = 0; i < 20; i += 1) { @@ -214,11 +189,13 @@ function Harness({ initialFallbacks = [], onFallbacks, onRetry, + dirty, }: { initialConfig?: LlmConfig; initialFallbacks?: LlmProviderEntry[]; onFallbacks?: (next: LlmProviderEntry[]) => void; onRetry?: (next: LlmRetryConfig) => void; + dirty?: boolean; }) { const [config, setConfig] = useState(initialConfig); const [enabled, setEnabled] = useState(true); @@ -244,6 +221,7 @@ function Harness({ onRetry?.(next); setRetry(next); }} + dirty={dirty} /> ); } @@ -303,6 +281,57 @@ describe("LlmConfigPanel 降级链界面", () => { }); }); + it("编辑模型名不触发任何模型列表请求", async () => { + render(); + + const model = await screen.findByLabelText("primary 模型"); + // 展开下拉、逐字输入,都不该打网络:模型名以手动输入为准,列表只由按钮拉取 + fireEvent.mouseDown(model); + fireEvent.focus(model); + for (const value of ["m", "my", "my-own-model"]) { + fireEvent.change(model, { target: { value } }); + } + + expect(model).toHaveValue("my-own-model"); + expect(vi.mocked(invoke).mock.calls.map(([command]) => command)).not.toContain("list_llm_models"); + expect(screen.queryByText(/请先填写 API Key/)).not.toBeInTheDocument(); + }); + + it("尚未保存的备用服务按界面草稿取模型列表,不再要求先保存配置", async () => { + vi.mocked(invoke).mockImplementation((command: string) => { + if (command === "list_llm_credential_status") { + return Promise.resolve({ + success: true, + data: [ + { entry_id: PRIMARY_LLM_ENTRY_ID, configured: true, source: "keychain" }, + { entry_id: "backup-a", configured: true, source: "keychain" }, + ], + error: null, + }); + } + if (command === "list_llm_models_for") { + return Promise.resolve({ success: true, data: ["deepseek-chat"], error: null }); + } + return Promise.resolve({ success: true, data: { configured: true, source: "keychain" }, error: null }); + }); + + // dirty 表示整份配置还没落盘:备用服务模型名为空时它根本保存不了, + // 这正是「取模型要先保存、保存要先有模型」死锁发生的场景 + render(); + + // 「备用 1」同时出现在折叠面板标题和无障碍标注里,点标题所在的 header 展开 + const [title] = await screen.findAllByText("备用 1"); + fireEvent.click(title.closest(".ant-collapse-header") as HTMLElement); + fireEvent.click(await screen.findByRole("button", { name: "刷新backup-a模型列表" })); + + await waitFor(() => expect(screen.getByLabelText("backup-a 模型")).toHaveValue("deepseek-chat")); + expect(vi.mocked(invoke)).toHaveBeenCalledWith("list_llm_models_for", { + entryId: "backup-a", + provider: "deepseek", + baseUrl: "https://api.deepseek.com", + }); + }); + it("停用只切换状态,不清除主用配置", async () => { render(); diff --git a/src/view/config/LlmConfigPanel.tsx b/src/view/config/LlmConfigPanel.tsx index cb6908b..60e8fb5 100644 --- a/src/view/config/LlmConfigPanel.tsx +++ b/src/view/config/LlmConfigPanel.tsx @@ -20,6 +20,7 @@ import { MIN_RETRY_BASE_DELAY_MS, MIN_LLM_REQUEST_TIMEOUT_SECONDS, PRIMARY_LLM_ENTRY_ID, + isLlmServiceUsable, type LlmConfig, type LlmProviderEntry, type LlmProviderPreset, @@ -59,16 +60,7 @@ export const LLM_PRESETS: Record; const resultError = (error: CommandError | null, fallback: string) => error ? `[${error.code}] ${error.message}` : fallback; -export const isValidLlmConfig = (value: LlmServiceFields | null) => Boolean(value?.base_url.trim() && value.model.trim()); -export const shouldFetchLlmModels = ( - config: LlmServiceFields | null, - credentialConfigured: boolean, - userRequested: boolean, -) => Boolean( - userRequested && - config?.base_url.trim() && - (!LLM_PRESETS[config.provider].requiresKey || credentialConfigured), -); +export const isValidLlmConfig = (value: LlmServiceFields | null) => isLlmServiceUsable(value); /** * 条目标识会被拼进系统凭据库的条目名,Rust 侧只接受字母、数字、下划线和连字符, @@ -285,7 +277,7 @@ export function LlmConfigPanel({ if (result.success && status) { setCredentials((current) => ({ ...current, [entryId]: status })); changeApiKey(entryId, ""); - if (showSuccess) showFeedback("success", "凭据已保存", "凭据已安全保存,可展开模型列表获取模型"); + if (showSuccess) showFeedback("success", "凭据已保存", "凭据已安全保存,可以点「获取模型」拉取模型列表了"); return true; } showFeedback("error", "保存凭据失败", resultError(result.error, "保存凭据失败")); @@ -305,46 +297,43 @@ export function LlmConfigPanel({ } else showFeedback("error", "清除凭据失败", resultError(result.error, "清除凭据失败")); }; - const fetchModels = async (entryId: string, showSuccess = false) => { + /** + * 拉取模型列表填充下拉备选项。 + * + * 只由「获取模型」按钮触发。模型名以手动输入为准,列表只是省去打字的辅助, + * 所以编辑输入框、展开下拉都不该发请求——那会在用户逐字输入模型名时反复打网络。 + */ + const loadModels = async (entryId: string) => { const service = serviceOf(entryId); if (!service) return; - if (!service.base_url.trim()) { showFeedback("error", "获取模型列表失败", "请先填写服务地址"); return; } + const baseUrl = service.base_url.trim(); + if (!baseUrl) { showFeedback("error", "获取模型列表失败", "请先填写服务地址"); return; } - // 首次配置时模型尚未落盘,但用户可能已经在当前表单输入了 API Key。 - // 先把这份草稿密钥保存到系统凭据库,再请求模型列表,避免「保存配置要模型、 - // 获取模型要密钥」互相等待。这里使用返回值而不是依赖异步更新后的 state。 - let credentialConfigured = Boolean(credentials[entryId]?.configured); + // 用户可能刚在输入框里填了 Key 还没点保存。先把这份草稿密钥落盘再取列表, + // 否则要么取不到,要么悄悄用了上一把旧密钥。这里用返回值判断,不等 state 更新。 if (LLM_PRESETS[service.provider].requiresKey) { const pendingKey = (apiKeys[entryId] ?? "").trim(); if (pendingKey) { - // 即使已有环境变量或旧凭据,用户刚输入的替换 Key 也必须先落盘, - // 否则刷新模型列表仍会悄悄使用旧密钥。 if (!await storeKey(entryId, false)) return; - credentialConfigured = true; - } else if (!credentialConfigured) { - showFeedback("info", "获取模型列表", "请先填写 API Key。填写后点击获取模型会自动保存并继续获取。"); + } else if (!credentials[entryId]?.configured) { + showFeedback("info", "获取模型列表", "请先填写 API Key,或直接在输入框里手动填写模型名称。"); return; } } - if (!shouldFetchLlmModels(service, credentialConfigured, true)) { - showFeedback("info", "获取模型列表", "请先填写服务地址后再获取模型"); - return; - } - // 备用服务的模型列表读的是已落盘的配置,草稿状态下拉取到的会是旧内容 - if (entryId !== PRIMARY_LLM_ENTRY_ID && dirty) { - showFeedback("info", "获取模型列表", "备用服务的模型列表读取的是已保存的配置,请等待自动保存完成后再获取"); - return; - } + setModelsLoadingId(entryId); try { + // 一律按界面上的当前值取列表,不读已落盘的配置: + // 用户刚改完地址就想看新服务有哪些模型,这时磁盘上还是旧的那份。 + const draft = { provider: service.provider, base_url: baseUrl }; const result = entryId === PRIMARY_LLM_ENTRY_ID - ? await listLlmModels({ provider: service.provider, base_url: service.base_url.trim() }) - : await listLlmModelsFor(entryId); + ? await listLlmModels(draft) + : await listLlmModelsFor(entryId, draft); if (result.success && result.data) { const list = result.data; setModels((current) => ({ ...current, [entryId]: list })); if (!service.model.trim() && list[0]) patchEntry(entryId, { model: list[0] }); - if (showSuccess) showFeedback("success", "模型列表已更新", `已获取 ${list.length} 个模型`); + showFeedback("success", "模型列表已更新", `已获取 ${list.length} 个模型`); } else { clearModels(entryId); showFeedback("error", "获取模型列表失败", resultError(result.error, "获取模型列表失败")); @@ -497,20 +486,20 @@ export function LlmConfigPanel({ aria-label={`${entryId} 模型`} value={service.model} options={entryModels.map((model) => ({ value: model, label: model }))} - placeholder="自动获取或手动输入模型名称" + placeholder="直接输入模型名称,或点右侧按钮获取" onChange={(model) => patchEntry(entryId, { model })} - onOpenChange={(open) => { - if (open && !loadingModels && entryModels.length === 0) void fetchModels(entryId); - }} filterOption={(input, option) => String(option?.value ?? "").toLowerCase().includes(input.toLowerCase())} />
); }; @@ -646,6 +635,15 @@ export function LlmConfigPanel({ /> {renderCredentialFields(PRIMARY_LLM_ENTRY_ID)} + {!isLlmServiceUsable(config) && ( + + )}
{chainEnabled && ( @@ -680,8 +678,11 @@ export function LlmConfigPanel({ {fallbackTitle(entry, index)} - - {entry.enabled ? "备用" : "已停用"} + + {!entry.enabled ? "已停用" : isLlmServiceUsable(entry) ? "备用" : "未填完"} 备用 {index + 1} @@ -726,13 +727,13 @@ export function LlmConfigPanel({
- {chain.some((entry) => !entry.base_url.trim() || !entry.model.trim()) && ( + {chain.some((entry) => !isLlmServiceUsable(entry)) && ( )}
From 22244e9e8327237efcbe42331553d5dd2f6af07e Mon Sep 17 00:00:00 2001 From: patricLee Date: Thu, 20 Aug 2026 16:23:10 +0800 Subject: [PATCH 6/7] =?UTF-8?q?update:=E7=AE=80=E5=8C=96=E6=8B=9F=E4=BA=BA?= =?UTF-8?q?=E5=8C=96=E5=BC=BA=E5=BA=A6=E9=80=89=E9=A1=B9=E7=9A=84=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每档原本有图标、标题、描述、产出说明、右侧徽标五个元素,徽标和产出说明 都在重复描述已经讲过的事。压到两个:档位名加一句话(含产出代价), 标准档缀一个推荐标记。底部的人格种子说明并入开关副标题, describePersona 随之删除。 行高从两行塌成一行后原间距显挤,选项间距和内边距相应放松。 --- src/view/config/HumanizeSection.test.tsx | 27 ++--- src/view/config/HumanizeSection.tsx | 145 +++++++---------------- 2 files changed, 55 insertions(+), 117 deletions(-) diff --git a/src/view/config/HumanizeSection.test.tsx b/src/view/config/HumanizeSection.test.tsx index f1f9538..379174c 100644 --- a/src/view/config/HumanizeSection.test.tsx +++ b/src/view/config/HumanizeSection.test.tsx @@ -1,6 +1,6 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; -import HumanizeSection, { describePersona } from "./HumanizeSection"; +import HumanizeSection from "./HumanizeSection"; import { DEFAULT_HUMANIZE_CONFIG, type HumanizeConfig, @@ -58,24 +58,19 @@ describe("HumanizeSection", () => { expect(screen.queryByRole("radio", { name: "轻度" })).toBeNull(); expect(screen.queryByRole("radio", { name: "标准" })).toBeNull(); expect(screen.queryByRole("radio", { name: "谨慎" })).toBeNull(); - expect(screen.queryByText("拟人化已关闭,任务会按原有节奏执行。")).toBeNull(); - }); -}); - -describe("describePersona", () => { - it("describes disabled and not-yet-generated states", () => { - expect(describePersona(config())).toBe("拟人化已关闭,任务会按原有节奏执行。"); - expect(describePersona(config({ enabled: true }))).toBe( - "启用后系统会生成一套专属的操作习惯,保存配置即生效。", - ); }); - it("describes a generated persona without exposing its seed", () => { - const description = describePersona( - config({ enabled: true, persona_seed: 123456 }), + it("每档只说清投递节奏和产出代价,不暴露人格种子", () => { + render( + , ); - expect(description).toContain("系统已按你的专属编号生成一套操作习惯"); - expect(description).not.toContain("123456"); + expect(screen.getByText("节奏基本不变,产出几乎无损失")).toBeTruthy(); + expect(screen.getByText("投一批歇几分钟,产出降一到两成")).toBeTruthy(); + expect(screen.getByText("休息更久、动作更慢,产出明显下降")).toBeTruthy(); + expect(screen.queryByText(/123456/)).toBeNull(); }); }); diff --git a/src/view/config/HumanizeSection.tsx b/src/view/config/HumanizeSection.tsx index 3f52fe0..f0bd139 100644 --- a/src/view/config/HumanizeSection.tsx +++ b/src/view/config/HumanizeSection.tsx @@ -1,14 +1,5 @@ import { Radio } from "antd"; -import { - AimOutlined, - BarChartOutlined, - CoffeeOutlined, - InfoCircleOutlined, - LineChartOutlined, - SafetyCertificateOutlined, - UserOutlined, -} from "@ant-design/icons"; -import type { ReactNode } from "react"; +import { SafetyCertificateOutlined } from "@ant-design/icons"; import { type HumanizeConfig, type HumanizeIntensity, @@ -18,42 +9,31 @@ import { SettingGroup, SettingToggle } from "@/components/SettingField"; /** * 强度档位只表达用户能感知到的行为差异,具体节奏仍由后端按人格种子推导。 * 这样 UI 不会把某一组固定数字误认为是运行时的硬编码参数。 + * + * 每档一句话说清「怎么投」和「少投多少」——挑档位时真正要权衡的就这两件事, + * 图标、标签、单列的产出说明都只是在重复它。 */ const INTENSITY_OPTIONS: Array<{ value: HumanizeIntensity; label: string; description: string; - cost: string; - icon: ReactNode; - badge: string; - badgeIcon: ReactNode; + recommended?: boolean; }> = [ { value: "light", label: "轻度", - description: "仅在既有节奏上小幅抖动,投递节奏基本不变", - cost: "产出几乎无损失", - icon: , - badge: "稳定优先", - badgeIcon: , + description: "节奏基本不变,产出几乎无损失", }, { value: "standard", label: "标准", - description: "投一批歇几分钟,偶尔跳过一个岗位、停下来发会儿呆", - cost: "产出约降一到两成", - icon: , - badge: "平衡推荐", - badgeIcon: , + description: "投一批歇几分钟,产出降一到两成", + recommended: true, }, { value: "cautious", label: "谨慎", - description: "休息更勤更久、跳过更多、动作更慢,适合已被限制过的账号", - cost: "产出明显下降", - icon: , - badge: "风控优先", - badgeIcon: , + description: "休息更久、动作更慢,产出明显下降", }, ]; @@ -74,85 +54,48 @@ export default function HumanizeSection({ config, onChange }: Props) { } title="启用拟人化" - description="模拟真人的操作节奏和行为模式,减少机械化特征" + description="模拟真人的操作节奏,同一账号每天的表现也会略有差异" checked={config.enabled} onChange={(enabled) => onChange({ enabled })} > {config.enabled && ( -
- onChange({ intensity: event.target.value as HumanizeIntensity })} - className="!block" - > -
- {INTENSITY_OPTIONS.map((option) => { - const selected = config.intensity === option.value; - return ( - -
- - {option.icon} - -
-
- - {option.label} - - - {option.description} - -
- - {option.cost} - -
- - {option.badgeIcon} - {option.badge} - -
-
- ); - })} -
-
- -
- - {describePersona(config)} + onChange({ intensity: event.target.value as HumanizeIntensity })} + className="!block" + > +
+ {INTENSITY_OPTIONS.map((option) => ( + + + + {option.label} + + {option.recommended && ( + + 推荐 + + )} + + {option.description} + + + + ))}
-
+ )} ); } - -/** - * 说明当前人格策略的来源,不展示运行时的具体随机数字。 - * 任务启动时后端会根据人格种子和当天日期推导实际节奏。 - */ -export function describePersona(config: HumanizeConfig): string { - if (!config.enabled) { - return "拟人化已关闭,任务会按原有节奏执行。"; - } - if (!config.persona_seed) { - return "启用后系统会生成一套专属的操作习惯,保存配置即生效。"; - } - return ( - "同一账号在不同天的表现也会随机化而略有差异。" - ); -} From b8935de97475acaa68c8b7892b4c86c1e97987ce Mon Sep 17 00:00:00 2001 From: patricLee Date: Thu, 20 Aug 2026 16:29:23 +0800 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=E6=8A=BD=E5=87=BA=20RadioCardGrou?= =?UTF-8?q?p=EF=BC=8C=E7=BB=9F=E4=B8=80=E4=B8=89=E5=A4=84=E5=8D=95?= =?UTF-8?q?=E9=80=89=E5=8D=A1=E7=89=87=E7=9A=84=E6=A0=B7=E5=BC=8F=E4=B8=8E?= =?UTF-8?q?=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 分析时机、回复策略、拟人化是同一套卡片样式各写了一遍,三份重复的 className,改动时漏掉一处就会出现深浅不一的选中态。抽成共享组件后 三处统一:标签与描述同排、选中态一致、推荐用小标记而不是写进标签。 描述统一精简到一行,长文案由 flex-wrap 自动折行,不必为长短各拆一种布局。 --- src/components/RadioCardGroup.tsx | 71 +++++++++++++++++++ src/view/config/HumanizeSection.tsx | 48 ++----------- src/view/config/index.tsx | 105 ++++++++-------------------- 3 files changed, 109 insertions(+), 115 deletions(-) create mode 100644 src/components/RadioCardGroup.tsx diff --git a/src/components/RadioCardGroup.tsx b/src/components/RadioCardGroup.tsx new file mode 100644 index 0000000..c5c28b0 --- /dev/null +++ b/src/components/RadioCardGroup.tsx @@ -0,0 +1,71 @@ +import { Radio } from "antd"; + +/** + * 单选卡片组:一组互斥选项,每项一张可点的卡片。 + * + * 标签和描述排在同一行,描述长了自动折行——不必为长短文案各拆一种布局。 + * 配置页里「选一个策略」的地方都用它,避免同一套卡片样式在各页面各写一遍, + * 改动时漏掉其中一处就会出现两种深浅不一的选中态。 + */ +export interface RadioCardOption { + value: T; + label: string; + description: string; + /** 标签后的推荐标记,一组里至多标一个 */ + recommended?: boolean; + disabled?: boolean; +} + +interface RadioCardGroupProps { + /** 整组的无障碍名称,例如「回复策略」 */ + ariaLabel: string; + value: T; + options: RadioCardOption[]; + onChange: (next: T) => void; +} + +export function RadioCardGroup({ + ariaLabel, + value, + options, + onChange, +}: RadioCardGroupProps) { + return ( + onChange(event.target.value as T)} + className="!block w-full" + > +
+ {options.map((option) => ( + + + + {option.label} + + {option.recommended && ( + + 推荐 + + )} + + {option.description} + + + + ))} +
+
+ ); +} diff --git a/src/view/config/HumanizeSection.tsx b/src/view/config/HumanizeSection.tsx index f0bd139..fe69afb 100644 --- a/src/view/config/HumanizeSection.tsx +++ b/src/view/config/HumanizeSection.tsx @@ -1,10 +1,10 @@ -import { Radio } from "antd"; import { SafetyCertificateOutlined } from "@ant-design/icons"; import { type HumanizeConfig, type HumanizeIntensity, } from "../../types/app-config"; import { SettingGroup, SettingToggle } from "@/components/SettingField"; +import { RadioCardGroup, type RadioCardOption } from "@/components/RadioCardGroup"; /** * 强度档位只表达用户能感知到的行为差异,具体节奏仍由后端按人格种子推导。 @@ -13,12 +13,7 @@ import { SettingGroup, SettingToggle } from "@/components/SettingField"; * 每档一句话说清「怎么投」和「少投多少」——挑档位时真正要权衡的就这两件事, * 图标、标签、单列的产出说明都只是在重复它。 */ -const INTENSITY_OPTIONS: Array<{ - value: HumanizeIntensity; - label: string; - description: string; - recommended?: boolean; -}> = [ +const INTENSITY_OPTIONS: RadioCardOption[] = [ { value: "light", label: "轻度", @@ -59,41 +54,12 @@ export default function HumanizeSection({ config, onChange }: Props) { onChange={(enabled) => onChange({ enabled })} > {config.enabled && ( - onChange({ intensity: event.target.value as HumanizeIntensity })} - className="!block" - > -
- {INTENSITY_OPTIONS.map((option) => ( - - - - {option.label} - - {option.recommended && ( - - 推荐 - - )} - - {option.description} - - - - ))} -
-
+ options={INTENSITY_OPTIONS} + onChange={(intensity) => onChange({ intensity })} + /> )} diff --git a/src/view/config/index.tsx b/src/view/config/index.tsx index 1fc4240..e472779 100644 --- a/src/view/config/index.tsx +++ b/src/view/config/index.tsx @@ -15,7 +15,6 @@ import { Cascader, Collapse, Alert, - Radio, Dropdown, Modal, Tabs, @@ -66,6 +65,7 @@ import { SettingSlider, SettingToggle, } from "@/components/SettingField"; +import { RadioCardGroup } from "@/components/RadioCardGroup"; import ReplyPollingSection from "./ReplyPollingSection"; import PeriodicDeliverySection from "./PeriodicDeliverySection"; import HumanizeSection from "./HumanizeSection"; @@ -149,30 +149,31 @@ const REPLY_STRATEGY_OPTIONS: { label: string; description: string; needsLlm: boolean; + recommended?: boolean; }[] = [ { value: "template_first", - label: "规则优先,AI 兜底(推荐)", - description: - "命中正则规则的消息直接发固定话术,不消耗模型额度;其余交给 AI 判断", + label: "规则优先,AI 兜底", + description: "命中规则的直接发话术,不耗额度;其余交给 AI", needsLlm: true, + recommended: true, }, { value: "llm", label: "仅 AI 回复", - description: "每条未读都交给大模型判断该回什么、要不要投简历", + description: "每条未读都交给大模型判断怎么回", needsLlm: true, }, { value: "template", label: "仅规则回复", - description: "只回命中正则规则的消息,其余留给人工处理", + description: "只回命中规则的消息,其余留给人工", needsLlm: false, }, { value: "off", label: "关闭自动回复", - description: "沟通任务只同步消息,不代你发送任何内容", + description: "只同步消息,不代你发送任何内容", needsLlm: false, }, ]; @@ -313,29 +314,31 @@ const ANALYSIS_TRIGGER_OPTIONS: Array<{ label: string; description: string; needsLlm: boolean; + recommended?: boolean; }> = [ { value: "off", label: "关闭自动分析", - description: "只在岗位详情页或岗位管理页的批量入口手动分析", + description: "只在岗位详情页手动触发", needsLlm: false, }, { value: "greet_sent", label: "打招呼发送成功后", - description: "只分析真正投出去的岗位,最省模型额度,推荐日常使用", + description: "只分析投出去的岗位,最省额度", needsLlm: true, + recommended: true, }, { value: "filter_passed", label: "通过筛选规则后", - description: "规则命中即分析,覆盖最全;被模型判定不该投的岗位也会消耗额度", + description: "规则命中即分析,覆盖最全但更费额度", needsLlm: true, }, { value: "reply_received", label: "收到 HR 回复后", - description: "对方回复了才分析,此时聊天记录已有内容,报告最贴合面试准备", + description: "对方回复后才分析,最贴合面试准备", needsLlm: true, }, ]; @@ -1544,33 +1547,15 @@ export function ConfigPage(props: ConfigPageProps) {
- - props.updateAnalysis({ - trigger: e.target.value as AnalysisTrigger, - }) - } - className="w-full" - > -
- {ANALYSIS_TRIGGER_OPTIONS.map((option) => ( - - - {option.label} - - - {option.description} - - - ))} -
-
+ options={ANALYSIS_TRIGGER_OPTIONS.map((option) => ({ + ...option, + disabled: option.needsLlm && !llmActive, + }))} + onChange={(trigger) => props.updateAnalysis({ trigger })} + /> {!llmActive && ( @@ -1688,45 +1673,17 @@ export function ConfigPage(props: ConfigPageProps) { - - props.updateReplay( - REPLY_STRATEGY_FLAGS[e.target.value as ReplyStrategy], - ) + options={REPLY_STRATEGY_OPTIONS.map((option) => ({ + ...option, + disabled: option.needsLlm && !llmActive, + }))} + onChange={(strategy) => + props.updateReplay(REPLY_STRATEGY_FLAGS[strategy]) } - className="!block w-full" - > -
- {REPLY_STRATEGY_OPTIONS.map((option) => { - const selected = replayStrategy === option.value; - const disabled = option.needsLlm && !llmActive; - return ( - -
- - {option.label} - - - {option.description} - -
-
- ); - })} -
-
+ /> {!llmActive && (