Skip to content
Closed
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
34 changes: 34 additions & 0 deletions app/api/manual-cache/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
import fs from 'fs';
import path from 'path';

// Note: Storing in /tmp to work around serverless readonly filesystems
// However, since serverless instances are ephemeral, ideally you'd use Redis or Vercel KV.
// We are storing in /tmp and verifying hash structure to prevent Path Traversal.

export async function POST(req: Request) {
try {
const { hash, response } = await req.json();

if (!hash || !response) {
return NextResponse.json({ error: 'Missing hash or response' }, { status: 400 });
}

// Validate hash to be strictly 32 alphanumeric hex chars to prevent path traversal
if (!/^[a-fA-F0-9]{32}$/.test(hash)) {
return NextResponse.json({ error: 'Invalid hash format' }, { status: 400 });
}

// Since serverless is readonly outside /tmp, use /tmp
const CACHE_DIR = path.join('/tmp', '.openmaic', 'manual_cache');
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}

fs.writeFileSync(path.join(CACHE_DIR, `${hash}.json`), response, 'utf-8');

return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: error instanceof Error ? error.message : 'Unknown error' }, { status: 500 });
}
}
28 changes: 21 additions & 7 deletions app/api/web-search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { NextRequest } from 'next/server';
import { callLLM } from '@/lib/ai/llm';
import { searchWithTavily, formatSearchResultsAsContext } from '@/lib/web-search/tavily';
import { searchWithSearXNG } from '@/lib/web-search/searxng';
import { resolveWebSearchApiKey } from '@/lib/server/provider-config';
import { createLogger } from '@/lib/logger';
import { apiError, apiSuccess } from '@/lib/server/api-response';
Expand All @@ -28,24 +29,37 @@ export async function POST(req: NextRequest) {
query: requestQuery,
pdfText,
apiKey: clientApiKey,
providerId,
baseUrl,
} = body as {
query?: string;
pdfText?: string;
apiKey?: string;
providerId?: string;
baseUrl?: string;
};
query = requestQuery;

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

const apiKey = resolveWebSearchApiKey(clientApiKey);
if (!apiKey) {
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.',
);
let result;
if (providerId === 'searxng') {
result = await searchWithSearXNG({
query: query.trim(),
baseUrl: baseUrl || process.env.SEARXNG_URL || 'http://127.0.0.1:8080/search'
});
} else {
const apiKey = resolveWebSearchApiKey(clientApiKey);
if (!apiKey) {
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.',
);
}
result = await searchWithTavily({ query: query.trim(), apiKey });
}

// Clamp rewrite input at the route boundary; framework body limits still apply to total request size.
Expand Down
135 changes: 101 additions & 34 deletions components/generation/generating-progress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, CheckCircle2, XCircle, Circle } from 'lucide-react';
import { Loader2, CheckCircle2, XCircle, Circle, Copy, Play } from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { toast } from 'sonner';

