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:
@@ -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 (
|
||||
|
||||
190
src/renderer/components/DuplicatesView/DuplicatesView.css
Normal file
190
src/renderer/components/DuplicatesView/DuplicatesView.css
Normal 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);
|
||||
}
|
||||
249
src/renderer/components/DuplicatesView/DuplicatesView.tsx
Normal file
249
src/renderer/components/DuplicatesView/DuplicatesView.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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 />),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user