feat: ghost text AI inline + Ollama qwen2.5-coder:1.5b
CI / lint (push) Failing after 7s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Failing after -6s

- 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
This commit is contained in:
2026-06-16 11:42:28 -04:00
parent 994a6e1749
commit eef01ee32a
4 changed files with 136 additions and 1 deletions
+7 -1
View File
@@ -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()
+22
View File
@@ -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:
+22
View File
@@ -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:
+85
View File
@@ -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;
}
</style>
</head>
<body>
@@ -276,6 +288,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<div class="ln-editor" id="ln-editor">
<div class="editor-wrap" id="editor-wrap">
<textarea class="textarea" id="ta" placeholder="Type / for commands..." spellcheck="false"></textarea>
<div class="ghost-overlay" id="ghost-overlay"><span class="ghost-prediction" id="ghost-prediction"></span></div>
<!-- Quick Insert menu -->
<div class="qi-menu" id="qi-menu">
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
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(); }