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

@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, real, uniqueIndex, primaryKey } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, real, blob, uniqueIndex, primaryKey } from 'drizzle-orm/sqlite-core';
// Projects table - stores blog projects/websites
export const projects = sqliteTable('projects', {
@@ -269,6 +269,26 @@ export const modelCatalogMeta = sqliteTable('ai_catalog_meta', {
value: text('value').notNull(),
});
// Embedding keys table - maps USearch bigint labels to post IDs for semantic similarity
export const embeddingKeys = sqliteTable('embedding_keys', {
label: integer('label').primaryKey(), // USearch bigint key (stored as number, cast to bigint at runtime)
postId: text('post_id').notNull(),
projectId: text('project_id').notNull(),
contentHash: text('content_hash').notNull(), // SHA-256 of title+content, for change detection
vector: blob('vector', { mode: 'buffer' }), // Raw Float32Array bytes (384 × 4 = 1536 bytes)
});
// Dismissed duplicate pairs - user has reviewed and dismissed these near-duplicates
export const dismissedDuplicatePairs = sqliteTable('dismissed_duplicate_pairs', {
id: text('id').primaryKey(),
projectId: text('project_id').notNull(),
postIdA: text('post_id_a').notNull(),
postIdB: text('post_id_b').notNull(),
dismissedAt: integer('dismissed_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
pairIdx: uniqueIndex('dismissed_pairs_idx').on(table.projectId, table.postIdA, table.postIdB),
}));
// Types for TypeScript
export type Project = typeof projects.$inferSelect;
export type NewProject = typeof projects.$inferInsert;
@@ -306,3 +326,7 @@ export type ModelCatalogModalityEntry = typeof modelCatalogModalities.$inferSele
export type NewModelCatalogModalityEntry = typeof modelCatalogModalities.$inferInsert;
export type ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect;
export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert;
export type EmbeddingKey = typeof embeddingKeys.$inferSelect;
export type NewEmbeddingKey = typeof embeddingKeys.$inferInsert;
export type DismissedDuplicatePair = typeof dismissedDuplicatePairs.$inferSelect;
export type NewDismissedDuplicatePair = typeof dismissedDuplicatePairs.$inferInsert;

View File

@@ -0,0 +1,771 @@
/**
* EmbeddingEngine
*
* Provides semantic similarity features using local ONNX embeddings (multilingual-e5-small)
* and HNSW vector search via USearch. All processing is fully local — no external API calls.
*
* Features:
* - findSimilar: Find thematically related posts (InsertModal, "have I written this?")
* - suggestTags: Infer tags from similar posts
* - findDuplicates: Audit tool for near-duplicate post detection
*
* Architecture:
* - Model stays loaded across project switches (one model, multiple indexes)
* - USearch index file per project: {userData}/projects/{projectId}/embeddings.usearch
* - Label→postId mapping in `embedding_keys` DB table (avoids bigint JSON issues)
* - Vector cache persisted in `embedding_keys.vector` DB column as BLOB for instant reload
*/
import { EventEmitter } from 'events';
import * as path from 'path';
import * as fs from 'fs/promises';
import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { eq, and, inArray } from 'drizzle-orm';
import { getDatabase } from '../database';
import { embeddingKeys, dismissedDuplicatePairs, posts } from '../database/schema';
export interface SimilarPost {
postId: string;
similarity: number; // cosine similarity 0-1
}
export interface TagSuggestion {
name: string;
score: number; // weighted frequency
}
export interface DuplicatePair {
postA: { id: string; title: string; slug: string; publishedAt?: Date };
postB: { id: string; title: string; slug: string; publishedAt?: Date };
similarity: number;
exactMatch?: boolean;
}
// Injected dependencies for testability
export interface EmbeddingEngineDeps {
/** Return the path to the USearch index file for a project */
getIndexPath: (projectId: string) => string;
/** Create the embedding pipeline (dependency-injected for tests) */
createPipeline?: () => Promise<EmbeddingPipeline>;
}
export interface EmbeddingPipeline {
embed(text: string): Promise<Float32Array>;
}
export class EmbeddingEngine extends EventEmitter {
private deps: EmbeddingEngineDeps;
private pipeline: EmbeddingPipeline | null = null;
private pipelineLoadPromise: Promise<EmbeddingPipeline> | null = null;
// USearch index (lazily loaded per-project)
private index: import('usearch').Index | null = null;
private currentProjectId: string | null = null;
// Label->postId map (backed by DB, kept in memory for fast lookup)
private labelToPostId: Map<bigint, string> = new Map();
private postIdToLabel: Map<string, bigint> = new Map();
private nextLabel: bigint = 1n;
// In-memory vector cache -- loaded from DB on startup, updated during embedding.
private vectorCache: Map<string, Float32Array> = new Map(); // postId -> vector
// Debounced save timer
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private readonly SAVE_DEBOUNCE_MS = 5000;
// Model dimensions
private readonly DIMENSIONS = 384;
private readonly MODEL_ID = 'Xenova/multilingual-e5-small';
constructor(deps: EmbeddingEngineDeps) {
super();
this.deps = deps;
}
// Lifecycle
async initialize(): Promise<void> {
if (this.pipeline) return;
if (this.pipelineLoadPromise) {
await this.pipelineLoadPromise;
return;
}
this.pipelineLoadPromise = this.loadPipeline();
this.pipeline = await this.pipelineLoadPromise;
}
private async loadPipeline(): Promise<EmbeddingPipeline> {
if (this.deps.createPipeline) {
return this.deps.createPipeline();
}
// Dynamic import to avoid loading heavy ONNX runtime at startup
const { pipeline, env } = await import('@huggingface/transformers');
// Configure cache for Electron -- use ~/.cache/huggingface
env.useFSCache = true;
const extractor = await pipeline('feature-extraction', this.MODEL_ID, {
dtype: 'fp32',
});
return {
embed: async (text: string): Promise<Float32Array> => {
const output = await extractor(text, { pooling: 'mean', normalize: true });
// v3: output.data is Float32Array
return output.data as Float32Array;
},
};
}
async shutdown(): Promise<void> {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
if (this.index && this.currentProjectId) {
await this.save();
}
this.index = null;
this.currentProjectId = null;
this.labelToPostId.clear();
this.postIdToLabel.clear();
this.vectorCache.clear();
this.nextLabel = 1n;
this.pipeline = null;
this.pipelineLoadPromise = null;
}
// Project switching
async setProjectContext(projectId: string): Promise<void> {
if (this.currentProjectId === projectId) return;
// Save and unload current index
if (this.index && this.currentProjectId) {
await this.save();
}
this.index = null;
this.labelToPostId.clear();
this.postIdToLabel.clear();
this.vectorCache.clear();
this.nextLabel = 1n;
this.currentProjectId = projectId;
// Load (or create) index for new project
await this.ensureIndexLoaded();
}
private async ensureIndexLoaded(): Promise<void> {
if (this.index) return;
if (!this.currentProjectId) return;
const { Index, MetricKind, ScalarKind } = await import('usearch');
this.index = new Index({
metric: MetricKind.Cos,
quantization: ScalarKind.F32,
dimensions: this.DIMENSIONS,
connectivity: 16,
expansion_add: 128,
expansion_search: 64,
multi: false,
});
const indexPath = this.deps.getIndexPath(this.currentProjectId);
try {
await fs.access(indexPath);
this.index.load(indexPath);
} catch {
// No index file yet -- start fresh
}
// Load key mapping and vectors from DB
await this.loadKeyMapFromDb(this.currentProjectId);
}
private async loadKeyMapFromDb(projectId: string): Promise<void> {
const db = getDatabase().getLocal();
const rows = await db
.select()
.from(embeddingKeys)
.where(eq(embeddingKeys.projectId, projectId));
this.labelToPostId.clear();
this.postIdToLabel.clear();
this.vectorCache.clear();
this.nextLabel = 1n;
for (const row of rows) {
const label = BigInt(row.label);
this.labelToPostId.set(label, row.postId);
this.postIdToLabel.set(row.postId, label);
if (label >= this.nextLabel) {
this.nextLabel = label + 1n;
}
if (row.vector) {
const buf = row.vector as Buffer;
this.vectorCache.set(row.postId, new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4));
}
}
}
// Core operations
async embedPost(postId: string, title: string, content: string): Promise<void> {
await this.initialize();
await this.ensureIndexLoaded();
if (!this.index || !this.pipeline || !this.currentProjectId) return;
const rawText = `${title}\n\n${content}`;
const hash = this.computeHash(rawText);
// Check if already indexed with same hash (no-op)
const db = getDatabase().getLocal();
const existing = await db
.select()
.from(embeddingKeys)
.where(
and(
eq(embeddingKeys.postId, postId),
eq(embeddingKeys.projectId, this.currentProjectId),
),
);
if (existing.length > 0 && existing[0]!.contentHash === hash) {
return; // Unchanged, skip re-embedding
}
// Remove old vector if exists
if (existing.length > 0) {
const oldLabel = BigInt(existing[0]!.label);
try {
this.index.remove(oldLabel);
} catch {
// Ignore remove errors -- label may not be in index
}
this.labelToPostId.delete(oldLabel);
this.postIdToLabel.delete(postId);
this.vectorCache.delete(postId);
await db.delete(embeddingKeys).where(
and(
eq(embeddingKeys.postId, postId),
eq(embeddingKeys.projectId, this.currentProjectId),
),
);
}
// Compute embedding
const text = `query: ${rawText}`;
const vector = await this.embedText(text);
// Assign new label
const label = this.nextLabel++;
this.index.add(label, vector);
this.labelToPostId.set(label, postId);
this.postIdToLabel.set(postId, label);
this.vectorCache.set(postId, vector);
// Persist key mapping + vector (label is bigint in-memory, stored as number in SQLite)
await db.insert(embeddingKeys).values({
label: Number(label),
postId,
projectId: this.currentProjectId,
contentHash: hash,
vector: Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength),
});
this.scheduleSave();
}
async removePost(postId: string): Promise<void> {
await this.ensureIndexLoaded();
if (!this.index || !this.currentProjectId) return;
const label = this.postIdToLabel.get(postId);
if (label === undefined) return;
try {
this.index.remove(label);
} catch {
// Ignore remove errors
}
this.labelToPostId.delete(label);
this.postIdToLabel.delete(postId);
this.vectorCache.delete(postId);
const db = getDatabase().getLocal();
await db.delete(embeddingKeys).where(
and(
eq(embeddingKeys.postId, postId),
eq(embeddingKeys.projectId, this.currentProjectId),
),
);
this.scheduleSave();
}
async findSimilar(postId: string, k = 5): Promise<SimilarPost[]> {
await this.ensureIndexLoaded();
if (!this.index || !this.currentProjectId) return [];
if (!this.postIdToLabel.has(postId)) return [];
// Guard against empty index (USearch throws on empty index search)
if (this.postIdToLabel.size < 2) return [];
// Get or compute vector for this post
const vector = await this.getOrComputeVector(postId);
if (!vector) return [];
// Search for k+1 (to exclude self) with HNSW
const result = this.index.search(vector, k + 1, 0);
if (!result) return [];
const results: SimilarPost[] = [];
for (let i = 0; i < result.keys.length; i++) {
const foundLabel = result.keys[i]!;
const foundPostId = this.labelToPostId.get(foundLabel);
if (!foundPostId || foundPostId === postId) continue;
const distance = result.distances[i]!;
// USearch cosine metric returns distance (0=identical), convert to similarity
const similarity = Math.max(0, 1 - distance);
results.push({ postId: foundPostId, similarity });
}
return results.sort((a, b) => b.similarity - a.similarity).slice(0, k);
}
/**
* Compute cosine similarity between a source post and a list of target posts.
* Returns a map of targetPostId → similarity (0-1). Posts without embeddings are omitted.
*/
async computeSimilarities(sourcePostId: string, targetPostIds: string[]): Promise<Record<string, number>> {
await this.ensureIndexLoaded();
if (!this.index || !this.currentProjectId || targetPostIds.length === 0) return {};
const sourceVec = await this.getOrComputeVector(sourcePostId);
if (!sourceVec) return {};
const result: Record<string, number> = {};
for (const targetId of targetPostIds) {
if (targetId === sourcePostId) continue;
const targetVec = await this.getOrComputeVector(targetId);
if (!targetVec) continue;
result[targetId] = this.cosineSimilarity(sourceVec, targetVec);
}
return result;
}
private cosineSimilarity(a: Float32Array, b: Float32Array): number {
let dot = 0, normA = 0, normB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i]! * b[i]!;
normA += a[i]! * a[i]!;
normB += b[i]! * b[i]!;
}
const denom = Math.sqrt(normA) * Math.sqrt(normB);
return denom === 0 ? 0 : Math.max(0, dot / denom);
}
// Derived features
async suggestTags(postId: string, excludeTags: string[]): Promise<TagSuggestion[]> {
const similar = await this.findSimilar(postId, 10);
if (similar.length === 0) return [];
if (!this.currentProjectId) return [];
// Get tags for similar posts
const similarPostIds = similar.map((s) => s.postId);
const db = getDatabase().getLocal();
const postRows = await db
.select({ id: posts.id, tags: posts.tags })
.from(posts)
.where(inArray(posts.id, similarPostIds));
const excludeSet = new Set(excludeTags.map((t) => t.toLowerCase()));
const tagScores = new Map<string, number>();
for (const row of postRows) {
const simItem = similar.find((s) => s.postId === row.id);
if (!simItem) continue;
const postTags: string[] = JSON.parse(row.tags || '[]');
for (const tag of postTags) {
if (excludeSet.has(tag.toLowerCase())) continue;
const current = tagScores.get(tag) || 0;
tagScores.set(tag, current + simItem.similarity);
}
}
return Array.from(tagScores.entries())
.map(([name, score]) => ({ name, score }))
.sort((a, b) => b.score - a.score)
.slice(0, 5);
}
async findDuplicates(threshold = 0.92, onProgress?: (checked: number, total: number) => void): Promise<DuplicatePair[]> {
await this.ensureIndexLoaded();
if (!this.index || !this.currentProjectId) return [];
const projectId = this.currentProjectId;
const db = getDatabase().getLocal();
// Get dismissed pairs
const dismissed = await db
.select()
.from(dismissedDuplicatePairs)
.where(eq(dismissedDuplicatePairs.projectId, projectId));
const dismissedSet = new Set<string>();
for (const d of dismissed) {
dismissedSet.add(this.pairKey(d.postIdA, d.postIdB));
}
// Get post info for all indexed posts
const allPostIds = Array.from(this.postIdToLabel.keys());
if (allPostIds.length === 0) return [];
const postRows = await db
.select({
id: posts.id,
title: posts.title,
slug: posts.slug,
content: posts.content,
status: posts.status,
filePath: posts.filePath,
publishedAt: posts.publishedAt,
})
.from(posts)
.where(inArray(posts.id, allPostIds));
const postMap = new Map(postRows.map((p) => [p.id, p]));
// Cache for lazily-loaded post bodies (needed for exact-match detection)
const bodyCache = new Map<string, string>();
const getBody = async (postId: string): Promise<string> => {
const cached = bodyCache.get(postId);
if (cached !== undefined) return cached;
const post = postMap.get(postId);
if (!post) { bodyCache.set(postId, ''); return ''; }
// Draft content is in the DB; published content is on the filesystem
if (post.content) {
bodyCache.set(postId, post.content);
return post.content;
}
if (post.filePath) {
try {
const raw = await fs.readFile(post.filePath, 'utf-8');
const { content: body } = (await import('gray-matter')).default(raw);
bodyCache.set(postId, body);
return body;
} catch {
bodyCache.set(postId, '');
return '';
}
}
bodyCache.set(postId, '');
return '';
};
const pairs: DuplicatePair[] = [];
const seenPairs = new Set<string>();
for (let idx = 0; idx < allPostIds.length; idx++) {
const postId = allPostIds[idx]!;
onProgress?.(idx + 1, allPostIds.length);
const vector = await this.getOrComputeVector(postId);
if (!vector) continue;
const result = this.index.search(vector, 21, 0);
if (!result) continue;
for (let i = 0; i < result.keys.length; i++) {
const otherLabel = result.keys[i]!;
const otherPostId = this.labelToPostId.get(otherLabel);
if (!otherPostId || otherPostId === postId) continue;
const distance = result.distances[i]!;
const similarity = Math.max(0, 1 - distance);
if (similarity < threshold) continue;
const key = this.pairKey(postId, otherPostId);
if (seenPairs.has(key) || dismissedSet.has(key)) continue;
seenPairs.add(key);
const postA = postMap.get(postId);
const postB = postMap.get(otherPostId);
if (!postA || !postB) continue;
pairs.push({
postA: {
id: postA.id,
title: postA.title,
slug: postA.slug,
publishedAt: postA.publishedAt ?? undefined,
},
postB: {
id: postB.id,
title: postB.title,
slug: postB.slug,
publishedAt: postB.publishedAt ?? undefined,
},
similarity,
});
}
}
// For pairs at 100% embedding similarity, compare actual bodies to find true exact duplicates
for (const pair of pairs) {
if (Math.round(pair.similarity * 100) >= 100) {
const bodyA = await getBody(pair.postA.id);
const bodyB = await getBody(pair.postB.id);
const postA = postMap.get(pair.postA.id);
const postB = postMap.get(pair.postB.id);
if (postA && postB && postA.title === postB.title && bodyA === bodyB) {
pair.exactMatch = true;
}
}
}
return pairs.sort((a, b) => {
if (a.exactMatch && !b.exactMatch) return -1;
if (!a.exactMatch && b.exactMatch) return 1;
return b.similarity - a.similarity;
});
}
async dismissPair(postIdA: string, postIdB: string): Promise<void> {
if (!this.currentProjectId) return;
const db = getDatabase().getLocal();
const [a, b] = this.sortedPairIds(postIdA, postIdB);
await db.insert(dismissedDuplicatePairs).values({
id: uuidv4(),
projectId: this.currentProjectId,
postIdA: a,
postIdB: b,
dismissedAt: new Date(),
}).onConflictDoNothing();
}
async dismissPairs(pairIds: Array<[string, string]>): Promise<void> {
if (!this.currentProjectId) return;
const db = getDatabase().getLocal();
const now = new Date();
const rows = pairIds.map(([idA, idB]) => {
const [a, b] = this.sortedPairIds(idA, idB);
return { id: uuidv4(), projectId: this.currentProjectId!, postIdA: a, postIdB: b, dismissedAt: now };
});
// Insert in batches of 100 to avoid SQLite variable limits
for (let i = 0; i < rows.length; i += 100) {
await db.insert(dismissedDuplicatePairs).values(rows.slice(i, i + 100)).onConflictDoNothing();
}
}
// Indexing management
async getIndexingProgress(): Promise<{ indexed: number; total: number }> {
if (!this.currentProjectId) return { indexed: 0, total: 0 };
await this.ensureIndexLoaded();
const db = getDatabase().getLocal();
const indexed = this.labelToPostId.size;
const allPosts = await db
.select({ id: posts.id })
.from(posts)
.where(eq(posts.projectId, this.currentProjectId));
return { indexed, total: allPosts.length };
}
async reindexAll(onProgress?: (indexed: number, total: number) => void): Promise<void> {
await this.ensureIndexLoaded();
if (!this.currentProjectId) return;
const db = getDatabase().getLocal();
// Clear existing index
await db.delete(embeddingKeys).where(eq(embeddingKeys.projectId, this.currentProjectId));
const { Index, MetricKind, ScalarKind } = await import('usearch');
this.index = new Index({
metric: MetricKind.Cos,
quantization: ScalarKind.F32,
dimensions: this.DIMENSIONS,
connectivity: 16,
expansion_add: 128,
expansion_search: 64,
multi: false,
});
this.labelToPostId.clear();
this.postIdToLabel.clear();
this.vectorCache.clear();
this.nextLabel = 1n;
await this.indexUnindexedPosts(onProgress);
}
async indexUnindexedPosts(onProgress?: (indexed: number, total: number) => void): Promise<void> {
await this.initialize();
await this.ensureIndexLoaded();
if (!this.currentProjectId) return;
const db = getDatabase().getLocal();
const allPosts = await db
.select({
id: posts.id,
title: posts.title,
content: posts.content,
filePath: posts.filePath,
})
.from(posts)
.where(eq(posts.projectId, this.currentProjectId));
// Resolve actual content for each post (read from file for published posts)
const resolvedPosts: Array<{ id: string; title: string; content: string }> = [];
for (const p of allPosts) {
let body = p.content || '';
if (!p.content && p.filePath) {
try {
const raw = await fs.readFile(p.filePath, 'utf-8');
const matter = (await import('gray-matter')).default;
const { content: fileBody } = matter(raw);
body = fileBody;
} catch {
// File not found — use empty
}
}
resolvedPosts.push({ id: p.id, title: p.title, content: body });
}
// Get current hashes from DB for change detection
const keyRows = await db
.select()
.from(embeddingKeys)
.where(eq(embeddingKeys.projectId, this.currentProjectId));
const hashMap = new Map(keyRows.map((r) => [r.postId, r.contentHash]));
const toIndex = resolvedPosts.filter((p) => {
const raw = `${p.title}\n\n${p.content}`;
const hash = this.computeHash(raw);
return hashMap.get(p.id) !== hash;
});
let count = 0;
let batchCount = 0;
const BATCH_SAVE_INTERVAL = 100;
for (const post of toIndex) {
await this.embedPost(post.id, post.title, post.content);
count++;
batchCount++;
onProgress?.(count, toIndex.length);
if (batchCount >= BATCH_SAVE_INTERVAL) {
await this.save();
batchCount = 0;
}
}
if (batchCount > 0) {
await this.save();
}
}
// Persistence
async save(): Promise<void> {
if (this.saveTimer) {
clearTimeout(this.saveTimer);
this.saveTimer = null;
}
if (!this.index || !this.currentProjectId) return;
const indexPath = this.deps.getIndexPath(this.currentProjectId);
const dir = path.dirname(indexPath);
await fs.mkdir(dir, { recursive: true });
this.index.save(indexPath);
}
private scheduleSave(): void {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => {
this.save().catch((err) => console.error('[EmbeddingEngine] save error:', err));
}, this.SAVE_DEBOUNCE_MS);
}
// Helpers
/**
* Get vector for a postId from in-memory cache (loaded from DB at startup).
* Falls back to re-computing from post content only if not in cache.
*/
private async getOrComputeVector(postId: string): Promise<Float32Array | null> {
const cached = this.vectorCache.get(postId);
if (cached) return cached;
// Re-embed from post content
await this.initialize();
if (!this.pipeline || !this.currentProjectId) return null;
const resolved = await this.resolvePostContent(postId);
if (!resolved) return null;
const rawText = `${resolved.title}\n\n${resolved.content}`;
const text = `query: ${rawText}`;
const vector = await this.embedText(text);
this.vectorCache.set(postId, vector);
return vector;
}
private async embedText(text: string): Promise<Float32Array> {
if (!this.pipeline) throw new Error('EmbeddingEngine not initialized');
return this.pipeline.embed(text);
}
/**
* Resolve the actual body text for a post.
* Draft posts have content in the DB; published posts have it on the filesystem.
*/
private async resolvePostContent(postId: string): Promise<{ title: string; content: string } | null> {
if (!this.currentProjectId) return null;
const db = getDatabase().getLocal();
const rows = await db
.select({ title: posts.title, content: posts.content, filePath: posts.filePath })
.from(posts)
.where(and(eq(posts.id, postId), eq(posts.projectId, this.currentProjectId)));
if (rows.length === 0) return null;
const post = rows[0]!;
if (post.content) return { title: post.title, content: post.content };
if (post.filePath) {
try {
const raw = await fs.readFile(post.filePath, 'utf-8');
const matter = (await import('gray-matter')).default;
const { content: body } = matter(raw);
return { title: post.title, content: body };
} catch {
// File not found or unreadable — fall back to empty
}
}
return { title: post.title, content: '' };
}
private computeHash(text: string): string {
return crypto.createHash('sha256').update(text).digest('hex');
}
private pairKey(idA: string, idB: string): string {
const [a, b] = this.sortedPairIds(idA, idB);
return `${a}::${b}`;
}
private sortedPairIds(idA: string, idB: string): [string, string] {
return idA < idB ? [idA, idB] : [idB, idA];
}
}

