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
This commit is contained in:
+7
-1
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,'&').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(); }
|
||||
|
||||
Reference in New Issue
Block a user