Implement semantic embeddings and duplicate review
This commit is contained in:
182
crates/bds-ui/src/views/duplicates.rs
Normal file
182
crates/bds-ui/src/views/duplicates.rs
Normal file
@@ -0,0 +1,182 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bds_core::engine::embedding::DuplicateSearchResult;
|
||||
use bds_core::i18n::UiLocale;
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, checkbox, column, container, row, scrollable, text};
|
||||
use iced::{Color, Element, Length};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::{t, tw};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct DuplicatesState {
|
||||
pub enabled: bool,
|
||||
pub is_loading: bool,
|
||||
pub has_run: bool,
|
||||
pub page: usize,
|
||||
pub result: DuplicateSearchResult,
|
||||
pub selected: HashSet<(String, String)>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub fn view(state: &DuplicatesState, locale: UiLocale) -> Element<'_, Message> {
|
||||
let refresh = if state.is_loading {
|
||||
button(text(t(locale, "duplicates.searching")).size(13)).style(inputs::secondary_button)
|
||||
} else {
|
||||
button(text(t(locale, "common.refresh")).size(13))
|
||||
.on_press(Message::DuplicatesRefresh)
|
||||
.style(inputs::secondary_button)
|
||||
}
|
||||
.padding([6, 16]);
|
||||
|
||||
let dismiss_checked = if state.selected.is_empty() || state.is_loading {
|
||||
button(text(t(locale, "duplicates.dismissChecked")).size(13))
|
||||
.style(inputs::secondary_button)
|
||||
} else {
|
||||
button(
|
||||
text(tw(
|
||||
locale,
|
||||
"duplicates.dismissCheckedCount",
|
||||
&[("count", &state.selected.len().to_string())],
|
||||
))
|
||||
.size(13),
|
||||
)
|
||||
.on_press(Message::DuplicatesDismissSelected)
|
||||
.style(inputs::primary_button)
|
||||
}
|
||||
.padding([6, 16]);
|
||||
|
||||
let toolbar = inputs::toolbar(
|
||||
vec![
|
||||
text(t(locale, "duplicates.title"))
|
||||
.size(20)
|
||||
.shaping(Shaping::Advanced)
|
||||
.into(),
|
||||
text(tw(
|
||||
locale,
|
||||
"duplicates.count",
|
||||
&[("count", &state.result.pairs.len().to_string())],
|
||||
))
|
||||
.size(12)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
.into(),
|
||||
],
|
||||
vec![
|
||||
button(text(t(locale, "duplicates.checkAll")).size(13))
|
||||
.on_press(Message::DuplicatesCheckAll)
|
||||
.padding([6, 12])
|
||||
.style(inputs::secondary_button)
|
||||
.into(),
|
||||
button(text(t(locale, "duplicates.uncheckAll")).size(13))
|
||||
.on_press(Message::DuplicatesUncheckAll)
|
||||
.padding([6, 12])
|
||||
.style(inputs::secondary_button)
|
||||
.into(),
|
||||
dismiss_checked.into(),
|
||||
refresh.into(),
|
||||
],
|
||||
);
|
||||
|
||||
let body: Element<'_, Message> = if !state.enabled {
|
||||
inputs::card(
|
||||
text(t(locale, "duplicates.disabled"))
|
||||
.size(14)
|
||||
.color(inputs::LABEL_COLOR),
|
||||
)
|
||||
.into()
|
||||
} else if state.is_loading && !state.has_run {
|
||||
inputs::card(
|
||||
text(t(locale, "duplicates.searching"))
|
||||
.size(14)
|
||||
.color(inputs::LABEL_COLOR),
|
||||
)
|
||||
.into()
|
||||
} else if let Some(error) = &state.error {
|
||||
inputs::card(
|
||||
text(error.clone())
|
||||
.size(14)
|
||||
.color(Color::from_rgb(0.90, 0.38, 0.38)),
|
||||
)
|
||||
.into()
|
||||
} else if state.has_run && state.result.pairs.is_empty() {
|
||||
inputs::card(
|
||||
text(t(locale, "duplicates.empty"))
|
||||
.size(14)
|
||||
.color(inputs::LABEL_COLOR),
|
||||
)
|
||||
.into()
|
||||
} else {
|
||||
let mut pairs = column!().spacing(8);
|
||||
for pair in &state.result.pairs {
|
||||
let key = (pair.post_id_a.clone(), pair.post_id_b.clone());
|
||||
let checked = state.selected.contains(&key);
|
||||
let badge = if pair.exact_match {
|
||||
t(locale, "duplicates.exactMatch")
|
||||
} else {
|
||||
format!("{:.1}%", pair.similarity * 100.0)
|
||||
};
|
||||
pairs = pairs.push(inputs::card(
|
||||
row![
|
||||
checkbox("", checked)
|
||||
.on_toggle({
|
||||
let a = pair.post_id_a.clone();
|
||||
let b = pair.post_id_b.clone();
|
||||
move |_| Message::DuplicatesToggle(a.clone(), b.clone())
|
||||
})
|
||||
.size(16),
|
||||
button(
|
||||
text(pair.title_a.clone())
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::DuplicatesOpenPost(pair.post_id_a.clone()))
|
||||
.padding([5, 8])
|
||||
.style(inputs::disclosure_button),
|
||||
text("→").size(14).color(inputs::LABEL_COLOR),
|
||||
button(
|
||||
text(pair.title_b.clone())
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::DuplicatesOpenPost(pair.post_id_b.clone()))
|
||||
.padding([5, 8])
|
||||
.style(inputs::disclosure_button),
|
||||
Space::with_width(Length::Fill),
|
||||
text(badge).size(12).color(if pair.exact_match {
|
||||
Color::from_rgb(0.96, 0.68, 0.28)
|
||||
} else {
|
||||
Color::from_rgb(0.55, 0.76, 0.92)
|
||||
}),
|
||||
button(text(t(locale, "duplicates.dismiss")).size(12))
|
||||
.on_press(Message::DuplicatesDismiss(
|
||||
pair.post_id_a.clone(),
|
||||
pair.post_id_b.clone()
|
||||
))
|
||||
.padding([5, 12])
|
||||
.style(inputs::secondary_button),
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Alignment::Center),
|
||||
));
|
||||
}
|
||||
if state.result.has_more {
|
||||
pairs = pairs.push(
|
||||
button(text(t(locale, "duplicates.showMore")).size(13))
|
||||
.on_press(Message::DuplicatesShowMore)
|
||||
.padding([7, 16])
|
||||
.style(inputs::secondary_button),
|
||||
);
|
||||
}
|
||||
scrollable(container(pairs).padding(2))
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
};
|
||||
|
||||
container(column![toolbar, body].spacing(12))
|
||||
.padding(16)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub mod activity_bar;
|
||||
pub mod chat_surfaces;
|
||||
pub mod chat_view;
|
||||
pub mod dashboard;
|
||||
pub mod duplicates;
|
||||
pub mod git;
|
||||
pub mod import_editor;
|
||||
pub mod media_editor;
|
||||
|
||||
@@ -76,6 +76,7 @@ pub struct PostEditorState {
|
||||
pub quick_actions_open: bool,
|
||||
pub tags_input: String,
|
||||
pub categories_input: String,
|
||||
pub semantic_tag_suggestions: Vec<String>,
|
||||
pub active_language: String,
|
||||
pub canonical_language: String,
|
||||
pub blog_languages: Vec<String>,
|
||||
@@ -123,6 +124,7 @@ impl Clone for PostEditorState {
|
||||
quick_actions_open: self.quick_actions_open,
|
||||
tags_input: self.tags_input.clone(),
|
||||
categories_input: self.categories_input.clone(),
|
||||
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
|
||||
active_language: self.active_language.clone(),
|
||||
canonical_language: self.canonical_language.clone(),
|
||||
blog_languages: self.blog_languages.clone(),
|
||||
@@ -188,6 +190,7 @@ impl PostEditorState {
|
||||
quick_actions_open: false,
|
||||
tags_input: String::new(),
|
||||
categories_input: String::new(),
|
||||
semantic_tag_suggestions: Vec::new(),
|
||||
active_language: canonical_lang.clone(),
|
||||
canonical_language: canonical_lang,
|
||||
blog_languages: blog_languages.to_vec(),
|
||||
@@ -342,6 +345,7 @@ pub enum PostEditorMsg {
|
||||
SwitchLanguage(String),
|
||||
TagsInputChanged(String),
|
||||
TagsInputSubmit,
|
||||
AddSuggestedTag(String),
|
||||
RemoveTag(String),
|
||||
CategoriesInputChanged(String),
|
||||
CategoriesInputSubmit,
|
||||
@@ -619,6 +623,28 @@ pub fn view<'a>(
|
||||
Message::PostEditor(PostEditorMsg::TagsInputSubmit),
|
||||
|tag| Message::PostEditor(PostEditorMsg::RemoveTag(tag)),
|
||||
);
|
||||
let semantic_tags: Element<'a, Message> = if state.semantic_tag_suggestions.is_empty() {
|
||||
Space::new(0, 0).into()
|
||||
} else {
|
||||
let mut chips = row![
|
||||
text(t(locale, "editor.semanticTagSuggestions"))
|
||||
.size(11)
|
||||
.color(inputs::LABEL_COLOR)
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Alignment::Center);
|
||||
for tag in &state.semantic_tag_suggestions {
|
||||
chips = chips.push(
|
||||
button(text(format!("+ {tag}")).size(11))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::AddSuggestedTag(
|
||||
tag.clone(),
|
||||
)))
|
||||
.padding([4, 8])
|
||||
.style(inputs::secondary_button),
|
||||
);
|
||||
}
|
||||
chips.into()
|
||||
};
|
||||
|
||||
// Categories chip input
|
||||
let categories_section = chip_input_field(
|
||||
@@ -776,6 +802,7 @@ pub fn view<'a>(
|
||||
meta_row1,
|
||||
meta_row2,
|
||||
tags_section,
|
||||
semantic_tags,
|
||||
categories_section,
|
||||
outlinks_section,
|
||||
backlinks_section,
|
||||
|
||||
@@ -18,6 +18,7 @@ use crate::views::{
|
||||
activity_bar,
|
||||
chat_view::{self, ChatEditorState},
|
||||
dashboard::DashboardState,
|
||||
duplicates::{self, DuplicatesState},
|
||||
git::{self, GitDiffState, GitUiState},
|
||||
media_editor::{self, MediaEditorState},
|
||||
metadata_diff::{self, MetadataDiffState},
|
||||
@@ -128,6 +129,7 @@ pub fn view<'a>(
|
||||
settings_state: Option<&'a SettingsViewState>,
|
||||
dashboard_state: Option<&'a DashboardState>,
|
||||
site_validation_state: &'a SiteValidationState,
|
||||
duplicates_state: &'a DuplicatesState,
|
||||
metadata_diff_state: &'a MetadataDiffState,
|
||||
translation_validation_state: &'a TranslationValidationState,
|
||||
git_state: &'a GitUiState,
|
||||
@@ -158,6 +160,7 @@ pub fn view<'a>(
|
||||
settings_state,
|
||||
dashboard_state,
|
||||
site_validation_state,
|
||||
duplicates_state,
|
||||
metadata_diff_state,
|
||||
translation_validation_state,
|
||||
git_diffs,
|
||||
@@ -400,6 +403,7 @@ fn route_content_area<'a>(
|
||||
settings_state: Option<&'a SettingsViewState>,
|
||||
dashboard_state: Option<&'a DashboardState>,
|
||||
site_validation_state: &'a SiteValidationState,
|
||||
duplicates_state: &'a DuplicatesState,
|
||||
metadata_diff_state: &'a MetadataDiffState,
|
||||
translation_validation_state: &'a TranslationValidationState,
|
||||
git_diffs: &'a HashMap<String, GitDiffState>,
|
||||
@@ -489,6 +493,7 @@ fn route_content_area<'a>(
|
||||
}
|
||||
}
|
||||
ContentRoute::SiteValidation => site_validation::view(site_validation_state, locale),
|
||||
ContentRoute::FindDuplicates => duplicates::view(duplicates_state, locale),
|
||||
ContentRoute::MetadataDiff => metadata_diff::view(metadata_diff_state, locale),
|
||||
ContentRoute::TranslationValidation => {
|
||||
translation_validation::view(translation_validation_state, locale)
|
||||
@@ -524,6 +529,7 @@ enum ContentRoute<'a> {
|
||||
Tags,
|
||||
Settings,
|
||||
SiteValidation,
|
||||
FindDuplicates,
|
||||
MetadataDiff,
|
||||
TranslationValidation,
|
||||
GitDiff(&'a str),
|
||||
@@ -612,11 +618,11 @@ fn route_kind<'a>(
|
||||
TabType::SiteValidation => ContentRoute::SiteValidation,
|
||||
TabType::MetadataDiff => ContentRoute::MetadataDiff,
|
||||
TabType::GitDiff => ContentRoute::GitDiff(tab_id),
|
||||
TabType::FindDuplicates => ContentRoute::FindDuplicates,
|
||||
TabType::Style
|
||||
| TabType::MenuEditor
|
||||
| TabType::Documentation
|
||||
| TabType::ApiDocumentation
|
||||
| TabType::FindDuplicates => ContentRoute::Placeholder(&tab.title),
|
||||
| TabType::ApiDocumentation => ContentRoute::Placeholder(&tab.title),
|
||||
TabType::TranslationValidation => ContentRoute::TranslationValidation,
|
||||
}
|
||||
}
|
||||
@@ -678,7 +684,6 @@ mod tests {
|
||||
TabType::MenuEditor,
|
||||
TabType::Documentation,
|
||||
TabType::ApiDocumentation,
|
||||
TabType::FindDuplicates,
|
||||
];
|
||||
|
||||
for tab_type in unsupported {
|
||||
@@ -703,6 +708,35 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_tab_routes_to_real_review_surface() {
|
||||
let tabs = vec![tab(
|
||||
"find_duplicates",
|
||||
TabType::FindDuplicates,
|
||||
"Duplicates",
|
||||
)];
|
||||
let empty_posts = HashMap::new();
|
||||
let empty_media = HashMap::new();
|
||||
let empty_templates = HashMap::new();
|
||||
let empty_scripts = HashMap::new();
|
||||
let empty_imports = HashMap::new();
|
||||
let site_validation = SiteValidationState::default();
|
||||
let route = route_kind(
|
||||
&tabs,
|
||||
Some("find_duplicates"),
|
||||
&empty_posts,
|
||||
&empty_media,
|
||||
&empty_templates,
|
||||
&empty_scripts,
|
||||
&empty_imports,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&site_validation,
|
||||
);
|
||||
assert!(matches!(route, ContentRoute::FindDuplicates));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_diff_tab_routes_to_real_view() {
|
||||
let empty_posts = HashMap::new();
|
||||
|
||||
Reference in New Issue
Block a user