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
3 changes: 2 additions & 1 deletion app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
CBL_DB_DIR=/app/data \
CBL_DB_NAME=cb_tools_db \
PORT=8080 \
CBQA_LOG_FILE=/app/logs/cbqa.log
CBQA_LOG_FILE=/app/logs/cbqa.log \
CBQA_IN_DOCKER=1
# Note: STORAGE_BACKEND is no longer needed — Couchbase Lite is the only
# supported storage backend. The Couchbase Server SDK has been removed.
# Note: SKIP_TOON_INSTALL is intentionally NOT set. `python-toon` is now
Expand Down
61 changes: 55 additions & 6 deletions app/ai_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Automatic garbage collection
"""

import os
import time
import hashlib
import secrets
Expand Down Expand Up @@ -50,12 +51,52 @@
OPENAI_SDK_AVAILABLE = False
logger.warning("OpenAI SDK not installed. Please run: pip install openai")

# Local OpenAI-compatible servers (Ollama, LM Studio, vLLM, llama.cpp).
# These speak the OpenAI Chat Completions API and typically need no real key.
LOCAL_OPENAI_COMPAT_IDS = frozenset({"local-openai", "ollama", "lmstudio", "vllm"})


def is_local_openai_compat(provider: str) -> bool:
return (provider or "").lower() in LOCAL_OPENAI_COMPAT_IDS


def running_in_docker() -> bool:
"""True when Flask is inside a container (cannot use localhost for host services)."""
flag = os.environ.get("CBQA_IN_DOCKER", "").lower()
if flag in ("1", "true", "yes"):
return True
return os.path.exists("/.dockerenv")


def rewrite_ai_url_for_runtime(url: str) -> str:
"""Rewrite localhost/127.0.0.1 to host.docker.internal when running in Docker.