View File

@@ -29,6 +29,7 @@ import type { BlogmarkPythonWorkerRuntime } from './BlogmarkPythonWorkerRuntime'
import type { PythonMacroWorkerRuntime } from './PythonMacroWorkerRuntime';
import type { PublishApiAdapter } from './PublishApiAdapter';
import type { AppApiAdapter } from './AppApiAdapter';
import type { EmbeddingEngine } from './EmbeddingEngine';
export interface EngineBundle {
postEngine: PostEngine;
@@ -52,4 +53,5 @@ export interface EngineBundle {
pythonMacroWorkerRuntime: PythonMacroWorkerRuntime;
publishApiAdapter: PublishApiAdapter;
appApiAdapter: AppApiAdapter;
embeddingEngine: EmbeddingEngine;
}

View File

@@ -52,6 +52,7 @@ export function decodeCursor(cursor: string): number {
interface PostEngineContract {
getAllPosts: (options?: PaginationOptions) => Promise<PaginatedResult<PostData>>;
getPost: (id: string) => Promise<PostData | null>;
getPostBySlug: (slug: string) => Promise<PostData | null>;
searchPosts: (query: string) => Promise<SearchResult[]>;
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<{ posts: PostData[]; total: number }>;
createPost: (data: Partial<PostData>) => Promise<PostData>;
@@ -606,6 +607,42 @@ export class MCPServer {
content: [{ type: 'text' as const, text: JSON.stringify(result) }],
};
});
// ── read_post_by_slug ──
server.registerTool('read_post_by_slug', {
title: 'Read Post by Slug',
description: 'Read the full content and metadata of a specific blog post by its slug. Includes backlinks and outlinks. Useful when you know the slug but not the ID.',
inputSchema: {
slug: z.string().describe('The slug of the post to read'),
},
annotations: { readOnlyHint: true, openWorldHint: false },
}, async (args) => {
const post = await this.deps.postEngine.getPostBySlug(args.slug);
if (!post) {
return {
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post with slug "${args.slug}" not found` }) }],
isError: true,
};
}
const [backlinks, linksTo] = await Promise.all([
this.deps.postEngine.getLinkedBy(post.id),
this.deps.postEngine.getLinksTo(post.id),
]);
return {
content: [{ type: 'text' as const, text: JSON.stringify({
post: {
id: post.id, title: post.title, slug: post.slug,
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
},
}) }],
};
});
}
private registerProposalTools(server: McpServer): void {

View File

@@ -28,6 +28,7 @@ export interface ProjectMetadata {
picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering
categoryMetadata?: Record<string, CategoryMetadata>; // Per-category metadata for UI/rendering
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
semanticSimilarityEnabled?: boolean; // Enable local ONNX embedding-based semantic similarity
}
export interface CategoryRenderSettings {
@@ -347,6 +348,7 @@ export class MetaEngine extends EventEmitter {
pythonRuntimeMode: normalizedUpdates.pythonRuntimeMode,
picoTheme: normalizedUpdates.picoTheme,
categoryMetadata: normalizedUpdates.categoryMetadata,
semanticSimilarityEnabled: normalizedUpdates.semanticSimilarityEnabled,
});
} else {
this.projectMetadata = normalizeProjectMetadata({

View File

@@ -596,7 +596,23 @@ export class PostEngine extends EventEmitter {
async getPost(id: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get();
return this.resolvePostData(dbPost);
}
async getPostBySlug(slug: string): Promise<PostData | null> {
const db = getDatabase().getLocal();
const dbPost = await db
.select()
.from(posts)
.where(and(
eq(posts.slug, slug),
eq(posts.projectId, this.currentProjectId)
))
.get();
return this.resolvePostData(dbPost);
}
private async resolvePostData(dbPost: typeof posts.$inferSelect | undefined): Promise<PostData | null> {
if (!dbPost) {
return null;
}

View File

@@ -19,6 +19,7 @@ import type { PostMediaLinkData } from '../PostMediaEngine';
export interface BlogToolDeps {
postEngine: {
getPost: (id: string) => Promise<PostData | null>;
getPostBySlug: (slug: string) => Promise<PostData | null>;
getAllPosts: (options?: PaginationOptions) => Promise<{ items: PostData[]; total: number }>;
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
searchPostsFiltered: (query: string, filter: PostFilter, pagination?: PaginationOptions) => Promise<{ posts: PostData[]; total: number }>;
@@ -240,6 +241,34 @@ export function createBlogTools(deps: BlogToolDeps) {
},
}),
read_post_by_slug: tool({
description: 'Read the full content and metadata of a specific blog post by its slug. Includes backlinks (posts linking to this post). Useful when you know the slug but not the ID.',
inputSchema: z.object({
slug: z.string().describe('The slug of the post to read'),
}),
execute: async ({ slug }) => {
const post = await postEngine.getPostBySlug(slug);
if (!post) return { success: false, error: 'Post not found' };
const [backlinks, linksTo] = await Promise.all([
postEngine.getLinkedBy(post.id),
postEngine.getLinksTo(post.id),
]);
return {
success: true,
post: {
id: post.id, title: post.title, slug: post.slug,
content: post.content, excerpt: post.excerpt,
status: post.status, author: post.author,
categories: post.categories, tags: post.tags,
createdAt: post.createdAt, updatedAt: post.updatedAt,
publishedAt: post.publishedAt,
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
},
};
},
}),
list_posts: tool({
description: 'List blog posts with optional filtering by status, category, tags, year, or month. Returns paginated results. Each post includes backlinks. The response includes "total" (global post count) and "filteredTotal" (count matching current filters). Use year/month filters to efficiently narrow to a time period. Use check_term first if unsure whether a term is a category or tag.',
inputSchema: z.object({

View File

@@ -98,6 +98,7 @@ export const ENGINE_MAP: Record<string, EngineGetter> = {
// Map API method names to engine method names where they differ
const METHOD_NAME_MAP: Record<string, string> = {
'posts.get': 'getPost',
'posts.getBySlug': 'getPostBySlug',
'posts.create': 'createPost',
'posts.update': 'updatePost',
'posts.delete': 'deletePost',

View File

@@ -0,0 +1,65 @@
import type { EngineBundle } from '../engine/EngineBundle';
import { startDuplicateSearchTask } from './handlers';
import { resolveUiLanguageFromSystemLocale, translateMenu } from '../shared/i18n';
import { app } from 'electron';
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
function tr(key: string): string {
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
const lang = resolveUiLanguageFromSystemLocale(systemLocale);
return translateMenu(lang, key);
}
export function registerEmbeddingHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
const engine = () => bundle.embeddingEngine;
safeHandle('embeddings:findSimilar', async (_, postId: string, k?: number) => {
return engine().findSimilar(postId, k);
});
safeHandle('embeddings:computeSimilarities', async (_, sourcePostId: string, targetPostIds: string[]) => {
return engine().computeSimilarities(sourcePostId, targetPostIds);
});
safeHandle('embeddings:getProgress', async () => {
return engine().getIndexingProgress();
});
safeHandle('embeddings:suggestTags', async (_, postId: string, excludeTags: string[]) => {
return engine().suggestTags(postId, excludeTags ?? []);
});
safeHandle('embeddings:findDuplicates', async (_, threshold?: number) => {
return engine().findDuplicates(threshold);
});
safeHandle('embeddings:dismissPair', async (_, postIdA: string, postIdB: string) => {
return engine().dismissPair(postIdA, postIdB);
});
safeHandle('embeddings:dismissPairs', async (_, pairIds: Array<[string, string]>) => {
return engine().dismissPairs(pairIds);
});
safeHandle('embeddings:runDuplicateSearch', async (_, threshold?: number) => {
startDuplicateSearchTask(bundle, threshold ?? 0.92);
});
safeHandle('embeddings:indexUnindexedPosts', async () => {
const taskId = `embedding-index-${Date.now()}`;
return bundle.taskManager.runTask({
id: taskId,
name: tr('task.embeddingIndex.name'),
execute: async (onProgress) => {
await engine().indexUnindexedPosts((indexed, total) => {
const pct = total > 0 ? (indexed / total) * 100 : 0;
onProgress(pct, tr('task.embeddingIndex.indexing')
.replace('{indexed}', String(indexed))
.replace('{total}', String(total)));
});
return engine().getIndexingProgress();
},
});
});
}

View File

@@ -17,8 +17,10 @@ import { generateBlogmarkBookmarkletSource } from '../shared/blogmark';
import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
import { registerBlogHandlers } from './blogHandlers';
import { registerPublishHandlers } from './publishHandlers';
import { registerEmbeddingHandlers } from './embeddingHandlers';
import { isOfflineModeActive } from './chatHandlers';
import type { EngineBundle } from '../engine/EngineBundle';
import { resolveUiLanguageFromSystemLocale, translateMenu } from '../shared/i18n';
/**
* Wrap an IPC handler so that "Database is closing" errors during shutdown
@@ -136,6 +138,82 @@ function buildMcpAgentConfigOptions(bundle: EngineBundle): import('../engine/MCP
};
}
/**
* Start a background task that indexes all unindexed posts for semantic similarity.
* Shows progress in the TaskPopup so the user can see model loading and indexing progress.
*/
export function startEmbeddingIndexTask(bundle: EngineBundle): void {
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
const lang = resolveUiLanguageFromSystemLocale(systemLocale);
const tr = (key: string) => translateMenu(lang, key);
bundle.taskManager.runTask({
id: `embedding-index-${Date.now()}`,
name: tr('task.embeddingIndex.name'),
execute: async (onProgress) => {
onProgress(0, tr('task.embeddingIndex.loading'));
await bundle.embeddingEngine.indexUnindexedPosts((indexed, total) => {
const pct = total > 0 ? Math.round((indexed / total) * 100) : 0;
onProgress(pct, tr('task.embeddingIndex.indexing')
.replace('{indexed}', String(indexed))
.replace('{total}', String(total)));
});
},
}).catch(() => {});
}
/**
* Start a background task that fully rebuilds the embedding index.
* Clears existing embeddings and re-indexes all posts with progress reporting.
*/
export function startRebuildEmbeddingIndexTask(bundle: EngineBundle): void {
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
const lang = resolveUiLanguageFromSystemLocale(systemLocale);
const tr = (key: string) => translateMenu(lang, key);
bundle.taskManager.runTask({
id: `rebuild-embedding-index-${Date.now()}`,
name: tr('task.rebuildEmbeddingIndex.name'),
execute: async (onProgress) => {
onProgress(0, tr('task.rebuildEmbeddingIndex.clearing'));
await bundle.embeddingEngine.reindexAll((indexed, total) => {
const pct = total > 0 ? Math.round((indexed / total) * 100) : 0;
onProgress(pct, tr('task.embeddingIndex.indexing')
.replace('{indexed}', String(indexed))
.replace('{total}', String(total)));
});
},
}).catch(() => {});
}
/**
* Start a background task that searches for duplicate posts using semantic similarity.
* Once complete, the results are forwarded to the renderer via 'embeddings:duplicateSearchResult'.
*/
export function startDuplicateSearchTask(bundle: EngineBundle, threshold = 0.92): void {
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
const lang = resolveUiLanguageFromSystemLocale(systemLocale);
const tr = (key: string) => translateMenu(lang, key);
bundle.taskManager.runTask({
id: `duplicate-search-${Date.now()}`,
name: tr('task.duplicateSearch.name'),
execute: async (onProgress) => {
onProgress(0, tr('task.duplicateSearch.searching').replace('{checked}', '0').replace('{total}', '…'));
const pairs = await bundle.embeddingEngine.findDuplicates(threshold, (checked, total) => {
const pct = total > 0 ? Math.round((checked / total) * 100) : 0;
onProgress(pct, tr('task.duplicateSearch.searching')
.replace('{checked}', String(checked))
.replace('{total}', String(total)));
});
onProgress(100, tr('task.duplicateSearch.name'));
return pairs;
},
}).then((pairs) => {
ipcMain.emit('forward-to-renderer', 'embeddings:duplicateSearchResult', pairs);
}).catch(() => {});
}
export function registerIpcHandlers(bundle: EngineBundle): void {
// ============ Git Handlers ============
@@ -454,6 +532,11 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.getPost(id);
});
safeHandle('posts:getBySlug', async (_, slug: string) => {
const engine = bundle.postEngine;
return engine.getPostBySlug(slug);
});
safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean }) => {
const engine = bundle.postEngine;
const post = await engine.getPost(id);
@@ -1065,6 +1148,11 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return;
}
if (typedAction === 'rebuildEmbeddingIndex') {
startRebuildEmbeddingIndexTask(bundle);
return;
}
const handledByWebContents = runWebContentsMenuAction((event as any)?.sender, typedAction);
if (handledByWebContents) {
return;
@@ -1189,10 +1277,15 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
return engine.getProjectMetadata();
});
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => {
safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }>; semanticSimilarityEnabled?: boolean }) => {
const engine = bundle.metaEngine;
await ensureMetaContext(engine);
const previousMetadata = await engine.getProjectMetadata();
const wasEnabled = previousMetadata?.semanticSimilarityEnabled === true;
await engine.updateProjectMetadata(updates);
if (updates.semanticSimilarityEnabled === true && !wasEnabled) {
startEmbeddingIndexTask(bundle);
}
return engine.getProjectMetadata();
});
@@ -1612,6 +1705,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
registerMetadataDiffHandlers(safeHandle, bundle);
registerBlogHandlers(safeHandle, bundle);
registerPublishHandlers(safeHandle, bundle);
registerEmbeddingHandlers(safeHandle, bundle);
// ============ MCP Config Handlers ============
@@ -1661,6 +1755,37 @@ export function registerEventForwarding(bundle: EngineBundle): void {
const metaEngine = bundle.metaEngine;
const tagEngine = bundle.tagEngine;
const postMediaEngine = bundle.postMediaEngine;
const embeddingEngine = bundle.embeddingEngine;
// Wire PostEngine events → EmbeddingEngine (opt-in via semanticSimilarityEnabled)
const ifSemanticEnabled = (fn: () => void): void => {
metaEngine.getProjectMetadata().then((metadata) => {
if (metadata?.semanticSimilarityEnabled === true) {
fn();
}
}).catch(() => {});
};
postEngine.on('postCreated', (post: { id: string; title: string; content: string }) => {
ifSemanticEnabled(() => {
embeddingEngine.embedPost(post.id, post.title, post.content ?? '').catch(() => {});
});
});
postEngine.on('postUpdated', (post: { id: string; title: string; content: string }) => {
ifSemanticEnabled(() => {
embeddingEngine.embedPost(post.id, post.title, post.content ?? '').catch(() => {});
});
});
postEngine.on('postDeleted', (id: string) => {
ifSemanticEnabled(() => {
embeddingEngine.removePost(id).catch(() => {});
});
});
postEngine.on('databaseRebuilt', () => {
ifSemanticEnabled(() => {
embeddingEngine.reindexAll().catch(() => {});
});
});
const forwardEvent = (eventName: string) => {
return (...args: unknown[]) => {
@@ -1673,6 +1798,11 @@ export function registerEventForwarding(bundle: EngineBundle): void {
projectEngine.on('projectUpdated', forwardEvent('project:updated'));
projectEngine.on('projectDeleted', forwardEvent('project:deleted'));
projectEngine.on('activeProjectChanged', forwardEvent('project:activeChanged'));
projectEngine.on('activeProjectChanged', (project: { id: string } | null) => {
if (project?.id) {
embeddingEngine.setProjectContext(project.id).catch(() => {});
}
});
postEngine.on('postCreated', forwardEvent('post:created'));
postEngine.on('postUpdated', forwardEvent('post:updated'));

View File

@@ -1,2 +1,2 @@
export { registerIpcHandlers, registerEventForwarding } from './handlers';
export { registerIpcHandlers, registerEventForwarding, startEmbeddingIndexTask, startDuplicateSearchTask, startRebuildEmbeddingIndexTask } from './handlers';
export { registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './chatHandlers';

View File

@@ -2,7 +2,7 @@ import { app, BrowserWindow, Menu, MenuItemConstructorOptions, ipcMain, protocol
import * as path from 'path';
import * as fs from 'fs';
import { getDatabase, initDatabase } from './database';
import { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers } from './ipc';
import { registerIpcHandlers, registerEventForwarding, registerChatHandlers, initializeChatHandlers, cleanupChatHandlers, startEmbeddingIndexTask, startRebuildEmbeddingIndexTask } from './ipc';
import { media } from './database/schema';
import { eq } from 'drizzle-orm';
import { MediaEngine } from './engine/MediaEngine';
@@ -26,6 +26,7 @@ import { BlogmarkPythonWorkerRuntime } from './engine/BlogmarkPythonWorkerRuntim
import { PythonMacroWorkerRuntime } from './engine/PythonMacroWorkerRuntime';
import { AppApiAdapter } from './engine/AppApiAdapter';
import { PublishApiAdapter } from './engine/PublishApiAdapter';
import { EmbeddingEngine } from './engine/EmbeddingEngine';
import { NoopNotifier } from './engine/CliNotifier';
import { NotificationWatcher } from './engine/NotificationWatcher';
import { setEngineBundle } from './engine/mainProcessPythonApiInvoker';
@@ -536,6 +537,9 @@ async function initializeActiveProjectContext(): Promise<void> {
mediaEngine.setProjectContext?.(project.id, dataDir, dataDir);
metaEngine.setProjectContext?.(project.id, dataDir);
const embeddingEngineInstance = bundle!.embeddingEngine;
await embeddingEngineInstance.setProjectContext(project.id);
const templateEngine = bundle!.templateEngine as {
setProjectContext?: (projectId: string, dataDir?: string) => void;
};
@@ -645,6 +649,11 @@ function createApplicationMenu(): Menu {
return;
}
if (action === 'rebuildEmbeddingIndex') {
startRebuildEmbeddingIndexTask(bundle!);
return;
}
const channel = APP_MENU_ACTION_EVENT_MAP[action];
if (channel) {
mainWindow?.webContents.send(channel);
@@ -928,6 +937,10 @@ app.whenReady().then(async () => {
const blogmarkPythonWorkerRuntime = new BlogmarkPythonWorkerRuntime();
const pythonMacroWorkerRuntime = new PythonMacroWorkerRuntime();
const blogmarkTransformService = new BlogmarkTransformService({ scriptEngine, metaEngine, blogmarkWorkerRuntime: blogmarkPythonWorkerRuntime });
const embeddingEngine = new EmbeddingEngine({
getIndexPath: (projectId: string) =>
path.join(userData, 'projects', projectId, 'embeddings.usearch'),
});
const appApiAdapter = new AppApiAdapter(projectEngine);
const publishApiAdapter = new PublishApiAdapter(projectEngine, publishEngine, taskManager);
const mcpServer = new MCPServer({
@@ -961,6 +974,7 @@ app.whenReady().then(async () => {
pythonMacroWorkerRuntime,
publishApiAdapter,
appApiAdapter,
embeddingEngine,
};
setEngineBundle(bundle);
@@ -1000,6 +1014,16 @@ app.whenReady().then(async () => {
await activeProjectContextReady;
appInitialized = true;
// If semantic similarity was already enabled when the app started, kick off indexing.
if (bundle) {
const startupBundle = bundle;
startupBundle.metaEngine.getProjectMetadata().then((metadata) => {
if (metadata?.semanticSimilarityEnabled === true) {
startEmbeddingIndexTask(startupBundle);
}
}).catch(() => {});
}
const startupDeepLinks = extractBlogmarkDeepLinks(process.argv);
for (const deepLink of startupDeepLinks) {
enqueueBlogmarkDeepLink(deepLink);
@@ -1038,6 +1062,12 @@ app.on('before-quit', async () => {
console.error('Failed to cleanup MCP server:', error);
}
try {
await bundle?.embeddingEngine.shutdown();
} catch (error) {
console.error('Failed to shutdown embedding engine:', error);
}
const db = getDatabase();
await db.close();
});

View File

@@ -54,6 +54,7 @@ export const electronAPI: ElectronAPI = {
update: (id: string, data: unknown) => ipcRenderer.invoke('posts:update', id, data),
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
get: (id: string) => ipcRenderer.invoke('posts:get', id),
getBySlug: (slug: string) => ipcRenderer.invoke('posts:getBySlug', slug),
getPreviewUrl: (id: string, options?: { draft?: boolean }) => ipcRenderer.invoke('posts:getPreviewUrl', id, options),
getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options),
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
@@ -191,7 +192,7 @@ export const electronAPI: ElectronAPI = {
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata),
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }>; semanticSimilarityEnabled?: boolean }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
getPublishingPreferences: () => ipcRenderer.invoke('meta:getPublishingPreferences'),
setPublishingPreferences: (prefs: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => ipcRenderer.invoke('meta:setPublishingPreferences', prefs),
clearPublishingPreferences: () => ipcRenderer.invoke('meta:clearPublishingPreferences'),
@@ -452,6 +453,19 @@ export const electronAPI: ElectronAPI = {
isConfigured: (agentId: string) => ipcRenderer.invoke('mcp:isConfigured', agentId),
getPort: () => ipcRenderer.invoke('mcp:getPort'),
},
// Semantic similarity / embeddings
embeddings: {
findSimilar: (postId: string, k?: number) => ipcRenderer.invoke('embeddings:findSimilar', postId, k),
computeSimilarities: (sourcePostId: string, targetPostIds: string[]) => ipcRenderer.invoke('embeddings:computeSimilarities', sourcePostId, targetPostIds),
getProgress: () => ipcRenderer.invoke('embeddings:getProgress'),
suggestTags: (postId: string, excludeTags: string[]) => ipcRenderer.invoke('embeddings:suggestTags', postId, excludeTags),
findDuplicates: (threshold?: number) => ipcRenderer.invoke('embeddings:findDuplicates', threshold),
runDuplicateSearch: (threshold?: number) => ipcRenderer.invoke('embeddings:runDuplicateSearch', threshold),
dismissPair: (postIdA: string, postIdB: string) => ipcRenderer.invoke('embeddings:dismissPair', postIdA, postIdB),
dismissPairs: (pairIds: Array<[string, string]>) => ipcRenderer.invoke('embeddings:dismissPairs', pairIds),
indexUnindexedPosts: () => ipcRenderer.invoke('embeddings:indexUnindexedPosts'),
},
};
contextBridge.exposeInMainWorld('electronAPI', electronAPI);

View File

@@ -54,6 +54,7 @@ export interface ProjectMetadata {
picoTheme?: import('./picoThemes').PicoThemeName;
categoryMetadata?: Record<string, CategoryMetadata>;
categorySettings?: Record<string, CategoryRenderSettings>;
semanticSimilarityEnabled?: boolean;
}
export interface CategoryRenderSettings {
@@ -505,6 +506,23 @@ export interface ChatSendMetadata {
import type { A2UIServerMessage, A2UIClientAction } from '../a2ui/types';
export type { A2UIServerMessage, A2UIClientAction };
export interface SimilarPost {
postId: string;
similarity: number;
}
export interface TagSuggestion {
name: string;
score: number;
}
export interface DuplicatePair {
postA: { id: string; title: string; slug: string; publishedAt?: Date };
postB: { id: string; title: string; slug: string; publishedAt?: Date };
similarity: number;
exactMatch?: boolean;
}
export interface SiteValidationReport {
sitemapPath: string;
sitemapChanged: boolean;
@@ -577,6 +595,7 @@ export interface ElectronAPI {
update: (id: string, data: Partial<PostData>) => Promise<PostData | null>;
delete: (id: string) => Promise<boolean>;
get: (id: string) => Promise<PostData | null>;
getBySlug: (slug: string) => Promise<PostData | null>;
getPreviewUrl: (id: string, options?: { draft?: boolean }) => Promise<string | null>;
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
getByStatus: (status: string) => Promise<PostData[]>;
@@ -728,7 +747,7 @@ export interface ElectronAPI {
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
getProjectMetadata: () => Promise<ProjectMetadata | null>;
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings> }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings>; semanticSimilarityEnabled?: boolean }) => Promise<ProjectMetadata | null>;
getPublishingPreferences: () => Promise<PublishingPreferences | null>;
setPublishingPreferences: (prefs: PublishingPreferences) => Promise<void>;
clearPublishingPreferences: () => Promise<void>;
@@ -986,6 +1005,17 @@ export interface ElectronAPI {
onA2UIMessage: (callback: (data: { conversationId: string; message: A2UIServerMessage }) => void) => () => void;
dispatchA2UIAction: (action: A2UIClientAction) => Promise<{ success: boolean; error?: string }>;
};
embeddings: {
findSimilar: (postId: string, k?: number) => Promise<SimilarPost[]>;
computeSimilarities: (sourcePostId: string, targetPostIds: string[]) => Promise<Record<string, number>>;
getProgress: () => Promise<{ indexed: number; total: number }>;
suggestTags: (postId: string, excludeTags: string[]) => Promise<TagSuggestion[]>;
findDuplicates: (threshold?: number) => Promise<DuplicatePair[]>;
runDuplicateSearch: (threshold?: number) => Promise<void>;
dismissPair: (postIdA: string, postIdB: string) => Promise<void>;
dismissPairs: (pairIds: Array<[string, string]>) => Promise<void>;
indexUnindexedPosts: () => Promise<void>;
};
on: (channel: string, callback: (...args: unknown[]) => void) => () => void;
once: (channel: string, callback: (...args: unknown[]) => void) => void;
/** Subscribe to entity-changed events fired by the CLI NotificationWatcher. */

View File

@@ -36,11 +36,13 @@
"menu.item.previewPost": "Beitragsvorschau",
"menu.item.rebuildDatabase": "Datenbank aus Dateien neu aufbauen",
"menu.item.reindexText": "Suchtext neu indizieren",
"menu.item.rebuildEmbeddingIndex": "Embedding-Index neu aufbauen",
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
"menu.item.editMenu": "Blog-Menü bearbeiten",
"menu.item.generateSitemap": "Site rendern",
"menu.item.regenerateCalendar": "Kalender neu erzeugen",
"menu.item.validateSite": "Website validieren",
"menu.item.findDuplicates": "Doppelte Beiträge finden",
"menu.item.uploadSite": "Website hochladen",
"menu.item.about": "Über Blogging Desktop Server",
"menu.item.openDocumentation": "Dokumentation öffnen",
@@ -80,5 +82,12 @@
"render.month.11": "Nov.",
"render.month.12": "Dezember",
"ai.imageAnalysis.system": "Du erzeugst Bild-Metadaten. Schreibe alle Werte auf Deutsch.\n\nRegeln:\n- \"title\": kurzer beschreibender Titel (3-8 Wörter)\n- \"alt\": sachliche Beschreibung des Sichtbaren (5-12 Wörter). Keine Interpretationen. Kein Präfix \"Bild von\".\n- \"caption\": ansprechende Blog-Bildunterschrift (5-20 Wörter)\n\nAntworte ausschließlich mit JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analysiere dieses Bild. Antworte mit JSON auf Deutsch."
"ai.imageAnalysis.user": "Analysiere dieses Bild. Antworte mit JSON auf Deutsch.",
"task.embeddingIndex.name": "Beiträge für semantische Ähnlichkeit indexieren",
"task.embeddingIndex.loading": "Modell wird geladen…",
"task.embeddingIndex.indexing": "Indexierung: {indexed}/{total}",
"task.rebuildEmbeddingIndex.name": "Embedding-Index neu aufbauen",
"task.rebuildEmbeddingIndex.clearing": "Index wird geleert…",
"task.duplicateSearch.name": "Doppelte Beiträge finden",
"task.duplicateSearch.searching": "Prüfe: {checked}/{total}"
}

View File

@@ -36,11 +36,13 @@
"menu.item.previewPost": "Preview Post",
"menu.item.rebuildDatabase": "Rebuild Database from Files",
"menu.item.reindexText": "Reindex Search Text",
"menu.item.rebuildEmbeddingIndex": "Rebuild Embedding Index",
"menu.item.metadataDiff": "Metadata Diff Tool",
"menu.item.editMenu": "Edit Blog Menu",
"menu.item.generateSitemap": "Render Site",
"menu.item.regenerateCalendar": "Regenerate Calendar",
"menu.item.validateSite": "Validate Site",
"menu.item.findDuplicates": "Find Duplicate Posts",
"menu.item.uploadSite": "Upload Site",
"menu.item.about": "About Blogging Desktop Server",
"menu.item.openDocumentation": "Open Documentation",
@@ -80,5 +82,12 @@
"render.month.11": "November",
"render.month.12": "December",
"ai.imageAnalysis.system": "You generate image metadata. Write all values in English.\n\nRules:\n- \"title\": short descriptive title (3-8 words)\n- \"alt\": factual description of what is visible (5-12 words). No interpretations. No \"Image of\" prefix.\n- \"caption\": engaging blog caption (5-20 words)\n\nRespond with JSON only: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analyze this image. Respond with JSON in English."
"ai.imageAnalysis.user": "Analyze this image. Respond with JSON in English.",
"task.embeddingIndex.name": "Index posts for Semantic Similarity",
"task.embeddingIndex.loading": "Loading model…",
"task.embeddingIndex.indexing": "Indexing: {indexed}/{total}",
"task.rebuildEmbeddingIndex.name": "Rebuild Embedding Index",
"task.rebuildEmbeddingIndex.clearing": "Clearing index…",
"task.duplicateSearch.name": "Find Duplicate Posts",
"task.duplicateSearch.searching": "Checking: {checked}/{total}"
}

View File

@@ -36,11 +36,13 @@
"menu.item.previewPost": "Vista previa de entrada",
"menu.item.rebuildDatabase": "Reconstruir Database from Files",
"menu.item.reindexText": "Reindex Buscar Text",
"menu.item.rebuildEmbeddingIndex": "Reconstruir índice de embeddings",
"menu.item.metadataDiff": "Herramienta diff de metadatos",
"menu.item.editMenu": "Editar menú del blog",
"menu.item.generateSitemap": "Renderizar sitio",
"menu.item.regenerateCalendar": "Regenerar calendario",
"menu.item.validateSite": "Validar sitio",
"menu.item.findDuplicates": "Buscar entradas duplicadas",
"menu.item.uploadSite": "Subir sitio",
"menu.item.about": "Acerca de Blogging Desktop Server",
"menu.item.openDocumentation": "Abrir documentación",
@@ -80,5 +82,12 @@
"render.month.11": "noviembre",
"render.month.12": "diciembre",
"ai.imageAnalysis.system": "Generas metadatos de imagen. Escribe todos los valores en español.\n\nReglas:\n- \"title\": título descriptivo corto (3-8 palabras)\n- \"alt\": descripción factual de lo visible (5-12 palabras). Sin interpretaciones. Sin prefijo \"Imagen de\".\n- \"caption\": pie de foto atractivo para blog (5-20 palabras)\n\nResponde solo con JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analiza esta imagen. Responde con JSON en español."
"ai.imageAnalysis.user": "Analiza esta imagen. Responde con JSON en español.",
"task.embeddingIndex.name": "Indexar entradas para similitud semántica",
"task.embeddingIndex.loading": "Cargando modelo…",
"task.embeddingIndex.indexing": "Indexando: {indexed}/{total}",
"task.rebuildEmbeddingIndex.name": "Reconstruir índice de embeddings",
"task.rebuildEmbeddingIndex.clearing": "Vaciando índice…",
"task.duplicateSearch.name": "Buscar entradas duplicadas",
"task.duplicateSearch.searching": "Comprobando: {checked}/{total}"
}

View File

@@ -36,11 +36,13 @@
"menu.item.previewPost": "Aperçu de larticle",
"menu.item.rebuildDatabase": "Reconstruire Database from Files",
"menu.item.reindexText": "Reindex Recherche Text",
"menu.item.rebuildEmbeddingIndex": "Reconstruire l'index d'embeddings",
"menu.item.metadataDiff": "Outil de diff des métadonnées",
"menu.item.editMenu": "Modifier le menu du blog",
"menu.item.generateSitemap": "Rendre le site",
"menu.item.regenerateCalendar": "Régénérer le calendrier",
"menu.item.validateSite": "Valider le site",
"menu.item.findDuplicates": "Trouver les articles en double",
"menu.item.uploadSite": "Publier le site",
"menu.item.about": "À propos de Blogging Desktop Server",
"menu.item.openDocumentation": "Ouvrir la documentation",
@@ -80,5 +82,12 @@
"render.month.11": "novembre",
"render.month.12": "décembre",
"ai.imageAnalysis.system": "Tu génères des métadonnées d'image. Écris toutes les valeurs en français.\n\nRègles :\n- \"title\" : titre descriptif court (3-8 mots)\n- \"alt\" : description factuelle de ce qui est visible (5-12 mots). Pas d'interprétations. Pas de préfixe \"Image de\".\n- \"caption\" : légende de blog engageante (5-20 mots)\n\nRéponds uniquement en JSON : {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analyse cette image. Réponds en JSON en français."
"ai.imageAnalysis.user": "Analyse cette image. Réponds en JSON en français.",
"task.embeddingIndex.name": "Indexer les articles pour la similarité sémantique",
"task.embeddingIndex.loading": "Chargement du modèle…",
"task.embeddingIndex.indexing": "Indexation : {indexed}/{total}",
"task.rebuildEmbeddingIndex.name": "Reconstruire l'index d'embeddings",
"task.rebuildEmbeddingIndex.clearing": "Vidage de l'index…",
"task.duplicateSearch.name": "Trouver les articles en double",
"task.duplicateSearch.searching": "Vérification : {checked}/{total}"
}

View File

@@ -36,11 +36,13 @@
"menu.item.previewPost": "Anteprima post",
"menu.item.rebuildDatabase": "Ricostruisci Database from Files",
"menu.item.reindexText": "Reindex Ricerca Text",
"menu.item.rebuildEmbeddingIndex": "Ricostruisci indice embeddings",
"menu.item.metadataDiff": "Strumento diff metadati",
"menu.item.editMenu": "Modifica menu blog",
"menu.item.generateSitemap": "Renderizza sito",
"menu.item.regenerateCalendar": "Rigenera calendario",
"menu.item.validateSite": "Valida sito",
"menu.item.findDuplicates": "Trova post duplicati",
"menu.item.uploadSite": "Carica sito",
"menu.item.about": "Informazioni su Blogging Desktop Server",
"menu.item.openDocumentation": "Apri documentazione",
@@ -80,5 +82,12 @@
"render.month.11": "novembre",
"render.month.12": "dicembre",
"ai.imageAnalysis.system": "Generi metadati per immagini. Scrivi tutti i valori in italiano.\n\nRegole:\n- \"title\": titolo descrittivo breve (3-8 parole)\n- \"alt\": descrizione fattuale di ciò che è visibile (5-12 parole). Nessuna interpretazione. Nessun prefisso \"Immagine di\".\n- \"caption\": didascalia blog coinvolgente (5-20 parole)\n\nRispondi solo con JSON: {\"title\": \"...\", \"alt\": \"...\", \"caption\": \"...\"}",
"ai.imageAnalysis.user": "Analizza questa immagine. Rispondi con JSON in italiano."
"ai.imageAnalysis.user": "Analizza questa immagine. Rispondi con JSON in italiano.",
"task.embeddingIndex.name": "Indicizza i post per la similarità semantica",
"task.embeddingIndex.loading": "Caricamento modello…",
"task.embeddingIndex.indexing": "Indicizzazione: {indexed}/{total}",
"task.rebuildEmbeddingIndex.name": "Ricostruisci indice embeddings",
"task.rebuildEmbeddingIndex.clearing": "Svuotamento indice…",
"task.duplicateSearch.name": "Trova post duplicati",
"task.duplicateSearch.searching": "Controllo: {checked}/{total}"
}

View File

@@ -31,6 +31,7 @@ export type AppMenuAction =
| 'previewPost'
| 'rebuildDatabase'
| 'reindexText'
| 'rebuildEmbeddingIndex'
| 'metadataDiff'
| 'editMenu'
| 'generateSitemap'
@@ -39,6 +40,7 @@ export type AppMenuAction =
| 'uploadSite'
| 'openDocumentation'
| 'openApiDocumentation'
| 'findDuplicates'
| 'about'
| 'viewOnGitHub'
| 'reportIssue';
@@ -127,12 +129,14 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
{ label: '', action: 'blog-separator-2', separator: true },
{ label: 'menu.item.rebuildDatabase', action: 'rebuildDatabase' },
{ label: 'menu.item.reindexText', action: 'reindexText' },
{ label: 'menu.item.rebuildEmbeddingIndex', action: 'rebuildEmbeddingIndex' },
{ label: '', action: 'blog-separator-3', separator: true },
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
{ label: 'menu.item.editMenu', action: 'editMenu' },
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
{ label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' },
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
{ label: 'menu.item.findDuplicates', action: 'findDuplicates' },
{ label: '', action: 'blog-separator-4', separator: true },
{ label: 'menu.item.uploadSite', action: 'uploadSite', accelerator: 'CmdOrCtrl+Shift+U' },
],
@@ -167,11 +171,13 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
publishSelected: 'menu:publishSelected',
rebuildDatabase: 'menu:rebuildDatabase',
reindexText: 'menu:reindexText',
rebuildEmbeddingIndex: 'menu:rebuildEmbeddingIndex',
metadataDiff: 'menu:metadataDiff',
editMenu: 'menu:editMenu',
generateSitemap: 'menu:generateSitemap',
regenerateCalendar: 'menu:regenerateCalendar',
validateSite: 'menu:validateSite',
findDuplicates: 'menu:findDuplicates',
uploadSite: 'menu:uploadSite',
openDocumentation: 'menu:openDocumentation',
openApiDocumentation: 'menu:openApiDocumentation',

View File

@@ -81,6 +81,7 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('posts.update', 'Update a post by id.', [requiredString('id'), requiredObject('data')], 'PostData | null'),
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
method('posts.getBySlug', 'Fetch one post by slug.', [requiredString('slug')], 'PostData | null'),
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
@@ -202,6 +203,14 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
method('sync.commitAll', 'Stage all changes and commit for active project.', [requiredString('message')], 'GitActionResult'),
method('publish.uploadSite', 'Upload rendered site to remote server via SSH.', [requiredObject('credentials')], 'PublishSiteResult'),
method('embeddings.findSimilar', 'Find posts semantically similar to the given post. Requires semantic similarity to be enabled in project settings.', [requiredString('postId'), optionalNumber('k')], 'SimilarPost[]'),
method('embeddings.computeSimilarities', 'Compute cosine similarity between a source post and a list of target posts. Returns a mapping of target post IDs to similarity scores (0.0-1.0). Posts without embeddings are omitted.', [requiredString('sourcePostId'), requiredArray('targetPostIds')], 'Record<string, number>'),
method('embeddings.getProgress', 'Get the embedding indexing progress for the active project.', [], '{ indexed: number; total: number }'),
method('embeddings.suggestTags', 'Suggest tags for a post based on tags used by semantically similar posts.', [requiredString('postId'), requiredArray('excludeTags')], 'TagSuggestion[]'),
method('embeddings.findDuplicates', 'Find post pairs with high content similarity (potential duplicates). Threshold is a similarity value from 0.0 to 1.0 (default 0.85).', [optionalNumber('threshold')], 'DuplicatePair[]'),
method('embeddings.dismissPair', 'Dismiss a duplicate pair so it no longer appears in results.', [requiredString('postIdA'), requiredString('postIdB')], 'void'),
method('embeddings.indexUnindexedPosts', 'Trigger background indexing of all posts not yet embedded.', [], 'void'),
];
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
@@ -345,6 +354,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' },
{ name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' },
{ name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' },
{ name: 'semanticSimilarityEnabled', type: 'boolean', required: false, description: 'Enable local ONNX embedding-based semantic similarity features.' },
],
},
{
@@ -415,11 +425,36 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
{ name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' },
],
},
{
name: 'SimilarPost',
description: 'A post with its semantic similarity score relative to a reference post.',
fields: [
{ name: 'postId', type: 'string', required: true, description: 'Post identifier.' },
{ name: 'similarity', type: 'number', required: true, description: 'Cosine similarity score from 0.0 to 1.0.' },
],
},
{
name: 'TagSuggestion',
description: 'A tag suggested based on semantic similarity to similar posts.',
fields: [
{ name: 'name', type: 'string', required: true, description: 'Tag name.' },
{ name: 'score', type: 'number', required: true, description: 'Aggregated suggestion score.' },
],
},
{
name: 'DuplicatePair',
description: 'A pair of posts with high content similarity that may be duplicates.',
fields: [
{ name: 'postA', type: '{ id: string; title: string; slug: string; publishedAt?: string }', required: true, description: 'First post in the pair.' },
{ name: 'postB', type: '{ id: string; title: string; slug: string; publishedAt?: string }', required: true, description: 'Second post in the pair.' },
{ name: 'similarity', type: 'number', required: true, description: 'Cosine similarity score from 0.0 to 1.0.' },
],
},
];
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
version: '1.11.0',
generatedAt: '2026-02-27T00:00:00.000Z',
version: '1.12.0',
generatedAt: '2026-03-05T00:00:00.000Z',
methods: METHODS_V1,
dataStructures: DATA_STRUCTURES_V1,
};

View File

@@ -4,6 +4,7 @@ import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils';
import { openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { persistDuplicatesResult } from './navigation/duplicatesPersistence';
import { executeActivityClick } from './navigation/activityExecution';
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
import {
@@ -444,6 +445,25 @@ const App: React.FC = () => {
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:findDuplicates', () => {
openSingletonToolTab(openTab, 'find-duplicates');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('embeddings:duplicateSearchResult', (...args: unknown[]) => {
const pairs = args[0] as import('../main/shared/electronApi').DuplicatePair[];
const projectId = useAppStore.getState().activeProject?.id;
if (projectId && pairs) {
persistDuplicatesResult(projectId, pairs);
window.dispatchEvent(new CustomEvent('bds:duplicates-updated', {
detail: { projectId },
}));
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:generateSitemap', async () => {
try {

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

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Lege fest, wo Python-Skripte für Transformationspipelines ausgeführt werden.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (empfohlen)",
"settings.technology.pythonRuntimeMode.mainThread": "Hauptthread (Legacy)",
"settings.technology.semanticSimilarityLabel": "Semantische Ähnlichkeit",
"settings.technology.semanticSimilarityDescription": "Aktiviert lokale KI-Einbettungen für Vorschläge zu verwandten Beiträgen, Tag-Hinweise und Erkennung von Duplikaten. Lädt beim ersten Start ein ~100 MB großes Modell herunter.",
"settings.publishing.sshTitle": "SSH-Veröffentlichung",
"settings.data.title": "Datenbankwartung",
"settings.data.fileSystemTitle": "Dateisystem",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Medien nach Name, Titel oder Alt-Text durchsuchen...",
"insert.status.searching": "Suche...",
"insert.status.typeMore": "Zum Suchen mindestens 2 Zeichen eingeben",
"insert.status.loadingRelated": "Ähnliche Beiträge werden geladen...",
"insert.section.relatedPosts": "Verwandte Beiträge",
"insert.status.noResults": "Keine {kind} für \"{query}\" gefunden",
"insert.label.url": "Webadresse",
"insert.label.linkTextOptional": "Linktext (optional)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Assistent-Sitzung",
"assistantSidebar.error.startFailed": "Assistent-Sitzung konnte nicht gestartet werden",
"assistantSidebar.error.actionFailed": "Assistent-Aktion konnte nicht ausgeführt werden",
"tagInput.aiSuggestedLabel": "KI-Vorschläge",
"tagInput.allTagsLabel": "Alle Tags",
"tagInput.alreadyAdded": "Tag bereits hinzugefügt",
"tagInput.remove": "{tag} entfernen",
"tagInput.createdTag": "Tag \"{name}\" erstellt",
@@ -1092,5 +1098,22 @@
"settings.toast.mcpConfigRemoveSuccess": "bDS MCP-Server aus der {agent}-Konfiguration entfernt",
"settings.toast.mcpConfigFailed": "Konfiguration von {agent} fehlgeschlagen: {error}",
"settings.toast.mcpConfigRemoveFailed": "Entfernen aus {agent} fehlgeschlagen: {error}",
"settings.toast.mcpConfigPath": "Konfiguration geschrieben nach {path}"
"settings.toast.mcpConfigPath": "Konfiguration geschrieben nach {path}",
"duplicatesView.tabTitle": "Duplikate finden",
"duplicatesView.title": "Doppelte Beiträge",
"duplicatesView.description": "Beiträge mit hoher inhaltlicher Ähnlichkeit, die möglicherweise Duplikate sind.",
"duplicatesView.loading": "Suche nach Duplikaten...",
"duplicatesView.empty": "Keine doppelten Beiträge gefunden.",
"duplicatesView.error": "Duplikate konnten nicht geladen werden",
"duplicatesView.refresh": "Aktualisieren",
"duplicatesView.dismiss": "Ignorieren",
"duplicatesView.similarity": "{value}% ähnlich",
"duplicatesView.exactMatch": "Exaktes Duplikat",
"duplicatesView.openPost": "Beitrag öffnen",
"duplicatesView.count": "{count} Paare gefunden",
"duplicatesView.showMore": "Mehr anzeigen",
"duplicatesView.checkAll": "Alle auswählen",
"duplicatesView.uncheckAll": "Alle abwählen",
"duplicatesView.dismissChecked": "Ausgewählte ignorieren ({count})",
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie."
}

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Choose where Python scripts execute for transform pipelines.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (Recommended)",
"settings.technology.pythonRuntimeMode.mainThread": "Main Thread (Legacy)",
"settings.technology.semanticSimilarityLabel": "Semantic Similarity",
"settings.technology.semanticSimilarityDescription": "Enable local AI embeddings for related-post suggestions, tag hints, and duplicate detection. Downloads a ~100 MB model on first use.",
"settings.publishing.sshTitle": "SSH Publishing",
"settings.data.title": "Database Maintenance",
"settings.data.fileSystemTitle": "File System",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Search media by name, title, or alt text...",
"insert.status.searching": "Searching...",
"insert.status.typeMore": "Type at least 2 characters to search",
"insert.status.loadingRelated": "Loading related posts...",
"insert.section.relatedPosts": "Related Posts",
"insert.status.noResults": "No {kind} found for \"{query}\"",
"insert.label.url": "URL",
"insert.label.linkTextOptional": "Link Text (optional)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Assistant Session",
"assistantSidebar.error.startFailed": "Failed to start assistant session",
"assistantSidebar.error.actionFailed": "Assistant action could not be executed",
"tagInput.aiSuggestedLabel": "AI Suggestions",
"tagInput.allTagsLabel": "All Tags",
"tagInput.alreadyAdded": "Tag already added",
"tagInput.remove": "Remove {tag}",
"tagInput.createdTag": "Tag \"{name}\" created",
@@ -1092,5 +1098,22 @@
"settings.toast.mcpConfigRemoveSuccess": "bDS MCP server removed from {agent} configuration",
"settings.toast.mcpConfigFailed": "Failed to configure {agent}: {error}",
"settings.toast.mcpConfigRemoveFailed": "Failed to remove from {agent}: {error}",
"settings.toast.mcpConfigPath": "Config written to {path}"
"settings.toast.mcpConfigPath": "Config written to {path}",
"duplicatesView.tabTitle": "Find Duplicates",
"duplicatesView.title": "Duplicate Posts",
"duplicatesView.description": "Posts with high content similarity that may be duplicates.",
"duplicatesView.loading": "Searching for duplicates...",
"duplicatesView.empty": "No duplicate posts found.",
"duplicatesView.error": "Failed to load duplicates",
"duplicatesView.refresh": "Refresh",
"duplicatesView.dismiss": "Dismiss",
"duplicatesView.similarity": "{value}% similar",
"duplicatesView.exactMatch": "Exact duplicate",
"duplicatesView.openPost": "Open post",
"duplicatesView.count": "{count} pairs found",
"duplicatesView.showMore": "Show more",
"duplicatesView.checkAll": "Check All",
"duplicatesView.uncheckAll": "Uncheck All",
"duplicatesView.dismissChecked": "Dismiss Checked ({count})",
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology."
}

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Elige dónde se ejecutan los scripts de Python para los flujos de transformación.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (recomendado)",
"settings.technology.pythonRuntimeMode.mainThread": "Hilo principal (heredado)",
"settings.technology.semanticSimilarityLabel": "Similitud semántica",
"settings.technology.semanticSimilarityDescription": "Activa incrustaciones de IA locales para sugerencias de publicaciones relacionadas, sugerencias de etiquetas y detección de duplicados. Descarga un modelo de ~100 MB en el primer uso.",
"settings.publishing.sshTitle": "Publicación SSH",
"settings.data.title": "Mantenimiento de base de datos",
"settings.data.fileSystemTitle": "Sistema de archivos",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Buscar medios por nombre, título o texto alternativo...",
"insert.status.searching": "Buscando...",
"insert.status.typeMore": "Escribe al menos 2 caracteres para buscar",
"insert.status.loadingRelated": "Cargando publicaciones relacionadas...",
"insert.section.relatedPosts": "Publicaciones relacionadas",
"insert.status.noResults": "No se encontró {kind} para \"{query}\"",
"insert.label.url": "Dirección URL",
"insert.label.linkTextOptional": "Texto del enlace (opcional)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Sesión de asistente",
"assistantSidebar.error.startFailed": "No se pudo iniciar la sesión del asistente",
"assistantSidebar.error.actionFailed": "No se pudo ejecutar la acción del asistente",
"tagInput.aiSuggestedLabel": "Sugerencias IA",
"tagInput.allTagsLabel": "Todos los tags",
"tagInput.alreadyAdded": "La etiqueta “{tag}” ya está añadida",
"tagInput.remove": "Quitar",
"tagInput.createdTag": "Etiqueta “{tag}” creada",
@@ -1092,5 +1098,22 @@
"settings.toast.mcpConfigRemoveSuccess": "Servidor MCP de bDS eliminado de la configuración de {agent}",
"settings.toast.mcpConfigFailed": "Error al configurar {agent}: {error}",
"settings.toast.mcpConfigRemoveFailed": "Error al eliminar de {agent}: {error}",
"settings.toast.mcpConfigPath": "Configuración escrita en {path}"
"settings.toast.mcpConfigPath": "Configuración escrita en {path}",
"duplicatesView.tabTitle": "Buscar duplicados",
"duplicatesView.title": "Entradas duplicadas",
"duplicatesView.description": "Entradas con alta similitud de contenido que pueden ser duplicadas.",
"duplicatesView.loading": "Buscando duplicados...",
"duplicatesView.empty": "No se encontraron entradas duplicadas.",
"duplicatesView.error": "Error al cargar duplicados",
"duplicatesView.refresh": "Actualizar",
"duplicatesView.dismiss": "Descartar",
"duplicatesView.similarity": "{value}% similar",
"duplicatesView.exactMatch": "Duplicado exacto",
"duplicatesView.openPost": "Abrir entrada",
"duplicatesView.count": "{count} pares encontrados",
"duplicatesView.showMore": "Mostrar más",
"duplicatesView.checkAll": "Seleccionar todo",
"duplicatesView.uncheckAll": "Deseleccionar todo",
"duplicatesView.dismissChecked": "Descartar seleccionados ({count})",
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología."
}

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Choisissez où les scripts Python sexécutent pour les pipelines de transformation.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (recommandé)",
"settings.technology.pythonRuntimeMode.mainThread": "Thread principal (hérité)",
"settings.technology.semanticSimilarityLabel": "Similarité sémantique",
"settings.technology.semanticSimilarityDescription": "Active les embeddings IA locaux pour les suggestions de publications similaires, les suggestions de tags et la détection de doublons. Télécharge un modèle d'environ 100 Mo lors du premier usage.",
"settings.publishing.sshTitle": "Publication SSH",
"settings.data.title": "Maintenance de la base de données",
"settings.data.fileSystemTitle": "Système de fichiers",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Rechercher des médias par nom, titre ou texte alternatif...",
"insert.status.searching": "Recherche...",
"insert.status.typeMore": "Saisissez au moins 2 caractères pour rechercher",
"insert.status.loadingRelated": "Chargement des publications connexes...",
"insert.section.relatedPosts": "Publications connexes",
"insert.status.noResults": "Aucun(e) {kind} trouvé(e) pour \"{query}\"",
"insert.label.url": "Adresse URL",
"insert.label.linkTextOptional": "Texte du lien (optionnel)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Session Assistant",
"assistantSidebar.error.startFailed": "Impossible de démarrer la session assistant",
"assistantSidebar.error.actionFailed": "Laction assistant na pas pu être exécutée",
"tagInput.aiSuggestedLabel": "Suggestions IA",
"tagInput.allTagsLabel": "Tous les tags",
"tagInput.alreadyAdded": "Le tag « {tag} » est déjà ajouté",
"tagInput.remove": "Supprimer",
"tagInput.createdTag": "Tag « {tag} » créé",
@@ -1090,5 +1096,22 @@
"settings.toast.mcpConfigRemoveSuccess": "Serveur MCP bDS retiré de la configuration de {agent}",
"settings.toast.mcpConfigFailed": "Échec de la configuration de {agent}: {error}",
"settings.toast.mcpConfigRemoveFailed": "Échec du retrait de {agent}: {error}",
"settings.toast.mcpConfigPath": "Configuration écrite dans {path}"
"settings.toast.mcpConfigPath": "Configuration écrite dans {path}",
"duplicatesView.tabTitle": "Trouver les doublons",
"duplicatesView.title": "Articles en double",
"duplicatesView.description": "Articles avec une grande similarité de contenu pouvant être des doublons.",
"duplicatesView.loading": "Recherche des doublons...",
"duplicatesView.empty": "Aucun article en double trouvé.",
"duplicatesView.error": "Impossible de charger les doublons",
"duplicatesView.refresh": "Actualiser",
"duplicatesView.dismiss": "Ignorer",
"duplicatesView.similarity": "{value}% similaire",
"duplicatesView.exactMatch": "Doublon exact",
"duplicatesView.openPost": "Ouvrir l'article",
"duplicatesView.count": "{count} paires trouvées",
"duplicatesView.showMore": "Afficher plus",
"duplicatesView.checkAll": "Tout cocher",
"duplicatesView.uncheckAll": "Tout décocher",
"duplicatesView.dismissChecked": "Ignorer cochés ({count})",
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie."
}

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Scegli dove eseguire gli script Python per le pipeline di trasformazione.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (consigliato)",
"settings.technology.pythonRuntimeMode.mainThread": "Thread principale (legacy)",
"settings.technology.semanticSimilarityLabel": "Similarità semantica",
"settings.technology.semanticSimilarityDescription": "Abilita gli embedding AI locali per suggerimenti di post correlati, suggerimenti di tag e rilevamento di duplicati. Scarica un modello di circa 100 MB al primo utilizzo.",
"settings.publishing.sshTitle": "Pubblicazione SSH",
"settings.data.title": "Manutenzione database",
"settings.data.fileSystemTitle": "Sistema file",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Cerca media per nome, titolo o testo alternativo...",
"insert.status.searching": "Ricerca...",
"insert.status.typeMore": "Digita almeno 2 caratteri per cercare",
"insert.status.loadingRelated": "Caricamento post correlati...",
"insert.section.relatedPosts": "Post correlati",
"insert.status.noResults": "Nessun {kind} trovato per \"{query}\"",
"insert.label.url": "Indirizzo URL",
"insert.label.linkTextOptional": "Testo link (opzionale)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Sessione assistente",
"assistantSidebar.error.startFailed": "Impossibile avviare la sessione assistente",
"assistantSidebar.error.actionFailed": "Impossibile eseguire lazione dellassistente",
"tagInput.aiSuggestedLabel": "Suggerimenti IA",
"tagInput.allTagsLabel": "Tutti i tag",
"tagInput.alreadyAdded": "Il tag “{tag}” è già stato aggiunto",
"tagInput.remove": "Rimuovi",
"tagInput.createdTag": "Tag “{tag}” creato",
@@ -1090,5 +1096,22 @@
"settings.toast.mcpConfigRemoveSuccess": "Server MCP bDS rimosso dalla configurazione di {agent}",
"settings.toast.mcpConfigFailed": "Configurazione di {agent} non riuscita: {error}",
"settings.toast.mcpConfigRemoveFailed": "Rimozione da {agent} non riuscita: {error}",
"settings.toast.mcpConfigPath": "Configurazione scritta in {path}"
"settings.toast.mcpConfigPath": "Configurazione scritta in {path}",
"duplicatesView.tabTitle": "Trova duplicati",
"duplicatesView.title": "Post duplicati",
"duplicatesView.description": "Post con elevata similitudine di contenuto che potrebbero essere duplicati.",
"duplicatesView.loading": "Ricerca duplicati...",
"duplicatesView.empty": "Nessun post duplicato trovato.",
"duplicatesView.error": "Impossibile caricare i duplicati",
"duplicatesView.refresh": "Aggiorna",
"duplicatesView.dismiss": "Ignora",
"duplicatesView.similarity": "{value}% simile",
"duplicatesView.exactMatch": "Duplicato esatto",
"duplicatesView.openPost": "Apri post",
"duplicatesView.count": "{count} coppie trovate",
"duplicatesView.showMore": "Mostra altri",
"duplicatesView.checkAll": "Seleziona tutto",
"duplicatesView.uncheckAll": "Deseleziona tutto",
"duplicatesView.dismissChecked": "Ignora selezionati ({count})",
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia."
}

View File

@@ -0,0 +1,30 @@
import type { DuplicatePair } from '../../main/shared/electronApi';
const store = new Map<string, DuplicatePair[]>();
export function persistDuplicatesResult(projectId: string, pairs: DuplicatePair[]): void {
store.set(projectId, pairs);
}
export function getPersistedDuplicatesResult(projectId: string): DuplicatePair[] | null {
return store.get(projectId) ?? null;
}
export function removeDismissedPair(projectId: string, postIdA: string, postIdB: string): void {
const pairs = store.get(projectId);
if (!pairs) return;
store.set(
projectId,
pairs.filter(p => !(p.postA.id === postIdA && p.postB.id === postIdB)),
);
}
export function removeDismissedPairs(projectId: string, pairIds: Array<[string, string]>): void {
const pairs = store.get(projectId);
if (!pairs) return;
const keySet = new Set(pairIds.map(([a, b]) => `${a}::${b}`));
store.set(
projectId,
pairs.filter(p => !keySet.has(`${p.postA.id}::${p.postB.id}`)),
);
}

View File

@@ -17,7 +17,8 @@ export type EditorRoute =
| 'api-documentation'
| 'site-validation'
| 'scripts'
| 'templates';
| 'templates'
| 'find-duplicates';
export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'dashboard'>> = {
post: 'post',
@@ -35,6 +36,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
'site-validation': 'site-validation',
scripts: 'scripts',
templates: 'templates',
'find-duplicates': 'find-duplicates',
};
export interface EditorRouteResolution {

View File

@@ -9,7 +9,8 @@ export type SingletonToolTabKey =
| 'documentation'
| 'api-documentation'
| 'metadata-diff'
| 'site-validation';
| 'site-validation'
| 'find-duplicates';
export interface CanonicalTabSpec {
type: TabType;
@@ -33,6 +34,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
'api-documentation': { type: 'api-documentation', id: 'api-documentation', isTransient: false },
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
'find-duplicates': { type: 'find-duplicates', id: 'find-duplicates', isTransient: false },
};
export function getSingletonToolTabSpec(key: SingletonToolTabKey): CanonicalTabSpec {

View File

@@ -13,7 +13,7 @@ import type {
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates';
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates' | 'find-duplicates';
export interface Tab {
type: TabType;