fix: brainstorm infinite loop, ghost cursor, embedding ::vector cast, semantic search, billing stats, usage meter accordion
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 5s

- Fix useBrainstormSocket: stable guestId via useRef, remove setState in cleanup
- Fix GhostCursor: direct DOM manipulation via refs, no useState re-renders
- Fix all SQL embedding queries: add ::vector cast on text columns
- Fix embedding truncation to 15000 chars (under 8192 token limit)
- Fix NoteEmbedding INSERT: remove non-existent updatedAt column
- Fix billing page: show all quota stats in grid instead of single metric
- Fix usage meter: accordion expand/collapse, per-feature detail
- Fix semantic search: rebuild 103 note embeddings, ::vector cast on vectorSearch
- Fix brainstorm expand/manual-idea/create: ::vector cast on embedding SQL
This commit is contained in:
Antigravity
2026-05-16 18:50:34 +00:00
parent ee8e2bda59
commit 8c7ca69640
117 changed files with 11732 additions and 834 deletions

View File

@@ -0,0 +1,359 @@
#!/usr/bin/env node
/**
* Builds es.json, it.json, pt.json i18n overrides from FR reference strings.
* Keys: locale still matches EN but FR differs (+ 11 mandatory keys).
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import https from 'https'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const localesDir = path.join(__dirname, '../../locales')
const outDir = __dirname
function flat(obj, prefix = '') {
const result = {}
for (const [k, v] of Object.entries(obj)) {
const key = prefix ? `${prefix}.${k}` : k
if (v && typeof v === 'object' && !Array.isArray(v)) Object.assign(result, flat(v, key))
else result[key] = v
}
return result
}
const MANDATORY = [
'ai.featureLocked',
'ai.quotaExceeded',
'profile.tab',
'about.tab',
'appearance.tab',
'billing.tab',
'usageMeter.featureChat',
'usageMeter.featureReformulate',
'usageMeter.featureBrainstormCreate',
'usageMeter.featureBrainstormExpand',
'usageMeter.featureBrainstormEnrich',
]
const LANG_MAP = {
es: 'es',
it: 'it',
pt: 'pt',
nl: 'nl',
pl: 'pl',
ru: 'ru',
ar: 'ar',
}
function worklistKeys(lang) {
const en = flat(JSON.parse(fs.readFileSync(path.join(localesDir, 'en.json'), 'utf8')))
const loc = flat(JSON.parse(fs.readFileSync(path.join(localesDir, `${lang}.json`), 'utf8')))
const fr = flat(JSON.parse(fs.readFileSync(path.join(localesDir, 'fr.json'), 'utf8')))
const keys = Object.keys(en).filter((k) => loc[k] === en[k] && fr[k] && fr[k] !== en[k])
for (const k of MANDATORY) {
if (fr[k] && !keys.includes(k)) keys.push(k)
}
return keys.sort()
}
/** Protect ICU-style placeholders during translation */
function shieldPlaceholders(text) {
const tokens = []
const shielded = text.replace(/\{[^}]+\}/g, (m) => {
const id = `__PH_${tokens.length}__`
tokens.push(m)
return id
})
return { shielded, tokens }
}
function unshieldPlaceholders(text, tokens) {
let out = text
tokens.forEach((tok, i) => {
out = out.replace(`__PH_${i}__`, tok)
out = out.replace(`__ PH _ ${i} __`, tok)
out = out.replace(new RegExp(`__\\s*PH\\s*_\\s*${i}\\s*__`, 'gi'), tok)
})
return out
}
function myMemoryTranslate(text, targetLang, attempt = 0) {
return new Promise((resolve, reject) => {
const { shielded, tokens } = shieldPlaceholders(text)
const q = encodeURIComponent(shielded.slice(0, 4500))
const url = `https://api.mymemory.translated.net/get?q=${q}&langpair=fr|${targetLang}`
https
.get(url, (res) => {
let body = ''
res.on('data', (c) => (body += c))
res.on('end', () => {
try {
const data = JSON.parse(body)
if (data.responseStatus === 200 && data.responseData?.translatedText) {
resolve(unshieldPlaceholders(data.responseData.translatedText, tokens))
} else if (attempt < 4) {
setTimeout(() => {
myMemoryTranslate(text, targetLang, attempt + 1)
.then(resolve)
.catch(reject)
}, 1500 * (attempt + 1))
} else {
reject(new Error(data.responseDetails || 'translate failed'))
}
} catch (e) {
reject(e)
}
})
})
.on('error', reject)
})
}
async function translateFr(text, targetLang) {
if (!text || !text.trim()) return text
return myMemoryTranslate(text, targetLang)
}
const MANUAL = {
es: {
'sidebar.sortAlpha': 'Orden A → Z',
'settings.cardSizeVariable': 'Tamaños variables (pequeño/mediano/grande)',
'brainstorm.aiIdea': 'IA',
'brainstorm.seedNodeBadge': 'SEMILLA',
'byokSettings.badgeActive': 'BYOK activo',
'aiSettings.title': 'IA',
'aiSettings.providerOllama': 'Ollama (local)',
'admin.ai.providerOllamaOption': '🦙 Ollama (local y gratuito)',
'admin.ai.providerLMStudioOption': '🖥️ LM Studio (local)',
'usageMeter.packName': 'Pack descubrimiento IA',
'landing.pricing.perUser': '+ 3,90€/usuario',
'landing.pricing.perUserAnnual': '+ 2,90€/usuario, facturación anual',
'ai.featureLocked':
'Esta función requiere el plan PRO o superior.',
'ai.quotaExceeded': 'Límite mensual alcanzado. Se restablece el próximo mes.',
'profile.tab': 'Perfil',
'about.tab': 'Acerca de',
'appearance.tab': 'Apariencia',
'billing.tab': 'Facturación',
'usageMeter.featureChat': 'Mensajes IA',
'usageMeter.featureReformulate': 'Reformulaciones',
'usageMeter.featureBrainstormCreate': 'Creaciones de brainstorm',
'usageMeter.featureBrainstormExpand': 'Expansiones de brainstorm',
'usageMeter.featureBrainstormEnrich': 'Enriquecimientos de brainstorm',
},
it: {
'sidebar.sortAlpha': 'Ordine A → Z',
'settings.cardSizeVariable': 'Dimensioni variabili (small/medium/large)',
'brainstorm.aiIdea': 'IA',
'brainstorm.seedNodeBadge': 'SEME',
'byokSettings.badgeActive': 'BYOK attivo',
'aiSettings.title': 'IA',
'aiSettings.providerOllama': 'Ollama (locale)',
'admin.ai.providerOllamaOption': '🦙 Ollama (locale e gratuito)',
'admin.ai.providerLMStudioOption': '🖥️ LM Studio (locale)',
'usageMeter.packName': 'Pacchetto scoperta IA',
'landing.pricing.perUser': '+ 3,90€/utente',
'landing.pricing.perUserAnnual': '+ 2,90€/utente, fatturazione annuale',
'auth.email': 'Email',
'ai.featureLocked':
'Questa funzione richiede il piano PRO o superiore.',
'ai.quotaExceeded': 'Limite mensile raggiunto. Si azzera il mese prossimo.',
'profile.tab': 'Profilo',
'about.tab': 'Informazioni',
'appearance.tab': 'Aspetto',
'billing.tab': 'Fatturazione',
'usageMeter.featureChat': 'Messaggi IA',
'usageMeter.featureReformulate': 'Riformulazioni',
'usageMeter.featureBrainstormCreate': 'Creazioni brainstorm',
'usageMeter.featureBrainstormExpand': 'Espansioni brainstorm',
'usageMeter.featureBrainstormEnrich': 'Arricchimenti brainstorm',
},
pt: {
'sidebar.sortAlpha': 'Ordenação A → Z',
'settings.cardSizeVariable': 'Tamanhos variáveis (pequeno/médio/grande)',
'brainstorm.aiIdea': 'IA',
'brainstorm.seedNodeBadge': 'SEMENTE',
'byokSettings.badgeActive': 'BYOK ativo',
'aiSettings.title': 'IA',
'aiSettings.providerOllama': 'Ollama (local)',
'admin.ai.providerOllamaOption': '🦙 Ollama (local e gratuito)',
'admin.ai.providerLMStudioOption': '🖥️ LM Studio (local)',
'usageMeter.packName': 'Pack descoberta IA',
'landing.pricing.perUser': '+ 3,90€/utilizador',
'landing.pricing.perUserAnnual': '+ 2,90€/utilizador, faturação anual',
'ai.featureLocked':
'Esta funcionalidade requer o plano PRO ou superior.',
'ai.quotaExceeded': 'Limite mensal atingido. Repõe-se no próximo mês.',
'profile.tab': 'Perfil',
'about.tab': 'Sobre',
'appearance.tab': 'Aparência',
'billing.tab': 'Faturação',
'usageMeter.featureChat': 'Mensagens IA',
'usageMeter.featureReformulate': 'Reformulações',
'usageMeter.featureBrainstormCreate': 'Criações de brainstorm',
'usageMeter.featureBrainstormExpand': 'Expansões de brainstorm',
'usageMeter.featureBrainstormEnrich': 'Enriquecimentos de brainstorm',
},
nl: {
'sidebar.sortAlpha': 'Sortering A → Z',
'brainstorm.aiIdea': 'AI',
'brainstorm.seedNodeBadge': 'ZAAD',
'byokSettings.badgeActive': 'BYOK actief',
'aiSettings.title': 'AI',
'usageMeter.remaining': '{count} resterend',
'brainstorm.liveOtherParticipants': '{count} andere deelnemers',
'brainstorm.playbackStep': 'Stap {current}/{total}',
'brainstorm.playbackStepsCount': '{count} stappen',
'brainstorm.waveBadge': 'Golf {wave}',
'landing.pricing.perUser': '+ 3,90€/gebruiker',
'landing.pricing.perUserAnnual': '+ 2,90€/gebruiker, jaarlijks gefactureerd',
'ai.featureLocked': 'Deze functie vereist het PRO-abonnement of hoger.',
'ai.quotaExceeded': 'Maandelijkse limiet bereikt. Reset volgende maand.',
'profile.tab': 'Profiel',
'about.tab': 'Over',
'appearance.tab': 'Weergave',
'billing.tab': 'Facturering',
'usageMeter.featureChat': 'AI-berichten',
'usageMeter.featureReformulate': 'Herschrijvingen',
'usageMeter.featureBrainstormCreate': 'Brainstorm-aanmaak',
'usageMeter.featureBrainstormExpand': 'Brainstorm-uitbreidingen',
'usageMeter.featureBrainstormEnrich': 'Brainstorm-verrijkingen',
},
pl: {
'sidebar.sortAlpha': 'Sortowanie A → Z',
'brainstorm.aiIdea': 'AI',
'brainstorm.seedNodeBadge': 'NASIONO',
'byokSettings.badgeActive': 'BYOK aktywny',
'aiSettings.title': 'AI',
'usageMeter.remaining': 'Pozostało: {count}',
'brainstorm.liveOtherParticipants': '{count} innych uczestników',
'brainstorm.playbackStep': 'Krok {current}/{total}',
'brainstorm.playbackStepsCount': '{count} kroków',
'brainstorm.waveBadge': 'Fala {wave}',
'landing.pricing.perUser': '+ 3,90€/użytkownika',
'landing.pricing.perUserAnnual': '+ 2,90€/użytkownika, rozliczenie roczne',
'ai.featureLocked': 'Ta funkcja wymaga planu PRO lub wyższego.',
'ai.quotaExceeded': 'Osiągnięto limit miesięczny. Reset w przyszłym miesiącu.',
'profile.tab': 'Profil',
'about.tab': 'O aplikacji',
'appearance.tab': 'Wygląd',
'billing.tab': 'Rozliczenia',
'usageMeter.featureChat': 'Wiadomości AI',
'usageMeter.featureReformulate': 'Przeformułowania',
'usageMeter.featureBrainstormCreate': 'Tworzenia brainstorm',
'usageMeter.featureBrainstormExpand': 'Rozszerzenia brainstorm',
'usageMeter.featureBrainstormEnrich': 'Wzbogacenia brainstorm',
},
ru: {
'sidebar.sortAlpha': 'Сортировка A → Z',
'brainstorm.aiIdea': 'ИИ',
'brainstorm.seedNodeBadge': 'СЕМЯ',
'byokSettings.badgeActive': 'BYOK активен',
'aiSettings.title': 'ИИ',
'usageMeter.remaining': 'осталось {count}',
'brainstorm.liveOtherParticipants': '{count} других участников',
'brainstorm.playbackStep': 'Шаг {current}/{total}',
'brainstorm.playbackStepsCount': '{count} шагов',
'brainstorm.waveBadge': 'Волна {wave}',
'landing.pricing.perUser': '+ 3,90€/пользователь',
'landing.pricing.perUserAnnual': '+ 2,90€/пользователь, ежегодная оплата',
'ai.featureLocked': 'Эта функция требует тарифа PRO или выше.',
'ai.quotaExceeded': 'Достигнут месячный лимит. Сброс в следующем месяце.',
'profile.tab': 'Профиль',
'about.tab': 'О приложении',
'appearance.tab': 'Оформление',
'billing.tab': 'Оплата',
'usageMeter.featureChat': 'Сообщения ИИ',
'usageMeter.featureReformulate': 'Переформулировки',
'usageMeter.featureBrainstormCreate': 'Создания brainstorm',
'usageMeter.featureBrainstormExpand': 'Расширения brainstorm',
'usageMeter.featureBrainstormEnrich': 'Обогащения brainstorm',
},
ar: {
'brainstorm.aiIdea': 'ذكاء اصطناعي',
'brainstorm.seedNodeBadge': 'بذرة',
'byokSettings.badgeActive': 'BYOK نشط',
'usageMeter.remaining': '{count} متبقي',
'brainstorm.liveOtherParticipants': '{count} مشاركين آخرين',
'brainstorm.playbackStep': 'الخطوة {current}/{total}',
'brainstorm.playbackStepsCount': '{count} خطوات',
'brainstorm.waveBadge': 'موجة {wave}',
'landing.pricing.perUser': '+ 3,90€/مستخدم',
'landing.pricing.perUserAnnual': '+ 3,90€/مستخدم، فوترة سنوية',
'ai.featureLocked': 'تتطلب هذه الميزة خطة PRO أو أعلى.',
'ai.quotaExceeded': 'تم الوصول إلى الحد الشهري. يُعاد التعيين الشهر القادم.',
'profile.tab': 'الملف الشخصي',
'about.tab': 'حول',
'appearance.tab': 'المظهر',
'billing.tab': 'الفوترة',
'usageMeter.featureChat': 'رسائل الذكاء الاصطناعي',
'usageMeter.featureReformulate': 'إعادة الصياغة',
'usageMeter.featureBrainstormCreate': 'إنشاءات العصف الذهني',
'usageMeter.featureBrainstormExpand': 'توسعات العصف الذهني',
'usageMeter.featureBrainstormEnrich': 'إثراءات العصف الذهني',
},
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function buildLang(lang) {
const fr = flat(JSON.parse(fs.readFileSync(path.join(localesDir, 'fr.json'), 'utf8')))
const keys = worklistKeys(lang)
const target = LANG_MAP[lang]
const outFile = path.join(outDir, `${lang}.json`)
const out = fs.existsSync(outFile)
? JSON.parse(fs.readFileSync(outFile, 'utf8'))
: {}
const frCache = new Map()
console.error(`[${lang}] ${keys.length} keys`)
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
if (MANUAL[lang]?.[key]) {
out[key] = MANUAL[lang][key]
continue
}
// Re-translate if placeholders were corrupted (non-ASCII inside braces)
const existing = out[key]
if (existing && /\{[^}]*[^\x00-\x7F][^}]*\}/.test(existing)) {
delete out[key]
}
const frText = fr[key]
if (!frText) {
console.error(` skip missing fr: ${key}`)
continue
}
const existingAfter = out[key]
if (existingAfter && existingAfter !== frText) {
continue
}
let translated
if (frCache.has(frText)) {
translated = frCache.get(frText)
} else {
try {
translated = await translateFr(frText, target)
frCache.set(frText, translated)
await sleep(350)
} catch (e) {
console.error(` FAIL ${key}:`, e.message)
translated = frText
}
}
out[key] = translated
if ((i + 1) % 50 === 0) console.error(` ${i + 1}/${keys.length}`)
}
const file = path.join(outDir, `${lang}.json`)
fs.writeFileSync(file, JSON.stringify(out, null, 2) + '\n', 'utf8')
console.error(`Wrote ${file} (${Object.keys(out).length} keys)`)
}
const langs = process.argv.slice(2)
const targets = langs.length ? langs : ['es', 'it', 'pt']
for (const lang of targets) {
await buildLang(lang)
}