From 0a1ac9f77985943f8ccb58f79857446a1a46fbcd Mon Sep 17 00:00:00 2001 From: JunieXD <1240840076@qq.com> Date: Sat, 31 Oct 2026 15:51:15 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E7=BC=96=E8=BE=91=E5=92=8C=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E4=B8=8D=E5=86=8D=E8=A6=86=E7=9B=96=E6=9C=9F=E9=97=B4?= =?UTF-8?q?=E5=88=AB=E5=A4=84=E7=9A=84=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...75ef2ac5949e72297ec5c38ee8601f989fd4.json} | 6 +-- crates/api/src/agent/confirmation.rs | 2 +- crates/api/src/lib.rs | 42 +++++++++++++--- crates/api/tests/agent_effects.rs | 30 ++++++++---- crates/email/src/lib.rs | 2 +- crates/email/tests/todos.rs | 2 +- crates/library/tests/library.rs | 4 +- crates/matching/src/lib.rs | 2 +- crates/matching/tests/matching.rs | 12 ++--- crates/mentor/src/lib.rs | 35 +++++++++++++ crates/mentor/src/merge.rs | 23 +++++++-- crates/mentor/tests/edit.rs | 42 +++++++++++++++- crates/mentor/tests/matching.rs | 20 ++++---- crates/mentor/tests/merge.rs | 49 ++++++++++++++++--- crates/profile/src/lib.rs | 34 +++++++------ crates/profile/tests/profile.rs | 20 +++++--- crates/receive/tests/outcome.rs | 4 +- crates/send/tests/send.rs | 2 +- crates/task/tests/tasks.rs | 12 +++-- crates/writing/tests/write.rs | 6 +-- docs/data-model.md | 2 +- docs/ui.md | 4 +- ui/src/api/types.ts | 12 +++-- ui/src/components/mentor-dialog.tsx | 13 +++-- ui/src/components/merge-dialog.tsx | 11 +++-- ui/src/pages/mentor.tsx | 21 +++++--- ui/src/pages/profile.test.tsx | 33 +++++++++++++ ui/src/pages/profile.tsx | 29 +++++------ 28 files changed, 350 insertions(+), 124 deletions(-) rename .sqlx/{query-96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319.json => query-c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4.json} (66%) diff --git a/.sqlx/query-96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319.json b/.sqlx/query-c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4.json similarity index 66% rename from .sqlx/query-96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319.json rename to .sqlx/query-c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4.json index 40d3ee6de..3cfdd544c 100644 --- a/.sqlx/query-96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319.json +++ b/.sqlx/query-c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "UPDATE profile\n SET name = ?1, research_interests = ?2, rewrite_preference = ?3,\n match_basis_changed_at = CASE WHEN research_interests IS NOT ?2 THEN max(\n coalesce(match_basis_changed_at, ?4), ?4) ELSE match_basis_changed_at END\n RETURNING name, research_interests, rewrite_preference, onboarded AS \"onboarded: bool\"", + "query": "UPDATE profile\n SET name = iif(?1, ?2, name), research_interests = iif(?3, ?4, research_interests),\n rewrite_preference = iif(?5, ?6, rewrite_preference),\n match_basis_changed_at = CASE WHEN ?3 AND research_interests IS NOT ?4 THEN max(\n coalesce(match_basis_changed_at, ?7), ?7) ELSE match_basis_changed_at END\n RETURNING name, research_interests, rewrite_preference, onboarded AS \"onboarded: bool\"", "describe": { "columns": [ { @@ -49,7 +49,7 @@ } ], "parameters": { - "Right": 4 + "Right": 7 }, "nullable": [ true, @@ -58,5 +58,5 @@ false ] }, - "hash": "96a24702eee8385434e669b392058c3b75158752c2495728db47c7eee34d0319" + "hash": "c8da8973a8c206ca185d191a960075ef2ac5949e72297ec5c38ee8601f989fd4" } diff --git a/crates/api/src/agent/confirmation.rs b/crates/api/src/agent/confirmation.rs index a4e36958a..c4f57c698 100644 --- a/crates/api/src/agent/confirmation.rs +++ b/crates/api/src/agent/confirmation.rs @@ -671,7 +671,7 @@ async fn describe( ) } "mergeMentors" => { - let input: crate::MentorMerge = parse(input)?; + let crate::PreviewedMerge { pair: input, .. } = parse(input)?; let drafts = mentor::merge::conflicting_drafts(conn, input.keep, input.other).await?; let canceled = sqlx::query_scalar!( r#"SELECT count(*) AS "count!: i64" FROM email diff --git a/crates/api/src/lib.rs b/crates/api/src/lib.rs index 89f899ae7..4159ee42e 100644 --- a/crates/api/src/lib.rs +++ b/crates/api/src/lib.rs @@ -643,7 +643,8 @@ operations! { "setMentorNote" [none] => set_mentor_note(MentorNote) -> (); /// 添加一位导师,返回新导师的 id。 "addMentor" [none] => add_mentor(mentor::MentorForm) -> MentorId; - /// 更新指定导师的资料。 + /// 更新指定导师的资料。before 是编辑前 getMentor 读到的 name、aliases、emails、 + /// affiliations、homepages、research;这之后资料有了变化就不保存,重新读取后再改。 "updateMentor" [confirm_if "更改已批准邮件的收件地址时"] => update_mentor(MentorEdit) -> mentor::MentorDetail; /// 设置联系导师使用的邮箱,mailboxId 为 null 时取消指定。 @@ -654,8 +655,9 @@ operations! { add_mentor_email(MentorAddress) -> mentor::MentorDetail; /// 预览把 other 导师并入 keep 导师的结果。 "previewMerge" [none] => preview_merge(MentorMerge) -> mentor::merge::Preview; - /// 把 other 导师合并到 keep 导师。 - "mergeMentors" [confirm] => merge_mentors(MentorMerge) -> (); + /// 把 other 导师合并到 keep 导师。discardedDrafts 是 previewMerge 给出的;这之后要删的草稿 + /// 有了变化就不合并,重新预览后再合并。 + "mergeMentors" [confirm] => merge_mentors(PreviewedMerge) -> (); /// 按筛选和分页条件读取回收站导师。 "listTrash" [none] => list_trash(mentor::trash::TrashQuery) -> mentor::MentorPage; /// 将指定 id 列表中的导师移入回收站。 @@ -710,7 +712,7 @@ operations! { "个人资料" { /// 读取学生个人资料。 "getProfile" [none] => get_profile(Nothing) -> profile::Profile; - /// 保存学生个人资料。 + /// 保存学生个人资料,只改传了的字段,空字符串清空。 "updateProfile" [none] => update_profile(profile::ProfileForm) -> profile::Profile; /// 标记首次使用引导已完成。 "finishOnboarding" [none] => finish_onboarding(Nothing) -> (); @@ -978,6 +980,8 @@ pub struct MentorIds { #[derive(Debug, Deserialize, TS)] pub struct MentorEdit { pub id: i64, + /// The fields as they were when editing began. + pub before: mentor::MentorFields, #[serde(flatten)] pub form: mentor::MentorForm, } @@ -1002,6 +1006,15 @@ pub struct MentorMerge { pub other: i64, } +/// A merge as its preview showed it. +#[derive(Debug, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct PreviewedMerge { + #[serde(flatten)] + pub pair: MentorMerge, + pub discarded_drafts: Vec, +} + #[derive(Debug, Deserialize, TS)] pub struct MentorNote { pub id: i64, @@ -1076,8 +1089,17 @@ async fn update_mentor( input: MentorEdit, ) -> Result { let fields = input.form.check().map_err(CallError::invalid)?; - let applied = mentor::update(&ctx.db, input.id, &fields, now()).await?; - require_applied(applied, NO_MENTOR)?; + match mentor::edit(&ctx.db, input.id, &input.before, &fields, now()).await? { + mentor::Edited::Saved => {} + mentor::Edited::Changed => { + return Err(CallError::invalid( + "打开编辑之后,这位导师的资料在别处有了变化(比如合并、导入或导师库更新)。\ + 关掉后重新打开,再改一次。" + .into(), + )); + } + mentor::Edited::Gone => return Err(CallError::not_found(NO_MENTOR)), + } look_up(ctx, input.id).await?; get_mentor(ctx, MentorId { id: input.id }).await } @@ -1123,8 +1145,9 @@ async fn preview_merge( Ok(mentor::merge::preview(&ctx.db, input.keep, input.other).await?) } -async fn merge_mentors(ctx: &Context, input: MentorMerge) -> Result<(), CallError> { - mentor::merge::merge(&ctx.db, input.keep, input.other, now()).await?; +async fn merge_mentors(ctx: &Context, input: PreviewedMerge) -> Result<(), CallError> { + let MentorMerge { keep, other } = input.pair; + mentor::merge::merge(&ctx.db, keep, other, &input.discarded_drafts, now()).await?; ctx.reply_processor.wake(); ctx.todo_reminders.wake(); Ok(()) @@ -1278,6 +1301,9 @@ impl From for CallError { "{name}老师还有 {count} 封邮件会发出(待发送、发送中或发送失败)。等它们发出,\ 或改回草稿后再合并。" )), + mentor::merge::MergeError::Changed => { + CallError::invalid("预览之后两位导师的草稿有了变化,请看过新的预览再合并。".into()) + } mentor::merge::MergeError::Db(e) => e.into(), } } diff --git a/crates/api/tests/agent_effects.rs b/crates/api/tests/agent_effects.rs index ee5c659a8..06107518f 100644 --- a/crates/api/tests/agent_effects.rs +++ b/crates/api/tests/agent_effects.rs @@ -632,6 +632,16 @@ async fn empty_save_does_not_delete_a_draft_whose_attachment_was_swapped(db: Sql } /// A paused task holding one approved first email to the mentor. +/// The profile dialog's save of the mentor with only its emails changed. +async fn emails_edit(ctx: &Context, id: i64, emails: &[&str]) -> Value { + let m = mentor::get(&ctx.db, id).await.unwrap().unwrap(); + let before = json!({"name":m.name, "aliases":m.aliases, "emails":m.emails, + "affiliations":m.affiliations, "homepages":m.homepages, "research":m.research}); + json!({"id":id, "before":before, "name":m.name, "aliases":m.aliases, "emails":emails, + "affiliations":m.affiliations, "homepages":m.homepages, + "research":m.research.unwrap_or_default()}) +} + async fn paused_task(ctx: &Context, mentor: i64) -> (i64, i64) { let task = sqlx::query_scalar::<_, i64>( r#"INSERT INTO task (name, kind, writer, schedule, state, created_at) @@ -672,8 +682,7 @@ async fn resume_task_rechecks_the_pending_recipient_address(db: SqlitePool) { call( &ctx, "updateMentor", - json!({"id":mentor, "name":"王伟", "emails":["new@example.invalid"], - "aliases":[], "affiliations":[], "homepages":[], "research":""}), + emails_edit(&ctx, mentor, &["new@example.invalid"]).await, ) .await .unwrap(); @@ -975,8 +984,7 @@ async fn recipient_edits_recheck_schedules_including_unassigned_task_mail(db: Sq .await .unwrap(); let input = if op == "updateMentor" { - json!({"id":mentor, "name":name, "emails":[after], "aliases":[], - "affiliations":[], "homepages":[], "research":""}) + emails_edit(&ctx, mentor, &[&after]).await } else { let candidate = sqlx::query_scalar::<_, i64>( r#"INSERT INTO candidate (name, emails, review, enrich) @@ -1653,8 +1661,7 @@ async fn resume_mailbox_rechecks_recipients_and_deleting_a_task_rechecks_its_ema call( &ctx, "updateMentor", - json!({"id":mentor, "name":"王伟", "emails":["after@example.invalid"], - "aliases":[], "affiliations":[], "homepages":[], "research":""}), + emails_edit(&ctx, mentor, &["after@example.invalid"]).await, ) .await .unwrap(); @@ -1839,10 +1846,7 @@ async fn recipient_edits_require_confirmation_only_for_approved_mail(db: SqliteP .unwrap(); let decisions = json!({"take":[], "matches":[], "restore":[]}); let input = match op { - "updateMentor" => json!({ - "id":mentor, "name":name, "emails":[after], "aliases":[], - "affiliations":[], "homepages":[], "research":"" - }), + "updateMentor" => emails_edit(&ctx, mentor, &[&after]).await, "addMentorEmail" => json!({"id":mentor, "address":after}), "importMentors" => json!({ "fileName":"mentors.csv", @@ -1901,6 +1905,12 @@ async fn recipient_edits_require_confirmation_only_for_approved_mail(db: SqliteP .execute(&ctx.db) .await .unwrap(); + // The safe edit changed the emails the first one began from. + let input = if op == "updateMentor" { + emails_edit(&ctx, mentor, &[&after]).await + } else { + input + }; call_agent(&ctx, op, input, |_| {}, pending()) .await .unwrap(); diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 9f519233d..9e049dcdd 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -186,7 +186,7 @@ pub async fn attached(conn: &mut SqliteConnection, email_id: i64) -> Result Vec { profile::update( db, &profile::ProfileForm { - name: "学生".into(), - research_interests: "视觉".into(), - rewrite_preference: "".into(), + name: Some("学生".into()), + research_interests: Some("视觉".into()), + rewrite_preference: Some("".into()), }, 1, ) @@ -413,9 +413,9 @@ async fn absent_model_consent_and_student_basis_are_explained_before_calling(db: profile::update( &db, &profile::ProfileForm { - name: "".into(), - research_interests: "".into(), - rewrite_preference: "".into(), + name: Some("".into()), + research_interests: Some("".into()), + rewrite_preference: Some("".into()), }, 30, ) diff --git a/crates/mentor/src/lib.rs b/crates/mentor/src/lib.rs index 4da752af1..2f7c932d4 100644 --- a/crates/mentor/src/lib.rs +++ b/crates/mentor/src/lib.rs @@ -956,6 +956,41 @@ pub async fn update<'c>( Ok(true) } +/// What became of an edit made in a form. +#[derive(Debug, PartialEq, Eq)] +pub enum Edited { + Saved, + /// The fields are no longer those the form started from, as after a merge, an import or a + /// library update; saving would undo that. + Changed, + Gone, +} + +/// Saves an edit of the fields `before` showed, unless they have changed since. +pub async fn edit( + db: &SqlitePool, + id: i64, + before: &MentorFields, + after: &MentorFields, + now: i64, +) -> Result { + let mut tx = db.begin().await?; + // Writing first holds the write lock from the comparison through the save. + let touched = sqlx::query!("UPDATE mentor SET updated_at = ? WHERE id = ?", now, id) + .execute(&mut *tx) + .await?; + if touched.rows_affected() == 0 { + return Ok(Edited::Gone); + } + let current = export::profiles(&mut tx, &[id]).await?; + if current.get(&id).map(|p| &p.fields) != Some(before) { + return Ok(Edited::Changed); + } + update(&mut *tx, id, after, now).await?; + tx.commit().await?; + Ok(Edited::Saved) +} + /// Writes the mentor's lists in the given order. An address the mentor keeps keeps its row, so /// that it is not looked up in the mailboxes again; one another mentor has fails the save. async fn save_lists( diff --git a/crates/mentor/src/merge.rs b/crates/mentor/src/merge.rs index 4a4530573..7d28a2b6e 100644 --- a/crates/mentor/src/merge.rs +++ b/crates/mentor/src/merge.rs @@ -14,6 +14,8 @@ pub enum MergeError { name: String, count: i64, }, + /// The drafts the merge would discard are no longer those its preview showed. + Changed, Db(sqlx::Error), } @@ -28,6 +30,7 @@ impl std::fmt::Display for MergeError { match self { MergeError::Gone => write!(f, "mentor gone"), MergeError::Busy { .. } => write!(f, "mentor has emails still to go out"), + MergeError::Changed => write!(f, "drafts changed since the preview"), MergeError::Db(e) => write!(f, "{e}"), } } @@ -81,9 +84,15 @@ pub async fn preview(db: &SqlitePool, keep: i64, other: i64) -> Result Result<(), MergeError> { +/// Merges `other` into `keep`, which takes its profile, mail and the rest, and deletes it. +/// `discarded` is what the preview said goes of the drafts. `now` is UTC milliseconds. +pub async fn merge( + db: &SqlitePool, + keep: i64, + other: i64, + discarded: &[email::DraftKind], + now: i64, +) -> Result<(), MergeError> { let mut tx = db.begin().await?; let both = sqlx::query!( "UPDATE mentor SET updated_at = ? WHERE id IN (?, ?) AND trashed_at IS NULL", @@ -117,6 +126,11 @@ pub async fn merge(db: &SqlitePool, keep: i64, other: i64, now: i64) -> Result<( count: b.count, }); } + // A draft each can have only one: the kept mentor's stays. + let drafts = conflicting_drafts(&mut tx, keep, other).await?; + if drafts != discarded { + return Err(MergeError::Changed); + } let (Some(a), Some(b)) = (detail(&mut tx, keep).await?, detail(&mut tx, other).await?) else { return Err(MergeError::Gone); }; @@ -203,8 +217,7 @@ pub async fn merge(db: &SqlitePool, keep: i64, other: i64, now: i64) -> Result<( .await?; } - // A draft each can have only one: the kept mentor's stays. - for kind in conflicting_drafts(&mut tx, keep, other).await? { + for kind in drafts { sqlx::query!( r#"DELETE FROM email diff --git a/crates/mentor/tests/edit.rs b/crates/mentor/tests/edit.rs index 6590b3244..51d5956dc 100644 --- a/crates/mentor/tests/edit.rs +++ b/crates/mentor/tests/edit.rs @@ -4,7 +4,7 @@ mod common; use common::names; -use mentor::{Affiliation, MentorFields, MentorForm, SaveError}; +use mentor::{Affiliation, Edited, MentorFields, MentorForm, SaveError}; use sqlx::SqlitePool; use test_fixtures::mail::mailbox; @@ -140,6 +140,46 @@ fn lists_keep_their_order_without_blanks_or_repeats() { assert_eq!(fields.homepages, ["https://x.cn"]); } +#[sqlx::test(migrator = "db::MIGRATOR")] +async fn an_edit_does_not_undo_what_changed_since_it_began(db: SqlitePool) { + let shown = MentorFields { + name: "张明".into(), + emails: vec!["zhang@pku.edu.cn".into()], + ..MentorFields::default() + }; + let id = mentor::create(&db, &shown, 0).await.unwrap(); + // As a library update or a merge would, while the form was open. + assert!( + mentor::add_email(&db, id, "zhang@gmail.com", 1) + .await + .unwrap() + ); + let renamed = |fields: &MentorFields| MentorFields { + name: "张明明".into(), + ..fields.clone() + }; + assert_eq!( + mentor::edit(&db, id, &shown, &renamed(&shown), 2) + .await + .unwrap(), + Edited::Changed + ); + let saved = mentor::get(&db, id).await.unwrap().unwrap(); + assert_eq!(saved.name, "张明"); + assert_eq!(saved.emails, ["zhang@pku.edu.cn", "zhang@gmail.com"]); + let shown = MentorFields { + emails: saved.emails, + ..shown + }; + assert_eq!( + mentor::edit(&db, id, &shown, &renamed(&shown), 3) + .await + .unwrap(), + Edited::Saved + ); + assert_eq!(mentor::get(&db, id).await.unwrap().unwrap().name, "张明明"); +} + #[sqlx::test(migrator = "db::MIGRATOR")] async fn editing_updates_the_keyword_index(db: SqlitePool) { let mut fields = form("张伟", "", "").check().unwrap(); diff --git a/crates/mentor/tests/matching.rs b/crates/mentor/tests/matching.rs index e5257057e..1b222425f 100644 --- a/crates/mentor/tests/matching.rs +++ b/crates/mentor/tests/matching.rs @@ -102,9 +102,9 @@ async fn results_survive_queue_failure_retry_and_cancellation(db: SqlitePool) { profile::update( &db, &profile::ProfileForm { - name: "".into(), - research_interests: "视觉".into(), - rewrite_preference: "".into(), + name: Some("".into()), + research_interests: Some("视觉".into()), + rewrite_preference: Some("".into()), }, 20, ) @@ -183,9 +183,9 @@ async fn results_survive_queue_failure_retry_and_cancellation(db: SqlitePool) { #[sqlx::test(migrator = "db::MIGRATOR")] async fn unchanged_interests_do_not_invalidate_a_result(db: SqlitePool) { let form = profile::ProfileForm { - name: "甲".into(), - research_interests: " 视觉 ".into(), - rewrite_preference: "".into(), + name: Some("甲".into()), + research_interests: Some(" 视觉 ".into()), + rewrite_preference: Some("".into()), }; profile::update(&db, &form, 10).await.unwrap(); let id = add(&db, Some("视觉")).await; @@ -193,7 +193,7 @@ async fn unchanged_interests_do_not_invalidate_a_result(db: SqlitePool) { profile::update( &db, &profile::ProfileForm { - name: "乙".into(), + name: Some("乙".into()), ..form }, 30, @@ -229,9 +229,9 @@ async fn starting_single_or_batch_analysis_resumes_a_paused_queue(db: SqlitePool #[sqlx::test(migrator = "db::MIGRATOR")] async fn older_interest_updates_cannot_move_the_basis_time_back(db: SqlitePool) { let form = |interests: &str| profile::ProfileForm { - name: "".into(), - research_interests: interests.into(), - rewrite_preference: "".into(), + name: Some("".into()), + research_interests: Some(interests.into()), + rewrite_preference: Some("".into()), }; profile::update(&db, &form("视觉"), 200).await.unwrap(); let id = add(&db, Some("视觉")).await; diff --git a/crates/mentor/tests/merge.rs b/crates/mentor/tests/merge.rs index 61d125fe4..06e0849a0 100644 --- a/crates/mentor/tests/merge.rs +++ b/crates/mentor/tests/merge.rs @@ -95,7 +95,9 @@ async fn a_mentor_listed_twice_becomes_one(db: SqlitePool) { let preview = merge::preview(&db, li, ming).await.unwrap(); assert_eq!((preview.emails, preview.affiliations), (2, 1)); - merge::merge(&db, li, ming, 10).await.unwrap(); + merge::merge(&db, li, ming, &preview.discarded_drafts, 10) + .await + .unwrap(); let merged = mentor::get(&db, li).await.unwrap().unwrap(); assert!(mentor::get(&db, ming).await.unwrap().is_none()); assert_eq!(merged.aliases, ["Ming Li"]); @@ -140,13 +142,38 @@ async fn a_mentor_listed_twice_becomes_one(db: SqlitePool) { reconciled(&mut db.acquire().await.unwrap()).await.unwrap(); } +#[sqlx::test(migrator = "db::MIGRATOR")] +async fn a_draft_written_after_the_preview_is_not_discarded(db: SqlitePool) { + let li = mentor(&db, "李明", &["liming@gmail.com"], "北京大学").await; + let ming = mentor(&db, "Ming Li", &["ming@pku.edu.cn"], "北京大学").await; + first_email(&db, li, "review", 0).await.unwrap(); + let preview = merge::preview(&db, li, ming).await.unwrap(); + assert!(preview.discarded_drafts.is_empty()); + // Written in another window while the merge dialog was open. + let draft = first_email(&db, ming, "review", 0).await.unwrap(); + assert!(matches!( + merge::merge(&db, li, ming, &preview.discarded_drafts, 10).await, + Err(MergeError::Changed) + )); + let state: String = sqlx::query_scalar("SELECT state FROM email WHERE id = ?") + .bind(draft) + .fetch_one(&db) + .await + .unwrap(); + assert_eq!(state, "review"); + let preview = merge::preview(&db, li, ming).await.unwrap(); + merge::merge(&db, li, ming, &preview.discarded_drafts, 10) + .await + .unwrap(); +} + #[sqlx::test(migrator = "db::MIGRATOR")] async fn mentors_with_mail_to_go_or_in_the_trash_are_not_merged(db: SqlitePool) { let li = mentor(&db, "李明", &[], "北京大学").await; let ming = mentor(&db, "李明", &[], "北京大学").await; let approved = first_email(&db, ming, "approved", 0).await.unwrap(); assert!(matches!( - merge::merge(&db, li, ming, 10).await, + merge::merge(&db, li, ming, &[], 10).await, Err(MergeError::Busy { count: 1, .. }) )); exec( @@ -157,11 +184,11 @@ async fn mentors_with_mail_to_go_or_in_the_trash_are_not_merged(db: SqlitePool) .await; exec(&db, "UPDATE mentor SET trashed_at = 1 WHERE id = ?", ming).await; assert!(matches!( - merge::merge(&db, li, ming, 10).await, + merge::merge(&db, li, ming, &[], 10).await, Err(MergeError::Gone) )); assert!(matches!( - merge::merge(&db, li, li, 10).await, + merge::merge(&db, li, li, &[], 10).await, Err(MergeError::Gone) )); assert!(mentor::get(&db, ming).await.unwrap().is_some()); @@ -186,7 +213,7 @@ async fn compatible_affiliations_fill_blanks_and_keep_local_title(db: SqlitePool let preview = merge::preview(&db, a, b).await.unwrap(); assert_eq!((preview.emails, preview.affiliations), (0, 1)); assert!(mentor::get(&db, b).await.unwrap().is_some()); - merge::merge(&db, a, b, 1).await.unwrap(); + merge::merge(&db, a, b, &[], 1).await.unwrap(); let m = mentor::get(&db, a).await.unwrap().unwrap(); assert_eq!( m.affiliations, @@ -245,7 +272,9 @@ async fn task_pair(db: &SqlitePool, states: [&str; 2]) -> (i64, [i64; 2], [i64; #[sqlx::test(migrator = "db::MIGRATOR")] async fn merging_two_canceled_task_members_keeps_only_the_retained_mentors_email(db: SqlitePool) { let (task, mentors, emails) = task_pair(&db, ["canceled", "canceled"]).await; - merge::merge(&db, mentors[0], mentors[1], 10).await.unwrap(); + merge::merge(&db, mentors[0], mentors[1], &[], 10) + .await + .unwrap(); let rows: Vec<(i64, i64, Option)> = sqlx::query_as("SELECT id,mentor_id,task_id FROM email ORDER BY id") .fetch_all(&db) @@ -266,7 +295,9 @@ async fn merging_sent_and_canceled_task_members_keeps_the_sent_email_in_either_d } else { emails[1] }; - merge::merge(&db, mentors[0], mentors[1], 10).await.unwrap(); + merge::merge(&db, mentors[0], mentors[1], &[], 10) + .await + .unwrap(); let rows: Vec<(i64, i64, String)> = sqlx::query_as("SELECT id,mentor_id,state FROM email WHERE task_id=?") .bind(task) @@ -295,7 +326,9 @@ async fn merging_two_sent_task_members_preserves_both_letters_and_only_one_membe .fetch_all(&db) .await .unwrap(); - merge::merge(&db, mentors[0], mentors[1], 10).await.unwrap(); + merge::merge(&db, mentors[0], mentors[1], &[], 10) + .await + .unwrap(); let after: Vec<(i64, String, String, i64)> = sqlx::query_as("SELECT id,message_id,body_md,sent_at FROM email ORDER BY id") .fetch_all(&db) diff --git a/crates/profile/src/lib.rs b/crates/profile/src/lib.rs index fc4f791ab..aedbb2310 100644 --- a/crates/profile/src/lib.rs +++ b/crates/profile/src/lib.rs @@ -14,13 +14,16 @@ pub struct Profile { pub onboarded: bool, } -/// The profile as typed; blank fields are cleared. -#[derive(Debug, Deserialize, TS)] +/// The fields to change, as typed; the others stay, and blank ones are cleared. +#[derive(Debug, Default, Deserialize, TS)] #[serde(rename_all = "camelCase")] pub struct ProfileForm { - pub name: String, - pub research_interests: String, - pub rewrite_preference: String, + #[ts(optional)] + pub name: Option, + #[ts(optional)] + pub research_interests: Option, + #[ts(optional)] + pub rewrite_preference: Option, } fn filled(text: &str) -> Option<&str> { @@ -45,21 +48,20 @@ pub async fn finish_onboarding(db: &SqlitePool) -> Result<(), sqlx::Error> { } pub async fn update(db: &SqlitePool, form: &ProfileForm, now: i64) -> Result { - let (name, research_interests, rewrite_preference) = ( - filled(&form.name), - filled(&form.research_interests), - filled(&form.rewrite_preference), - ); sqlx::query_as!( Profile, r#"UPDATE profile - SET name = ?1, research_interests = ?2, rewrite_preference = ?3, - match_basis_changed_at = CASE WHEN research_interests IS NOT ?2 THEN max( - coalesce(match_basis_changed_at, ?4), ?4) ELSE match_basis_changed_at END + SET name = iif(?1, ?2, name), research_interests = iif(?3, ?4, research_interests), + rewrite_preference = iif(?5, ?6, rewrite_preference), + match_basis_changed_at = CASE WHEN ?3 AND research_interests IS NOT ?4 THEN max( + coalesce(match_basis_changed_at, ?7), ?7) ELSE match_basis_changed_at END RETURNING name, research_interests, rewrite_preference, onboarded AS "onboarded: bool""#, - name, - research_interests, - rewrite_preference, + form.name.is_some(), + form.name.as_deref().and_then(filled), + form.research_interests.is_some(), + form.research_interests.as_deref().and_then(filled), + form.rewrite_preference.is_some(), + form.rewrite_preference.as_deref().and_then(filled), now, ) .fetch_one(db) diff --git a/crates/profile/tests/profile.rs b/crates/profile/tests/profile.rs index 7ad042a30..1a2bf8413 100644 --- a/crates/profile/tests/profile.rs +++ b/crates/profile/tests/profile.rs @@ -5,16 +5,24 @@ use profile::{LetterFont, LetterStyle, ProfileForm}; use sqlx::SqlitePool; #[sqlx::test(migrator = "db::MIGRATOR")] -async fn blank_fields_are_cleared(db: SqlitePool) { - let form = |name: &str| ProfileForm { - name: name.into(), - research_interests: "自然语言处理\n".into(), - rewrite_preference: " ".into(), +async fn blank_fields_are_cleared_and_missing_ones_stay(db: SqlitePool) { + let form = ProfileForm { + name: Some(" 林晓 ".into()), + research_interests: Some("自然语言处理\n".into()), + rewrite_preference: Some(" ".into()), }; - let saved = profile::update(&db, &form(" 林晓 "), 1).await.unwrap(); + let saved = profile::update(&db, &form, 1).await.unwrap(); assert_eq!(saved.name.as_deref(), Some("林晓")); assert_eq!(saved.research_interests.as_deref(), Some("自然语言处理")); assert_eq!(saved.rewrite_preference, None); + // The page saves the name while an Agent's new research interests stand. + let renamed = ProfileForm { + name: Some("林晓晓".into()), + ..ProfileForm::default() + }; + let saved = profile::update(&db, &renamed, 2).await.unwrap(); + assert_eq!(saved.name.as_deref(), Some("林晓晓")); + assert_eq!(saved.research_interests.as_deref(), Some("自然语言处理")); } #[sqlx::test(migrator = "db::MIGRATOR")] diff --git a/crates/receive/tests/outcome.rs b/crates/receive/tests/outcome.rs index 80c79bd42..753bf9484 100644 --- a/crates/receive/tests/outcome.rs +++ b/crates/receive/tests/outcome.rs @@ -190,7 +190,7 @@ async fn newest_first_recovery_trash_merge_and_cascade_preserve_work(db: SqliteP ) .await .unwrap(); - mentor::merge::merge(&db, keep, m, 6).await.unwrap(); + mentor::merge::merge(&db, keep, m, &[], 6).await.unwrap(); let claim = queue::next(&db).await.unwrap().unwrap(); assert_eq!(claim.id, b); assert!(queue::complete(&db, claim, R::Interested).await.unwrap()); @@ -1064,7 +1064,7 @@ async fn shared_reply_todos_choose_the_sender_address_then_lowest_mentor_id(db: .unwrap(); assert_eq!(actual, owner); } - mentor::merge::merge(&db, low, high, 5).await.unwrap(); + mentor::merge::merge(&db, low, high, &[], 5).await.unwrap(); assert_eq!(email::todos::for_mentor(&db, low).await.unwrap().len(), 2); mentor::trash::trash(&db, &[low], 6).await.unwrap(); mentor::trash::delete(&db, &[low]).await.unwrap(); diff --git a/crates/send/tests/send.rs b/crates/send/tests/send.rs index d68c3fe24..5adb9f1d1 100644 --- a/crates/send/tests/send.rs +++ b/crates/send/tests/send.rs @@ -3358,7 +3358,7 @@ async fn merging_preserves_canceled_mail_for_late_bounces(db: SqlitePool) { .unwrap(); empty.push(id); } - mentor::merge::merge(&db, keep, other, at + DAY) + mentor::merge::merge(&db, keep, other, &[], at + DAY) .await .unwrap(); assert!( diff --git a/crates/task/tests/tasks.rs b/crates/task/tests/tasks.rs index 4db8d729c..b5c27f145 100644 --- a/crates/task/tests/tasks.rs +++ b/crates/task/tests/tasks.rs @@ -2049,7 +2049,9 @@ async fn merging_first_and_follow_up_drafts_keeps_only_the_retained_mentors(db: [email::DraftKind::FollowUp] ) && !keep_follow_up ); - mentor::merge::merge(&db, keep, other, T + 1).await.unwrap(); + mentor::merge::merge(&db, keep, other, &preview.discarded_drafts, T + 1) + .await + .unwrap(); let pending: Vec<(i64, String)> = sqlx::query_as( r#"SELECT id, kind FROM email @@ -2104,7 +2106,9 @@ async fn merging_two_follow_up_drafts_keeps_only_the_retained_mentors(db: Sqlite preview.discarded_drafts.as_slice(), [email::DraftKind::FollowUp] )); - mentor::merge::merge(&db, keep, other, T + 1).await.unwrap(); + mentor::merge::merge(&db, keep, other, &preview.discarded_drafts, T + 1) + .await + .unwrap(); let drafts: Vec = sqlx::query_scalar( r#"SELECT id FROM email @@ -2140,7 +2144,9 @@ async fn merging_replied_mentors_cancels_follow_up_in_either_direction(db: Sqlit "From: m{replied}@example.com\nMessage-ID: \nSubject: 回复\n\n欢迎交流。" )); receive::round(&db, &net, T + 2).await.unwrap(); - mentor::merge::merge(&db, keep, other, T + 3).await.unwrap(); + mentor::merge::merge(&db, keep, other, &[], T + 3) + .await + .unwrap(); assert!(email::follow_up::draft(&db, keep).await.unwrap().is_none()); let canceled = email::list(&db, keep) .await diff --git a/crates/writing/tests/write.rs b/crates/writing/tests/write.rs index 75ae6e7a3..97ce074a2 100644 --- a/crates/writing/tests/write.rs +++ b/crates/writing/tests/write.rs @@ -17,9 +17,9 @@ const T: i64 = 1_800_000_000_000; /// returns the mentor and the material for writing. async fn setup(db: &SqlitePool) -> (i64, i64) { let me = profile::ProfileForm { - name: "林晓".into(), - research_interests: "多模态学习".into(), - rewrite_preference: "语气再正式一些".into(), + name: Some("林晓".into()), + research_interests: Some("多模态学习".into()), + rewrite_preference: Some("语气再正式一些".into()), }; profile::update(db, &me, T).await.unwrap(); let material = async |name: &str, for_writing: bool, text: &str| { diff --git a/docs/data-model.md b/docs/data-model.md index 93499d7fe..bf04154ed 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -231,7 +231,7 @@ | 研究方向、来源网址、负责邮箱、匹配结果 | 保留的导师有就用它的,没有用另一位的 | | 备注 | 两位都有时接在一起 | | 邮件、来往邮件、待办 | 都归到保留的导师,联系状态按合并后的重算;同一封来往邮件两位都有的,只留一份 | -| 没写完的首封、单独追问和回复 | 首封和单独追问算同一类,回复另算一类;同一类两位都有时留保留的导师的,删掉另一位的(合并预览说明删掉哪位导师的哪种草稿) | +| 没写完的首封、单独追问和回复 | 首封和单独追问算同一类,回复另算一类;同一类两位都有时留保留的导师的,删掉另一位的(合并预览说明删掉哪位导师的哪种草稿;合并时重新算一遍,和预览说的不同就不合并,重新预览后再确认) | | 同一个任务里两位都有邮件 | 这个任务里只留一封:发出过的优先,都发出过或都没发出时留保留的导师的。另一封取消了的删掉(取消前交出过一次的除外);发出过的照样归到保留的导师,只是不再算在这个任务里 | | 候选导师、导师库的待处理更新 | 改为对应保留的导师 | diff --git a/docs/ui.md b/docs/ui.md index 53891c3ad..620f97151 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -68,7 +68,7 @@ - 右上角"跳过引导",最后一步下面"完成":都会结束引导,去导师列表,以后不再显示。 - 标签:导师详情的"标签"旁点"编辑",弹出所有标签逐个勾选,下面的输入框按回车新建并直接加上,新标签按预设颜色依次取色。"管理标签"里改名、换颜色、调顺序和删除,删除要再点一次确认;标签在各处都按这里的顺序排。 - 勾选:表格每行前面有复选框,表头的复选框勾选或取消本页全部。翻页保留勾选,换联系状态或关键词时清空。批量操作条浮在内容页底部中间:取消选择、已选几位和操作。 -- 个人资料(设置 › 个人 › 个人资料):姓名、意向研究方向和草稿改写偏好,输入后自动保存。意向研究方向在分析匹配度时和材料一起作为依据,改写偏好在 AI 写草稿和改写时遵循。 +- 个人资料(设置 › 个人 › 个人资料):姓名、意向研究方向和草稿改写偏好,输入后自动保存,只保存改的那一项;Agent 同时改了别的项,页面照新的显示,不会被写回旧的。意向研究方向在分析匹配度时和材料一起作为依据,改写偏好在 AI 写草稿和改写时遵循。 - 材料(设置 › 个人 › 材料):列出各份材料的文件名和大小(文字超过 1 万字的注明"AI 只读前 1 万字"),每份下面勾选用途:匹配分析、写信参考、默认附件,改了马上保存。"添加材料"选 PDF、DOCX 或 DOC 文件(不超过 20 MB),提取完文字后出现在列表里。新材料默认用于匹配分析和写信参考,不默认作附件:附件会改变导师收到的内容,由用户自己决定。没提取到文字的(比如扫描件)标出来,只能勾选默认附件。"查看文字"显示提取出的文字,供核对;删除前先确认,待发的邮件用它作附件时不能删,说明原因。 - 模板(设置 › 个人 › 模板):列出各个模板的名称和主题,可以添加、编辑和删除。可以把一个模板"设为默认",标上"默认",再按一次"取消默认";添加的第一个模板自动设为默认。编辑框里的名称、主题和正文都要填;正文上方的工具栏有加粗、斜体、链接、无序和有序列表,以及"插入占位符"。认得的占位符在正文里高亮显示,打错的不高亮。粘贴进来的内容只保留这几种格式。编辑框上方有"从 Word 导入":选 DOCX 或 DOC 文件(不超过 20 MB),正文换成文件里的文字,和粘贴一样只保留段落、换行、加粗、斜体、链接和列表;标题当普通段落,表格的每个格子一段,页眉页脚、脚注、批注和图片不导入(DOC 文件里的文本框也不导入)。名称空着时填文件名;正文已经有内容时先确认替换;主题自己填。链接只能指向网页和邮箱地址,没写协议的按网页处理,带 @ 的按邮箱地址处理。 - 信件样式(设置 › 个人 › 信件样式):字体(跟随收件人的邮箱、宋体、黑体)、字号(五号、小四、四号)、行距、段距和段首空两格,改了马上保存。下面用一封示例信按当前样式预览,背景始终是白色。 @@ -99,7 +99,7 @@ - 赞助到期后不显示开关和"立即备份",写"赞助已到期,云端备份已停止。现有的备份还能恢复,将于3月1日删除。",旁边是"续赞助"。 - 从云端恢复:按钮换成"正在下载…",下载、检查好后弹出和本地恢复一样的确认框。云端那份用旧密码加密、解不开时不显示"从云端恢复"。 - 删除云端备份:确认框写"删除后云端不再有备份,自动备份也会关闭。这台电脑上的数据不动。",按"删除"。 -- 导师资料(添加导师、编辑资料):姓名、别名、邮箱、单位、主页和研究方向。邮箱、别名和主页每个一行,"添加"多一行,行尾的"×"去掉一行;别名多数导师没有,没有时只显示"添加别名";第一个邮箱标"发信",别的邮箱可以"设为发信地址",移到第一个。单位一组是学校、学院、系所和职称四栏,第一组标"主要",别的可以"设为主要";手机上四栏上下排。只有一个的时候和普通的输入框一样,不显示这些标记。 +- 导师资料(添加导师、编辑资料):姓名、别名、邮箱、单位、主页和研究方向。邮箱、别名和主页每个一行,"添加"多一行,行尾的"×"去掉一行;别名多数导师没有,没有时只显示"添加别名";第一个邮箱标"发信",别的邮箱可以"设为发信地址",移到第一个。单位一组是学校、学院、系所和职称四栏,第一组标"主要",别的可以"设为主要";手机上四栏上下排。只有一个的时候和普通的输入框一样,不显示这些标记。编辑资料时,如果打开以后这位导师的资料在别处变了(合并、导入、导师库更新),保存时不覆盖,提示关掉重新打开再改。 - 发件邮箱(导师详情的资料里,自己有两个以上邮箱时显示):下拉框里是"自动"和各个邮箱,选了马上保存。"自动"按轮转选,第一次发出后记下,之后显示记下的那个;选了一个邮箱,首封、追问和回复都从它发,它暂停时等它恢复。联系过的导师换了邮箱,下面说明导师之后看到的发件地址会变。 - 合并导师:在导师列表勾选两位,批量操作条上点"合并"。弹窗里并排是两位的姓名、主要单位、邮箱和联系状态,选保留哪一位(默认先勾选的那位),下面说明合并后的结果:邮箱和单位各有几个、另一位的姓名记为别名,两位都有没写完的信时删掉哪位导师的哪种草稿:首封和单独追问算同一类,回复另算一类,同一类留保留的导师的。"合并"后进入保留的导师的详情。有还会发出的邮件时不合并,说明是哪位导师的几封、怎么处理。 - 匹配度(导师列表的一栏):分数;在排队的显示"排队中",正在分析的显示"分析中",有旧分数的旧分数照常显示在旁边;失败的显示"失败",悬停看原因;依据有变化的分数旁边标一个灰点,悬停说明"材料或意向研究方向改过,可以重新分析"。 diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index ecf3b1643..884e8d4b2 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -441,7 +441,11 @@ affiliations: Array, homepages: Array, */ sourceUrl: string | null, research: string | null, papers: Array, note: string | null, analysis: MatchAnalysis, contact: Contact, tags: Array, trashed: boolean, library: Array, }; -export type MentorEdit = { id: number, name: string, aliases: Array, emails: Array, affiliations: Array, homepages: Array, research: string, }; +export type MentorEdit = { id: number, +/** + * The fields as they were when editing began. + */ +before: MentorFields, name: string, aliases: Array, emails: Array, affiliations: Array, homepages: Array, research: string, }; export type MentorFields = { name: string, aliases: Array, emails: Array, affiliations: Array, homepages: Array, research: string | null, }; @@ -663,6 +667,8 @@ export type Preview = { sheets: Array, sheet: number, columns: Array, keep: number, other: number, }; + export type PreviousLetter = { subject: string | null, sentAt: number, }; export type PreviousPreview = { subject: string | null, sentAt: number, attribution: string, bodyText: string | null, }; @@ -671,7 +677,7 @@ export type Problem = { "kind": "placeholder", name: string, } | { "kind": "no_s export type Profile = { name: string | null, researchInterests: string | null, rewritePreference: string | null, onboarded: boolean, }; -export type ProfileForm = { name: string, researchInterests: string, rewritePreference: string, }; +export type ProfileForm = { name?: string, researchInterests?: string, rewritePreference?: string, }; export type Progress = { generating: number, review: number, generationFailed: number, approved: number, sent: number, failed: number, canceled: number, }; @@ -1044,7 +1050,7 @@ export interface Operations { setMentorMailbox: { input: MentorMailbox; output: MentorDetail }; addMentorEmail: { input: MentorAddress; output: MentorDetail }; previewMerge: { input: MentorMerge; output: MergePreview }; - mergeMentors: { input: MentorMerge; output: null }; + mergeMentors: { input: PreviewedMerge; output: null }; listTrash: { input: TrashQuery; output: MentorPage }; trashMentors: { input: MentorIds; output: null }; restoreMentors: { input: MentorIds; output: null }; diff --git a/ui/src/components/mentor-dialog.tsx b/ui/src/components/mentor-dialog.tsx index e8725cb6f..fa60d3eba 100644 --- a/ui/src/components/mentor-dialog.tsx +++ b/ui/src/components/mentor-dialog.tsx @@ -3,7 +3,7 @@ import { Plus, X } from "lucide-react"; import { type ReactNode, useRef, useState } from "react"; import { flushSync } from "react-dom"; import { errorMessage } from "@/api/call"; -import type { Affiliation, MentorDetail, MentorForm } from "@/api/types"; +import type { Affiliation, MentorDetail, MentorFields, MentorForm } from "@/api/types"; import { Field, formClose } from "@/components/form"; import { Button } from "@/components/ui/button"; import { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@/components/ui/dialog"; @@ -32,18 +32,23 @@ const text = (label: string, value: string, onChange: (value: string) => void) = onChange(e.target.value)} /> ); -/** The form for a mentor's existing fields; blank ones stay blank. */ -export function formOf(m: MentorDetail): MentorForm { +/** A mentor's own fields as shown, which an edit starts from. */ +export function fieldsOf(m: MentorDetail): MentorFields { return { name: m.name, aliases: m.aliases, emails: m.emails, affiliations: m.affiliations, homepages: m.homepages, - research: m.research ?? "", + research: m.research, }; } +/** The form for a mentor's existing fields; blank ones stay blank. */ +export function formOf(m: MentorDetail): MentorForm { + return { ...fieldsOf(m), research: m.research ?? "" }; +} + /** * Adding or editing a mentor's own fields. The core trims and checks them, leaves out blank rows and * says what to fix. Mounted while open, so every opening starts from `initial`. diff --git a/ui/src/components/merge-dialog.tsx b/ui/src/components/merge-dialog.tsx index fa05bfb34..324e7dc44 100644 --- a/ui/src/components/merge-dialog.tsx +++ b/ui/src/components/merge-dialog.tsx @@ -2,7 +2,7 @@ import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/rea import { useNavigate } from "@tanstack/react-router"; import { useState } from "react"; import { call, errorMessage } from "@/api/call"; -import type { MentorDetail, MergePreview } from "@/api/types"; +import type { DraftKind, MentorDetail, MergePreview } from "@/api/types"; import { ContactLabel, dropDrafts, placeOf, refreshMentors } from "@/components/mentor"; import { Button } from "@/components/ui/button"; import { Dialog, DialogBody, DialogClose, DialogContent, DialogFooter, DialogHeader } from "@/components/ui/dialog"; @@ -24,7 +24,9 @@ export function MergeDialog({ ids, onClose }: { ids: [number, number]; onClose: queryFn: () => call("previewMerge", { keep, other }), }); const merge = useMutation({ - mutationFn: (pair: { keep: number; other: number }) => call("mergeMentors", pair), + mutationFn: (pair: { keep: number; other: number; discardedDrafts: DraftKind[] }) => call("mergeMentors", pair), + // Drafts changed since the preview: show the new one before merging. + onError: () => void queryClient.invalidateQueries({ queryKey: ["previewMerge"] }), onSuccess: async (_, pair) => { queryClient.removeQueries({ queryKey: ["getMentor", pair.other] }); dropDrafts(queryClient, [pair.keep, pair.other]); @@ -78,7 +80,10 @@ export function MergeDialog({ ids, onClose }: { ids: [number, number]; onClose: {merge.isError && errorMessage(merge.error)}

}>取消 - diff --git a/ui/src/pages/mentor.tsx b/ui/src/pages/mentor.tsx index 8c90ae58b..d1d9d407b 100644 --- a/ui/src/pages/mentor.tsx +++ b/ui/src/pages/mentor.tsx @@ -6,7 +6,7 @@ import { call, errorMessage } from "@/api/call"; import type { MentorDetail } from "@/api/types"; import { Confirm } from "@/components/batch-bar"; import { Correspondence } from "@/components/correspondence"; -import { formOf, MentorDialog } from "@/components/mentor-dialog"; +import { fieldsOf, formOf, MentorDialog } from "@/components/mentor-dialog"; import { Avatar, ContactLabel, @@ -211,13 +211,14 @@ function Section({ title, action, children }: { title: string; action?: ReactNod function Profile({ mentor: m }: { mentor: MentorDetail }) { const queryClient = useQueryClient(); - const [editing, setEditing] = useState(false); + // The mentor as the edit began; the core refuses the edit if it has changed since. + const [editing, setEditing] = useState(null); const edit = ( @@ -228,8 +229,14 @@ function Profile({ mentor: m }: { mentor: MentorDetail }) { call("updateMentor", { id: m.id, ...form })} + initial={formOf(editing)} + save={(form) => + call("updateMentor", { id: editing.id, before: fieldsOf(editing), ...form }).catch((error: unknown) => { + // Opening again starts from what the mentor has now. + void queryClient.invalidateQueries({ queryKey: ["getMentor", editing.id] }); + throw error; + }) + } onSaved={async (saved) => { // A load still under way may have read the mentor before this save. await queryClient.cancelQueries({ queryKey: ["getMentor", m.id] }); @@ -237,9 +244,9 @@ function Profile({ mentor: m }: { mentor: MentorDetail }) { // An edited profile may now agree with a library update, which then no longer waits. for (const queryKey of [["listMentors"], ["listLibraryUpdates"], ["libraryUpdateCount"]]) void queryClient.invalidateQueries({ queryKey }); - setEditing(false); + setEditing(null); }} - onClose={() => setEditing(false)} + onClose={() => setEditing(null)} /> )}
diff --git a/ui/src/pages/profile.test.tsx b/ui/src/pages/profile.test.tsx index 17392ae5c..2c52602b0 100644 --- a/ui/src/pages/profile.test.tsx +++ b/ui/src/pages/profile.test.tsx @@ -141,3 +141,36 @@ it("finishes onboarding after an in-flight profile save and keeps the completed container.remove(); } }); + +it("saves only the fields typed in and shows what an Agent saved in the others", async () => { + const initial: Profile = { name: "林晓", researchInterests: "视觉", rewritePreference: null, onboarded: false }; + remote.profile = initial; + remote.save.mockReset().mockImplementation(async (form) => { + remote.profile = { ...remote.profile, ...form }; + return remote.profile; + }); + const client = new QueryClient(); + client.setQueryData(["getProfile"], initial); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => + root.render( + + + , + ), + ); + // An Agent saves while the page is open. + remote.profile = { ...initial, researchInterests: "多模态学习" }; + await act(async () => client.setQueryData(["getProfile"], remote.profile)); + await edit("林晓晓"); + await vi.waitFor(() => expect(remote.save).toHaveBeenCalledWith({ name: "林晓晓" })); + expect(document.querySelector("#profile-research")?.value).toBe("多模态学习"); + } finally { + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } +}); diff --git a/ui/src/pages/profile.tsx b/ui/src/pages/profile.tsx index 67352b851..b7706ac61 100644 --- a/ui/src/pages/profile.tsx +++ b/ui/src/pages/profile.tsx @@ -22,7 +22,8 @@ export function ProfileSection() { const mutationKey = ["updateProfile"]; /** - * Saves as the user types. Saves run one at a time and outlive this page, so the last text wins + * Saves as the user types, only the fields typed in on this visit, so that what an Agent saved + * meanwhile in the others stays and shows. Saves run one at a time and outlive this page, so the last text wins * even after leaving and coming back quickly, and unsaved text is shown again on return. `basic` * leaves out the rewrite preference, as the first-run guide does. */ @@ -43,16 +44,10 @@ export function ProfileFields({ profile, basic = false }: { profile: Profile; ba select: (m) => m.state, }).at(-1); const unsaved = last?.status === "success" ? undefined : last?.variables; - const [form, setForm] = useState( - () => - unsaved ?? { - name: profile.name ?? "", - researchInterests: profile.researchInterests ?? "", - rewritePreference: profile.rewritePreference ?? "", - }, - ); - function edit(next: ProfileForm) { - setForm(next); + const [typed, setTyped] = useState(() => unsaved ?? {}); + function edit(change: ProfileForm) { + const next = { ...typed, ...change }; + setTyped(next); save.mutate(next); } return ( @@ -61,8 +56,8 @@ export function ProfileFields({ profile, basic = false }: { profile: Profile; ba edit({ ...form, name: e.target.value })} + value={typed.name ?? profile.name ?? ""} + onChange={(e) => edit({ name: e.target.value })} /> @@ -70,8 +65,8 @@ export function ProfileFields({ profile, basic = false }: { profile: Profile; ba id="profile-research" rows={3} placeholder="比如:自然语言处理,对话系统和大模型的可解释性" - value={form.researchInterests} - onChange={(e) => edit({ ...form, researchInterests: e.target.value })} + value={typed.researchInterests ?? profile.researchInterests ?? ""} + onChange={(e) => edit({ researchInterests: e.target.value })} />

分析匹配度时,和材料一起作为依据。

@@ -81,8 +76,8 @@ export function ProfileFields({ profile, basic = false }: { profile: Profile; ba id="profile-rewrite" rows={3} placeholder="比如:语气正式但不生硬,300 字以内,不用感叹号" - value={form.rewritePreference} - onChange={(e) => edit({ ...form, rewritePreference: e.target.value })} + value={typed.rewritePreference ?? profile.rewritePreference ?? ""} + onChange={(e) => edit({ rewritePreference: e.target.value })} />

AI 写草稿和改写时照着做。

From bd7d2e78ad2140c25e08fc33f2274478da56f9cf Mon Sep 17 00:00:00 2001 From: JunieXD <1240840076@qq.com> Date: Sat, 31 Oct 2026 19:29:03 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E8=B5=84=E6=96=99?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E9=87=8D=E5=8F=91=E5=B7=B2=E4=BF=9D=E5=AD=98?= =?UTF-8?q?=E7=9A=84=E5=AD=97=E6=AE=B5=EF=BC=8CAgent=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=A0=B8=E5=AF=B9=E8=8D=89=E7=A8=BF=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保存只带这次输入的和还没保存上的字段;Agent 合并的确认说明和执行时核对的是同一份要删的草稿;冲突后等新资料读到再提示;编辑改用 BEGIN IMMEDIATE。 --- ...b8ecf8a32b67df63138379a7658a2efe077f.json} | 4 +- crates/api/src/agent/confirmation.rs | 9 +++- crates/api/tests/agent_effects.rs | 39 ++++++++++++++++ crates/mentor/src/lib.rs | 14 ++---- crates/mentor/src/merge.rs | 2 +- docs/ui.md | 2 +- ui/src/components/merge-dialog.tsx | 4 +- ui/src/pages/mentor.tsx | 12 +++-- ui/src/pages/profile.test.tsx | 46 +++++++++++-------- ui/src/pages/profile.tsx | 37 +++++++++------ ui/src/pages/welcome.tsx | 2 +- 11 files changed, 116 insertions(+), 55 deletions(-) rename .sqlx/{query-3f66c9addea10d59c3a09a3e158aa84918788353a8430adcc8e889f7f699045c.json => query-42907e8917661393af7d7343b31bb8ecf8a32b67df63138379a7658a2efe077f.json} (87%) diff --git a/.sqlx/query-3f66c9addea10d59c3a09a3e158aa84918788353a8430adcc8e889f7f699045c.json b/.sqlx/query-42907e8917661393af7d7343b31bb8ecf8a32b67df63138379a7658a2efe077f.json similarity index 87% rename from .sqlx/query-3f66c9addea10d59c3a09a3e158aa84918788353a8430adcc8e889f7f699045c.json rename to .sqlx/query-42907e8917661393af7d7343b31bb8ecf8a32b67df63138379a7658a2efe077f.json index 20da59a14..00d603371 100644 --- a/.sqlx/query-3f66c9addea10d59c3a09a3e158aa84918788353a8430adcc8e889f7f699045c.json +++ b/.sqlx/query-42907e8917661393af7d7343b31bb8ecf8a32b67df63138379a7658a2efe077f.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT kind AS \"kind: email::DraftKind\" FROM email\n WHERE mentor_id = ?2 AND state = 'review' AND task_id IS NULL\n AND EXISTS (SELECT 1 FROM email k WHERE k.mentor_id = ?1 AND k.state = 'review'\n AND k.task_id IS NULL AND (k.kind = email.kind\n OR k.kind IN ('first', 'follow_up') AND email.kind IN ('first', 'follow_up')))\n ORDER BY id", + "query": "SELECT kind AS \"kind: email::DraftKind\" FROM email\n WHERE mentor_id = ?2 AND state = 'review' AND task_id IS NULL\n AND EXISTS (SELECT 1 FROM email k WHERE k.mentor_id = ?1 AND k.state = 'review'\n AND k.task_id IS NULL AND (k.kind = email.kind\n OR k.kind IN ('first', 'follow_up') AND email.kind IN ('first', 'follow_up')))\n ORDER BY kind", "describe": { "columns": [ { @@ -22,5 +22,5 @@ false ] }, - "hash": "3f66c9addea10d59c3a09a3e158aa84918788353a8430adcc8e889f7f699045c" + "hash": "42907e8917661393af7d7343b31bb8ecf8a32b67df63138379a7658a2efe077f" } diff --git a/crates/api/src/agent/confirmation.rs b/crates/api/src/agent/confirmation.rs index c4f57c698..061b195a6 100644 --- a/crates/api/src/agent/confirmation.rs +++ b/crates/api/src/agent/confirmation.rs @@ -671,8 +671,15 @@ async fn describe( ) } "mergeMentors" => { - let crate::PreviewedMerge { pair: input, .. } = parse(input)?; + let crate::PreviewedMerge { + pair: input, + discarded_drafts, + } = parse(input)?; let drafts = mentor::merge::conflicting_drafts(conn, input.keep, input.other).await?; + // What the user agrees to must be what the merge then checks it discards. + if drafts != discarded_drafts { + return Err(mentor::merge::MergeError::Changed.into()); + } let canceled = sqlx::query_scalar!( r#"SELECT count(*) AS "count!: i64" FROM email WHERE mentor_id IN (?1, ?2) AND task_id IS NOT NULL diff --git a/crates/api/tests/agent_effects.rs b/crates/api/tests/agent_effects.rs index 06107518f..a892422e4 100644 --- a/crates/api/tests/agent_effects.rs +++ b/crates/api/tests/agent_effects.rs @@ -2117,3 +2117,42 @@ async fn confirmation_detects_recipient_changes_after_the_first_forty_characters async fn confirmation_detects_a_change_to_only_the_sixth_mentor(db: SqlitePool) { refuses_changed_candidate_recipient(db, 6, "shown@a.edu", "unshown@b.edu").await; } + +#[sqlx::test(migrator = "db::MIGRATOR")] +async fn a_merge_is_confirmed_only_with_the_drafts_it_discards(db: SqlitePool) { + let ctx = Context::new(db, net::Net::new(vec![]).unwrap(), std::path::Path::new("")); + let mut ids = Vec::new(); + for name in ["王伟", "王伟伟"] { + let id = mentor::create( + &ctx.db, + &mentor::MentorFields { + name: name.into(), + ..Default::default() + }, + 0, + ) + .await + .unwrap(); + email::save_first_draft(&ctx.db, id, "申请", "草稿", &[], 0) + .await + .unwrap(); + ids.push(id); + } + // A preview from before the other mentor's draft: the user would agree to discarding none. + let error = call_agent( + &ctx, + "mergeMentors", + json!({"keep":ids[0], "other":ids[1], "discardedDrafts":[]}), + |_| panic!("a merge whose preview is out of date was put to the user"), + pending(), + ) + .await + .unwrap_err(); + assert!(matches!(error.code, ErrorCode::Invalid)); + refused( + &ctx, + "mergeMentors", + json!({"keep":ids[0], "other":ids[1], "discardedDrafts":["first"]}), + ) + .await; +} diff --git a/crates/mentor/src/lib.rs b/crates/mentor/src/lib.rs index 2f7c932d4..bba4d0a2d 100644 --- a/crates/mentor/src/lib.rs +++ b/crates/mentor/src/lib.rs @@ -974,16 +974,12 @@ pub async fn edit( after: &MentorFields, now: i64, ) -> Result { - let mut tx = db.begin().await?; - // Writing first holds the write lock from the comparison through the save. - let touched = sqlx::query!("UPDATE mentor SET updated_at = ? WHERE id = ?", now, id) - .execute(&mut *tx) - .await?; - if touched.rows_affected() == 0 { - return Ok(Edited::Gone); - } + let mut tx = db.begin_with("BEGIN IMMEDIATE").await?; let current = export::profiles(&mut tx, &[id]).await?; - if current.get(&id).map(|p| &p.fields) != Some(before) { + let Some(current) = current.get(&id) else { + return Ok(Edited::Gone); + }; + if current.fields != *before { return Ok(Edited::Changed); } update(&mut *tx, id, after, now).await?; diff --git a/crates/mentor/src/merge.rs b/crates/mentor/src/merge.rs index 7d28a2b6e..22808d2d6 100644 --- a/crates/mentor/src/merge.rs +++ b/crates/mentor/src/merge.rs @@ -58,7 +58,7 @@ pub async fn conflicting_drafts( AND EXISTS (SELECT 1 FROM email k WHERE k.mentor_id = ?1 AND k.state = 'review' AND k.task_id IS NULL AND (k.kind = email.kind OR k.kind IN ('first', 'follow_up') AND email.kind IN ('first', 'follow_up'))) - ORDER BY id"#, + ORDER BY kind"#, keep, other, ) diff --git a/docs/ui.md b/docs/ui.md index 620f97151..67c382d13 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -68,7 +68,7 @@ - 右上角"跳过引导",最后一步下面"完成":都会结束引导,去导师列表,以后不再显示。 - 标签:导师详情的"标签"旁点"编辑",弹出所有标签逐个勾选,下面的输入框按回车新建并直接加上,新标签按预设颜色依次取色。"管理标签"里改名、换颜色、调顺序和删除,删除要再点一次确认;标签在各处都按这里的顺序排。 - 勾选:表格每行前面有复选框,表头的复选框勾选或取消本页全部。翻页保留勾选,换联系状态或关键词时清空。批量操作条浮在内容页底部中间:取消选择、已选几位和操作。 -- 个人资料(设置 › 个人 › 个人资料):姓名、意向研究方向和草稿改写偏好,输入后自动保存,只保存改的那一项;Agent 同时改了别的项,页面照新的显示,不会被写回旧的。意向研究方向在分析匹配度时和材料一起作为依据,改写偏好在 AI 写草稿和改写时遵循。 +- 个人资料(设置 › 个人 › 个人资料):姓名、意向研究方向和草稿改写偏好,输入后自动保存,只保存改了还没保存上的项;Agent 改过的项页面照新的显示,不会被写回旧的。意向研究方向在分析匹配度时和材料一起作为依据,改写偏好在 AI 写草稿和改写时遵循。 - 材料(设置 › 个人 › 材料):列出各份材料的文件名和大小(文字超过 1 万字的注明"AI 只读前 1 万字"),每份下面勾选用途:匹配分析、写信参考、默认附件,改了马上保存。"添加材料"选 PDF、DOCX 或 DOC 文件(不超过 20 MB),提取完文字后出现在列表里。新材料默认用于匹配分析和写信参考,不默认作附件:附件会改变导师收到的内容,由用户自己决定。没提取到文字的(比如扫描件)标出来,只能勾选默认附件。"查看文字"显示提取出的文字,供核对;删除前先确认,待发的邮件用它作附件时不能删,说明原因。 - 模板(设置 › 个人 › 模板):列出各个模板的名称和主题,可以添加、编辑和删除。可以把一个模板"设为默认",标上"默认",再按一次"取消默认";添加的第一个模板自动设为默认。编辑框里的名称、主题和正文都要填;正文上方的工具栏有加粗、斜体、链接、无序和有序列表,以及"插入占位符"。认得的占位符在正文里高亮显示,打错的不高亮。粘贴进来的内容只保留这几种格式。编辑框上方有"从 Word 导入":选 DOCX 或 DOC 文件(不超过 20 MB),正文换成文件里的文字,和粘贴一样只保留段落、换行、加粗、斜体、链接和列表;标题当普通段落,表格的每个格子一段,页眉页脚、脚注、批注和图片不导入(DOC 文件里的文本框也不导入)。名称空着时填文件名;正文已经有内容时先确认替换;主题自己填。链接只能指向网页和邮箱地址,没写协议的按网页处理,带 @ 的按邮箱地址处理。 - 信件样式(设置 › 个人 › 信件样式):字体(跟随收件人的邮箱、宋体、黑体)、字号(五号、小四、四号)、行距、段距和段首空两格,改了马上保存。下面用一封示例信按当前样式预览,背景始终是白色。 diff --git a/ui/src/components/merge-dialog.tsx b/ui/src/components/merge-dialog.tsx index 324e7dc44..8e6100b1b 100644 --- a/ui/src/components/merge-dialog.tsx +++ b/ui/src/components/merge-dialog.tsx @@ -25,8 +25,8 @@ export function MergeDialog({ ids, onClose }: { ids: [number, number]; onClose: }); const merge = useMutation({ mutationFn: (pair: { keep: number; other: number; discardedDrafts: DraftKind[] }) => call("mergeMentors", pair), - // Drafts changed since the preview: show the new one before merging. - onError: () => void queryClient.invalidateQueries({ queryKey: ["previewMerge"] }), + // Drafts changed since the preview: the new one shows before merging can be tried again. + onError: () => queryClient.invalidateQueries({ queryKey: ["previewMerge"] }), onSuccess: async (_, pair) => { queryClient.removeQueries({ queryKey: ["getMentor", pair.other] }); dropDrafts(queryClient, [pair.keep, pair.other]); diff --git a/ui/src/pages/mentor.tsx b/ui/src/pages/mentor.tsx index d1d9d407b..d3f8d146d 100644 --- a/ui/src/pages/mentor.tsx +++ b/ui/src/pages/mentor.tsx @@ -231,11 +231,13 @@ function Profile({ mentor: m }: { mentor: MentorDetail }) { action="保存" initial={formOf(editing)} save={(form) => - call("updateMentor", { id: editing.id, before: fieldsOf(editing), ...form }).catch((error: unknown) => { - // Opening again starts from what the mentor has now. - void queryClient.invalidateQueries({ queryKey: ["getMentor", editing.id] }); - throw error; - }) + call("updateMentor", { id: editing.id, before: fieldsOf(editing), ...form }).catch( + async (error: unknown) => { + // Opening again starts from what the mentor has now, so the error waits for it. + await queryClient.invalidateQueries({ queryKey: ["getMentor", editing.id] }); + throw error; + }, + ) } onSaved={async (saved) => { // A load still under way may have read the mentor before this save. diff --git a/ui/src/pages/profile.test.tsx b/ui/src/pages/profile.test.tsx index 2c52602b0..5fdeae40f 100644 --- a/ui/src/pages/profile.test.tsx +++ b/ui/src/pages/profile.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vitest"; @@ -42,25 +42,24 @@ function nameInput() { return document.querySelector("#profile-name"); } -function edit(value: string) { +function research() { + return document.querySelector("#profile-research")?.value; +} + +function edit(value: string, field = "#profile-name") { return act(async () => { - const input = nameInput(); - Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, value); + const input = document.querySelector(field); + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(input), "value")?.set?.call(input, value); input?.dispatchEvent(new Event("input", { bubbles: true })); }); } -function Fields() { - const { data } = useQuery({ queryKey: ["getProfile"], staleTime: Infinity }); - return data && ; -} - it("keeps the last edit across navigation and uses the saved profile after queued saves", async () => { const initial: Profile = { name: "旧姓名", researchInterests: null, rewritePreference: null, onboarded: false }; const first = deferredProfile(); const second = deferredProfile(); remote.save.mockReset().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); - const client = new QueryClient(); + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }); client.setQueryData(["getProfile"], initial); const oldQuery = deferredProfile(); void client.fetchQuery({ queryKey: ["getProfile"], queryFn: () => oldQuery.promise }); @@ -68,7 +67,9 @@ it("keeps the last edit across navigation and uses the saved profile after queue document.body.append(container); const root = createRoot(container); const render = (visible: boolean) => - act(async () => root.render({visible && })); + act(async () => + root.render({visible && }), + ); try { await render(true); await edit("第一次"); @@ -142,14 +143,15 @@ it("finishes onboarding after an in-flight profile save and keeps the completed } }); -it("saves only the fields typed in and shows what an Agent saved in the others", async () => { - const initial: Profile = { name: "林晓", researchInterests: "视觉", rewritePreference: null, onboarded: false }; +it("saves only what is typed and not saved yet, and shows what an Agent saved", async () => { + const initial: Profile = { name: "林晓", researchInterests: null, rewritePreference: null, onboarded: false }; remote.profile = initial; + // Keeps the text trimmed, as the core does. remote.save.mockReset().mockImplementation(async (form) => { - remote.profile = { ...remote.profile, ...form }; + remote.profile = { ...remote.profile, ...Object.fromEntries(Object.entries(form).map(([k, v]) => [k, v.trim()])) }; return remote.profile; }); - const client = new QueryClient(); + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }); client.setQueryData(["getProfile"], initial); const container = document.createElement("div"); document.body.append(container); @@ -158,16 +160,22 @@ it("saves only the fields typed in and shows what an Agent saved in the others", await act(async () => root.render( - + , ), ); + await edit("视觉\n", "#profile-research"); + await vi.waitFor(() => expect(client.getQueryData(["getProfile"])?.researchInterests).toBe("视觉")); + // Still as typed, so the next line starts where the user left off. + expect(research()).toBe("视觉\n"); // An Agent saves while the page is open. - remote.profile = { ...initial, researchInterests: "多模态学习" }; + remote.profile = { ...remote.profile, researchInterests: "多模态学习" }; await act(async () => client.setQueryData(["getProfile"], remote.profile)); + await vi.waitFor(() => expect(research()).toBe("多模态学习")); await edit("林晓晓"); - await vi.waitFor(() => expect(remote.save).toHaveBeenCalledWith({ name: "林晓晓" })); - expect(document.querySelector("#profile-research")?.value).toBe("多模态学习"); + await vi.waitFor(() => expect(remote.profile.name).toBe("林晓晓")); + expect(remote.save).toHaveBeenLastCalledWith({ name: "林晓晓" }); + expect(research()).toBe("多模态学习"); } finally { await act(async () => root.unmount()); client.clear(); diff --git a/ui/src/pages/profile.tsx b/ui/src/pages/profile.tsx index b7706ac61..a96097046 100644 --- a/ui/src/pages/profile.tsx +++ b/ui/src/pages/profile.tsx @@ -14,7 +14,7 @@ export function ProfileSection() {

AI 写信和分析匹配度时参考这些内容。输入后自动保存。

{profile.error &&

个人资料没能加载:{errorMessage(profile.error)}

} - {profile.data && } + {profile.data && }
); } @@ -22,13 +22,16 @@ export function ProfileSection() { const mutationKey = ["updateProfile"]; /** - * Saves as the user types, only the fields typed in on this visit, so that what an Agent saved - * meanwhile in the others stays and shows. Saves run one at a time and outlive this page, so the last text wins - * even after leaving and coming back quickly, and unsaved text is shown again on return. `basic` - * leaves out the rewrite preference, as the first-run guide does. + * Saves as the user types. A save carries the field typed and those not saved yet, never one saved + * already, so that what an Agent saved meanwhile stays and shows. Saves run one at a time and + * outlive this page, so the last text wins even after leaving and coming back quickly, and unsaved + * text is shown again on return. `basic` leaves out the rewrite preference, as the first-run guide + * does. */ -export function ProfileFields({ profile, basic = false }: { profile: Profile; basic?: boolean }) { +export function ProfileFields({ basic = false }: { basic?: boolean }) { const queryClient = useQueryClient(); + // Read here, not passed in, so that a render sees the profile and the saves at the same moment. + const profile = useQuery({ queryKey: ["getProfile"], queryFn: () => call("getProfile", {}) }).data; const save = useMutation({ mutationKey, scope: { id: "updateProfile" }, @@ -43,20 +46,26 @@ export function ProfileFields({ profile, basic = false }: { profile: Profile; ba filters: { mutationKey }, select: (m) => m.state, }).at(-1); - const unsaved = last?.status === "success" ? undefined : last?.variables; - const [typed, setTyped] = useState(() => unsaved ?? {}); + // As every save carries what is not saved yet, the last one has all of it. + const unsaved = (last?.status === "success" ? undefined : last?.variables) ?? {}; + // The text as typed on this visit, which the profile holds trimmed. + const [typed, setTyped] = useState({}); + if (!profile) return null; function edit(change: ProfileForm) { - const next = { ...typed, ...change }; - setTyped(next); - save.mutate(next); + setTyped({ ...typed, ...change }); + save.mutate({ ...unsaved, ...change }); } + const shown = (key: keyof ProfileForm) => { + const saved = profile[key] ?? ""; + return unsaved[key] ?? (typed[key]?.trim() === saved ? typed[key] : saved); + }; return (
edit({ name: e.target.value })} /> @@ -65,7 +74,7 @@ export function ProfileFields({ profile, basic = false }: { profile: Profile; ba id="profile-research" rows={3} placeholder="比如:自然语言处理,对话系统和大模型的可解释性" - value={typed.researchInterests ?? profile.researchInterests ?? ""} + value={shown("researchInterests")} onChange={(e) => edit({ researchInterests: e.target.value })} />

分析匹配度时,和材料一起作为依据。

@@ -76,7 +85,7 @@ export function ProfileFields({ profile, basic = false }: { profile: Profile; ba id="profile-rewrite" rows={3} placeholder="比如:语气正式但不生硬,300 字以内,不用感叹号" - value={typed.rewritePreference ?? profile.rewritePreference ?? ""} + value={shown("rewritePreference")} onChange={(e) => edit({ rewritePreference: e.target.value })} />

AI 写草稿和改写时照着做。

diff --git a/ui/src/pages/welcome.tsx b/ui/src/pages/welcome.tsx index 5f218e3ca..f22411033 100644 --- a/ui/src/pages/welcome.tsx +++ b/ui/src/pages/welcome.tsx @@ -78,7 +78,7 @@ export function WelcomePage() { done: profile.data.name !== null, result: profile.data.name, intro: "AI 写信时用你的姓名落款,分析匹配度时参考你的意向研究方向。", - body: , + body: , }, { title: "发信邮箱", From f2a6e3612c0d1cae1525b95fefa0314bc8777cbe Mon Sep 17 00:00:00 2001 From: JunieXD <1240840076@qq.com> Date: Sat, 31 Oct 2026 19:44:24 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BF=9D=E5=AD=98=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E8=B5=84=E6=96=99=E6=97=B6=E6=8C=89=E6=9C=80=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=8A=B6=E6=80=81=E5=86=B3=E5=AE=9A=E9=87=8D?= =?UTF-8?q?=E5=8F=91=E5=93=AA=E4=BA=9B=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一次保存刚成功、页面还没重新渲染时,输入不再把已保存的字段一起重发。 --- ui/src/pages/profile.test.tsx | 39 ++++++++++++++++++++++++++++++++++- ui/src/pages/profile.tsx | 4 +++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/ui/src/pages/profile.test.tsx b/ui/src/pages/profile.test.tsx index 5fdeae40f..49b9f1109 100644 --- a/ui/src/pages/profile.test.tsx +++ b/ui/src/pages/profile.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment happy-dom -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { defaultScheduler, notifyManager, QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, type ReactNode } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vitest"; @@ -182,3 +182,40 @@ it("saves only what is typed and not saved yet, and shows what an Agent saved", container.remove(); } }); + +it("does not send a field again whose save finished before the page heard of it", async () => { + const initial: Profile = { name: "林晓", researchInterests: null, rewritePreference: null, onboarded: false }; + remote.profile = initial; + remote.save.mockReset().mockImplementation(async (form) => { + remote.profile = { ...remote.profile, ...form }; + return remote.profile; + }); + const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity } } }); + client.setQueryData(["getProfile"], initial); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const held: (() => void)[] = []; + try { + await act(async () => + root.render( + + + , + ), + ); + // The page renders again only when told, as when the next key comes before that. + notifyManager.setScheduler((notify) => held.push(notify)); + await edit("视觉", "#profile-research"); + await vi.waitFor(() => expect(client.getMutationCache().getAll().at(-1)?.state.status).toBe("success")); + remote.profile = { ...remote.profile, researchInterests: "多模态学习" }; + await edit("林晓晓"); + expect(remote.save).toHaveBeenLastCalledWith({ name: "林晓晓" }); + } finally { + notifyManager.setScheduler(defaultScheduler); + for (const notify of held) notify(); + await act(async () => root.unmount()); + client.clear(); + container.remove(); + } +}); diff --git a/ui/src/pages/profile.tsx b/ui/src/pages/profile.tsx index a96097046..ae1646968 100644 --- a/ui/src/pages/profile.tsx +++ b/ui/src/pages/profile.tsx @@ -53,7 +53,9 @@ export function ProfileFields({ basic = false }: { basic?: boolean }) { if (!profile) return null; function edit(change: ProfileForm) { setTyped({ ...typed, ...change }); - save.mutate({ ...unsaved, ...change }); + // The last save may have finished since this render. + const done = queryClient.getMutationCache().findAll({ mutationKey }).at(-1)?.state.status === "success"; + save.mutate({ ...(done ? {} : unsaved), ...change }); } const shown = (key: keyof ProfileForm) => { const saved = profile[key] ?? ""; From b5c66892347487e9dd2c128cb85db05534443d06 Mon Sep 17 00:00:00 2001 From: JunieXD <1240840076@qq.com> Date: Sat, 31 Oct 2026 19:52:12 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E5=86=99=E6=98=8E=E4=B8=AA=E4=BA=BA?= =?UTF-8?q?=E8=B5=84=E6=96=99=E8=A1=A8=E5=8D=95=E5=90=8C=E6=97=B6=E5=8F=AA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=B8=80=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/pages/profile.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/src/pages/profile.tsx b/ui/src/pages/profile.tsx index ae1646968..faf380c72 100644 --- a/ui/src/pages/profile.tsx +++ b/ui/src/pages/profile.tsx @@ -53,7 +53,8 @@ export function ProfileFields({ basic = false }: { basic?: boolean }) { if (!profile) return null; function edit(change: ProfileForm) { setTyped({ ...typed, ...change }); - // The last save may have finished since this render. + // The last save may have finished since this render. It is still the one this render saw: the + // settings page and the first-run guide never show the form together, so only this `edit` saves. const done = queryClient.getMutationCache().findAll({ mutationKey }).at(-1)?.state.status === "success"; save.mutate({ ...(done ? {} : unsaved), ...change }); }