Feature/semantic similarity (#36)

* fix: mixed up migrations

* feat: semantic similarity first take

* feat: semantic similarity first round of fixes

* feat: more work on making semantic similarity work properly

* feat: getPostBySlug for the AI

* feat: show similarity in post-link-insert-modal

* chore: remove done doc

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-05 22:05:32 +01:00
committed by GitHub
parent 8ac8305e01
commit 7e1e8981a3
64 changed files with 6429 additions and 499 deletions

View File

@@ -22,10 +22,9 @@ interface ConfirmDeleteModalProps {
export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details, onClose }) => {
const { t: tr } = useI18n();
if (!details) return null;
const handleConfirm = useCallback(async () => {
await details.onConfirm();
await details?.onConfirm();
onClose();
}, [details, onClose]);
@@ -35,6 +34,8 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
}
}, [onClose]);
if (!details) return null;
const hasReferences = details.references.length > 0;
return (

View File

@@ -0,0 +1,190 @@
.duplicates-view {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow: auto;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
}
.duplicates-view-header h2 {
margin: 0 0 4px 0;
font-size: 1.1rem;
}
.duplicates-view-header p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 0.875rem;
}
.duplicates-view-actions {
display: flex;
gap: 8px;
}
.duplicates-view-refresh {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.duplicates-view-refresh:hover:not(:disabled) {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.duplicates-view-refresh:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.duplicates-view-status {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 0.875rem;
}
.duplicates-view-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.duplicate-pair {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border));
border-radius: 6px;
}
.duplicate-pair-posts {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.duplicate-pair-post {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--vscode-textLink-foreground);
font-size: 0.875rem;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.duplicate-pair-post:hover {
color: var(--vscode-textLink-activeForeground);
text-decoration: underline;
}
.duplicate-pair-separator {
font-size: 0.75rem;
color: var(--vscode-descriptionForeground);
padding-left: 2px;
}
.duplicate-pair-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
flex-shrink: 0;
}
.duplicate-pair-score {
font-size: 0.8rem;
font-weight: 600;
color: var(--vscode-charts-orange, #f8ae4a);
white-space: nowrap;
}
.duplicate-pair-score--exact {
color: var(--vscode-errorForeground, #f44747);
}
.duplicate-pair--exact {
border-color: var(--vscode-errorForeground, #f44747);
}
.duplicate-pair-dismiss {
background: none;
border: 1px solid var(--vscode-button-border, var(--vscode-panel-border));
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}
.duplicate-pair-dismiss:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-foreground);
}
.duplicates-view-not-enabled {
padding: 32px;
text-align: center;
color: var(--vscode-descriptionForeground);
}
.duplicates-view-count {
margin: 0;
font-size: 0.8rem;
color: var(--vscode-descriptionForeground);
}
.duplicates-view-show-more {
align-self: center;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
padding: 6px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.duplicates-view-show-more:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.duplicates-view-dismiss-checked {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.duplicates-view-dismiss-checked:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
.duplicates-view-dismiss-checked:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.duplicate-pair-checkbox {
flex-shrink: 0;
cursor: pointer;
accent-color: var(--vscode-focusBorder);
}

View File

@@ -0,0 +1,249 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useAppStore } from '../../store';
import { openEntityTab } from '../../navigation/tabPolicy';
import { useI18n } from '../../i18n';
import { getPersistedDuplicatesResult, removeDismissedPair, removeDismissedPairs } from '../../navigation/duplicatesPersistence';
import type { DuplicatePair } from '../../../main/shared/electronApi';
import './DuplicatesView.css';
const PAGE_SIZE = 500;
function pairKey(pair: DuplicatePair): string {
return `${pair.postA.id}::${pair.postB.id}`;
}
export const DuplicatesView: React.FC = () => {
const { t } = useI18n();
const { openTab, activeProject } = useAppStore();
const [pairs, setPairs] = useState<DuplicatePair[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
const [isDismissing, setIsDismissing] = useState(false);
const [notEnabled, setNotEnabled] = useState(false);
const [checked, setChecked] = useState(false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const projectId = activeProject?.id;
// Load persisted results (or check if feature is enabled)
useEffect(() => {
if (!projectId) return;
let cancelled = false;
(async () => {
const metadata = await window.electronAPI?.meta.getProjectMetadata();
if (cancelled) return;
if (!metadata?.semanticSimilarityEnabled) {
setNotEnabled(true);
setPairs([]);
setChecked(true);
return;
}
setNotEnabled(false);
const persisted = getPersistedDuplicatesResult(projectId);
if (persisted) {
setPairs(persisted);
}
setChecked(true);
})();
return () => { cancelled = true; };
}, [projectId]);
// Listen for search result updates from the background task
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
if (!projectId || detail?.projectId !== projectId) return;
const persisted = getPersistedDuplicatesResult(projectId);
setPairs(persisted ?? []);
setIsSearching(false);
setVisibleCount(PAGE_SIZE);
setCheckedKeys(new Set());
};
window.addEventListener('bds:duplicates-updated', handler);
return () => window.removeEventListener('bds:duplicates-updated', handler);
}, [projectId]);
const visiblePairs = useMemo(() => pairs.slice(0, visibleCount), [pairs, visibleCount]);
const hasMore = visibleCount < pairs.length;
const handleRunSearch = useCallback(() => {
setIsSearching(true);
window.electronAPI?.embeddings.runDuplicateSearch(0.92);
}, []);
const handleDismiss = useCallback(async (postIdA: string, postIdB: string) => {
try {
await window.electronAPI?.embeddings.dismissPair(postIdA, postIdB);
setPairs(prev => prev.filter(p => !(p.postA.id === postIdA && p.postB.id === postIdB)));
setCheckedKeys(prev => { const next = new Set(prev); next.delete(`${postIdA}::${postIdB}`); return next; });
if (projectId) {
removeDismissedPair(projectId, postIdA, postIdB);
}
} catch (err) {
console.error('Failed to dismiss duplicate pair:', err);
}
}, [projectId]);
const handleDismissChecked = useCallback(async () => {
if (checkedKeys.size === 0) return;
setIsDismissing(true);
const pairIds: Array<[string, string]> = [];
for (const key of checkedKeys) {
const [a, b] = key.split('::') as [string, string];
pairIds.push([a, b]);
}
try {
await window.electronAPI?.embeddings.dismissPairs(pairIds);
setPairs(prev => prev.filter(p => !checkedKeys.has(pairKey(p))));
if (projectId) {
removeDismissedPairs(projectId, pairIds);
}
setCheckedKeys(new Set());
} catch (err) {
console.error('Failed to dismiss pairs:', err);
} finally {
setIsDismissing(false);
}
}, [checkedKeys, projectId]);
const handleToggleCheck = useCallback((key: string) => {
setCheckedKeys(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}, []);
const handleCheckAll = useCallback(() => {
setCheckedKeys(new Set(visiblePairs.map(pairKey)));
}, [visiblePairs]);
const handleUncheckAll = useCallback(() => {
setCheckedKeys(new Set());
}, []);
const handleOpenPost = useCallback((postId: string) => {
openEntityTab(openTab, 'post', postId, 'pin');
}, [openTab]);
const handleShowMore = useCallback(() => {
setVisibleCount(prev => prev + PAGE_SIZE);
}, []);
const hasCachedResults = pairs.length > 0 || (checked && getPersistedDuplicatesResult(projectId ?? '') !== null);
return (
<div className="duplicates-view">
<div className="duplicates-view-header">
<h2>{t('duplicatesView.title')}</h2>
<p>{t('duplicatesView.description')}</p>
</div>
{notEnabled && (
<p className="duplicates-view-not-enabled">{t('duplicatesView.notEnabled')}</p>
)}
{!notEnabled && checked && (
<div className="duplicates-view-actions">
<button
type="button"
className="duplicates-view-refresh"
onClick={handleRunSearch}
disabled={isSearching}
>
{t('duplicatesView.refresh')}
</button>
{pairs.length > 0 && (
<>
<button type="button" className="duplicates-view-refresh" onClick={handleCheckAll}>
{t('duplicatesView.checkAll')}
</button>
<button type="button" className="duplicates-view-refresh" onClick={handleUncheckAll} disabled={checkedKeys.size === 0}>
{t('duplicatesView.uncheckAll')}
</button>
<button
type="button"
className="duplicates-view-dismiss-checked"
onClick={handleDismissChecked}
disabled={checkedKeys.size === 0 || isDismissing}
>
{t('duplicatesView.dismissChecked', { count: checkedKeys.size })}
</button>
</>
)}
</div>
)}
{!notEnabled && isSearching && (
<p className="duplicates-view-status">{t('duplicatesView.loading')}</p>
)}
{!notEnabled && !isSearching && checked && !hasCachedResults && (
<p className="duplicates-view-status">{t('duplicatesView.empty')}</p>
)}
{!notEnabled && !isSearching && pairs.length > 0 && (
<div className="duplicates-view-list">
<p className="duplicates-view-count">
{t('duplicatesView.count', { count: pairs.length })}
</p>
{visiblePairs.map(pair => {
const key = pairKey(pair);
return (
<div key={key} className={`duplicate-pair${pair.exactMatch ? ' duplicate-pair--exact' : ''}`}>
<input
type="checkbox"
className="duplicate-pair-checkbox"
checked={checkedKeys.has(key)}
onChange={() => handleToggleCheck(key)}
/>
<div className="duplicate-pair-posts">
<button
type="button"
className="duplicate-pair-post"
onClick={() => handleOpenPost(pair.postA.id)}
title={t('duplicatesView.openPost')}
>
{pair.postA.title || pair.postA.slug}
</button>
<span className="duplicate-pair-separator"></span>
<button
type="button"
className="duplicate-pair-post"
onClick={() => handleOpenPost(pair.postB.id)}
title={t('duplicatesView.openPost')}
>
{pair.postB.title || pair.postB.slug}
</button>
</div>
<div className="duplicate-pair-meta">
<span className={`duplicate-pair-score${pair.exactMatch ? ' duplicate-pair-score--exact' : ''}`}>
{pair.exactMatch
? t('duplicatesView.exactMatch')
: t('duplicatesView.similarity', { value: Math.round(pair.similarity * 100) })}
</span>
<button
type="button"
className="duplicate-pair-dismiss"
onClick={() => void handleDismiss(pair.postA.id, pair.postB.id)}
>
{t('duplicatesView.dismiss')}
</button>
</div>
</div>
);
})}
{hasMore && (
<button
type="button"
className="duplicates-view-show-more"
onClick={handleShowMore}
>
{t('duplicatesView.showMore')}
</button>
)}
</div>
)}
</div>
);
};

View File

@@ -21,6 +21,7 @@ import { DocumentationView } from '../DocumentationView/DocumentationView';
import { SiteValidationView } from '../SiteValidationView';
import { ScriptsView } from '../ScriptsView/ScriptsView';
import { TemplatesView } from '../TemplatesView/TemplatesView';
import { DuplicatesView } from '../DuplicatesView/DuplicatesView';
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
@@ -794,6 +795,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
value={tags}
onChange={setTags}
placeholder={tr('editor.placeholder.tags')}
postId={postId}
/>
</div>
<div className="editor-field">
@@ -1024,6 +1026,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
onClose={() => setShowPostSearch(false)}
currentPostTags={tags}
currentPostCategories={selectedCategories}
currentPostId={postId}
/>
)}
@@ -1903,6 +1906,7 @@ export const Editor: React.FC = () => {
/>
),
'site-validation': () => <SiteValidationView />,
'find-duplicates': () => <DuplicatesView />,
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
templates: () => <TemplatesView templateId={editorRoute.tabId} />,
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),