interface GeneratingProgressProps {
outlineReady: boolean; // Is outline generation complete?
Expand Down Expand Up @@ -62,6 +65,14 @@ export function GeneratingProgress({
}: GeneratingProgressProps) {
const { t } = useI18n();
const [dots, setDots] = useState('');
const [manualResponse, setManualResponse] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);

// Extract hash and prompt
const isManualIntervention = error?.startsWith('MANUAL_INTERVENTION_REQUIRED|||');
const errorParts = isManualIntervention ? error?.split('|||') : [];
const promptHash = (errorParts && errorParts[1]) || '';
const manualPromptText = (errorParts && errorParts[2]) || '';

// Animated dots for loading state
useEffect(() => {
Expand All @@ -73,12 +84,41 @@ export function GeneratingProgress({
}
}, [error, firstPageReady]);

const handleCopyPrompt = () => {
if (manualPromptText) {
navigator.clipboard.writeText(manualPromptText);
toast.success("Prompt copied to clipboard");
}
};

const handleSubmitManualResponse = async () => {
setIsSubmitting(true);
try {
await fetch('/api/manual-cache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hash: promptHash, response: manualResponse })
});

// Reload the page. The user will click "Generate" again,
// but the backend will instantly skip the step using the cache!
toast.success("Saved! Please restart the generation.");
window.location.reload();
} catch (_e) {
toast.error("Failed to save response.");
} finally {
setIsSubmitting(false);
}
};

return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{error ? (
{isManualIntervention ? (
<><XCircle className="size-5 text-amber-500" /> Action Required: Gemini Blocked Output</>
) : error ? (
<>
<XCircle className="size-5 text-destructive" />
{t('generation.generationFailed')}
Expand All @@ -98,40 +138,67 @@ export function GeneratingProgress({
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Two milestone status items */}
<div className="divide-y">
<StatusItem
completed={outlineReady}
inProgress={!outlineReady && !error}
hasError={!outlineReady && !!error}
label={
outlineReady ? t('generation.outlineReady') : t('generation.generatingOutlines')
}
/>
<StatusItem
completed={firstPageReady}
inProgress={outlineReady && !firstPageReady && !error}
hasError={outlineReady && !firstPageReady && !!error}
label={
firstPageReady
? t('generation.firstPageReady')
: t('generation.generatingFirstPage')
}
/>
</div>
{isManualIntervention ? (
<div className="space-y-4 bg-amber-500/10 border border-amber-500/20 p-4 rounded-lg">
<p className="text-sm text-amber-700 dark:text-amber-400">
The API blocked this specific prompt. Copy the text, paste it into the Gemini Web App, and paste the JSON result here.
</p>
<div className="relative">
<Textarea value={manualPromptText} readOnly className="h-48 text-xs font-mono bg-background/50" />
<Button size="sm" variant="secondary" className="absolute top-2 right-2" onClick={handleCopyPrompt}>
<Copy className="size-4 mr-2" /> Copy Prompt
</Button>
</div>
<div className="pt-4 border-t border-amber-500/20">
<Textarea
placeholder="Paste the JSON response from Gemini here..."
value={manualResponse}
onChange={(e) => setManualResponse(e.target.value)}
className="h-32 text-xs font-mono"
/>
<Button className="mt-2 w-full" disabled={!manualResponse || isSubmitting} onClick={handleSubmitManualResponse}>
<Play className="size-4 mr-2" /> Inject & Restart Generation
</Button>
</div>
</div>
) : (
<>
{/* Two milestone status items */}
<div className="divide-y">
<StatusItem
completed={outlineReady}
inProgress={!outlineReady && !error}
hasError={!outlineReady && !!error}
label={
outlineReady ? t('generation.outlineReady') : t('generation.generatingOutlines')
}
/>
<StatusItem
completed={firstPageReady}
inProgress={outlineReady && !firstPageReady && !error}
hasError={outlineReady && !firstPageReady && !!error}
label={
firstPageReady
? t('generation.firstPageReady')
: t('generation.generatingFirstPage')
}
/>
</div>

{/* Status message */}
{statusMessage && !error && (
<div className="pt-2 border-t">
<p className="text-sm text-muted-foreground">{statusMessage}</p>
</div>
)}
{/* Status message */}
{statusMessage && !error && (
<div className="pt-2 border-t">
<p className="text-sm text-muted-foreground">{statusMessage}</p>
</div>
)}

{/* Error message */}
{error && (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{error}</p>
</div>
{/* Error message */}
{error && (
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{error}</p>
</div>
)}
</>
)}
</CardContent>
</Card>
Expand Down
71 changes: 70 additions & 1 deletion lib/ai/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ import { generateText, streamText } from 'ai';
import type { GenerateTextResult, StreamTextResult } from 'ai';
import { createLogger } from '@/lib/logger';
import { PROVIDERS } from './providers';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';

// --- Add these helpers at the top ---
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

function getPromptHash(params: Record<string, unknown>): string {
const data = JSON.stringify({ system: params.system, prompt: params.prompt, messages: params.messages });
return crypto.createHash('md5').update(data).digest('hex');
}

// Temporary cache dir for manual overrides, use /tmp for serverless
const CACHE_DIR = path.join('/tmp', '.openmaic', 'manual_cache');
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });

function getManualCache(hash: string): string | null {
if (!/^[a-fA-F0-9]{32}$/.test(hash)) return null;
const filePath = path.join(CACHE_DIR, `${hash}.json`);
if (fs.existsSync(filePath)) return fs.readFileSync(filePath, 'utf-8');
return null;
}
// -------------------------------------
import { thinkingContext } from './thinking-context';
import type { ProviderType, ThinkingCapability, ThinkingConfig } from '@/lib/types/provider';
const log = createLogger('LLM');
Expand Down Expand Up @@ -292,6 +315,15 @@ export async function callLLM<T extends GenerateTextParams>(
const maxAttempts = (retryOptions?.retries ?? 0) + 1;
const validate = retryOptions?.validate ?? (maxAttempts > 1 ? DEFAULT_VALIDATE : undefined);

// 0. CACHE INTERCEPTION: Check if the user manually provided an answer for this prompt
const promptHash = getPromptHash(params as Record<string, unknown>);
const cachedResponse = getManualCache(promptHash);
if (cachedResponse) {
log.info(`[${source}] 🚀 Using manual cached response for hash: ${promptHash}`);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return { text: cachedResponse } as unknown as GenerateTextResult<any, any>; // Mock the AI SDK response object
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
let lastResult: GenerateTextResult<any, any> | undefined;
let lastError: unknown;
Expand Down Expand Up @@ -319,8 +351,45 @@ export async function callLLM<T extends GenerateTextParams>(
}

return result;
} catch (error) {
} catch (error: unknown) {
lastError = error;
const err = error as Record<string, unknown>;

// 1. RATE LIMIT PAUSING
if (err?.statusCode === 429 || (typeof err?.message === 'string' && (err.message.includes('429') || err.message.includes('Too Many Requests')))) {
log.warn(`[${source}] Rate limit hit. Pausing 20s...`);
await sleep(20000);
continue;
}

// 2. MANUAL FALLBACK TRIGGER
const isUnsupported = typeof err?.message === 'string' && (err.message.includes('unsupported') || err.message.includes('schema'));
const isSafety = typeof err?.message === 'string' && (err.message.includes('safety') || err.message.includes('SAFETY'));

if (isUnsupported || isSafety) {
let promptText = "";
const p = params as Record<string, unknown>;
if (p.system) promptText += `[SYSTEM]\n${p.system}\n\n`;
if (p.prompt) promptText += `[USER]\n${p.prompt}\n\n`;
if (p.messages && Array.isArray(p.messages)) {
promptText += p.messages.map((m: Record<string, unknown>) => {
let contentStr = "";
if (typeof m.content === 'string') {
contentStr = m.content;
} else if (Array.isArray(m.content)) {
contentStr = m.content.map((part: Record<string, unknown>) => {
if (part.type === 'text') return part.text;
if (part.type === 'image') return `\n[⚠️ ACTION REQUIRED: Drag and drop the original image/PDF into the Gemini chat here] \n`;
return JSON.stringify(part);
}).join('\n');
}
return `[${(m.role || 'USER').toString().toUpperCase()}]:\n${contentStr}`;
}).join('\n\n');
}

// Pass the Hash along with the error
throw new Error(`MANUAL_INTERVENTION_REQUIRED|||${promptHash}|||${promptText}`);
}

if (attempt < maxAttempts) {
log.warn(`[${source}] Call failed (attempt ${attempt}/${maxAttempts}), retrying...`, error);
Expand Down
6 changes: 6 additions & 0 deletions lib/pdf/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export const PDF_PROVIDERS: Record<PDFProviderId, PDFProviderConfig> = {
icon: '/logos/mineru.png',
features: ['text', 'images', 'tables', 'formulas', 'layout-analysis'],
},
local_vision: {
id: 'local_vision',
name: 'Local Vision (Qwen2-VL/Llama-3.2-Vision)',
requiresApiKey: false,
features: ['text', 'images', 'ocr', 'layout-analysis'],
},
};

/**
Expand Down
Loading
Loading