diff --git a/docs/architecture.md b/docs/architecture.md index b753ea31f..daa95d22e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,8 +51,8 @@ Client-side environment selection is a developer convenience, not authorization. 1. A feature page validates input and calls a feature wrapper. 2. The client reads the current Supabase session. -3. It invokes `ai` or `ai-stream` with selected controls and active source context. -4. The Edge Function validates authentication, input bounds, supported model and control combinations, access limits, and feature-specific rules. +3. It invokes `ai` or `ai-stream` with selected controls, stable active-source IDs, and a bounded recent Ask history when needed. +4. The Edge Function validates authentication, loads active personal sources by authenticated user and ID, then enforces input bounds, supported model and control combinations, access limits, and feature-specific rules. 5. The backend optionally gathers research, builds prompts, calls an allowlisted model through OpenRouter, validates output, and records the provider-reported usage and charge. 6. The client validates or transforms the response and updates the relevant store. @@ -68,7 +68,9 @@ Shared research, source ranking, URL validation, and prompt construction live un The import endpoint validates public HTTP URLs, resolves redirect targets, blocks private networks, and enforces response timeout, content type, and size limits. Research paths bound source count and content before prompt construction. -Authenticated AI requests carry stable source IDs only for user-scoped assessment updates; prompt construction still receives bounded title, content, origin, and type fields. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration. +Authenticated personal AI requests carry stable source IDs instead of source content. The Edge Function loads matching active rows for the authenticated user, bounds their title, content, origin, and type, then passes that context to ranking and prompt construction. Jev scores relevance, importance, and evidence strength after the existing deterministic quality floor, while failures preserve the prior order. Generate also records a bounded source-use snapshot in the saved case output. Semantic counts and the latest case-use flag live in the existing source `metadata` JSON, so the Library can default to Smart ordering without a schema migration. + +Ordinary Ask remains stateless while a conversation is in progress. The browser sends only the newest eight messages within a UTF-8 byte budget. Pressing Save persists the chat and its messages in Supabase; Workspace threads use their separate durable server-side context path. Legacy local-only sources are not sent to the authenticated AI endpoint because they have no persisted UUID to authorize and load. ## Database boundary diff --git a/src/lib/ai/client.ts b/src/lib/ai/client.ts index aff8843dd..56148ec8e 100644 --- a/src/lib/ai/client.ts +++ b/src/lib/ai/client.ts @@ -45,9 +45,14 @@ import type { WorkspaceToolContext } from '@/lib/workspace/tool-context'; export const REQUEST_TIMEOUT_MS = 200 * 1000; export const CROSSFIRE_REQUEST_TIMEOUT_MS = 75 * 1000; const MAX_CLIENT_ERROR_BODY_BYTES = 16_000; -const MAX_CROSSFIRE_SOURCE_COUNT = 10; -const MAX_CROSSFIRE_SOURCE_CHARS = 8_000; -const MAX_CROSSFIRE_SOURCE_TOTAL_CHARS = 24_000; +const MAX_CLIENT_REQUEST_BODY_BYTES = 190_000; +const MAX_REQUEST_SOURCE_IDS = 10; +const MAX_ASK_HISTORY_ITEMS = 8; +const MAX_ASK_HISTORY_CONTENT_BYTES = 6_000; +const MAX_ASK_HISTORY_TOTAL_BYTES = 48_000; +const MAX_ASK_MESSAGE_BYTES = 80_000; +const MAX_CUSTOM_SYSTEM_PROMPT_BYTES = 32_000; +const SOURCE_UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; interface AIRequestOptions { includeActiveSources?: boolean; @@ -67,6 +72,45 @@ export function withOutputLanguage(input: Record): Record Math.max(0, maxBytes - 4); end -= 1) { + try { + return decoder.decode(encoded.subarray(0, end)); + } catch { + // Move to the prior complete UTF-8 code point. + } + } + return ''; +} + +export function boundAskMessage(message: string): string { + if (!isAskMessageWithinBudget(message)) { + throw new AIError('Message is too large. Shorten it before sending.', { statusCode: 413 }); + } + return message; +} + +export function isAskMessageWithinBudget(message: string): boolean { + return utf8ByteLength(message) <= MAX_ASK_MESSAGE_BYTES; +} + +export function boundAskHistory(history: { role: 'user' | 'assistant'; content: string }[]) { + let remaining = MAX_ASK_HISTORY_TOTAL_BYTES; + return history.slice(-MAX_ASK_HISTORY_ITEMS).reverse().flatMap((message) => { + if (remaining <= 0) return []; + const content = truncateUtf8(message.content, Math.min(MAX_ASK_HISTORY_CONTENT_BYTES, remaining)); + remaining -= utf8ByteLength(content); + return content ? [{ ...message, content }] : []; + }).reverse(); +} + function waitForAbortable(operation: () => PromiseLike, signal: AbortSignal): Promise { if (signal.aborted) return Promise.reject(new DOMException('The request was canceled.', 'AbortError')); return new Promise((resolve, reject) => { @@ -76,29 +120,6 @@ function waitForAbortable(operation: () => PromiseLike, signal: AbortSigna }); } -interface AIRequestSource { - id?: string; - title: string; - content: string; - origin?: string; - type: string; - metadata?: SourceProvenance; -} - -function boundCrossfireRequestSources(sources: AIRequestSource[]): AIRequestSource[] { - let remaining = MAX_CROSSFIRE_SOURCE_TOTAL_CHARS; - return sources.slice(0, MAX_CROSSFIRE_SOURCE_COUNT).flatMap((source) => { - if (remaining <= 0) return []; - const title = source.title.trim().slice(0, 240); - const content = source.content.trim().slice(0, Math.min(MAX_CROSSFIRE_SOURCE_CHARS, remaining)); - const type = source.type.trim().slice(0, 64); - if (!title || !content || !type) return []; - remaining -= content.length; - const origin = source.origin?.trim().slice(0, 240); - return [{ title, content, type, ...(origin ? { origin } : {}) }]; - }); -} - /** A web source discovered during research, with auditable ranking metadata from the edge function. */ export interface DiscoveredSource extends Omit { title: string; @@ -425,21 +446,13 @@ async function callSupabaseAI( const settings = useSettingsStore.getState(); const session = useSessionStore.getState(); - const selectedSources = options.workspaceContext || options.includeActiveSources === false || feature === 'sparring' + const sourceIds = options.workspaceContext || options.includeActiveSources === false || feature === 'sparring' ? [] : session.sourceLibrary .filter((s) => s.isActive) - .map((s) => ({ - ...(s.id ? { id: s.id } : {}), - title: s.title, - content: s.content, - ...(s.origin ? { origin: s.origin } : {}), - type: s.type, - ...(s.metadata ? { metadata: s.metadata } : {}), - })); - const activeSources = feature === 'crossfire' - ? boundCrossfireRequestSources(selectedSources) - : selectedSources; + .map((s) => s.id) + .filter((id) => SOURCE_UUID_PATTERN.test(id)) + .slice(0, MAX_REQUEST_SOURCE_IDS); const provider = 'openrouter'; captureProductEvent('ai_request_started', { @@ -479,7 +492,7 @@ async function callSupabaseAI( provider, model: config.model, hasAccessToken: !!accessToken, - activeSources: activeSources.length, + activeSources: sourceIds.length, }); } @@ -494,11 +507,17 @@ async function callSupabaseAI( model: config.model, temperature: config.temperature, reasoningEffort: config.reasoningEffort ?? 'low', - customSystemPrompt: options.workspaceContext || options.includeCustomSystemPrompt === false ? null : settings.customSystemPrompt || null, - sources: activeSources, + customSystemPrompt: options.workspaceContext || options.includeCustomSystemPrompt === false + ? null + : settings.customSystemPrompt ? truncateUtf8(settings.customSystemPrompt, MAX_CUSTOM_SYSTEM_PROMPT_BYTES) : null, + sourceIds, workspaceContext: options.workspaceContext, }; + if (utf8ByteLength(JSON.stringify(requestBody)) > MAX_CLIENT_REQUEST_BODY_BYTES) { + throw new AIError('Request is too large. Shorten the input and try again.', { statusCode: 413, requestId }); + } + const { data, error } = await invokeAuthenticatedFunction>('ai', { headers: { Authorization: `Bearer ${accessToken}` }, body: requestBody, @@ -1309,8 +1328,8 @@ export async function askChat( options: AskChatOptions = {} ): Promise { return await callSupabaseAI('ask', withOutputLanguage({ - message, - history, + message: boundAskMessage(message), + history: boundAskHistory(history), mode, citationMode: Boolean(options.citationMode), webSearch: Boolean(options.webSearch), diff --git a/src/lib/ai/streaming.ts b/src/lib/ai/streaming.ts index 02188868b..51e0defa2 100644 --- a/src/lib/ai/streaming.ts +++ b/src/lib/ai/streaming.ts @@ -1,6 +1,6 @@ import { getActiveAccessToken, supabase, SUPABASE_URL } from '@/lib/supabase/client'; import { captureProductEvent } from '@/lib/observability'; -import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, mapAIError, reportAIFailure, withOutputLanguage } from './client'; +import { AIAuthError, AIResponseError, REQUEST_TIMEOUT_MS, TimeoutError, boundAskHistory, boundAskMessage, mapAIError, reportAIFailure, withOutputLanguage } from './client'; import type { ProviderConfig } from './providers/types'; import { AskOutputSchema, type AskOutput } from './schemas/ask'; @@ -193,8 +193,8 @@ export async function streamAskChat( body: JSON.stringify({ feature: 'ask', input: withOutputLanguage({ - message, - history, + message: boundAskMessage(message), + history: boundAskHistory(history), mode, citationMode: Boolean(options.citationMode), webSearch: Boolean(options.webSearch), diff --git a/src/locales/en.json b/src/locales/en.json index f0b9635dd..e7d0d3b1e 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -324,6 +324,8 @@ "timeoutDesc": "The request took too long. Please try again.", "saved": "Saved", "savedDesc": "Source added to library.", + "inputTooLargeTitle": "Message is too large", + "inputTooLargeDesc": "Remove some attachment text or shorten your question before sending.", "streamInterrupted": "The streamed response was interrupted before completion.", "streamInterruptedDesc": "You can ask again to get a complete answer." }, diff --git a/src/locales/es.json b/src/locales/es.json index de4ffdfae..af3fb55e8 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -324,6 +324,8 @@ "timeoutDesc": "La solicitud tardó demasiado. Inténtalo de nuevo.", "saved": "Guardado", "savedDesc": "Fuente añadida a la biblioteca.", + "inputTooLargeTitle": "El mensaje es demasiado grande", + "inputTooLargeDesc": "Elimina parte del texto adjunto o acorta la pregunta antes de enviarla.", "streamInterrupted": "La respuesta en streaming se interrumpió antes de completarse.", "streamInterruptedDesc": "Puedes preguntar de nuevo para obtener una respuesta completa." }, diff --git a/src/locales/hu.json b/src/locales/hu.json index 92f2c6c3c..2e1149212 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -324,6 +324,8 @@ "timeoutDesc": "A kérés túl sokáig tartott. Próbáld újra.", "saved": "Mentve", "savedDesc": "Forrás hozzáadva a könyvtárhoz.", + "inputTooLargeTitle": "Az üzenet túl nagy", + "inputTooLargeDesc": "Küldés előtt távolíts el néhány csatolt szöveget, vagy rövidítsd le a kérdést.", "streamInterrupted": "A streamelt válasz a befejezés előtt megszakadt.", "streamInterruptedDesc": "Kérdezz újra a teljes válaszért." }, diff --git a/src/pages/AskPage.tsx b/src/pages/AskPage.tsx index c8cf8a012..c5ff24b0c 100644 --- a/src/pages/AskPage.tsx +++ b/src/pages/AskPage.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { useSessionStore } from '@/lib/stores/session-store'; import { useSettingsStore } from '@/lib/stores/settings-store'; import { AskChat } from '@/components/features/ask/AskChat'; -import { askChat, isTimeoutError } from '@/lib/ai/client'; +import { askChat, isAskMessageWithinBudget, isTimeoutError } from '@/lib/ai/client'; import { isAskStreamingSupported, streamAskChat } from '@/lib/ai/streaming'; import { useSubscriptionError } from '@/hooks/use-subscription-error'; import { getUserFacingAIError } from '@/lib/ai/error-display'; @@ -110,7 +110,7 @@ export default function AskPage() { const history = workspace.context ? [] : askMessages.map(m => ({ role: m.role, content: m.content })); const fullMessage = fileContext ? `CONTEXT:\n${fileContext}\n\nQUESTION: ${message}` : message; - if (fullMessage.length > MAX_AI_INPUT_LENGTH) { + if (fullMessage.length > MAX_AI_INPUT_LENGTH || !isAskMessageWithinBudget(fullMessage)) { toast({ title: t('ask.inputTooLargeTitle', 'Message is too large'), description: t('ask.inputTooLargeDesc', 'Remove some attachment text or shorten your question before sending.'), diff --git a/src/test/ai/client-branches.test.ts b/src/test/ai/client-branches.test.ts index ee2c9d3ad..4e335d805 100644 --- a/src/test/ai/client-branches.test.ts +++ b/src/test/ai/client-branches.test.ts @@ -16,8 +16,8 @@ const { const applySourceAssessmentsMock = vi.fn(); const sessionState = { sourceLibrary: [ - { title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true }, - { title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false }, + { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true }, + { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false }, ], addSource: addSourceMock, applySourceAssessments: applySourceAssessmentsMock, @@ -120,8 +120,8 @@ describe('AI client branch coverage', () => { beforeEach(() => { vi.clearAllMocks(); sessionState.sourceLibrary = [ - { title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true }, - { title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false }, + { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Useful evidence', origin: 'https://example.com', type: 'url', isActive: true }, + { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Ignore me', origin: 'https://example.com/2', type: 'url', isActive: false }, ]; getSessionMock.mockResolvedValue({ data: { session: { access_token: 'token-123' } }, @@ -275,18 +275,19 @@ describe('AI client branch coverage', () => { }); it('sends stable source IDs and applies returned semantic assessments', async () => { - sessionState.sourceLibrary[0] = { ...sessionState.sourceLibrary[0], id: 'source-active', metadata: { qualityLabel: 'strong' } } as any; + const sourceId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + sessionState.sourceLibrary[0] = { ...sessionState.sourceLibrary[0], id: sourceId, metadata: { qualityLabel: 'strong' } } as any; invokeMock.mockResolvedValueOnce({ data: { output: validGenerateOutput, - sourceAssessments: [{ key: 'source-active', id: 'source-active', relevance: 0.9, importance: 3, strength: 2, evaluatedAt: '2026-09-21T00:00:00.000Z', retained: true, usedInCase: true }], + sourceAssessments: [{ key: sourceId, id: sourceId, relevance: 0.9, importance: 3, strength: 2, evaluatedAt: '2026-09-21T00:00:00.000Z', retained: true, usedInCase: true }], }, error: null, }); await generateCase(validGenerateInput, config); - expect(invokeMock).toHaveBeenCalledWith('ai', expect.objectContaining({ body: expect.objectContaining({ sources: [expect.objectContaining({ id: 'source-active' })] }) })); - expect(sessionState.applySourceAssessments).toHaveBeenCalledWith([expect.objectContaining({ id: 'source-active', usedInCase: true })]); + expect(invokeMock).toHaveBeenCalledWith('ai', expect.objectContaining({ body: expect.objectContaining({ sourceIds: [sourceId] }) })); + expect(sessionState.applySourceAssessments).toHaveBeenCalledWith([expect.objectContaining({ id: sourceId, usedInCase: true })]); }); it('does not autosave a semantically irrelevant discovered source', async () => { @@ -831,14 +832,7 @@ describe('AI client branch coverage', () => { body: expect.objectContaining({ provider: 'openrouter', customSystemPrompt: 'custom-system-prompt', - sources: [ - { - title: 'Active source', - content: 'Useful evidence', - origin: 'https://example.com', - type: 'url', - }, - ], + sourceIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], }), })); }); diff --git a/src/test/ai/client.test.ts b/src/test/ai/client.test.ts index 58d7db267..98bdb3253 100644 --- a/src/test/ai/client.test.ts +++ b/src/test/ai/client.test.ts @@ -19,8 +19,8 @@ const clientMocks = vi.hoisted(() => ({ }, session: { sourceLibrary: [ - { title: 'Source 1', content: 'Content 1', origin: 'url1', type: 'web', isActive: true }, - { title: 'Source 2', content: 'Content 2', origin: 'url2', type: 'web', isActive: false }, + { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Source 1', content: 'Content 1', origin: 'url1', type: 'web', isActive: true }, + { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Source 2', content: 'Content 2', origin: 'url2', type: 'web', isActive: false }, ], addSource: vi.fn(), }, @@ -121,15 +121,12 @@ describe('AI Client Integration', () => { temperature: 0.8, input: expect.objectContaining(input), customSystemPrompt: 'test-system-prompt', - sources: expect.arrayContaining([ - expect.objectContaining({ title: 'Source 1' }) // Only active source - ]) + sourceIds: ['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'], }), })); - // Verify sources count const callArgs = (supabaseMock.functions.invoke.mock.calls[0] as any[])[1]; - expect(callArgs.body.sources).toHaveLength(1); + expect(callArgs.body.sources).toBeUndefined(); }); it('sends the configured reasoning effort in the non-streaming request', async () => { @@ -293,7 +290,7 @@ describe('AI Client Integration', () => { await askChat('Question', [], 'general', mockConfig, { workspaceContext }); expect(supabaseMock.functions.invoke).toHaveBeenCalledWith('ai', expect.objectContaining({ - body: expect.objectContaining({ customSystemPrompt: null, sources: [], workspaceContext }), + body: expect.objectContaining({ customSystemPrompt: null, sourceIds: [], workspaceContext }), })); }); diff --git a/src/test/ai/crossfire-client.test.ts b/src/test/ai/crossfire-client.test.ts index 4444d212b..27625e8ea 100644 --- a/src/test/ai/crossfire-client.test.ts +++ b/src/test/ai/crossfire-client.test.ts @@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({ }, session: { sourceLibrary: [ - { title: 'Active source', content: 'Grounded evidence', origin: 'https://example.com', type: 'url', isActive: true }, - { title: 'Inactive source', content: 'Do not send', origin: 'https://inactive.example', type: 'url', isActive: false }, + { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Grounded evidence', origin: 'https://example.com', type: 'url', isActive: true }, + { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Do not send', origin: 'https://inactive.example', type: 'url', isActive: false }, ], }, })); @@ -96,8 +96,8 @@ describe('Crossfire AI client', () => { vi.clearAllMocks(); mocks.settings.outputLanguage = 'hu'; mocks.session.sourceLibrary.splice(0, mocks.session.sourceLibrary.length, - { title: 'Active source', content: 'Grounded evidence', origin: 'https://example.com', type: 'url', isActive: true }, - { title: 'Inactive source', content: 'Do not send', origin: 'https://inactive.example', type: 'url', isActive: false }, + { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', title: 'Active source', content: 'Grounded evidence', origin: 'https://example.com', type: 'url', isActive: true }, + { id: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', title: 'Inactive source', content: 'Do not send', origin: 'https://inactive.example', type: 'url', isActive: false }, ); mocks.getSession.mockResolvedValue({ data: { session: { access_token: 'token' } } }); }); @@ -109,21 +109,20 @@ describe('Crossfire AI client', () => { body: expect.objectContaining({ feature: 'crossfire', input: expect.objectContaining({ language: 'hu' }), - sources: [], + sourceIds: [], customSystemPrompt: null, }), })); await crossfire(nextTurnInput(true), config); const secondCall = mocks.invoke.mock.calls[1][1]; - expect(secondCall.body.sources).toEqual([ - expect.objectContaining({ title: 'Active source', content: 'Grounded evidence' }), - ]); + expect(secondCall.body.sourceIds).toEqual(['aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa']); }); - it('bounds active Crossfire sources before serializing non-ASCII content', async () => { + it('sends only bounded Crossfire source IDs when stored content is large', async () => { mocks.session.sourceLibrary.splice(0, mocks.session.sourceLibrary.length, ...Array.from({ length: 12 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, title: `Source ${index}`, content: '界'.repeat(9_000), origin: `https://example.com/${index}`, @@ -135,10 +134,8 @@ describe('Crossfire AI client', () => { await crossfire(nextTurnInput(true), config); - const sources = mocks.invoke.mock.calls[0][1].body.sources as Array<{ content: string }>; - expect(sources).toHaveLength(3); - expect(sources.every((source) => source.content.length <= 8_000)).toBe(true); - expect(sources.reduce((sum, source) => sum + source.content.length, 0)).toBe(24_000); + const sourceIds = mocks.invoke.mock.calls[0][1].body.sourceIds as string[]; + expect(sourceIds).toHaveLength(10); expect(new TextEncoder().encode(JSON.stringify(mocks.invoke.mock.calls[0][1].body)).byteLength).toBeLessThan(200_000); }); diff --git a/src/test/ai/request-dispatch.test.ts b/src/test/ai/request-dispatch.test.ts index f06363834..683071ef4 100644 --- a/src/test/ai/request-dispatch.test.ts +++ b/src/test/ai/request-dispatch.test.ts @@ -48,6 +48,8 @@ import { setActiveAccessToken, supabase } from '@/lib/supabase/client'; afterEach(() => { setActiveAccessToken(null); + sessionState.sourceLibrary = []; + settingsState.customSystemPrompt = ''; vi.restoreAllMocks(); }); @@ -107,3 +109,80 @@ it('maps a rejected fetch through the Edge Function error contract', async () => statusCode: 500, }); }); + +it('keeps large Ask context below the Edge Function request limit', async () => { + setActiveAccessToken('test-access-token'); + sessionState.sourceLibrary = [{ + id: 'local_source_legacy', + title: 'Local source', + content: 'local only', + type: 'text', + isActive: true, + }, ...Array.from({ length: 12 }, (_, index) => ({ + id: `00000000-0000-4000-8000-${String(index).padStart(12, '0')}`, + title: `Source ${index}`, + content: 'source context '.repeat(3_000), + type: 'text', + isActive: true, + }))]; + settingsState.customSystemPrompt = '界'.repeat(50_000); + const history = Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? 'user' as const : 'assistant' as const, + content: '界'.repeat(20_000), + })); + + let sentBody = ''; + vi.spyOn(globalThis, 'fetch').mockImplementation(async (_input, init) => { + sentBody = String(init?.body ?? ''); + if (new TextEncoder().encode(sentBody).byteLength > 200_000) { + return new Response(JSON.stringify({ error: 'Request body exceeds 200000 bytes' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + return new Response(JSON.stringify({ + output: { response: 'Mock AI response.', followUpQuestions: [], uncertainties: [] }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }); + + await expect(askChat('界'.repeat(20_000), history, 'clarify', { + provider: 'openrouter', + model: 'openai/gpt-5.6-luna', + temperature: 0.7, + reasoningEffort: 'low', + })).resolves.toMatchObject({ response: 'Mock AI response.' }); + + const body = JSON.parse(sentBody) as { + customSystemPrompt: string; + input: { history: Array<{ content: string }>; message: string }; + sourceIds: string[]; + sources?: unknown; + }; + expect(new TextEncoder().encode(sentBody).byteLength).toBeLessThanOrEqual(200_000); + expect(body.sourceIds).toHaveLength(10); + expect(body.sourceIds).not.toContain('local_source_legacy'); + expect(body.sources).toBeUndefined(); + expect(body.input.history).toHaveLength(8); + expect(body.input.history.every((message) => new TextEncoder().encode(message.content).byteLength <= 6_000)).toBe(true); + expect(body.input.history.reduce((total, message) => total + new TextEncoder().encode(message.content).byteLength, 0)).toBeLessThanOrEqual(48_000); + expect(new TextEncoder().encode(body.input.message).byteLength).toBeLessThanOrEqual(80_000); + expect(new TextEncoder().encode(body.customSystemPrompt).byteLength).toBeLessThanOrEqual(32_000); +}); + +it('rejects an oversized multibyte Ask message instead of shortening it silently', async () => { + setActiveAccessToken('test-access-token'); + const fetchRequest = vi.spyOn(globalThis, 'fetch'); + + await expect(askChat('😀'.repeat(30_000), [], 'clarify', { + provider: 'openrouter', + model: 'openai/gpt-5.6-luna', + temperature: 0.7, + reasoningEffort: 'low', + })).rejects.toMatchObject({ + name: 'AIError', + message: 'Message is too large. Shorten it before sending.', + statusCode: 413, + }); + + expect(fetchRequest).not.toHaveBeenCalled(); +}); diff --git a/src/test/pages/ConversationPages.test.tsx b/src/test/pages/ConversationPages.test.tsx index 64496729f..c166c0f41 100644 --- a/src/test/pages/ConversationPages.test.tsx +++ b/src/test/pages/ConversationPages.test.tsx @@ -92,6 +92,7 @@ vi.mock('@/lib/ai/client', () => ({ askChat: (...args: unknown[]) => (mockAskChat as (...callArgs: unknown[]) => unknown)(...args), brainstorm: (...args: unknown[]) => (mockBrainstorm as (...callArgs: unknown[]) => unknown)(...args), factoids: (...args: unknown[]) => (mockFactoids as (...callArgs: unknown[]) => unknown)(...args), + isAskMessageWithinBudget: (message: string) => new TextEncoder().encode(message).byteLength <= 80_000, isTimeoutError: (...args: unknown[]) => (mockIsTimeoutError as (...callArgs: unknown[]) => unknown)(...args), })); @@ -111,6 +112,7 @@ vi.mock('@/components/features/ask/AskChat', () => ({
+ @@ -259,6 +261,21 @@ describe('Conversation Pages', () => { }); }); + it('AskPage rejects an oversized multibyte message before calling AI', async () => { + render( + + + + ); + + fireEvent.click(screen.getByTestId('ask-oversized')); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ title: 'Message is too large' })); + }); + expect(mockAskChat).not.toHaveBeenCalled(); + }); + it('AskPage streams basic ask replies when supported', async () => { mockIsAskStreamingSupported.mockReturnValueOnce(true); mockStreamAskChat.mockImplementationOnce(async (...args: any[]) => { diff --git a/supabase/functions/ai/handler-research.test.ts b/supabase/functions/ai/handler-research.test.ts index b07d7b577..0fa81831a 100644 --- a/supabase/functions/ai/handler-research.test.ts +++ b/supabase/functions/ai/handler-research.test.ts @@ -388,8 +388,19 @@ function askRequest(model: string, reasoningEffort?: string): Request { Deno.test("AI handler ranks stable library sources and returns bounded assessments", async () => { const originalFetch = globalThis.fetch; const providerBodies: Array> = []; + const sourceA = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const sourceB = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; globalThis.fetch = (async (input: Request | URL | string, init?: RequestInit) => { const url = new URL(input instanceof Request ? input.url : String(input)); + if (url.origin === "https://supabase.test" && url.pathname.includes("/rest/v1/sources")) { + if (url.searchParams.get("user_id") !== "eq.user-1" || url.searchParams.get("is_active") !== "eq.true") { + throw new Error(`source lookup was not user scoped: ${url}`); + } + return jsonResponse([ + { id: sourceA, title: "Source A", content: "Evidence A", type: "text", metadata: { qualityLabel: "strong", qualityScore: 90 } }, + { id: sourceB, title: "Source B", content: "Evidence B", type: "text", metadata: { qualityLabel: "strong", qualityScore: 80 } }, + ]); + } const supabaseResponse = supabaseTestResponse(url); if (supabaseResponse) return supabaseResponse; if (url.href === "https://openrouter.ai/api/alpha/decisions") { @@ -431,15 +442,12 @@ Deno.test("AI handler ranks stable library sources and returns bounded assessmen feature: "ask", model: "openai/gpt-5.6-luna", input: { message: "Compare the evidence", mode: "general", webSearch: false }, - sources: [ - { id: "source-a", title: "Source A", content: "Evidence A", type: "text", metadata: { qualityLabel: "strong", qualityScore: 90 } }, - { id: "source-b", title: "Source B", content: "Evidence B", type: "text", metadata: { qualityLabel: "strong", qualityScore: 80 } }, - ], + sourceIds: [sourceA, sourceB], }), })); if (response.status !== 200) throw new Error(`source-ranking request returned ${response.status}: ${await response.text()}`); const payload = await response.json() as { sourceAssessments?: Array<{ id?: string; importance?: number }> }; - if (payload.sourceAssessments?.map((item) => item.id).join(",") !== "source-a,source-b") throw new Error("stable source IDs were not returned"); + if (payload.sourceAssessments?.map((item) => item.id).join(",") !== `${sourceA},${sourceB}`) throw new Error("stable source IDs were not returned"); if (payload.sourceAssessments?.[1]?.importance !== 3) throw new Error("semantic assessment was not returned"); const providerText = JSON.stringify(providerBodies); if (providerText.indexOf("Source B") > providerText.indexOf("Source A")) throw new Error("semantic source order did not reach the provider prompt"); diff --git a/supabase/functions/ai/index.ts b/supabase/functions/ai/index.ts index b35d37ff2..ecd3ef819 100644 --- a/supabase/functions/ai/index.ts +++ b/supabase/functions/ai/index.ts @@ -186,6 +186,8 @@ function badRequest(message: string, corsHeaders: Record, status const MAX_INPUT_LENGTH = 50000; const MAX_ASK_CONTEXT_LENGTH = 32000; export const MAX_REQUEST_BODY_BYTES = 200_000; +const MAX_PERSONAL_SOURCE_IDS = 10; +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; export const MAX_CROSSFIRE_PROMPT_CHARS = 100_000; export const MAX_CROSSFIRE_TRANSCRIPT_TURNS = 40; export const MAX_CROSSFIRE_TRANSCRIPT_CHARS = 24_000; @@ -270,6 +272,18 @@ function hasOnlyKeys(value: Record, keys: readonly string[]): b return Object.keys(value).every((key) => allowed.has(key)); } +function parsePersonalSourceIds(value: unknown): { value?: string[] } | { error: string } { + if (value === undefined) return { value: undefined }; + if (!Array.isArray(value) || value.length > MAX_PERSONAL_SOURCE_IDS) { + return { error: `sourceIds must contain at most ${MAX_PERSONAL_SOURCE_IDS} items` }; + } + if (!value.every((id): id is string => typeof id === "string" && UUID_PATTERN.test(id))) { + return { error: "sourceIds must contain valid UUIDs" }; + } + if (new Set(value).size !== value.length) return { error: "sourceIds must not contain duplicates" }; + return { value }; +} + function crossfireText(value: unknown, fieldName: string, maxLength: number): { value: string } | { error: string } { if (typeof value !== "string") return { error: `${fieldName} must be a string` }; const sanitized = sanitizePromptInput(value, fieldName); @@ -862,10 +876,20 @@ export async function handleRequest(req: Request): Promise { const customSystemPrompt = feature === "crossfire" ? null : body.customSystemPrompt as string | null | undefined; - const rawSources = body.sources; + let rawSources = body.sources; + const sourceIdsResult = parsePersonalSourceIds(body.sourceIds); + if ("error" in sourceIdsResult) return badRequest(sourceIdsResult.error, corsHeaders); + if (sourceIdsResult.value !== undefined && body.sources !== undefined) { + return badRequest("Send sourceIds instead of source content", corsHeaders); + } // Log incoming request - log(requestId, "INFO", "Request received", { feature, provider, model: requestedModel, sourcesCount: Array.isArray(rawSources) ? rawSources.length : 0 }); + log(requestId, "INFO", "Request received", { + feature, + provider, + model: requestedModel, + sourcesCount: sourceIdsResult.value?.length ?? (Array.isArray(rawSources) ? rawSources.length : 0), + }); if (!feature) return badRequest("Missing feature", corsHeaders); if (!isRecord(rawInput)) return badRequest("Missing input", corsHeaders); @@ -882,13 +906,6 @@ export async function handleRequest(req: Request): Promise { } crossfireInput = crossfireResult.value; input = crossfireInput as unknown as Record; - const sourcesResult = boundCrossfireSources(rawSources, crossfireInput.config.useSourceLibrary); - if ("error" in sourcesResult) { - log(requestId, "WARN", "Crossfire source validation failed", { reason: sourcesResult.error }); - return badRequest(sourcesResult.error, corsHeaders); - } - crossfireSources = sourcesResult.value; - crossfireSourcesTruncated = sourcesResult.truncated; } const sanitizeResult = sanitizeInput(input); if ("rejected" in sanitizeResult) { @@ -982,6 +999,45 @@ export async function handleRequest(req: Request): Promise { const userId: string = user.id; log(requestId, "INFO", "User authenticated", { userId }); + + if (sourceIdsResult.value !== undefined) { + if (body.workspaceContext !== undefined && sourceIdsResult.value.length > 0) { + return badRequest("Personal sources cannot be mixed with workspace context", corsHeaders); + } + if (sourceIdsResult.value.length === 0) { + rawSources = []; + sources = []; + } else { + const { data, error } = await supabase + .from("sources") + .select("id,title,content,origin,type,metadata") + .eq("user_id", userId) + .eq("is_active", true) + .in("id", sourceIdsResult.value); + if (error) { + log(requestId, "ERROR", "Unable to load personal sources", { error: error.message }); + return badRequest("Unable to load source library", corsHeaders, 500); + } + const rows = Array.isArray(data) ? data.filter(isRecord) : []; + const rowById = new Map(rows.map((row) => [String(row.id), row])); + const orderedRows = sourceIdsResult.value.flatMap((id) => { + const row = rowById.get(id); + if (!row || typeof row.title !== "string" || !row.title.trim() || typeof row.content !== "string" || !row.content || typeof row.type !== "string" || !row.type.trim()) return []; + return [row]; + }); + const bounded = boundPromptSources(orderedRows); + rawSources = bounded.flatMap((source, index) => { + const row = orderedRows[index]; + return row ? [{ + ...source, + id: String(row.id), + ...(isRecord(row.metadata) ? { metadata: row.metadata } : {}), + }] : []; + }); + sources = bounded; + } + } + if (crossfireInput?.operation === "signal") { if (await checkUserBanned(userId)) return badRequest("Your account has been suspended. Please contact support.", corsHeaders, 403); if (!(await verifyCrossfireSignalToken(userId, crossfireInput))) return badRequest("Invalid or expired Crossfire signal token", corsHeaders, 403); @@ -1006,6 +1062,18 @@ export async function handleRequest(req: Request): Promise { }); } + if (feature === "crossfire" && crossfireInput) { + const sourcesResult = boundCrossfireSources(rawSources, crossfireInput.config.useSourceLibrary); + if ("error" in sourcesResult) { + log(requestId, "WARN", "Crossfire source validation failed", { reason: sourcesResult.error }); + return badRequest(sourcesResult.error, corsHeaders); + } + crossfireSources = sourcesResult.value; + crossfireSourcesTruncated = sourcesResult.truncated; + sources = crossfireSources; + responseTruncated = responseTruncated || crossfireSourcesTruncated; + } + if (body.workspaceContext !== undefined) { try { const loaded = await loadAuthorizedWorkspaceContext(workspaceDatabase, userId, body.workspaceContext);