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")
|
logger = logging.getLogger("obsigate.ai")
|
||||||
|
|
||||||
ProviderName = Literal["deepseek", "openrouter", "gemini"]
|
ProviderName = Literal["deepseek", "openrouter", "gemini", "ollama"]
|
||||||
|
|
||||||
# Provider configurations — keys loaded from file or .env
|
# Provider configurations — keys loaded from file or .env
|
||||||
AI_KEYS_FILE = Path("data/api_keys.json")
|
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"),
|
"model": os.getenv("GEMINI_MODEL", "gemini-2.0-flash"),
|
||||||
"auth_header": None,
|
"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()
|
PROVIDERS = _load_provider_keys()
|
||||||
|
|||||||
@@ -20,3 +20,25 @@ services:
|
|||||||
- OBSIGATE_AUTH_ENABLED=false
|
- OBSIGATE_AUTH_ENABLED=false
|
||||||
- OBSIGATE_ADMIN_USER=admin
|
- OBSIGATE_ADMIN_USER=admin
|
||||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
- 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_AUTH_ENABLED=false
|
||||||
- OBSIGATE_ADMIN_USER=admin
|
- OBSIGATE_ADMIN_USER=admin
|
||||||
- OBSIGATE_ADMIN_PASSWORD=test123
|
- 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-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-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; }
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="ln-editor" id="ln-editor">
|
||||||
<div class="editor-wrap" id="editor-wrap">
|
<div class="editor-wrap" id="editor-wrap">
|
||||||
<textarea class="textarea" id="ta" placeholder="Type / for commands..." spellcheck="false"></textarea>
|
<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 -->
|
<!-- Quick Insert menu -->
|
||||||
<div class="qi-menu" id="qi-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) {
|
if (lnGutter) {
|
||||||
document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop;
|
document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop;
|
||||||
}
|
}
|
||||||
|
// Sync ghost overlay scroll
|
||||||
|
if (ghostOverlay) {
|
||||||
|
ghostOverlay.scrollTop = editorWrap.scrollTop;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update line numbers on input
|
// Update line numbers on input
|
||||||
@@ -1763,9 +1780,77 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
|
|||||||
loadAndTrigger();
|
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,'"'); }
|
function escHtml(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
|
|
||||||
ta.addEventListener('keydown', function(e) {
|
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 (acDropdown.classList.contains('active')) {
|
||||||
if (e.key === 'ArrowDown') { e.preventDefault(); acIdx = Math.min(acIdx + 1, acItems.length - 1); highlightAcItem(); }
|
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(); }
|
else if (e.key === 'ArrowUp') { e.preventDefault(); acIdx = Math.max(acIdx - 1, 0); highlightAcItem(); }
|
||||||
|
|||||||
Reference in New Issue
Block a user