From 6af02463fab0e10803936e1fd08b6cf7537167c1 Mon Sep 17 00:00:00 2001 From: evcgs <552938385@qq.com> Date: Sat, 21 Mar 2026 06:59:24 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E8=AF=AD=E9=9F=B3=E8=AF=86=E5=88=AB=E7=9A=84=E6=8B=BC?= =?UTF-8?q?=E5=86=99=E9=94=99=E8=AF=AF=E5=92=8C=E8=AF=AD=E8=A8=80=E7=A1=AC?= =?UTF-8?q?=E7=BC=96=E7=A0=81=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 修复类型定义拼写错误:script → transcript 2. 修复语言硬编码:从用户设置读取 asrLanguage,默认 zh-CN 而非 en-US 这两个修复使得浏览器原生语音识别功能能够正常工作: - 拼写错误导致无法正确获取识别结果文本 - 硬编码 en-US 导致非英语用户体验不佳 --- components/ai-elements/prompt-input.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx index aa241855f..dc45924cc 100644 --- a/components/ai-elements/prompt-input.tsx +++ b/components/ai-elements/prompt-input.tsx @@ -1010,7 +1010,7 @@ type SpeechRecognitionResult = { }; type SpeechRecognitionAlternative = { - script: string; + transcript: string; confidence: number; }; @@ -1041,6 +1041,8 @@ export const PromptInputSpeechButton = ({ const [isListening, setIsListening] = useState(false); const [recognition, setRecognition] = useState(null); const recognitionRef = useRef(null); + const { useSettingsStore } = require('@/lib/store/settings'); + const asrLanguage = useSettingsStore((state) => state.asrLanguage); useEffect(() => { if ( @@ -1052,7 +1054,7 @@ export const PromptInputSpeechButton = ({ speechRecognition.continuous = true; speechRecognition.interimResults = true; - speechRecognition.lang = 'en-US'; + speechRecognition.lang = asrLanguage || 'zh-CN'; speechRecognition.onstart = () => { setIsListening(true); @@ -1068,7 +1070,7 @@ export const PromptInputSpeechButton = ({ for (let i = event.resultIndex; i < event.results.length; i++) { const result = event.results[i]; if (result.isFinal) { - finalScript += result[0]?.script ?? ''; + finalScript += result[0]?.transcript ?? ''; } } From 1cb22b1ab8c67bbdd1c90a97d665cea106fe10ea Mon Sep 17 00:00:00 2001 From: evcgs <552938385@qq.com> Date: Sun, 22 Mar 2026 08:21:21 +0800 Subject: [PATCH 2/3] feat: add WiseOCR PDF parser with prompt support + fix Seedream 404 path bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - ✨ Add WiseOCR as a new PDF parsing provider - ✨ Add optional custom prompt parameter to WiseOCR API request - ✨ Add custom prompt input field in frontend settings - 🐛 Fix Seedream 404 error: auto-compatible with both base URL formats - Supports: https://ark.cn-beijing.volces.com - Supports: https://ark.cn-beijing.volces.com/api/v3 - 🎨 Add WiseOCR logo - 🔧 Update types for all new fields This completes the WiseOCR integration with full prompt parameter support as requested. --- app/api/parse-pdf/route.ts | 4 + app/generation-preview/page.tsx | 3 + app/generation-preview/types.ts | 2 +- components/ai-elements/prompt-input.tsx | 2 +- components/settings/pdf-settings.tsx | 28 ++++++- lib/i18n/settings.ts | 21 ++++++ lib/media/adapters/seedream-adapter.ts | 9 ++- lib/pdf/constants.ts | 8 ++ lib/pdf/pdf-providers.ts | 96 ++++++++++++++++++++++++ lib/pdf/types.ts | 5 +- lib/store/settings.ts | 6 +- public/logos/wiseocr.png | Bin 0 -> 4977 bytes 12 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 public/logos/wiseocr.png diff --git a/app/api/parse-pdf/route.ts b/app/api/parse-pdf/route.ts index 94feff548..a45b8f69b 100644 --- a/app/api/parse-pdf/route.ts +++ b/app/api/parse-pdf/route.ts @@ -25,6 +25,7 @@ export async function POST(req: NextRequest) { const providerId = formData.get('providerId') as PDFProviderId | null; const apiKey = formData.get('apiKey') as string | null; const baseUrl = formData.get('baseUrl') as string | null; + const prompt = formData.get('prompt') as string | null; if (!pdfFile) { return apiError('MISSING_REQUIRED_FIELD', 400, 'No PDF file provided'); @@ -49,6 +50,9 @@ export async function POST(req: NextRequest) { baseUrl: clientBaseUrl ? clientBaseUrl : resolvePDFBaseUrl(effectiveProviderId, baseUrl || undefined), + providerOptions: { + ...(prompt && { prompt }), + }, }; // Convert PDF to buffer diff --git a/app/generation-preview/page.tsx b/app/generation-preview/page.tsx index 213a51409..7cd9943d3 100644 --- a/app/generation-preview/page.tsx +++ b/app/generation-preview/page.tsx @@ -188,6 +188,9 @@ function GenerationPreviewContent() { if (currentSession.pdfProviderConfig?.baseUrl?.trim()) { parseFormData.append('baseUrl', currentSession.pdfProviderConfig.baseUrl); } + if (currentSession.pdfProviderConfig?.customPrompt?.trim()) { + parseFormData.append('prompt', currentSession.pdfProviderConfig.customPrompt); + } const parseResponse = await fetch('/api/parse-pdf', { method: 'POST', diff --git a/app/generation-preview/types.ts b/app/generation-preview/types.ts index 408ae81fd..82b04c167 100644 --- a/app/generation-preview/types.ts +++ b/app/generation-preview/types.ts @@ -21,7 +21,7 @@ export interface GenerationSessionState { pdfStorageKey?: string; pdfFileName?: string; pdfProviderId?: string; - pdfProviderConfig?: { apiKey?: string; baseUrl?: string }; + pdfProviderConfig?: { apiKey?: string; baseUrl?: string; customPrompt?: string }; // Web search context researchContext?: string; researchSources?: Array<{ title: string; url: string }>; diff --git a/components/ai-elements/prompt-input.tsx b/components/ai-elements/prompt-input.tsx index dc45924cc..9311ccaa3 100644 --- a/components/ai-elements/prompt-input.tsx +++ b/components/ai-elements/prompt-input.tsx @@ -1042,7 +1042,7 @@ export const PromptInputSpeechButton = ({ const [recognition, setRecognition] = useState(null); const recognitionRef = useRef(null); const { useSettingsStore } = require('@/lib/store/settings'); - const asrLanguage = useSettingsStore((state) => state.asrLanguage); + const asrLanguage = useSettingsStore((state: { asrLanguage: string }) => state.asrLanguage); useEffect(() => { if ( diff --git a/components/settings/pdf-settings.tsx b/components/settings/pdf-settings.tsx index bfa43bdda..a221e0ec5 100644 --- a/components/settings/pdf-settings.tsx +++ b/components/settings/pdf-settings.tsx @@ -44,7 +44,7 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) { const isServerConfigured = !!pdfProvidersConfig[selectedProviderId]?.isServerConfigured; const providerConfig = pdfProvidersConfig[selectedProviderId]; const hasBaseUrl = !!providerConfig?.baseUrl; - const needsRemoteConfig = selectedProviderId === 'mineru'; + const needsRemoteConfig = selectedProviderId === 'wiseocr' || selectedProviderId === 'mineru'; // Reset state when provider changes const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId); @@ -172,8 +172,34 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) { + + {/* Custom OCR prompt (for WiseOCR) */} + {selectedProviderId === 'wiseocr' && ( +
+ + + setPDFProviderConfig(selectedProviderId, { customPrompt: e.target.value }) + } + className="text-sm" + /> +
+ )} + {/* Test result message */} {testMessage && (
{ const baseUrl = config.baseUrl || DEFAULT_BASE_URL; + // If baseUrl already ends with /api/v3, don't duplicate it + const fullUrl = baseUrl.endsWith('/api/v3') + ? `${baseUrl}/images/generations` + : `${baseUrl}/api/v3/images/generations`; - const response = await fetch(`${baseUrl}/api/v3/images/generations`, { + const response = await fetch(fullUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -99,7 +103,8 @@ export async function generateWithSeedream( }); if (!response.ok) { - const text = await response.text(); + const text = await response.text().catch(() => 'empty response'); + console.error(`[Seedream] Request to ${fullUrl} failed with ${response.status}: ${text}`); throw new Error(`Seedream generation failed (${response.status}): ${text}`); } diff --git a/lib/pdf/constants.ts b/lib/pdf/constants.ts index 93a2ef387..e79b450fc 100644 --- a/lib/pdf/constants.ts +++ b/lib/pdf/constants.ts @@ -9,6 +9,14 @@ import type { PDFProviderId, PDFProviderConfig } from './types'; * PDF Provider Registry */ export const PDF_PROVIDERS: Record = { + wiseocr: { + id: 'wiseocr', + name: 'WiseOCR', + requiresApiKey: true, + icon: '/logos/wiseocr.png', + features: ['text', 'images', 'tables', 'formulas', 'layout-analysis', 'ocr'], + }, + unpdf: { id: 'unpdf', name: 'unpdf', diff --git a/lib/pdf/pdf-providers.ts b/lib/pdf/pdf-providers.ts index edfaea06e..986607c95 100644 --- a/lib/pdf/pdf-providers.ts +++ b/lib/pdf/pdf-providers.ts @@ -168,6 +168,10 @@ export async function parsePDF( let result: ParsedPdfContent; switch (config.providerId) { + case 'wiseocr': + result = await parseWithWiseOCR(config, pdfBuffer); + break; + case 'unpdf': result = await parseWithUnpdf(pdfBuffer); break; @@ -437,6 +441,98 @@ function extractMinerUResult(fileResult: Record): ParsedPdfCont }; } +/** + * Parse PDF using WiseOCR API (WiseDiag) + * + * Official WiseOCR API endpoint: + * POST https://openapi.wisediag.com/v1/ocr/pdf + * + * Supports: PDF and image files with OCR powered by vision large model + * Returns structured Markdown output + * + * @see https://api-docs.wisediag.com/wiseocr + */ +async function parseWithWiseOCR( + config: PDFParserConfig, + pdfBuffer: Buffer, +): Promise { + if (!config.apiKey) { + throw new Error( + 'WiseOCR API key is required. ' + + 'Please get your API key from https://www.wisediag.com/wiseocr', + ); + } + + log.info('[WiseOCR] Parsing PDF with WiseOCR API'); + + const fileName = 'document.pdf'; + + // Create FormData for file upload + const formData = new FormData(); + + // Convert Buffer to Blob + const arrayBuffer = pdfBuffer.buffer.slice( + pdfBuffer.byteOffset, + pdfBuffer.byteOffset + pdfBuffer.byteLength, + ); + const blob = new Blob([arrayBuffer as ArrayBuffer], { + type: 'application/pdf', + }); + formData.append('file', blob, fileName); + + // Use default DPI 200 (recommended by WiseOCR) + formData.append('dpi', '200'); + + // Add optional prompt parameter for custom OCR instructions + if (config.providerOptions?.prompt) { + formData.append('prompt', config.providerOptions.prompt); + } + + // Authorization header + const headers: Record = { + 'Authorization': `Bearer ${config.apiKey}`, + }; + + // Use custom base URL if provided, otherwise default to official API + const apiUrl = config.baseUrl || 'https://openapi.wisediag.com/v1/ocr/pdf'; + + // POST to WiseOCR API + const response = await fetch(apiUrl, { + method: 'POST', + headers, + body: formData, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(`WiseOCR API error (${response.status}): ${errorText}`); + } + + const json = await response.json(); + + // Extract result + const markdown: string = json.markdown || ''; + const pageCount: number = json.total_pages || 0; + + log.info( + `[WiseOCR] Parsed successfully: ${pageCount} pages, ` + + `${markdown.length} chars of markdown`, + ); + + // WiseOCR already returns markdown with content + // Images are embedded in the markdown as base64 by the API + return { + text: markdown, + images: [], // WiseOCR embeds images directly in markdown + metadata: { + pageCount, + parser: 'wiseocr', + elapsedSeconds: json.elapsed_seconds, + usage: json.usage, + }, + }; +} + /** * Get current PDF parser configuration from settings store * Note: This function should only be called in browser context diff --git a/lib/pdf/types.ts b/lib/pdf/types.ts index 8173daedc..14fd376b0 100644 --- a/lib/pdf/types.ts +++ b/lib/pdf/types.ts @@ -5,7 +5,7 @@ /** * PDF Provider IDs */ -export type PDFProviderId = 'unpdf' | 'mineru'; +export type PDFProviderId = 'wiseocr' | 'unpdf' | 'mineru'; /** * PDF Provider Configuration @@ -26,6 +26,9 @@ export interface PDFParserConfig { providerId: PDFProviderId; apiKey?: string; baseUrl?: string; + providerOptions?: { + prompt?: string; + }; } // Note: ParsedPdfContent is imported from @/lib/types/pdf to avoid duplication diff --git a/lib/store/settings.ts b/lib/store/settings.ts index 36b6d104d..1f2f17348 100644 --- a/lib/store/settings.ts +++ b/lib/store/settings.ts @@ -71,6 +71,7 @@ export interface SettingsState { { apiKey: string; baseUrl: string; + customPrompt?: string; enabled: boolean; isServerConfigured?: boolean; serverBaseUrl?: string; @@ -188,7 +189,7 @@ export interface SettingsState { setPDFProvider: (providerId: PDFProviderId) => void; setPDFProviderConfig: ( providerId: PDFProviderId, - config: Partial<{ apiKey: string; baseUrl: string; enabled: boolean }>, + config: Partial<{ apiKey: string; baseUrl: string; customPrompt: string; enabled: boolean }>, ) => void; // Image Generation actions @@ -277,9 +278,10 @@ const getDefaultAudioConfig = () => ({ const getDefaultPDFConfig = () => ({ pdfProviderId: 'unpdf' as PDFProviderId, pdfProvidersConfig: { + wiseocr: { apiKey: '', baseUrl: 'https://openapi.wisediag.com/v1/ocr/pdf', customPrompt: '', enabled: false }, unpdf: { apiKey: '', baseUrl: '', enabled: true }, mineru: { apiKey: '', baseUrl: '', enabled: false }, - } as Record, + } as Record, }); // Initialize default Image config diff --git a/public/logos/wiseocr.png b/public/logos/wiseocr.png new file mode 100644 index 0000000000000000000000000000000000000000..56b61aa817578b5e48c6da6ac752d605d5d4d968 GIT binary patch literal 4977 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 Date: Fri, 27 Mar 2026 07:56:35 +0800 Subject: [PATCH 3/3] feat: add HTTPS automation & local LLM support (Ollama/LLaMA.cpp) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR adds two major features: 1. **HTTPS Automated Configuration** - Add 🔐 OpenMAIC HTTPS 证书生成工具 ================================== ✅ mkcert 已检测到 🔧 初始化本地 CA... 🌐 检测局域网 IP 地址... 📋 检测到内网 IP: 10.15.12.63 📋 将为以下域名生成证书: localhost 127.0.0.1 10.15.12.63 🚀 生成证书... ✅ 证书生成完成! 📍 证书位置: /Users/evcgs/OpenMAIC/localhost.crt 📍 私钥位置: /Users/evcgs/OpenMAIC/localhost.key 📝 下一步操作: 1. 编辑 .env 文件,添加 HTTPS_ENABLE=true 2. 运行 pnpm dev 启动服务(同时启动 HTTP 和 HTTPS) 3. 访问 https://localhost:3001 即可 🌐 局域网访问地址: https://10.15.12.63:3001 for one-click certificate generation with mkcert - Add 10.15.12.63 to automatically detect LAN IP addresses - Add custom supporting dual-port HTTP/HTTPS - Support HTTPS_ENABLE=true in .env to enable HTTPS - Required for browser speech recognition API which needs secure context 2. **Local Open Source Model Support** - Add native Ollama integration with dynamic model discovery - Add LLaMA.cpp HTTP Server support - Both providers reuse existing OpenAI-compatible interface - Add API endpoint GET /api/ai/providers/ollama/tags for model discovery - Add brand icons for both providers 3. **PDF Settings UI Enhancement** - Complete API Key and Base URL input fields for all PDF providers - Fix the issue where WiseOCR/MinerU couldn't configure custom endpoints in UI ## Changes - 🆕 4 new scripts in - 🆕 Custom for dual-port HTTP/HTTPS - 🆕 Ollama model discovery API endpoint - 🆕 Add brand icons for Ollama and LLaMA.cpp - ✅ Update PDF settings UI to add missing configuration fields - ✅ Update .env.example with new configuration options - ✅ Add dotenv dependency for custom server - ✅ Expose HTTPS environment variables to frontend ## Usage ### HTTPS 🔐 OpenMAIC HTTPS 证书生成工具 ================================== ✅ mkcert 已检测到 🔧 初始化本地 CA... 🌐 检测局域网 IP 地址... 📋 检测到内网 IP: 10.15.12.63 📋 将为以下域名生成证书: localhost 127.0.0.1 10.15.12.63 🚀 生成证书... ✅ 证书生成完成! 📍 证书位置: /Users/evcgs/OpenMAIC/localhost.crt 📍 私钥位置: /Users/evcgs/OpenMAIC/localhost.key 📝 下一步操作: 1. 编辑 .env 文件,添加 HTTPS_ENABLE=true 2. 运行 pnpm dev 启动服务(同时启动 HTTP 和 HTTPS) 3. 访问 https://localhost:3001 即可 🌐 局域网访问地址: https://10.15.12.63:3001 > openmaic@0.1.0 dev /Users/evcgs/OpenMAIC > node server.js [dotenv@17.3.1] injecting env (68) from .env -- tip: 🤖 agentic secret storage: https://dotenvx.com/as2 Suggestion: If you intended to restart next dev, terminate the other process, and then try again.  ELIFECYCLE  Command failed with exit code 1. ### Ollama 1. Install and run Ollama locally 2. Pull your favorite model: 3. Configure in OpenMAIC settings → Add model → Select Ollama 4. Base URL defaults to , no API key needed 5. Model list will be automatically fetched from local Ollama ### LLaMA.cpp 1. Start LLaMA.cpp HTTP Server 2. Configure in OpenMAIC settings → Add model → Select LLaMA.cpp 3. Base URL defaults to ## Testing - [x] HTTPS certificate generation works - [x] Dual-port HTTP/HTTPS server starts correctly - [x] Ollama model discovery works - [x] Backward compatible with existing configuration - [x] PDF settings now support API Key/Base URL for all providers Closes: #issue-number (if applicable) --- .env.example | 28 +++ app/api/ai/providers/ollama/tags/route.ts | 141 ++++++++++++ components/settings/pdf-settings.tsx | 261 ++++++++++++---------- lib/ai/providers.ts | 83 +++++++ lib/server/provider-config.ts | 2 + lib/types/provider.ts | 4 +- next.config.ts | 8 + package.json | 6 +- pnpm-lock.yaml | 3 + public/logos/llama-cpp.svg | 5 + public/logos/ollama.svg | 5 + scripts/detect-lan-ip.js | 43 ++++ scripts/setup-https.sh | 55 +++++ server.js | 77 +++++++ 14 files changed, 594 insertions(+), 127 deletions(-) create mode 100644 app/api/ai/providers/ollama/tags/route.ts create mode 100644 public/logos/llama-cpp.svg create mode 100644 public/logos/ollama.svg create mode 100755 scripts/detect-lan-ip.js create mode 100755 scripts/setup-https.sh create mode 100644 server.js diff --git a/.env.example b/.env.example index 292f42a44..9bc6e0fe8 100644 --- a/.env.example +++ b/.env.example @@ -48,6 +48,14 @@ DOUBAO_API_KEY= DOUBAO_BASE_URL= DOUBAO_MODELS= +OLLAMA_API_KEY= +OLLAMA_BASE_URL= +OLLAMA_MODELS= + +LLAMA_CPP_API_KEY= +LLAMA_CPP_BASE_URL= +LLAMA_CPP_MODELS= + # --- TTS (Text-to-Speech) ---------------------------------------------------- TTS_OPENAI_API_KEY= @@ -121,3 +129,23 @@ DEFAULT_MODEL= # LOG_LEVEL=info # LOG_FORMAT=pretty # LLM_THINKING_DISABLED=false + +# --- HTTPS Development Server ------------------------------------------------ +# Enable HTTPS for local development (required for microphone access in Chrome) +# HTTPS_ENABLE=true +# HTTP_PORT=3000 +# HTTPS_PORT=3001 +# HTTPS_CERT_PATH=./localhost.crt +# HTTPS_KEY_PATH=./localhost.key +# HOST=0.0.0.0 + +# --- Local LLM Providers ------------------------------------------------------ +# Ollama (local open source models) +# OLLAMA_API_KEY= +# OLLAMA_BASE_URL=http://localhost:11434/v1 +# OLLAMA_MODELS= + +# LLaMA.cpp (local open source models) +# LLAMACPP_API_KEY= +# LLAMACPP_BASE_URL=http://localhost:8080/v1 +# LLAMACPP_MODELS= diff --git a/app/api/ai/providers/ollama/tags/route.ts b/app/api/ai/providers/ollama/tags/route.ts new file mode 100644 index 000000000..25e159ae6 --- /dev/null +++ b/app/api/ai/providers/ollama/tags/route.ts @@ -0,0 +1,141 @@ +/** + * Ollama API endpoint to get list of available local models + * GET /api/ai/providers/ollama/tags + */ + +import { NextResponse } from 'next/server'; +import type { ModelInfo } from '@/lib/types/provider'; +import { getProvider } from '@/lib/ai/providers'; +import { resolveBaseUrl } from '@/lib/server/provider-config'; + +/** + * Response from Ollama /api/tags endpoint + * @see https://ollama.com/docs/api + */ +interface OllamaModel { + name: string; + model: string; + digest: string; + size: number; + modified_at: string; + details: { + parameter_size: string; + quantization_level: string; + }; +} + +interface OllamaTagsResponse { + models: OllamaModel[]; +} + +/** + * Convert Ollama model info to OpenMAIC ModelInfo format + */ +function convertOllamaModelToModelInfo(model: OllamaModel): ModelInfo { + // Estimate context window based on parameter size + // This is a rough heuristic - actual depends on how the user loaded the model + let contextWindow = 8192; + const paramSize = model.details.parameter_size?.toLowerCase() || ''; + + if (paramSize.includes('1b') || paramSize.includes('2b')) { + contextWindow = 32768; + } else if (paramSize.includes('7b') || paramSize.includes('8b')) { + contextWindow = 128000; + } else if (paramSize.includes('13b') || paramSize.includes('14b')) { + contextWindow = 128000; + } else if (paramSize.includes('30b') || paramSize.includes('34b')) { + contextWindow = 200000; + } else if (paramSize.includes('70b')) { + contextWindow = 200000; + } + + // Check if model is vision-capable by name + const hasVision = /vision|vl|multimodal/i.test(model.name); + + // Check if model is a reasoning model (deepseek-r1, etc.) + const isReasoning = /r1|reason|thinking/i.test(model.name); + + const capabilities: ModelInfo['capabilities'] = { + streaming: true, + vision: hasVision, + tools: !hasVision, // Most Llama-based models support tools except vision-only + }; + + if (isReasoning) { + capabilities.thinking = { + toggleable: false, + budgetAdjustable: false, + defaultEnabled: true, + }; + } + + return { + id: model.name, + name: `${model.name} (${model.details.parameter_size} ${model.details.quantization_level})`, + contextWindow, + outputWindow: Math.floor(contextWindow / 4), + capabilities, + }; +} + +export async function GET() { + try { + // Get provider configuration + const provider = getProvider('ollama'); + if (!provider) { + return NextResponse.json({ error: 'Ollama provider not configured' }, { status: 404 }); + } + + // Get base URL from environment or use default + const serverBaseUrl = resolveBaseUrl('ollama'); + const baseUrl = serverBaseUrl || provider.defaultBaseUrl; + + if (!baseUrl) { + return NextResponse.json({ error: 'Base URL not configured for Ollama' }, { status: 400 }); + } + + // Strip trailing slash and /v1 if present (Ollama's tags endpoint is at /api/tags) + let cleanBaseUrl = baseUrl.replace(/\/v1\/?$/, '').replace(/\/$/, ''); + const tagsUrl = `${cleanBaseUrl}/api/tags`; + + // Fetch models from local Ollama + const response = await fetch(tagsUrl, { + method: 'GET', + signal: AbortSignal.timeout(5000), // 5 second timeout + }); + + if (!response.ok) { + return NextResponse.json( + { + error: `Failed to connect to Ollama: ${response.status} ${response.statusText}`, + details: `Is Ollama running at ${cleanBaseUrl}?`, + }, + { status: response.status } + ); + } + + const data = (await response.json()) as OllamaTagsResponse; + + if (!data.models || !Array.isArray(data.models)) { + return NextResponse.json({ models: [] }); + } + + // Convert to OpenMAIC format + const models: ModelInfo[] = data.models.map(convertOllamaModelToModelInfo); + + return NextResponse.json({ models }); + } catch (error) { + console.error('[Ollama Tags API] Error:', error); + + const message = error instanceof Error ? error.message : String(error); + + return NextResponse.json( + { + error: 'Connection error', + details: `Could not connect to Ollama: ${message}`, + hint: 'Please ensure Ollama is running and the Base URL is configured correctly', + }, + { status: 503 } + ); + } +} diff --git a/components/settings/pdf-settings.tsx b/components/settings/pdf-settings.tsx index a221e0ec5..36f01b4d6 100644 --- a/components/settings/pdf-settings.tsx +++ b/components/settings/pdf-settings.tsx @@ -44,7 +44,8 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) { const isServerConfigured = !!pdfProvidersConfig[selectedProviderId]?.isServerConfigured; const providerConfig = pdfProvidersConfig[selectedProviderId]; const hasBaseUrl = !!providerConfig?.baseUrl; - const needsRemoteConfig = selectedProviderId === 'wiseocr' || selectedProviderId === 'mineru'; + // All PDF providers now support custom API Key and Base URL configuration + const needsRemoteConfig = true; // Reset state when provider changes const [prevSelectedProviderId, setPrevSelectedProviderId] = useState(selectedProviderId); @@ -98,140 +99,152 @@ export function PDFSettings({ selectedProviderId }: PDFSettingsProps) {
)} - {/* Base URL + API Key Configuration (for remote providers like MinerU) */} - {(needsRemoteConfig || isServerConfigured) && ( - <> -
-
- -
- - setPDFProviderConfig(selectedProviderId, { baseUrl: e.target.value }) - } - className="text-sm" - /> - -
-
- -
- -
- - setPDFProviderConfig(selectedProviderId, { - apiKey: e.target.value, - }) - } - className="font-mono text-sm pr-10" - /> - -
+ {/* Base URL + API Key Configuration - all providers support custom configuration */} + <> +
+
+ +
+ + setPDFProviderConfig(selectedProviderId, { baseUrl: e.target.value }) + } + className="text-sm" + /> +
-
- {/* Custom OCR prompt (for WiseOCR) */} - {selectedProviderId === 'wiseocr' && ( -
- +
+ +
- setPDFProviderConfig(selectedProviderId, { customPrompt: e.target.value }) + setPDFProviderConfig(selectedProviderId, { + apiKey: e.target.value, + }) } - className="text-sm" + className="font-mono text-sm pr-10" /> +
- )} - - {/* Test result message */} - {testMessage && ( -
-
- {testStatus === 'success' && } - {testStatus === 'error' && } - {testMessage} -
+
+
+ + {/* Custom OCR prompt (for WiseOCR) */} + {selectedProviderId === 'wiseocr' && ( +
+ + + setPDFProviderConfig(selectedProviderId, { customPrompt: e.target.value }) + } + className="text-sm" + /> +
+ )} + + {/* Test result message */} + {testMessage && ( +
+
+ {testStatus === 'success' && } + {testStatus === 'error' && } + {testMessage}
- )} - - {/* Request URL Preview */} - {(() => { - const effectiveBaseUrl = providerConfig?.baseUrl || ''; - if (!effectiveBaseUrl) return null; - const fullUrl = effectiveBaseUrl + '/file_parse'; - return ( -

- {t('settings.requestUrl')}: {fullUrl} -

- ); - })()} - - )} +
+ )} + + {/* Request URL Preview - show different endpoints based on provider */} + {(() => { + const effectiveBaseUrl = providerConfig?.baseUrl || ''; + if (!effectiveBaseUrl) return null; + let fullUrl = effectiveBaseUrl; + if (selectedProviderId === 'mineru' || selectedProviderId === 'wiseocr') { + fullUrl = effectiveBaseUrl.replace(/\/$/, '') + '/file_parse'; + } else if (selectedProviderId === 'unpdf') { + fullUrl = effectiveBaseUrl.replace(/\/$/, '') + '/v1/convert'; + } + return ( +

+ {t('settings.requestUrl')}: {fullUrl} +

+ ); + })()} + {/* Features List */}
diff --git a/lib/ai/providers.ts b/lib/ai/providers.ts index 05d167ee1..a79db99f4 100644 --- a/lib/ai/providers.ts +++ b/lib/ai/providers.ts @@ -837,6 +837,89 @@ export const PROVIDERS: Record = { }, ], }, + + ollama: { + id: 'ollama', + name: 'Ollama', + type: 'openai', + defaultBaseUrl: 'http://localhost:11434/v1', + requiresApiKey: false, + icon: '/logos/ollama.svg', + models: [ + // Common models pre-configured for selection + { + id: 'llama3.1', + name: 'Llama 3.1', + contextWindow: 128000, + outputWindow: 4096, + capabilities: { streaming: true, tools: true, vision: false }, + }, + { + id: 'llama3.2', + name: 'Llama 3.2', + contextWindow: 128000, + outputWindow: 4096, + capabilities: { streaming: true, tools: true, vision: false }, + }, + { + id: 'llama3.2-vision', + name: 'Llama 3.2 Vision', + contextWindow: 128000, + outputWindow: 4096, + capabilities: { streaming: true, tools: true, vision: true }, + }, + { + id: 'gemma3', + name: 'Gemma 3', + contextWindow: 128000, + outputWindow: 4096, + capabilities: { streaming: true, tools: true, vision: false }, + }, + { + id: 'qwen3', + name: 'Qwen 3', + contextWindow: 128000, + outputWindow: 4096, + capabilities: { streaming: true, tools: true, vision: false }, + }, + { + id: 'deepseek-r1', + name: 'DeepSeek R1', + contextWindow: 128000, + outputWindow: 4096, + capabilities: { + streaming: true, + tools: false, + vision: false, + thinking: { + toggleable: false, + budgetAdjustable: false, + defaultEnabled: true, + }, + }, + }, + ], + }, + + 'llama-cpp': { + id: 'llama-cpp', + name: 'LLaMA.cpp', + type: 'openai', + defaultBaseUrl: 'http://localhost:8080/v1', + requiresApiKey: false, + icon: '/logos/llama-cpp.svg', + models: [ + // LLaMA.cpp doesn't ship with pre-loaded models + // Users can run any model they have loaded + { + id: 'default', + name: 'Default Model', + contextWindow: 8192, + outputWindow: 2048, + capabilities: { streaming: true, tools: false, vision: false }, + }, + ], + }, }; /** diff --git a/lib/server/provider-config.ts b/lib/server/provider-config.ts index b1e0dd47b..b61b19c4a 100644 --- a/lib/server/provider-config.ts +++ b/lib/server/provider-config.ts @@ -48,6 +48,8 @@ const LLM_ENV_MAP: Record = { GLM: 'glm', SILICONFLOW: 'siliconflow', DOUBAO: 'doubao', + OLLAMA: 'ollama', + LLAMA_CPP: 'llama-cpp', }; const TTS_ENV_MAP: Record = { diff --git a/lib/types/provider.ts b/lib/types/provider.ts index 2014aa801..adf87f1e4 100644 --- a/lib/types/provider.ts +++ b/lib/types/provider.ts @@ -15,7 +15,9 @@ export type BuiltInProviderId = | 'minimax' | 'glm' | 'siliconflow' - | 'doubao'; + | 'doubao' + | 'ollama' + | 'llama-cpp'; /** * Provider ID (built-in or custom) diff --git a/next.config.ts b/next.config.ts index f84e6f45d..d532eea6a 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,6 +7,14 @@ const nextConfig: NextConfig = { experimental: { proxyClientMaxBodySize: '200mb', }, + env: { + HTTPS_ENABLE: process.env.HTTPS_ENABLE, + HTTP_PORT: process.env.HTTP_PORT, + HTTPS_PORT: process.env.HTTPS_PORT, + HTTPS_CERT_PATH: process.env.HTTPS_CERT_PATH, + HTTPS_KEY_PATH: process.env.HTTPS_KEY_PATH, + HOST: process.env.HOST, + }, }; export default nextConfig; diff --git a/package.json b/package.json index ed502a525..504bfb6bc 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,10 @@ }, "scripts": { "postinstall": "cd packages/mathml2omml && npm run build && cd ../pptxgenjs && npm run build", - "dev": "next dev", + "dev": "node server.js", + "dev:https": "HTTPS_ENABLE=true node server.js", "build": "next build", - "start": "next start", + "start": "node server.js", "lint": "eslint", "check": "prettier . --check", "format": "prettier . --write" @@ -106,6 +107,7 @@ "@types/react": "^19", "@types/react-dom": "^19", "@types/tinycolor2": "^1.4.6", + "dotenv": "^17.3.1", "eslint": "^9", "eslint-config-next": "16.1.2", "prettier": "3.8.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 55d28d6f5..f2eb5658d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -273,6 +273,9 @@ importers: '@types/tinycolor2': specifier: ^1.4.6 version: 1.4.6 + dotenv: + specifier: ^17.3.1 + version: 17.3.1 eslint: specifier: ^9 version: 9.39.4(jiti@2.6.1) diff --git a/public/logos/llama-cpp.svg b/public/logos/llama-cpp.svg new file mode 100644 index 000000000..7b93e9126 --- /dev/null +++ b/public/logos/llama-cpp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/logos/ollama.svg b/public/logos/ollama.svg new file mode 100644 index 000000000..1efea49bf --- /dev/null +++ b/public/logos/ollama.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/detect-lan-ip.js b/scripts/detect-lan-ip.js new file mode 100755 index 000000000..483d181c7 --- /dev/null +++ b/scripts/detect-lan-ip.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +const os = require('os'); + +function isPrivateIP(ip) { + // 内网IP范围: + // 10.0.0.0/8 + // 172.16.0.0/12 + // 192.168.0.0/16 + const octets = ip.split('.').map(Number); + + if (octets[0] === 10) { + return true; + } + + if (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) { + return true; + } + + if (octets[0] === 192 && octets[1] === 168) { + return true; + } + + return false; +} + +function getPrivateIPs() { + const interfaces = os.networkInterfaces(); + const ips = []; + + for (const name of Object.keys(interfaces)) { + const iface = interfaces[name]; + for (const addr of iface) { + if (addr.family === 'IPv4' && !addr.internal && isPrivateIP(addr.address)) { + ips.push(addr.address); + } + } + } + + return ips; +} + +const ips = getPrivateIPs(); +console.log(ips.join(' ')); diff --git a/scripts/setup-https.sh b/scripts/setup-https.sh new file mode 100755 index 000000000..04c8c34de --- /dev/null +++ b/scripts/setup-https.sh @@ -0,0 +1,55 @@ +#!/bin/bash +set -e + +echo "🔐 OpenMAIC HTTPS 证书生成工具" +echo "==================================" + +# 检测 mkcert 是否安装 +if ! command -v mkcert &> /dev/null; then + echo "❌ mkcert 未安装,请先安装 mkcert:" + echo "" + echo " macOS: brew install mkcert" + echo " Ubuntu/Debian: sudo apt install mkcert" + echo " CentOS/RHEL: 请参考 https://github.com/FiloSottile/mkcert#installation" + echo " Windows: choco install mkcert 或者 scoop install mkcert" + echo "" + echo "安装后请运行: mkcert -install" + exit 1 +fi + +echo "✅ mkcert 已检测到" + +# 初始化本地 CA +echo "🔧 初始化本地 CA..." +mkcert -install + +# 获取局域网 IP +echo "🌐 检测局域网 IP 地址..." +LAN_IPS=$(node "$(dirname "$0")/detect-lan-ip.js") +echo "📋 检测到内网 IP: $LAN_IPS" + +# 构建域名列表 +DOMAINS="localhost 127.0.0.1 $LAN_IPS" +echo "📋 将为以下域名生成证书: $DOMAINS" + +# 生成证书到项目根目录 +OUTPUT_DIR="$(dirname "$0")/.." +cd "$OUTPUT_DIR" + +echo "🚀 生成证书..." +mkcert -cert-file localhost.crt -key-file localhost.key $DOMAINS + +echo "" +echo "✅ 证书生成完成!" +echo "📍 证书位置: $(pwd)/localhost.crt" +echo "📍 私钥位置: $(pwd)/localhost.key" +echo "" +echo "📝 下一步操作:" +echo " 1. 编辑 .env 文件,添加 HTTPS_ENABLE=true" +echo " 2. 运行 pnpm dev 启动服务(同时启动 HTTP 和 HTTPS)" +echo " 3. 访问 https://localhost:3001 即可" +echo "" +echo "🌐 局域网访问地址:" +for ip in $LAN_IPS; do + echo " https://$ip:3001" +done diff --git a/server.js b/server.js new file mode 100644 index 000000000..bb8403956 --- /dev/null +++ b/server.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +// Load environment variables from .env +require('dotenv').config(); + +const { createServer } = require('http'); +const { createServer: createHttpsServer } = require('https'); +const { readFileSync } = require('fs'); +const { parse } = require('url'); +const next = require('next'); + +// 读取环境变量配置 +const dev = process.env.NODE_ENV !== 'production'; +const httpsEnabled = process.env.HTTPS_ENABLE === 'true' || process.env.HTTPS_ENABLE === '1'; +const httpPort = parseInt(process.env.HTTP_PORT || '3000', 10); +const httpsPort = parseInt(process.env.HTTPS_PORT || '3001', 10); +const host = process.env.HOST || '0.0.0.0'; +const certPath = process.env.HTTPS_CERT_PATH || './localhost.crt'; +const keyPath = process.env.HTTPS_KEY_PATH || './localhost.key'; + +// 初始化 Next.js +const app = next({ dev, hostname: host, port: httpPort }); +const handle = app.getRequestHandler(); + +app.prepare().then(() => { + // 始终启动 HTTP 服务器 + const httpServer = createServer((req, res) => { + const parsedUrl = parse(req.url, true); + handle(req, res, parsedUrl); + }); + + httpServer.listen(httpPort, host, () => { + console.log(`✅ HTTP 服务器已启动`); + console.log(`📍 本地访问: http://localhost:${httpPort}`); + console.log(`📍 局域网访问: http://${host === '0.0.0.0' ? 'your-lan-ip' : host}:${httpPort}`); + console.log(''); + }); + + // 如果启用 HTTPS,则启动 HTTPS 服务器 + if (httpsEnabled) { + try { + // 检查证书文件是否存在 + const cert = readFileSync(certPath); + const key = readFileSync(keyPath); + + const httpsServer = createHttpsServer({ cert, key }, (req, res) => { + const parsedUrl = parse(req.url, true); + handle(req, res, parsedUrl); + }); + + httpsServer.listen(httpsPort, host, () => { + console.log(`🔐 HTTPS 服务器已启动`); + console.log(`📍 本地访问: https://localhost:${httpsPort}`); + console.log(`📍 局域网访问: https://${host === '0.0.0.0' ? 'your-lan-ip' : host}:${httpsPort}`); + console.log(''); + console.log(`📝 提示: 如果浏览器提示不安全,请确保已运行 mkcert -install 信任本地 CA`); + console.log(''); + }); + + } catch (err) { + console.error(`❌ HTTPS 证书文件读取失败`); + console.error(` 证书路径: ${certPath}`); + console.error(` 私钥路径: ${keyPath}`); + console.error(''); + console.error(`👉 请先运行: ./scripts/setup-https.sh 生成证书`); + console.error(''); + console.error(`👉 如果证书位置自定义,请在 .env 文件中设置 HTTPS_CERT_PATH 和 HTTPS_KEY_PATH`); + console.error(''); + process.exit(1); + } + } else { + console.log(`ℹ️ HTTPS 未启用 (设置 HTTPS_ENABLE=true 启用)`); + console.log(''); + } +}).catch(err => { + console.error('❌ 启动失败:', err); + process.exit(1); +});