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
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:
@@ -15,16 +15,24 @@ export interface EmbeddingResult {
|
||||
|
||||
export class EmbeddingService {
|
||||
private readonly EMBEDDING_DIMENSION = 1536
|
||||
private readonly MAX_CHARS = 15000
|
||||
|
||||
private truncateForEmbedding(text: string): string {
|
||||
if (text.length <= this.MAX_CHARS) return text
|
||||
return text.slice(0, this.MAX_CHARS)
|
||||
}
|
||||
|
||||
async generateEmbedding(text: string): Promise<EmbeddingResult> {
|
||||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Cannot generate embedding for empty text')
|
||||
}
|
||||
|
||||
const truncated = this.truncateForEmbedding(text)
|
||||
|
||||
try {
|
||||
const config = await getSystemConfig()
|
||||
const embedding = await withAiProviderFallback('embedding', config, (provider) =>
|
||||
provider.getEmbeddings(text)
|
||||
provider.getEmbeddings(truncated)
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -41,7 +49,7 @@ export class EmbeddingService {
|
||||
async generateBatchEmbeddings(texts: string[]): Promise<EmbeddingResult[]> {
|
||||
if (!texts || texts.length === 0) return []
|
||||
|
||||
const validTexts = texts.filter(t => t && t.trim().length > 0)
|
||||
const validTexts = texts.filter(t => t && t.trim().length > 0).map(t => this.truncateForEmbedding(t))
|
||||
if (validTexts.length === 0) return []
|
||||
|
||||
try {
|
||||
|
||||
@@ -77,14 +77,14 @@ export class MemoryEchoService {
|
||||
for (const note of notesWithoutEmbeddings) {
|
||||
if (!note.content || note.content.trim().length === 0) continue
|
||||
try {
|
||||
const embedding = await provider.getEmbeddings(note.content)
|
||||
const embedding = await provider.getEmbeddings(note.content.slice(0, 15000))
|
||||
if (embedding && embedding.length > 0) {
|
||||
const vecStr = `[${embedding.join(',')}]`
|
||||
await prisma.$executeRawUnsafe(
|
||||
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
|
||||
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt")
|
||||
VALUES (gen_random_uuid(), $1, $2::vector, now())
|
||||
ON CONFLICT ("noteId")
|
||||
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
|
||||
DO UPDATE SET "embedding" = $2::vector`,
|
||||
note.id,
|
||||
vecStr
|
||||
)
|
||||
|
||||
@@ -286,11 +286,15 @@ Only return the translated text, nothing else.`,
|
||||
Keep the explanation clear, educational, and concise.`
|
||||
}
|
||||
|
||||
const systemSuffix = mode === 'explain'
|
||||
? `CRITICAL: Respond ONLY with your explanation in ${language}. No meta-commentary, no English. Format strictly as ${format === 'html' ? 'HTML tags' : 'Markdown'}.`
|
||||
: `CRITICAL: Respond ONLY with the refactored text in ${language}. No explanations, no meta-commentary, no English. Format strictly as ${format === 'html' ? 'HTML tags' : 'Markdown'}.`
|
||||
|
||||
return `${instructions[mode]}
|
||||
|
||||
${content}
|
||||
|
||||
CRITICAL: Respond ONLY with the refactored text in ${language}. No explanations, no meta-commentary, no English. Format strictly as ${format === 'html' ? 'HTML tags' : 'Markdown'}.`
|
||||
${systemSuffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -235,15 +235,15 @@ export class SemanticSearchService {
|
||||
|
||||
const rows: Array<{ noteId: string; similarity: number }> = await prisma.$queryRawUnsafe(
|
||||
`SELECT n.id AS "noteId",
|
||||
1 - (e."embedding" <=> ${vecParam}) AS similarity
|
||||
1 - (e."embedding"::vector <=> ${vecParam}) AS similarity
|
||||
FROM "Note" n
|
||||
INNER JOIN "NoteEmbedding" e ON e."noteId" = n.id
|
||||
WHERE n."trashedAt" IS NULL
|
||||
AND n."isArchived" = false
|
||||
${userClause}
|
||||
${notebookClause}
|
||||
AND 1 - (e."embedding" <=> ${vecParam}) >= ${thresholdParam}
|
||||
ORDER BY e."embedding" <=> ${vecParam} ASC
|
||||
AND 1 - (e."embedding"::vector <=> ${vecParam}) >= ${thresholdParam}
|
||||
ORDER BY e."embedding"::vector <=> ${vecParam} ASC
|
||||
LIMIT ${limitParam}`,
|
||||
...params
|
||||
)
|
||||
@@ -356,10 +356,10 @@ export class SemanticSearchService {
|
||||
const vecStr = embeddingService.toVectorString(embedding)
|
||||
|
||||
await prisma.$queryRawUnsafe(
|
||||
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt", "updatedAt")
|
||||
VALUES (gen_random_uuid(), $1, $2::vector, now(), now())
|
||||
`INSERT INTO "NoteEmbedding" ("id", "noteId", "embedding", "createdAt")
|
||||
VALUES (gen_random_uuid(), $1, $2::vector, now())
|
||||
ON CONFLICT ("noteId")
|
||||
DO UPDATE SET "embedding" = $2::vector, "updatedAt" = now()`,
|
||||
DO UPDATE SET "embedding" = $2::vector`,
|
||||
noteId,
|
||||
vecStr
|
||||
)
|
||||
@@ -427,7 +427,7 @@ export class SemanticSearchService {
|
||||
AND n."trashedAt" IS NULL
|
||||
AND n."userId" = $3
|
||||
${noteFilter}
|
||||
ORDER BY dc."embedding" <=> $1::vector
|
||||
ORDER BY dc."embedding"::vector <=> $1::vector
|
||||
LIMIT $2`,
|
||||
...params
|
||||
) as any[]
|
||||
|
||||
@@ -31,6 +31,7 @@ export class QuotaExceededError extends Error {
|
||||
billingOwnerId?: string;
|
||||
triggeredByUserId?: string;
|
||||
isGuestActor?: boolean;
|
||||
currentTier?: string;
|
||||
|
||||
constructor(
|
||||
upgradeTier: 'PRO' | 'BUSINESS',
|
||||
@@ -42,6 +43,7 @@ export class QuotaExceededError extends Error {
|
||||
billingOwnerId?: string;
|
||||
triggeredByUserId?: string;
|
||||
isGuestActor?: boolean;
|
||||
currentTier?: string;
|
||||
},
|
||||
) {
|
||||
super(`Quota exceeded for ${feature}`);
|
||||
@@ -53,6 +55,7 @@ export class QuotaExceededError extends Error {
|
||||
this.billingOwnerId = sessionMeta?.billingOwnerId;
|
||||
this.triggeredByUserId = sessionMeta?.triggeredByUserId;
|
||||
this.isGuestActor = sessionMeta?.isGuestActor;
|
||||
this.currentTier = sessionMeta?.currentTier;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
@@ -62,6 +65,7 @@ export class QuotaExceededError extends Error {
|
||||
upgradeTier: this.upgradeTier,
|
||||
byokConfigured: this.byokConfigured,
|
||||
isGuestActor: this.isGuestActor ?? false,
|
||||
currentTier: this.currentTier,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -121,6 +125,18 @@ local newCount = tonumber(redis.call('GET', KEYS[1]))
|
||||
return newCount
|
||||
`;
|
||||
|
||||
const INCREMENT_BY_LUA = `
|
||||
local count = tonumber(ARGV[1]) or 1
|
||||
local ttl = tonumber(ARGV[2])
|
||||
redis.call('INCRBY', KEYS[1], count)
|
||||
local ttlResult = redis.call('TTL', KEYS[1])
|
||||
if ttlResult == -1 then
|
||||
redis.call('EXPIRE', KEYS[1], ttl)
|
||||
end
|
||||
local newCount = tonumber(redis.call('GET', KEYS[1]))
|
||||
return newCount
|
||||
`;
|
||||
|
||||
const RESERVE_LUA = `
|
||||
local limit = tonumber(ARGV[1])
|
||||
local ttl = tonumber(ARGV[2])
|
||||
@@ -252,6 +268,7 @@ export async function checkEntitlementOrThrow(
|
||||
result.limit,
|
||||
result.limit > 0 ? result.limit - result.remaining : 0,
|
||||
result.byokConfigured ?? false,
|
||||
{ currentTier: result.tier },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -261,7 +278,7 @@ export async function reserveUsageOrThrow(
|
||||
feature: string,
|
||||
): Promise<void> {
|
||||
if (!isValidFeature(feature)) {
|
||||
throw new QuotaExceededError('PRO', feature, 0, 0, false);
|
||||
throw new QuotaExceededError('PRO', feature, 0, 0, false, { currentTier: 'BASIC' });
|
||||
}
|
||||
|
||||
const tier = await getEffectiveTier(userId);
|
||||
@@ -274,6 +291,7 @@ export async function reserveUsageOrThrow(
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
{ currentTier: tier },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,6 +315,7 @@ export async function reserveUsageOrThrow(
|
||||
limit,
|
||||
limit,
|
||||
byokConfigured,
|
||||
{ currentTier: tier },
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -324,11 +343,11 @@ export async function checkSessionEntitlementOrThrow(
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementUsageAsync(userId: string, feature: string): Promise<void> {
|
||||
export function incrementUsageAsync(userId: string, feature: string, count: number = 1): Promise<void> {
|
||||
if (!isValidFeature(feature)) return Promise.resolve();
|
||||
|
||||
const key = getRedisKey(userId, feature);
|
||||
return redis.eval(INCREMENT_LUA, 1, key, String(TTL_SECONDS)).then(() => {}).catch((err) => {
|
||||
return redis.eval(INCREMENT_BY_LUA, 1, key, String(count), String(TTL_SECONDS)).then(() => {}).catch((err) => {
|
||||
console.error('[entitlements] Async increment failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user