feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
32
memento-note/lib/ai/semantic-proximity.ts
Normal file
32
memento-note/lib/ai/semantic-proximity.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Seuil Memory Echo (prod) — connexions sous ce score ne sont pas proposées. */
|
||||
export const SEMANTIC_SIMILARITY_FLOOR = 0.75
|
||||
export const SEMANTIC_SIMILARITY_FLOOR_DEMO = 0.5
|
||||
|
||||
/** Ratio 0 (seuil) → 1 (identique), pour étaler l’affichage au-dessus du seuil. */
|
||||
export function semanticProximityRatio(
|
||||
similarity: number,
|
||||
floor = SEMANTIC_SIMILARITY_FLOOR,
|
||||
): number {
|
||||
const clamped = Math.max(floor, Math.min(1, similarity))
|
||||
if (floor >= 1) return 1
|
||||
return (clamped - floor) / (1 - floor)
|
||||
}
|
||||
|
||||
/** Pourcentage « proximité » affiché (0–100), étalé entre le seuil et 100 %. */
|
||||
export function semanticProximityPercent(
|
||||
similarity: number,
|
||||
floor = SEMANTIC_SIMILARITY_FLOOR,
|
||||
): number {
|
||||
return Math.round(semanticProximityRatio(similarity, floor) * 100)
|
||||
}
|
||||
|
||||
/** Rayon orbite : plus la proximité est forte, plus le nœud est proche du centre. */
|
||||
export function semanticOrbitRadius(
|
||||
similarity: number,
|
||||
floor = SEMANTIC_SIMILARITY_FLOOR,
|
||||
): number {
|
||||
const MIN_R = 34
|
||||
const MAX_R = 94
|
||||
const t = semanticProximityRatio(similarity, floor)
|
||||
return MAX_R - t * (MAX_R - MIN_R)
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* Chart Suggestion Service
|
||||
* Frontend service for calling the AI chart suggestions API
|
||||
* NOW WITH FAST PARSER - skips AI when possible (< 50ms vs 2 minutes)
|
||||
*/
|
||||
|
||||
import { parseChartData, generateChartSuggestions } from '@/lib/chart/parser'
|
||||
|
||||
export interface ChartSuggestion {
|
||||
type: 'bar' | 'horizontal-bar' | 'line' | 'area' | 'pie' | 'radar' | 'funnel' | 'gauge'
|
||||
title: string
|
||||
@@ -64,6 +67,7 @@ function setCached(key: string, data: SuggestChartsResponse): void {
|
||||
|
||||
/**
|
||||
* Call the AI chart suggestions API
|
||||
* FAST PATH: Try regex parser first (< 50ms), only call AI if no data found
|
||||
* @param request - The request parameters
|
||||
* @returns Promise with the chart suggestions
|
||||
*/
|
||||
@@ -78,7 +82,29 @@ export async function suggestCharts(request: SuggestChartsRequest): Promise<Sugg
|
||||
return cached
|
||||
}
|
||||
|
||||
console.log('[suggestCharts] CACHE MISS - calling API')
|
||||
console.log('[suggestCharts] CACHE MISS - trying fast parser first')
|
||||
|
||||
// FAST PATH: Try regex parser first - NO AI call needed!
|
||||
const textToParse = request.selection || request.content || ''
|
||||
const parsed = parseChartData(textToParse)
|
||||
|
||||
if (parsed.hasData && parsed.confidence > 0.3) {
|
||||
console.log('[suggestCharts] FAST PATH - regex parser found data, skipping AI!')
|
||||
const suggestions = generateChartSuggestions(parsed.data)
|
||||
|
||||
const response: SuggestChartsResponse = {
|
||||
suggestions,
|
||||
analyzedText: textToParse.substring(0, 200),
|
||||
detectedData: `${parsed.data.length} data points found`,
|
||||
hasData: true,
|
||||
}
|
||||
|
||||
// Cache the fast result
|
||||
setCached(cacheKey, response)
|
||||
return response
|
||||
}
|
||||
|
||||
console.log('[suggestCharts] Parser found no good data, calling AI API (slow...)')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ai/suggest-charts', {
|
||||
|
||||
@@ -96,6 +96,16 @@ export class MemoryEchoService {
|
||||
* Find meaningful connections between user's notes
|
||||
*/
|
||||
async findConnections(userId: string, demoMode: boolean = false): Promise<NoteConnection[]> {
|
||||
// GDPR AI Consent check — compliance skip if not granted (AC6)
|
||||
const userSettings = await prisma.userAISettings.findUnique({
|
||||
where: { userId },
|
||||
select: { aiProcessingConsent: true },
|
||||
})
|
||||
if (!userSettings?.aiProcessingConsent) {
|
||||
console.log(`[MemoryEchoService] User ${userId} has not given AI consent. Skipping connection generation for compliance.`)
|
||||
return []
|
||||
}
|
||||
|
||||
// Ensure all notes have embeddings before searching for connections
|
||||
await this.ensureEmbeddings(userId)
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ const RECIPES: Record<string, Recipe> = {
|
||||
|
||||
function resolveRecipe(name?: string): Recipe {
|
||||
if (!name || name === 'auto') return RECIPES['architectural-saas']
|
||||
const key = name.toLowerCase().replace(/[^a-z]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
// Normalize: underscores to dashes for consistency
|
||||
const key = name.toLowerCase().replace(/[\s_]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
return RECIPES[key] ?? RECIPES['architectural-saas']
|
||||
}
|
||||
|
||||
@@ -345,30 +346,45 @@ function renderRadarChart(data: { label: string; value: number }[], r: Recipe):
|
||||
const cx = 150, cy = 150, radius = 110
|
||||
const max = Math.max(...data.map(d => d.value), 1)
|
||||
const angleStep = (2 * Math.PI) / n
|
||||
|
||||
// Choose grid color based on theme darkness
|
||||
const gridColor = r.isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'
|
||||
const labelColor = r.isDark ? 'rgba(255,255,255,0.7)' : 'rgba(0,0,0,0.7)'
|
||||
|
||||
// Grid
|
||||
const gridLevels = [0.25, 0.5, 0.75, 1].map(f => {
|
||||
const pts = Array.from({ length: n }, (_, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
return `${cx + Math.cos(a) * radius * f},${cy + Math.sin(a) * radius * f}`
|
||||
}).join(' ')
|
||||
return `<polygon points="${pts}" fill="none" stroke="${r.svgGrid}" stroke-width="1"/>`
|
||||
return `<polygon points="${pts}" fill="none" stroke="${gridColor}" stroke-width="1"/>`
|
||||
}).join('')
|
||||
|
||||
// Axis lines from center
|
||||
const axisLines = Array.from({ length: n }, (_, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
return `<line x1="${cx}" y1="${cy}" x2="${cx + Math.cos(a) * radius}" y2="${cy + Math.sin(a) * radius}" stroke="${gridColor}" stroke-width="1"/>`
|
||||
}).join('')
|
||||
|
||||
// Data polygon
|
||||
const dataPts = data.map((d, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
const r2 = (d.value / max) * radius
|
||||
return `${cx + Math.cos(a) * r2},${cy + Math.sin(a) * r2}`
|
||||
}).join(' ')
|
||||
// Labels
|
||||
|
||||
// Labels with better contrast
|
||||
const labels = data.map((d, i) => {
|
||||
const a = i * angleStep - Math.PI / 2
|
||||
const lx = cx + Math.cos(a) * (radius + 20)
|
||||
const ly = cy + Math.sin(a) * (radius + 20)
|
||||
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="10" fill="${r.textMuted}">${esc(d.label)}</text>`
|
||||
return `<text x="${lx}" y="${ly}" text-anchor="middle" font-size="11" font-weight="600" fill="${labelColor}">${esc(d.label)}</text>`
|
||||
}).join('')
|
||||
|
||||
return `<svg viewBox="0 0 300 300" style="width:100%;max-width:320px;height:auto;margin:0 auto;display:block;">
|
||||
${axisLines}
|
||||
${gridLevels}
|
||||
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.15" stroke="${r.accent1}" stroke-width="2"/>
|
||||
<polygon points="${dataPts}" fill="${r.accent1}" fill-opacity="0.2" stroke="${r.accent1}" stroke-width="2.5"/>
|
||||
${labels}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
@@ -43,21 +43,21 @@ export const PALETTE_ALIASES: Record<string, string> = {
|
||||
premium: 'platinum_white_gold', clean: 'vibrant_tech', stage: 'stage_dark',
|
||||
architectural: 'architectural_mono', silk: 'minimal_silk',
|
||||
black: 'keynote', white: 'platinum_white_gold', nuit: 'galaxy', sombre: 'stage_dark',
|
||||
|
||||
// Recipe explicit theme mappings
|
||||
architectural_saas: 'architectural_mono',
|
||||
midnight_cathedral: 'keynote',
|
||||
aurora_borealis: 'galaxy',
|
||||
tokyo_neon: 'vibrant_tech',
|
||||
sunlit_gallery: 'bohemian',
|
||||
clinical_precision: 'modern_wellness',
|
||||
venture_pitch: 'vibrant_orange_mint',
|
||||
forest_floor: 'forest_eco',
|
||||
steel_glass: 'luxury_mystery',
|
||||
cyberpunk_terminal: 'tech_night',
|
||||
editorial_ink: 'vintage_academic',
|
||||
coastal_morning: 'coastal_coral',
|
||||
paper_studio: 'craft_artisan',
|
||||
|
||||
// Recipe explicit theme mappings (both underscore and dash variants)
|
||||
architectural_saas: 'architectural_mono', 'architectural-saas': 'architectural_mono',
|
||||
midnight_cathedral: 'keynote', 'midnight-cathedral': 'keynote',
|
||||
aurora_borealis: 'galaxy', 'aurora-borealis': 'galaxy',
|
||||
tokyo_neon: 'vibrant_tech', 'tokyo-neon': 'vibrant_tech',
|
||||
sunlit_gallery: 'bohemian', 'sunlit-gallery': 'bohemian',
|
||||
clinical_precision: 'modern_wellness', 'clinical-precision': 'modern_wellness',
|
||||
venture_pitch: 'vibrant_orange_mint', 'venture-pitch': 'vibrant_orange_mint',
|
||||
forest_floor: 'forest_eco', 'forest-floor': 'forest_eco',
|
||||
steel_glass: 'luxury_mystery', 'steel-glass': 'luxury_mystery',
|
||||
cyberpunk_terminal: 'tech_night', 'cyberpunk-terminal': 'tech_night',
|
||||
editorial_ink: 'vintage_academic', 'editorial-ink': 'vintage_academic',
|
||||
coastal_morning: 'coastal_coral', 'coastal-morning': 'coastal_coral',
|
||||
paper_studio: 'craft_artisan', 'paper-studio': 'craft_artisan',
|
||||
}
|
||||
|
||||
export const THEME_NAMES: Record<string, string> = {
|
||||
@@ -74,7 +74,8 @@ export const THEME_NAMES: Record<string, string> = {
|
||||
}
|
||||
|
||||
export function resolvePalette(spec: Pick<PresentationSpec, 'theme'>): { palette: Palette; key: string } {
|
||||
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_')
|
||||
// Normalize theme name: handle both dashes and underscores
|
||||
const name = (spec.theme || '').toLowerCase().replace(/[\s-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')
|
||||
const key = PALETTE_ALIASES[name] || (PALETTES[name] ? name : 'keynote')
|
||||
return { palette: PALETTES[key]!, key }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user