Skip to content
Merged
15 changes: 12 additions & 3 deletions src-tauri/src/agent/tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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<'_> {
Expand All @@ -205,7 +210,7 @@ impl AgentTask for GreetTask<'_> {
fn params(&self) -> Result<Value, AppError> {
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),
});
Expand Down Expand Up @@ -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<'_> {
Expand Down Expand Up @@ -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),
});
Expand Down
42 changes: 19 additions & 23 deletions src-tauri/src/command/communicated_jobs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -263,6 +265,7 @@ fn collect_conversation_messages(
page: &ChromiumPage,
snapshot: &CommunicatedJobCardSnapshot,
fallback_job_id: &str,
direction: &mut MessageDirection,
) -> Result<usize, anyhow::Error> {
let history_listener = page.listen_url("zpchat/geek/historyMsg")?;
let boss_data_listener = page.listen_url("zpchat/geek/getBossData")?;
Expand All @@ -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 {
Expand Down Expand Up @@ -520,26 +523,15 @@ fn extract_detail_text(page: &Page) -> Result<String, anyhow::Error> {
})()
"#;
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> {
Expand Down Expand Up @@ -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。"]
);
}

Expand Down
8 changes: 6 additions & 2 deletions src-tauri/src/command/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ pub fn load_app_config(app_handle: tauri::AppHandle) -> CommandResult<AppRuntime
}
}

/// 保存配置,并把**落盘后**的那份交回前端。
///
/// 保存路径上会做迁移、夹取、补生成人格种子,落盘内容与提交内容并不相同。
/// 前端拿这个返回值刷新自己手里的配置,才不会带着旧值继续编辑
#[tauri::command]
pub fn save_app_config(
app_handle: tauri::AppHandle,
config: AppRuntimeConfig,
) -> CommandResult<()> {
) -> CommandResult<AppRuntimeConfig> {
match config::save_app_config_inner(app_handle, config) {
Ok(()) => CommandResult::ok(()),
Ok(saved) => CommandResult::ok(saved),
Err(err) => CommandResult::err(err),
}
}
Expand Down
28 changes: 26 additions & 2 deletions src-tauri/src/command/job.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -630,6 +631,22 @@ pub fn job_delete(id: String) -> CommandResult<()> {
}
}

/// 岗位描述的结构化视图。
///
/// 前端不再自己洗一遍 JD:清洗规则跟着平台页面结构走,前后端各存一份必然漂移。
/// 页面要展示什么就从这个出口取,和喂给模型的是同一份文本
#[tauri::command]
pub fn job_description_view(job_id: String) -> CommandResult<ParsedJobDescription> {
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<Vec<ChatMessageRecord>> {
match chat_message_dao::find_by_job_id(&job_id) {
Expand Down Expand Up @@ -675,6 +692,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(
Expand All @@ -684,7 +704,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),
],
)
}
Expand Down Expand Up @@ -745,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);

Expand Down
89 changes: 71 additions & 18 deletions src-tauri/src/command/llm_provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,8 @@ where
}

/// 在降级链中按标识定位一个服务。
/// 链由已保存的配置推导而来,所以「找不到」既可能是标识写错
/// 也可能是用户刚在界面上填完还没保存,错误文案需要同时覆盖这两种情况
/// 链只收录填写完整且处于启用状态的服务,所以「找不到」通常是缺服务地址或模型名
/// 其次才是该服务被停用了
fn find_chain_link<'a>(
chain: &'a [LlmChainLink],
entry_id: &str,
Expand All @@ -139,9 +139,7 @@ fn find_chain_link<'a>(
.iter()
.find(|link| link.id == entry_id)
.ok_or_else(|| {
AppError::configuration(
"未找到该大模型服务,请先保存配置后再测试;未保存或已停用的服务无法测试",
)
AppError::configuration("该大模型服务尚不可用,请补齐服务地址和模型名称并确认它已启用")
})
}

Expand Down Expand Up @@ -179,19 +177,43 @@ pub async fn test_llm_entry_connection(
}
}

/// 列出降级链中某个服务可用的模型
/// 界面上正在编辑的服务参数。两项齐备才算数:只传一半无从判断该配哪个客户端,
/// 与其猜一个,不如退回已落盘的配置。
fn draft_override(
provider: Option<LlmProviderPreset>,
base_url: Option<String>,
) -> 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<LlmProviderPreset>,
base_url: Option<String>,
) -> CommandResult<Vec<String>> {
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> {
Expand Down Expand Up @@ -463,23 +485,54 @@ 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]
fn find_chain_link_on_empty_chain_reports_the_same_error() {
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]
Expand Down
Loading
Loading