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
8 changes: 5 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down
105 changes: 62 additions & 43 deletions src/lib/ai/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T = unknown> {
includeActiveSources?: boolean;
Expand All @@ -67,6 +72,45 @@ export function withOutputLanguage(input: Record<string, unknown>): Record<strin
return { ...input, language: selectedOutputLanguage() };
}

function utf8ByteLength(value: string): number {
return new TextEncoder().encode(value).byteLength;
}

function truncateUtf8(value: string, maxBytes: number): string {
const encoded = new TextEncoder().encode(value);
if (encoded.byteLength <= maxBytes) return value;
const decoder = new TextDecoder('utf-8', { fatal: true });
for (let end = maxBytes; end > 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<T>(operation: () => PromiseLike<T>, signal: AbortSignal): Promise<T> {
if (signal.aborted) return Promise.reject(new DOMException('The request was canceled.', 'AbortError'));
return new Promise<T>((resolve, reject) => {
Expand All @@ -76,29 +120,6 @@ function waitForAbortable<T>(operation: () => PromiseLike<T>, 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<SourceProvenance, 'url'> {
title: string;
Expand Down Expand Up @@ -425,21 +446,13 @@ async function callSupabaseAI<T = unknown>(
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)
Comment thread
Programcode marked this conversation as resolved.
Resolved
.filter((id) => SOURCE_UUID_PATTERN.test(id))
.slice(0, MAX_REQUEST_SOURCE_IDS);

const provider = 'openrouter';
captureProductEvent('ai_request_started', {
Expand Down Expand Up @@ -479,7 +492,7 @@ async function callSupabaseAI<T = unknown>(
provider,
model: config.model,
hasAccessToken: !!accessToken,
activeSources: activeSources.length,
activeSources: sourceIds.length,
});
}

Expand All @@ -494,11 +507,17 @@ async function callSupabaseAI<T = unknown>(
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<Record<string, unknown>>('ai', {
headers: { Authorization: `Bearer ${accessToken}` },
body: requestBody,
Expand Down Expand Up @@ -1309,8 +1328,8 @@ export async function askChat(
options: AskChatOptions = {}
): Promise<AskOutput> {
return await callSupabaseAI<AskOutput>('ask', withOutputLanguage({
message,
history,
message: boundAskMessage(message),
Comment thread
Programcode marked this conversation as resolved.
Resolved
history: boundAskHistory(history),
mode,
citationMode: Boolean(options.citationMode),
webSearch: Boolean(options.webSearch),
Expand Down
6 changes: 3 additions & 3 deletions src/lib/ai/streaming.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
2 changes: 2 additions & 0 deletions src/locales/hu.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
4 changes: 2 additions & 2 deletions src/pages/AskPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.'),
Expand Down
26 changes: 10 additions & 16 deletions src/test/ai/client-branches.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' } },
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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'],
}),
}));
});
Expand Down
13 changes: 5 additions & 8 deletions src/test/ai/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 }),
}));
});

Expand Down
Loading
Loading