Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
187f5bd
Added search tool support for Claude
joseph-mpo-yeti Apr 9, 2026
73d8894
Merge branch 'main' into feature/claude-search
joseph-mpo-yeti Apr 9, 2026
94f7e69
New locales for i18n
joseph-mpo-yeti Apr 9, 2026
c102a0e
Updated translations
joseph-mpo-yeti Apr 9, 2026
982623e
Prevent default web search base URLs from being prepopulated
joseph-mpo-yeti Apr 9, 2026
c8b6b4a
fixed coding style issues
joseph-mpo-yeti Apr 9, 2026
5595cc5
fixed coding style issues
joseph-mpo-yeti Apr 10, 2026
c50e454
fixing lint errors
joseph-mpo-yeti Apr 10, 2026
b633b5f
fix: add SSRF protection to fetchPageContent in claude.ts
Copilot Apr 10, 2026
08b80da
fix: address all remaining review feedback from PR review thread
Copilot Apr 10, 2026
feb0f28
fix: rename ambiguous variable names in settings store for clarity
Copilot Apr 10, 2026
35d5e20
fixing lint errors
joseph-mpo-yeti Apr 10, 2026
6a18278
fix: address 5 items from second PR review thread
Copilot Apr 10, 2026
70fdcef
fixing model verification error
joseph-mpo-yeti Apr 10, 2026
a5ad376
fix: address 3 items from third PR review thread
Copilot Apr 10, 2026
c821b10
fixing lint errors
joseph-mpo-yeti Apr 10, 2026
7907f2b
fix: resolved Copilot review comments
joseph-mpo-yeti Apr 10, 2026
f5f4f91
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 11, 2026
e2cdfa0
fix: returning string promise for ssrf-guard
joseph-mpo-yeti Apr 11, 2026
3444c04
fix: returning string promise for ssrf-guard
joseph-mpo-yeti Apr 11, 2026
be090e6
Merge branch 'THU-MAIC:main' into feat/claude-search
joseph-mpo-yeti Apr 12, 2026
fd30a25
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 13, 2026
4c34705
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 18, 2026
0c5e96d
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 18, 2026
69dcaaa
fix: updated locales, added allowed_callers for claude search tools, …
joseph-mpo-yeti Apr 18, 2026
bea14b9
fix: updated locales, added allowed_callers for claude search tools,…
joseph-mpo-yeti Apr 18, 2026
6e6b6f9
updated tests
joseph-mpo-yeti Apr 18, 2026
89dbb8f
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 18, 2026
74ed4b0
using Anthropic SDK for search and connection test in search settings.
joseph-mpo-yeti Apr 18, 2026
939f651
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 18, 2026
2424830
Merge main into feat/claude-search, resolving page.tsx conflict
joseph-mpo-yeti Apr 19, 2026
2b8b61a
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 19, 2026
46527a7
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 19, 2026
c3b3766
uses @ai-sdk with a custom fetch including allowed_callers in payload…
joseph-mpo-yeti Apr 19, 2026
b712922
fixed format
joseph-mpo-yeti Apr 19, 2026
4edcd26
removed @anthropic-ai/sdk as direct dependency
joseph-mpo-yeti Apr 19, 2026
c323563
fix: resolve TypeScript errors in claude web-search tests
joseph-mpo-yeti Apr 19, 2026
ef3a27e
Merge branch 'main' into feat/claude-search
joseph-mpo-yeti Apr 20, 2026
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
70 changes: 65 additions & 5 deletions app/api/web-search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,25 @@
* Web Search API
*
* POST /api/web-search
* Simple JSON request/response using Tavily search.
* Supports multiple search providers (Tavily, Claude).
*/

import { NextRequest } from 'next/server';
import { callLLM } from '@/lib/ai/llm';
import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily';
import { searchWithClaude } from '@/lib/web-search/claude';
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
import { resolveWebSearchApiKey } from '@/lib/server/provider-config';
import { validateUrlForSSRF } from '@/lib/server/ssrf-guard';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
import { API_ERROR_CODES, apiError, apiSuccess } from '@/lib/server/api-response';
import {
buildSearchQuery,
SEARCH_QUERY_REWRITE_EXCERPT_LENGTH,
} from '@/lib/server/search-query-builder';
import { resolveModelFromHeaders } from '@/lib/server/resolve-model';
import type { AICallFn } from '@/lib/generation/pipeline-types';
import type { WebSearchProviderId } from '@/lib/web-search/types';