View File

@@ -140,6 +140,18 @@
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
}
.insert-modal-similarity-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
font-size: 11px;
font-weight: 500;
border-radius: 4px;
background: var(--color-bg-muted, rgba(255, 255, 255, 0.08));
color: var(--color-text-muted, #888);
vertical-align: middle;
}
.insert-modal-external {
padding: 20px;
display: flex;

View File

@@ -42,6 +42,7 @@ interface InsertModalProps {
initialText?: string; // Selected text in editor
currentPostTags?: string[];
currentPostCategories?: string[];
currentPostId?: string; // For semantic "related posts" suggestions
}
function isPostResult(result: SearchResult): result is PostSearchResult {
@@ -60,6 +61,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
initialText = '',
currentPostTags,
currentPostCategories,
currentPostId,
}) => {
const { t: tr } = useI18n();
const openTabInBackground = useAppStore((s) => s.openTabInBackground);
@@ -74,6 +76,42 @@ export const InsertModal: React.FC<InsertModalProps> = ({
const [isCreating, setIsCreating] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const externalUrlRef = useRef<HTMLInputElement>(null);
const [relatedPosts, setRelatedPosts] = useState<PostSearchResult[]>([]);
const [isLoadingRelated, setIsLoadingRelated] = useState(false);
const [similarityMap, setSimilarityMap] = useState<Record<string, number>>({});
// Load related posts via semantic similarity when idle (query < 2 chars)
useEffect(() => {
if (mode !== 'link' || !currentPostId || activeTab !== 'internal' || query.length >= 2) {
setRelatedPosts([]);
return;
}
let cancelled = false;
setIsLoadingRelated(true);
(async () => {
try {
const similar = await window.electronAPI.embeddings.findSimilar(currentPostId, 5);
if (cancelled || similar.length === 0) { setRelatedPosts([]); return; }
const posts = await Promise.all(similar.map(s => window.electronAPI.posts.get(s.postId)));
if (!cancelled) {
// Store similarity scores
const simMap: Record<string, number> = {};
for (const s of similar) { simMap[s.postId] = s.similarity; }
setSimilarityMap(simMap);
setRelatedPosts(
posts.filter((p): p is NonNullable<typeof p> => p != null).map(p => ({
id: p.id, title: p.title, slug: p.slug, excerpt: p.excerpt,
})),
);
}
} catch {
if (!cancelled) setRelatedPosts([]);
} finally {
if (!cancelled) setIsLoadingRelated(false);
}
})();
return () => { cancelled = true; };
}, [currentPostId, mode, activeTab, query]);
// Whether to show the "Create post" option
const showCreateOption = mode === 'link' &&
@@ -124,6 +162,22 @@ export const InsertModal: React.FC<InsertModalProps> = ({
return () => clearTimeout(timeoutId);
}, [query, mode, activeTab]);
// Fetch similarity scores for search results relative to current post
useEffect(() => {
if (mode !== 'link' || !currentPostId || results.length === 0) return;
const postResults = results.filter(isPostResult);
if (postResults.length === 0) return;
let cancelled = false;
(async () => {
try {
const targetIds = postResults.map(r => r.id);
const sims = await window.electronAPI.embeddings.computeSimilarities(currentPostId, targetIds);
if (!cancelled) setSimilarityMap(prev => ({ ...prev, ...sims }));
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [results, currentPostId, mode]);
// Handle creating a new post from the search query
const handleCreatePost = useCallback(async () => {
const title = query.trim();
@@ -284,12 +338,43 @@ export const InsertModal: React.FC<InsertModalProps> = ({
<div className="insert-modal-status">{tr('insert.status.searching')}</div>
)}
{!isSearching && query.length < 2 && (
{!isSearching && query.length < 2 && relatedPosts.length === 0 && !isLoadingRelated && (
<div className="insert-modal-status">
{tr('insert.status.typeMore')}
</div>
)}
{!isSearching && query.length < 2 && isLoadingRelated && (
<div className="insert-modal-status">{tr('insert.status.loadingRelated')}</div>
)}
{!isSearching && query.length < 2 && relatedPosts.length > 0 && (
<>
<div className="insert-modal-section-label">{tr('insert.section.relatedPosts')}</div>
{relatedPosts.map((result, index) => (
<div
key={result.id}
className={`insert-modal-result-item ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelectResult(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="insert-modal-result-title">
{result.title}
{similarityMap[result.id] != null && (
<span className="insert-modal-similarity-badge">{Math.round(similarityMap[result.id]! * 100)}%</span>
)}
</div>
{result.excerpt && (
<div className="insert-modal-result-excerpt">
{result.excerpt.length > 120 ? result.excerpt.substring(0, 120) + '...' : result.excerpt}
</div>
)}
<div className="insert-modal-result-path">/posts/{result.slug}</div>
</div>
))}
</>
)}
{!isSearching && query.length >= 2 && results.length === 0 && !showCreateOption && (
<div className="insert-modal-status">
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
@@ -305,7 +390,12 @@ export const InsertModal: React.FC<InsertModalProps> = ({
>
{isPostResult(result) ? (
<>
<div className="insert-modal-result-title">{result.title}</div>
<div className="insert-modal-result-title">
{result.title}
{currentPostId && similarityMap[result.id] != null && (
<span className="insert-modal-similarity-badge">{Math.round(similarityMap[result.id]! * 100)}%</span>
)}
</div>
{result.excerpt && (
<div className="insert-modal-result-excerpt">
{result.excerpt.length > 120

View File

@@ -226,6 +226,7 @@ export const SettingsView: React.FC = () => {
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article');
const [projectPythonRuntimeMode, setProjectPythonRuntimeMode] = useState<'webworker' | 'main-thread'>('webworker');
const [semanticSimilarityEnabled, setSemanticSimilarityEnabled] = useState(false);
// Post categories management
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
@@ -314,6 +315,9 @@ export const SettingsView: React.FC = () => {
const incomingPythonRuntimeMode = (metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode;
setProjectPythonRuntimeMode(incomingPythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker');
const incomingSemanticSimilarity = (metadata as { semanticSimilarityEnabled?: unknown } | null)?.semanticSimilarityEnabled;
setSemanticSimilarityEnabled(incomingSemanticSimilarity === true);
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
setCategoryMetadata((current) => {
@@ -545,6 +549,7 @@ export const SettingsView: React.FC = () => {
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined,
pythonRuntimeMode: projectPythonRuntimeMode,
semanticSimilarityEnabled,
categoryMetadata,
});
}
@@ -592,7 +597,7 @@ export const SettingsView: React.FC = () => {
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode', 'ollama', 'lmstudio', 'lm studio', 'local'];
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution'];
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution', 'semantic', 'similarity', 'embedding', 'ai', 'search', 'duplicate'];
const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync'];
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem'];
const mcpKeywords = ['mcp', 'server', 'agent', 'claude', 'copilot', 'gemini', 'opencode', 'model context protocol', 'coding', 'configuration'];
@@ -1823,6 +1828,23 @@ export const SettingsView: React.FC = () => {
<option value="main-thread">{t('settings.technology.pythonRuntimeMode.mainThread')}</option>
</select>
</SettingRow>
<SettingRow
id="semantic-similarity-enabled"
label={t('settings.technology.semanticSimilarityLabel')}
description={t('settings.technology.semanticSimilarityDescription')}
>
<input
id="semantic-similarity-enabled"
type="checkbox"
checked={semanticSimilarityEnabled}
onChange={(e) => {
const checked = e.target.checked;
setSemanticSimilarityEnabled(checked);
window.electronAPI?.meta.updateProjectMetadata({ semanticSimilarityEnabled: checked }).catch(() => {});
}}
/>
</SettingRow>
</SettingSection>
);

View File

@@ -87,6 +87,10 @@ const getTabTitle = (
return tr('siteValidation.tabTitle');
}
if (tab.type === 'find-duplicates') {
return tr('duplicatesView.tabTitle');
}
if (tab.type === 'scripts') {
return scriptTitles.get(tab.id) || tr('tabBar.scripts');
}
@@ -180,6 +184,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
</svg>
);
case 'find-duplicates':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5V5H9V2.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7H11v2h2.5A1.5 1.5 0 0 1 15 10.5v3A1.5 1.5 0 0 1 13.5 15h-3A1.5 1.5 0 0 1 9 13.5V11H7v2.5A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3A1.5 1.5 0 0 1 2.5 9H5V7H2.5A1.5 1.5 0 0 1 1 5.5v-3zM2 2.5v3a.5.5 0 0 0 .5.5H5V2H2.5a.5.5 0 0 0-.5.5zm8 0V6h2.5a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5H10.5a.5.5 0 0 0-.5.5zM2 10.5v3a.5.5 0 0 0 .5.5H5v-4H2.5a.5.5 0 0 0-.5.5zm8 3v-4h-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5H13.5a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5H11z"/>
</svg>
);
case 'scripts':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">

View File

@@ -22,6 +22,8 @@ interface TagInputProps {
disabled?: boolean;
/** Input mode (tags or categories) */
mode?: 'tag' | 'category';
/** Post ID for AI-based tag suggestions (semantic similarity) */
postId?: string;
}
export const TagInput: React.FC<TagInputProps> = ({
@@ -30,6 +32,7 @@ export const TagInput: React.FC<TagInputProps> = ({
placeholder = 'Add tags...',
disabled = false,
mode = 'tag',
postId,
}) => {
const { t } = useI18n();
const [inputValue, setInputValue] = useState('');
@@ -38,7 +41,8 @@ export const TagInput: React.FC<TagInputProps> = ({
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isCreating, setIsCreating] = useState(false);
const [aiSuggestedTags, setAiSuggestedTags] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -92,6 +96,26 @@ export const TagInput: React.FC<TagInputProps> = ({
setSelectedIndex(-1);
}, [inputValue, allTags, value]);
// Load AI tag suggestions when focused on an empty input (mode=tag only)
useEffect(() => {
if (mode !== 'tag' || !postId || inputValue.trim()) {
setAiSuggestedTags([]);
return;
}
let cancelled = false;
(async () => {
try {
const suggestions = await window.electronAPI.embeddings.suggestTags(postId, value);
if (!cancelled) {
setAiSuggestedTags(suggestions.map(s => s.name));
}
} catch {
if (!cancelled) setAiSuggestedTags([]);
}
})();
return () => { cancelled = true; };
}, [postId, mode, value, inputValue]);
// Close suggestions when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -273,8 +297,25 @@ export const TagInput: React.FC<TagInputProps> = ({
</div>
{/* Suggestions dropdown */}
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
{showSuggestions && (suggestions.length > 0 || showCreateOption || aiSuggestedTags.length > 0) && (
<div className="tag-suggestions">
{/* AI-suggested tags shown when input is empty */}
{!inputValue.trim() && aiSuggestedTags.length > 0 && (
<>
<div className="tag-suggestion-section-label">{t('tagInput.aiSuggestedLabel')}</div>
{aiSuggestedTags.map((tagName) => (
<button
key={`ai-${tagName}`}
type="button"
className="tag-suggestion ai-suggested"
onClick={() => addTag(tagName)}
>
<span className="tag-suggestion-name">{tagName}</span>
</button>
))}
{suggestions.length > 0 && <div className="tag-suggestion-section-label">{t('tagInput.allTagsLabel')}</div>}
</>
)}
{suggestions.map((tag, index) => {
const hasColor = !!tag.color;
const style: React.CSSProperties = hasColor