Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/api/ai/ask/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { getApiUser } from "@/lib/session";
import { getUserSettings } from "@/lib/app-settings";
import { resolveModel } from "@/lib/ai/providers";
import { articleText, errorMessage, languageInstruction } from "@/lib/ai/content";
import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota";
import { accessibleArticles } from "@/lib/article-state";

const MAX_HISTORY = 10;
Expand Down Expand Up @@ -39,6 +40,7 @@ export async function POST(request: Request) {
} catch (err) {
return Response.json({ error: errorMessage(err, await getLocale()) }, { status: 400 });
}
if (!allowSharedAiCall(user.id, resolved.provider.userId == null)) return aiQuotaResponse();

const result = streamText({
model: resolved.model,
Expand Down
27 changes: 21 additions & 6 deletions src/app/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ import { articleText, errorMessage, languageInstruction } from "@/lib/ai/content
import { parseJsonArray, truncate } from "@/lib/utils";
import { acquireLock } from "@/lib/jobs/lock";
import { semanticSearch } from "@/lib/ai/embeddings";
import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota";

export const maxDuration = 120;

const MAX_CHAT_MESSAGES = 40;
const MAX_CHAT_CHARS = 100_000;

const body = z.object({
threadId: z.string(),
messages: z.array(z.custom<UIMessage>((m) => typeof m === "object" && m !== null && "role" in m)),
messages: z.array(z.custom<UIMessage>((m) => typeof m === "object" && m !== null && "role" in m)).max(500),
// Escolhas do compositor (opcionais): provedor, modelo e nível de raciocínio.
providerId: z.string().optional(),
model: z.string().max(200).optional(),
Expand All @@ -29,7 +33,11 @@ export async function POST(request: Request) {

const parsed = body.safeParse(await request.json().catch(() => null));
if (!parsed.success) return new Response("Bad request", { status: 400 });
const { threadId, messages, providerId, model, reasoning } = parsed.data;
const { threadId, providerId, model, reasoning } = parsed.data;
const messages = parsed.data.messages
.filter((message) => message.role === "user" || message.role === "assistant" || message.role === "system")
.slice(-MAX_CHAT_MESSAGES);
if (JSON.stringify(messages).length > MAX_CHAT_CHARS) return new Response("Payload too large", { status: 413 });

const thread = await db.chatThread.findFirst({ where: { id: threadId, userId: user.id } });
if (!thread) return new Response("Not found", { status: 404 });
Expand All @@ -43,11 +51,18 @@ export async function POST(request: Request) {
return Response.json({ error: errorMessage(err, locale) }, { status: 400 });
}

if (!allowSharedAiCall(user.id, resolved.provider.userId == null)) return aiQuotaResponse();

const subscribed = { feed: { subscriptions: { some: { userId: user.id } } } };
// Artigos da conversa: assinados ou salvos (links avulsos salvos não têm assinatura).
const contextArticles = await db.article.findMany({
where: { id: { in: parseJsonArray(thread.articleIds) }, ...accessibleArticles(user.id) },
take: 5,
// Artigos da conversa, na ordem em que foram anexados (no máximo cinco).
const contextIds = parseJsonArray(thread.articleIds).slice(0, 5);
const contextRows = contextIds.length
? await db.article.findMany({ where: { id: { in: contextIds }, ...accessibleArticles(user.id) } })
: [];
const contextById = new Map(contextRows.map((article) => [article.id, article]));
const contextArticles = contextIds.flatMap((id) => {
const article = contextById.get(id);
return article ? [article] : [];
});

const tools = {
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/ai/speech/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getApiUser } from "@/lib/session";
import { articleText, errorMessage } from "@/lib/ai/content";
import { localizeError, LocalizedError } from "@/lib/localized-error";
import { mergeAudioParts, requestSpeech, resolveTtsProvider, ttsMimeType } from "@/lib/ai/tts";
import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota";

const body = z.object({
articleId: z.string(),
Expand All @@ -28,6 +29,7 @@ export async function POST(request: Request) {

try {
const provider = await resolveTtsProvider(user.id);
if (!allowSharedAiCall(user.id, provider.userId == null)) return aiQuotaResponse();
const speechText = parsed.data.source === "summary"
? parsed.data.text!
.replace(/\[([^\]]+)]\([^)]+\)/g, "$1")
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/ai/summarize/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getApiUser } from "@/lib/session";
import { getUserSettings } from "@/lib/app-settings";
import { resolveModel } from "@/lib/ai/providers";
import { answerInLanguage, articleText, errorMessage, languageInstruction } from "@/lib/ai/content";
import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota";

const body = z.object({ articleId: z.string(), force: z.boolean().optional() });

Expand All @@ -30,6 +31,7 @@ export async function POST(request: Request) {
} catch (err) {
return Response.json({ error: errorMessage(err, await getLocale()) }, { status: 400 });
}
if (!allowSharedAiCall(user.id, resolved.provider.userId == null)) return aiQuotaResponse();

const cacheKey = {
articleId,
Expand Down
2 changes: 2 additions & 0 deletions src/app/api/ai/translate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { getUserSettings } from "@/lib/app-settings";
import { resolveModel } from "@/lib/ai/providers";
import { sanitizeArticleHtml } from "@/lib/feeds/sanitize";
import { articleHtml, errorMessage, languageInstruction, languageName } from "@/lib/ai/content";
import { aiQuotaResponse, allowSharedAiCall } from "@/lib/ai/quota";

const body = z.object({ articleId: z.string(), force: z.boolean().optional() });

Expand All @@ -31,6 +32,7 @@ export async function POST(request: Request) {
} catch (err) {
return Response.json({ error: errorMessage(err, await getLocale()) }, { status: 400 });
}
if (!allowSharedAiCall(user.id, resolved.provider.userId == null)) return aiQuotaResponse();

const cacheKey = {
articleId,
Expand Down
27 changes: 27 additions & 0 deletions src/lib/ai/quota.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** Cota por usuário quando a chamada usa a chave da instância, não a chave dele. */
const WINDOW_MS = 10 * 60_000;
const MAX_CALLS = 30;

const calls = new Map<string, number[]>();

export function allowSharedAiCall(userId: string, usesSharedKey: boolean): boolean {
if (!usesSharedKey) return true;
const now = Date.now();
const recent = (calls.get(userId) ?? []).filter((at) => now - at < WINDOW_MS);
if (recent.length >= MAX_CALLS) {
calls.set(userId, recent);
return false;
}
recent.push(now);
calls.set(userId, recent);
if (calls.size > 5000) {
for (const [key, stamps] of calls) {
if (stamps.every((at) => now - at >= WINDOW_MS)) calls.delete(key);
}
}
return true;
}

export function aiQuotaResponse() {
return new Response("Too many requests", { status: 429, headers: { "retry-after": "60" } });
}
Loading