const log = createLogger('WebSearch');

Expand All @@ -28,23 +32,47 @@ export async function POST(req: NextRequest) {
query: requestQuery,
pdfText,
apiKey: clientApiKey,
providerId: requestProviderId,
providerConfig,
} = body as {
query?: string;
pdfText?: string;
apiKey?: string;
providerId?: WebSearchProviderId;
providerConfig?: {
modelId?: string;
baseUrl?: string;
tools?: Array<{ type: string; name: string }>;
};
};
query = requestQuery;

// Provider must be explicitly specified
const providerId: WebSearchProviderId | null = requestProviderId ?? null;

if (!query || !query.trim()) {
return apiError('MISSING_REQUIRED_FIELD', 400, 'query is required');
}

const apiKey = resolveWebSearchApiKey(clientApiKey);
if (!providerId) {
return apiError(
'MISSING_PROVIDER',
400,
'Web search provider is not selected. Please select a provider in the toolbar.',
);
}

if (!(providerId in WEB_SEARCH_PROVIDERS)) {
return apiError('INVALID_REQUEST', 400, `Unknown web search provider: ${providerId}`);
}

const apiKey = resolveWebSearchApiKey(providerId, clientApiKey);
if (!apiKey) {
const envVar = providerId === 'claude' ? 'ANTHROPIC_API_KEY' : 'TAVILY_API_KEY';
return apiError(
'MISSING_API_KEY',
400,
'Tavily API key is not configured. Set it in Settings → Web Search or set TAVILY_API_KEY env var.',
`${providerId} API key is not configured. Set it in Settings → Web Search or set ${envVar} env var.`,
);
}