Ollama/LM Studio run on the laptop, not in the container. Docker Desktop
exposes the host as host.docker.internal; compose also sets extra_hosts.
"""
if not url:
return url
if not running_in_docker():
return url
rewritten = url
for host in ("localhost", "127.0.0.1", "0.0.0.0"):
rewritten = rewritten.replace(f"://{host}", "://host.docker.internal")
if rewritten != url:
logger.info("rewrote AI URL for Docker host access")
logger.debug("ai_url_rewrite from=%s to=%s", url, rewritten)
return rewritten


def dummy_key_for_local(api_key: Optional[str]) -> str:
"""Ollama accepts any Bearer token; the proxy still sends Authorization."""
key = (api_key or "").strip()
return key if key else "ollama"

# ============================================================================
# PyInstaller Resource Path Helper
# ============================================================================

import sys
import os

def get_resource_path(filename: str) -> str:
"""
Expand Down Expand Up @@ -2318,6 +2359,12 @@ def call_ai_provider(provider: str,
import json

logger.info("calling_ai_provider provider=%s model=%s language=%s", provider, model, language)

api_url = rewrite_ai_url_for_runtime(api_url or "")
if is_local_openai_compat(provider):
api_key = dummy_key_for_local(api_key)
if not api_url:
api_url = "http://localhost:11434/v1"

# Get dynamic max_tokens based on model
max_tokens = get_max_output_tokens(provider, model)
Expand All @@ -2329,7 +2376,7 @@ def call_ai_provider(provider: str,
# ---------------------------------------------------------
# Option 1: Use OpenAI SDK (Preferred for OpenAI/Grok)
# ---------------------------------------------------------
if (provider == 'openai' or provider == 'grok') and OPENAI_SDK_AVAILABLE:
if (provider == 'openai' or provider == 'grok') and OPENAI_SDK_AVAILABLE and not is_local_openai_compat(provider):
try:
logger.debug("using_openai_sdk provider=%s", provider)
start_time = time.time()
Expand Down Expand Up @@ -2401,7 +2448,7 @@ def call_ai_provider(provider: str,
# ---------------------------------------------------------

# Format request payload for specific provider
if provider == 'openai' or provider == 'grok':
if provider == 'openai' or provider == 'grok' or is_local_openai_compat(provider):
ai_request_payload = {
'model': model,
'messages': [
Expand All @@ -2416,8 +2463,10 @@ def call_ai_provider(provider: str,
]
}

# OpenAI uses max_completion_tokens, Grok uses max_tokens
if provider == 'openai':
# OpenAI cloud uses max_completion_tokens + JSON mode.
# Grok and local OpenAI-compatible servers (Ollama, LM Studio) use max_tokens
# and often reject response_format=json_object.
if provider == 'openai' and not is_local_openai_compat(provider):
ai_request_payload['max_completion_tokens'] = max_tokens
ai_request_payload['response_format'] = {'type': 'json_object'}
else:
Expand Down Expand Up @@ -2550,7 +2599,7 @@ def call_custom_ai_provider(
logger.debug("custom_api_name %s", custom_config.get("name"))
logger.debug("custom_api_url_configured")

url = custom_config.get('url', '')
url = rewrite_ai_url_for_runtime(custom_config.get('url', ''))
model = custom_config.get('model', 'default')
auth_type = custom_config.get('authType', 'none')
request_template = custom_config.get('requestTemplate', '')
Expand Down
13 changes: 10 additions & 3 deletions app/app_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ def analyze_with_ai():
logger.debug(" Model: %s", model)
logger.debug(" Has API Key: %s", bool(api_key))

if not api_key:
if not api_key and not ai_analyzer.is_local_openai_compat(provider):
logger.error("No API key configured for provider: %s", provider)
return jsonify({
'success': False,
Expand Down Expand Up @@ -1142,7 +1142,7 @@ def test_ai_api():
)
else:
# Test built-in provider
if not api_key:
if not api_key and not ai_analyzer.is_local_openai_compat(provider):
return jsonify({
'success': False,
'error': 'API key is required'
Expand All @@ -1158,6 +1158,8 @@ def test_ai_api():
api_url = 'https://api.x.ai/v1'
elif provider in ('gemini', 'google'):
api_url = 'https://generativelanguage.googleapis.com/v1beta'
elif ai_analyzer.is_local_openai_compat(provider):
api_url = 'http://localhost:11434/v1'

# Set default models if not provided
if not model:
Expand All @@ -1169,6 +1171,8 @@ def test_ai_api():
model = 'grok-3-mini'
elif provider in ('gemini', 'google'):
model = 'gemini-2.5-flash'
elif ai_analyzer.is_local_openai_compat(provider):
model = 'llama3.2'

# Determine endpoint
endpoint = '/chat/completions'
Expand Down Expand Up @@ -1315,17 +1319,20 @@ def ai_api_call():
max_retries = data.get('maxRetries', 3)

# Validation
if not api_key:
if not api_key and not ai_analyzer.is_local_openai_compat(provider):
return jsonify({
'success': False,
'error': 'API key is required'
}), 400
if ai_analyzer.is_local_openai_compat(provider):
api_key = ai_analyzer.dummy_key_for_local(api_key)

if not api_url:
return jsonify({
'success': False,
'error': 'API URL is required'
}), 400
api_url = ai_analyzer.rewrite_ai_url_for_runtime(api_url)

# Build full URL — Gemini embeds {model}:generateContent in the path
if provider in ('gemini', 'google') and model:
Expand Down
13 changes: 13 additions & 0 deletions app/assets/css/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -762,6 +762,19 @@ body { display: block !important; height: auto !important; overflow: visible !im
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.1);
}

.ai-provider-help {
font-size: 12px;
color: #666;
margin: 6px 0 0 0;
line-height: 1.45;
}
.ai-provider-help code {
background: #f0f0f0;
padding: 1px 5px;
border-radius: 3px;
font-size: 11px;
}

/* Default badge */
.default-badge {
background: #007bff;
Expand Down
8 changes: 8 additions & 0 deletions app/assets/img/ai-logos/local-openai.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
19 changes: 15 additions & 4 deletions app/assets/js/ai-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export async function getDefaultAIProvider() {
if (preferences && preferences.aiApis && Array.isArray(preferences.aiApis)) {
const defaultProvider = preferences.aiApis[0]; // First is default

if (defaultProvider && defaultProvider.apiKey) {
const localCompat = defaultProvider && ['local-openai', 'ollama', 'lmstudio', 'vllm'].includes(defaultProvider.id);
if (defaultProvider && (defaultProvider.apiKey || localCompat)) {
Logger.info('Using default AI provider:', defaultProvider.name);
return defaultProvider;
}
Expand Down Expand Up @@ -64,10 +65,12 @@ export async function callAI(options) {
Logger.info('🤖 AI API Call:', { provider, model, endpoint });

// Validation
if (!apiKey) {
const localCompat = ['local-openai', 'ollama', 'lmstudio', 'vllm'].includes(provider);
if (!apiKey && !localCompat) {
Logger.error('API key is required');
return { success: false, error: 'API key is required' };
}
const resolvedKey = apiKey || (localCompat ? 'ollama' : apiKey);

if (!apiUrl) {
Logger.error('API URL is required');
Expand All @@ -83,7 +86,7 @@ export async function callAI(options) {
body: JSON.stringify({
provider,
model,
apiKey,
apiKey: resolvedKey,
apiUrl,
endpoint,
method,
Expand Down Expand Up @@ -142,6 +145,10 @@ export async function callDefaultAI(userMessage, options = {}) {
case 'deepseek':
case 'openrouter':
case 'together':
case 'local-openai':
case 'ollama':
case 'lmstudio':
case 'vllm':
// OpenAI-compatible format
endpoint = '/chat/completions';
payload = {
Expand Down Expand Up @@ -204,7 +211,7 @@ export async function callDefaultAI(userMessage, options = {}) {
return callAI({
provider: provider.id,
model: provider.model,
apiKey: provider.apiKey,
apiKey: provider.apiKey || (['local-openai', 'ollama', 'lmstudio', 'vllm'].includes(provider.id) ? 'ollama' : provider.apiKey),
apiUrl: provider.apiUrl,
endpoint,
payload
Expand Down Expand Up @@ -233,6 +240,10 @@ export function extractAIResponse(result, provider) {
case 'deepseek':
case 'openrouter':
case 'together':
case 'local-openai':
case 'ollama':
case 'lmstudio':
case 'vllm':
// OpenAI format: data.choices[0].message.content
return data.choices?.[0]?.message?.content || null;

Expand Down
3 changes: 2 additions & 1 deletion app/assets/js/main-legacy.js
Original file line number Diff line number Diff line change
Expand Up @@ -27636,7 +27636,8 @@ ${info.features.map((f) => ` • ${f}`).join("\n")}

// Add standard AI providers
aiApis.forEach((api, index) => {
const hasApiKey = api.apiKey && api.apiKey.length > 0;
const localCompat = ['local-openai', 'ollama', 'lmstudio', 'vllm'].includes(api.id);
const hasApiKey = localCompat || (api.apiKey && api.apiKey.length > 0);
const status = hasApiKey ? '✅' : '❌';
const defaultTag = index === 0 ? ' (Default)' : '';
const modelInfo = api.model ? ` - ${api.model}` : '';
Expand Down
Loading
Loading