From eef01ee32adc4aefd21aaf71c73a45e4d18ac0a8 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 16 Jun 2026 11:42:28 -0400 Subject: [PATCH] feat: ghost text AI inline + Ollama qwen2.5-coder:1.5b - Ollama provider ajoute dans ai.py (OpenAI-compatible API) - Service ollama dans docker-compose (auto-pull qwen2.5-coder:1.5b) - Ghost overlay dans Forge: prediction en texte pale apres le curseur - Tab pour accepter, Escape pour rejeter, taper pour annuler - Declenchement automatique apres 800ms de pause - Scroll sync entre overlay et textarea - Env vars: AI_DEFAULT_PROVIDER, OLLAMA_BASE_URL, OLLAMA_MODEL --- backend/ai.py | 8 +++- docker-compose.podman.yml | 22 ++++++++++ docker-compose.test.yml | 22 ++++++++++ frontend/editor-poc.html | 85 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) diff --git a/backend/ai.py b/backend/ai.py index 845b264..7401cba 100644 --- a/backend/ai.py +++ b/backend/ai.py @@ -13,7 +13,7 @@ from typing import Optional, Literal logger = logging.getLogger("obsigate.ai") -ProviderName = Literal["deepseek", "openrouter", "gemini"] +ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama"] # Provider configurations — keys loaded from file or .env AI_KEYS_FILE = Path("data/api_keys.json") @@ -54,6 +54,12 @@ def _load_provider_keys(): "model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"), "auth_header": None, }, + "ollama": { + "api_key": "ollama", + "base_url": os.getenv("OLLAMA_BASE_URL", "http://ollama:11434/v1"), + "model": os.getenv("OLLAMA_MODEL", "qwen2.5-coder:1.5b"), + "auth_header": "Bearer {api_key}", + }, } PROVIDERS = _load_provider_keys() diff --git a/docker-compose.podman.yml b/docker-compose.podman.yml index da61798..4c23524 100644 --- a/docker-compose.podman.yml +++ b/docker-compose.podman.yml @@ -20,3 +20,25 @@ services: - OBSIGATE_AUTH_ENABLED=false - OBSIGATE_ADMIN_USER=admin - OBSIGATE_ADMIN_PASSWORD=test123 + - AI_DEFAULT_PROVIDER=deepseek + - OLLAMA_BASE_URL=http://ollama:11434/v1 + - OLLAMA_MODEL=qwen2.5-coder:1.5b + + ollama: + image: ollama/ollama:latest + container_name: obsigate-ollama + restart: unless-stopped + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + entrypoint: ["/bin/sh", "-c"] + command: + - | + ollama serve & + sleep 5 + ollama pull qwen2.5-coder:1.5b + wait + +volumes: + ollama_data: diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 1bd8182..5676884 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -20,3 +20,25 @@ services: - OBSIGATE_AUTH_ENABLED=false - OBSIGATE_ADMIN_USER=admin - OBSIGATE_ADMIN_PASSWORD=test123 + - AI_DEFAULT_PROVIDER=deepseek + - OLLAMA_BASE_URL=http://ollama:11434/v1 + - OLLAMA_MODEL=qwen2.5-coder:1.5b + + ollama: + image: ollama/ollama:latest + container_name: obsigate-ollama + restart: unless-stopped + ports: + - "11434:11434" + volumes: + - ollama_data:/root/.ollama + entrypoint: ["/bin/sh", "-c"] + command: + - | + ollama serve & + sleep 5 + ollama pull qwen2.5-coder:1.5b + wait + +volumes: + ollama_data: diff --git a/frontend/editor-poc.html b/frontend/editor-poc.html index 54c4c35..0ea40b6 100644 --- a/frontend/editor-poc.html +++ b/frontend/editor-poc.html @@ -233,6 +233,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig .ac-item-label { font-family: var(--mono); font-weight: 500; white-space: nowrap; } .ac-item-detail { font-size: 10.5px; color: var(--text3); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; } .ac-item-context { font-size: 9px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; padding: 2px 10px 4px; } +/* Ghost text overlay */ +.ghost-overlay { + position: absolute; top: 0; left: 0; right: 0; bottom: 0; + pointer-events: none; z-index: 1; + padding: 24px 16px 24px 12px; + font-family: var(--mono); font-size: 13.5px; line-height: 1.75; + white-space: pre-wrap; word-wrap: break-word; + overflow: hidden; color: transparent; +} +.ghost-prediction { + color: var(--text3); opacity: 0.5; +} @@ -276,6 +288,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
+
@@ -1509,6 +1522,10 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig if (lnGutter) { document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop; } + // Sync ghost overlay scroll + if (ghostOverlay) { + ghostOverlay.scrollTop = editorWrap.scrollTop; + } }); // Update line numbers on input @@ -1763,9 +1780,77 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig loadAndTrigger(); } + // ---- Ghost Text (inline AI prediction via Ollama) ---- + var ghostOverlay = document.getElementById('ghost-overlay'); + var ghostPred = document.getElementById('ghost-prediction'); + var _ghostTimer = null; + var _ghostText = ''; + + function clearGhost() { + _ghostText = ''; + ghostOverlay.textContent = ''; + } + + function showGhost(prediction) { + if (!prediction) { clearGhost(); return; } + _ghostText = prediction; + var before = ta.value.slice(0, ta.selectionStart); + ghostOverlay.textContent = before + prediction; + } + + function acceptGhost() { + if (!_ghostText) return; + var start = ta.selectionStart; + var before = ta.value.slice(0, start); + var after = ta.value.slice(start); + ta.value = before + _ghostText + after; + ta.setSelectionRange(start + _ghostText.length, start + _ghostText.length); + clearGhost(); + ta.focus(); + markDirty(); + autoHeight(); + updateLineNumbers(); + } + + function requestGhostCompletion() { + var text = ta.value.slice(0, ta.selectionStart); + if (text.trim().length < 3) return; + fetch('/api/ai/inline-complete', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: text, provider: 'ollama' }) + }) + .then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }) + .then(function(data) { + var prediction = (data.result || '').trim(); + if (prediction && !ta.value.slice(ta.selectionStart).startsWith(prediction.slice(0, 10))) { + showGhost(prediction); + } + }) + .catch(function() { /* silent */ }); + } + + // Trigger after typing pause + ta.addEventListener('input', function() { + clearGhost(); + if (_ghostTimer) clearTimeout(_ghostTimer); + _ghostTimer = setTimeout(requestGhostCompletion, 800); + }); + function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } ta.addEventListener('keydown', function(e) { + // Tab: accept ghost text prediction + if (e.key === 'Tab' && _ghostText && !acDropdown.classList.contains('active')) { + e.preventDefault(); + acceptGhost(); + return; + } + // Escape: clear ghost text + if (e.key === 'Escape' && _ghostText && !acDropdown.classList.contains('active')) { + clearGhost(); + return; + } if (acDropdown.classList.contains('active')) { if (e.key === 'ArrowDown') { e.preventDefault(); acIdx = Math.min(acIdx + 1, acItems.length - 1); highlightAcItem(); } else if (e.key === 'ArrowUp') { e.preventDefault(); acIdx = Math.max(acIdx - 1, 0); highlightAcItem(); }