Expand Down Expand Up @@ -75,13 +103,45 @@ export async function POST(req: NextRequest) {
const searchQuery = await buildSearchQuery(query, boundedPdfText, aiCall);

log.info('Running web search API request', {
provider: providerId,
hasPdfContext: searchQuery.hasPdfContext,
rawRequirementLength: searchQuery.rawRequirementLength,
rewriteAttempted: searchQuery.rewriteAttempted,
finalQueryLength: searchQuery.finalQueryLength,
});

const result = await searchWithTavily({ query: searchQuery.query, apiKey });
// Validate client-supplied base URL against SSRF in all environments
if (providerConfig?.baseUrl) {
const ssrfError = await validateUrlForSSRF(providerConfig.baseUrl);
if (ssrfError) {
return apiError(API_ERROR_CODES.INVALID_URL, 400, ssrfError);
}
}

const baseUrl =
providerConfig?.baseUrl || WEB_SEARCH_PROVIDERS[providerId].defaultBaseUrl || '';

let result;
switch (providerId) {
case 'claude': {
result = await searchWithClaude({
query: searchQuery.query,
apiKey,
baseUrl,
modelId: providerConfig?.modelId,
tools: providerConfig?.tools,
});
break;
}
case 'tavily': {
result = await searchWithTavily({
query: searchQuery.query,
apiKey,
baseUrl,
});
break;
}
}
const context = formatSearchResultsAsContext(result);

return apiSuccess({
Expand Down
16 changes: 13 additions & 3 deletions app/generation-preview/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -300,15 +300,25 @@ function GenerationPreviewContent() {
setWebSearchSources([]);

const wsSettings = useSettingsStore.getState();
const wsApiKey =
wsSettings.webSearchProvidersConfig?.[wsSettings.webSearchProviderId]?.apiKey;
const wsProviderId = wsSettings.webSearchProviderId;
const wsProviderConfig = wsProviderId
? wsSettings.webSearchProvidersConfig?.[wsProviderId]
: null;
const res = await fetch('/api/web-search', {
method: 'POST',
headers: getApiHeaders(),
body: JSON.stringify({
query: currentSession.requirements.requirement,
pdfText: currentSession.pdfText || undefined,
apiKey: wsApiKey || undefined,
providerId: wsProviderId || undefined,
apiKey: wsProviderConfig?.apiKey || undefined,
providerConfig: wsProviderId
? {
modelId: wsProviderConfig?.modelId,
baseUrl: wsProviderConfig?.baseUrl,
tools: wsProviderConfig?.tools,
}
: undefined,
}),
signal,
});
Expand Down
32 changes: 17 additions & 15 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
BotOff,
ChevronUp,
Upload,
Sparkles,

Check warning on line 22 in app/page.tsx

View workflow job for this annotation

GitHub Actions / Lint, Typecheck & Unit Tests

'Sparkles' is defined but never used. Allowed unused vars must match /^_/u
Atom,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
Expand Down Expand Up @@ -55,21 +55,18 @@

const log = createLogger('Home');

const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled';
const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen';
const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled';

interface FormState {
pdfFile: File | null;
requirement: string;
webSearch: boolean;
interactiveMode: boolean;
}

const initialFormState: FormState = {
pdfFile: null,
requirement: '',
webSearch: false,
interactiveMode: false,
};

Expand All @@ -89,6 +86,8 @@

// Model setup state
const currentModelId = useSettingsStore((s) => s.modelId);
const webSearchEnabled = useSettingsStore((s) => s.webSearchEnabled);
const setWebSearchEnabled = useSettingsStore((s) => s.setWebSearchEnabled);
const [recentOpen, setRecentOpen] = useState(true);

// Hydrate client-only state after mount (avoids SSR mismatch)
Expand All @@ -101,16 +100,20 @@
/* localStorage unavailable */
}
try {
const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY);
const savedInteractiveMode = localStorage.getItem(INTERACTIVE_MODE_STORAGE_KEY);
const updates: Partial<FormState> = {};
if (savedWebSearch === 'true') updates.webSearch = true;
if (savedInteractiveMode === 'true') updates.interactiveMode = true;
if (Object.keys(updates).length > 0) {
setForm((prev) => ({ ...prev, ...updates }));
// Migrate webSearchEnabled from old localStorage key into the Zustand store
const oldWebSearch = localStorage.getItem('webSearchEnabled');
if (oldWebSearch === 'true' && !useSettingsStore.getState().webSearchEnabled) {
const store = useSettingsStore.getState();
if (!store.webSearchProviderId) {
store.setWebSearchProvider('tavily');
}
store.setWebSearchEnabled(true);
}
if (oldWebSearch !== null) localStorage.removeItem('webSearchEnabled');
const savedInteractiveMode = localStorage.getItem(INTERACTIVE_MODE_STORAGE_KEY);
if (savedInteractiveMode === 'true') setForm((prev) => ({ ...prev, interactiveMode: true }));
} catch {
/* localStorage unavailable */
/* ignore */
}
}, []);
/* eslint-enable react-hooks/set-state-in-effect */
Expand Down Expand Up @@ -204,7 +207,6 @@
const updateForm = <K extends keyof FormState>(field: K, value: FormState[K]) => {
setForm((prev) => ({ ...prev, [field]: value }));
try {
if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value));
if (field === 'interactiveMode')
localStorage.setItem(INTERACTIVE_MODE_STORAGE_KEY, String(value));
if (field === 'requirement') updateRequirementCache(value as string);
Expand Down Expand Up @@ -268,7 +270,7 @@
requirement: form.requirement,
userNickname: userProfile.nickname || undefined,
userBio: userProfile.bio || undefined,
webSearch: form.webSearch || undefined,
webSearch: webSearchEnabled || undefined,
interactiveMode: form.interactiveMode,
};

Expand Down Expand Up @@ -513,8 +515,8 @@
<div className="px-3 pb-3 flex items-end gap-2">
<div className="flex-1 min-w-0">
<GenerationToolbar
webSearch={form.webSearch}
onWebSearchChange={(v) => updateForm('webSearch', v)}
webSearch={webSearchEnabled}
onWebSearchChange={setWebSearchEnabled}
onSettingsOpen={(section) => {
setSettingsSection(section);
setSettingsOpen(true);
Expand Down
Loading
Loading