mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 22:48:11 +02:00
Phase 6 : configuration de l'agent fonctionnelle
Backend : - agent_config.py : profil persisté (invite système, génération, outils actifs) avec valeurs par défaut et fusion robuste - routes/config.py : GET/PUT /api/config - db.py : table config clé/valeur (get/set JSON) - agent.py : run_agent filtre les outils selon enabled_tools - routes/chat.py : utilise l'invite système, les options de génération et les outils actifs issus de la config sauvegardée Frontend : - api/client.ts + store : getConfig/saveConfig, état config - SettingsView : sliders interactifs (température, top-p, top-k, jetons max), toggles d'outils réels, éditeur d'invite système, bouton Enregistrer - ChatPanel : la barre de contexte reflète température et outils actifs réels Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SVay7z3y7q2gEe54ByAE6N
This commit is contained in:
1 parent
b6bf4edf97
commit
2db6beea5c
11 files changed
+499
-206
No files matched your search
+10
-2
@@ -40,9 +40,17 @@ async def run_agent(
|
|||||||
convo: list[dict],
|
convo: list[dict],
|
||||||
*,
|
*,
|
||||||
options: dict | None = None,
|
options: dict | None = None,
|
||||||
tools_enabled: bool = True,
|
enabled_tools: list[str] | None = None,
|
||||||
) -> AsyncIterator[dict]:
|
) -> AsyncIterator[dict]:
|
||||||
tools = TOOL_DEFINITIONS if tools_enabled else None
|
# enabled_tools=None -> tous les outils ; liste vide -> aucun outil.
|
||||||
|
if enabled_tools is None:
|
||||||
|
tools = TOOL_DEFINITIONS
|
||||||
|
elif enabled_tools:
|
||||||
|
tools = [
|
||||||
|
t for t in TOOL_DEFINITIONS if t["function"]["name"] in enabled_tools
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
tools = None
|
||||||
collected: list[dict] = []
|
collected: list[dict] = []
|
||||||
text_parts: list[str] = []
|
text_parts: list[str] = []
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Profil de configuration de l'agent : invite système, génération, outils.
|
||||||
|
|
||||||
|
Persisté en base sous la clé `agent`. Fournit les valeurs par défaut et la
|
||||||
|
fusion avec ce qui est stocké, pour rester robuste aux montées de version.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
CONFIG_KEY = "agent"
|
||||||
|
|
||||||
|
DEFAULT_SYSTEM_PROMPT = (
|
||||||
|
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
|
||||||
|
"d'outils pour lire, écrire et lister des fichiers dans le workspace. "
|
||||||
|
"Utilise-les pour accomplir les tâches concrètement, puis réponds de façon "
|
||||||
|
"concise en français. Après avoir écrit un fichier, propose un aperçu."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Outils réellement disponibles (les sensibles arrivent en phase 7).
|
||||||
|
AVAILABLE_TOOLS = ["read_file", "write_file", "list_dir"]
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict = {
|
||||||
|
"system_prompt": DEFAULT_SYSTEM_PROMPT,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"top_p": 0.9,
|
||||||
|
"top_k": 40,
|
||||||
|
"max_tokens": 2048,
|
||||||
|
"tools": {name: True for name in AVAILABLE_TOOLS},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_config() -> dict:
|
||||||
|
"""Config courante = défauts fusionnés avec le stockage."""
|
||||||
|
stored = db.get_config_value(CONFIG_KEY) or {}
|
||||||
|
cfg = {**DEFAULT_CONFIG, **stored}
|
||||||
|
cfg["tools"] = {
|
||||||
|
name: bool(stored.get("tools", {}).get(name, True))
|
||||||
|
for name in AVAILABLE_TOOLS
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(patch: dict) -> dict:
|
||||||
|
"""Applique une mise à jour partielle et renvoie la config complète."""
|
||||||
|
cfg = {**get_config(), **{k: v for k, v in patch.items() if v is not None}}
|
||||||
|
if "tools" in patch and patch["tools"]:
|
||||||
|
cfg["tools"] = {
|
||||||
|
name: bool(patch["tools"].get(name, cfg["tools"].get(name, True)))
|
||||||
|
for name in AVAILABLE_TOOLS
|
||||||
|
}
|
||||||
|
db.set_config_value(CONFIG_KEY, cfg)
|
||||||
|
return get_config()
|
||||||
|
|
||||||
|
|
||||||
|
def ollama_options(cfg: dict) -> dict:
|
||||||
|
"""Traduit la config en options de génération Ollama."""
|
||||||
|
return {
|
||||||
|
"temperature": cfg["temperature"],
|
||||||
|
"top_p": cfg["top_p"],
|
||||||
|
"top_k": cfg["top_k"],
|
||||||
|
"num_predict": cfg["max_tokens"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_tool_names(cfg: dict) -> list[str]:
|
||||||
|
return [name for name, on in cfg["tools"].items() if on]
|
||||||
@@ -54,6 +54,10 @@ def init_db() -> None:
|
|||||||
cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")}
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(messages)")}
|
||||||
if "meta" not in cols:
|
if "meta" not in cols:
|
||||||
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
|
conn.execute("ALTER TABLE messages ADD COLUMN meta TEXT")
|
||||||
|
# Table clé/valeur pour la configuration de l'agent.
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _now() -> float:
|
def _now() -> float:
|
||||||
@@ -160,3 +164,22 @@ def list_messages_for_model(sid: str) -> list[dict]:
|
|||||||
{"role": m["role"], "content": m["content"]}
|
{"role": m["role"], "content": m["content"]}
|
||||||
for m in list_messages(sid)
|
for m in list_messages(sid)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Configuration (clé/valeur JSON) ──────────────────────────────────────
|
||||||
|
def get_config_value(key: str) -> dict | None:
|
||||||
|
with _LOCK, _connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM config WHERE key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
return json.loads(row["value"]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_config_value(key: str, value: dict) -> None:
|
||||||
|
payload = json.dumps(value, ensure_ascii=False)
|
||||||
|
with _LOCK, _connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO config (key, value) VALUES (?, ?)"
|
||||||
|
" ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, payload),
|
||||||
|
)
|
||||||
+2
-1
@@ -14,7 +14,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
|
|
||||||
from . import db
|
from . import db
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .routes import chat, files, models, sessions
|
from .routes import chat, config, files, models, sessions
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -40,6 +40,7 @@ app.include_router(models.router)
|
|||||||
app.include_router(sessions.router)
|
app.include_router(sessions.router)
|
||||||
app.include_router(chat.router)
|
app.include_router(chat.router)
|
||||||
app.include_router(files.router)
|
app.include_router(files.router)
|
||||||
|
app.include_router(config.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -14,26 +14,17 @@ from fastapi import APIRouter, HTTPException
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from .. import db
|
from .. import agent_config, db
|
||||||
from ..agent import run_agent
|
from ..agent import run_agent
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["chat"])
|
router = APIRouter(prefix="/api", tags=["chat"])
|
||||||
|
|
||||||
SYSTEM_PROMPT = (
|
|
||||||
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
|
|
||||||
"d'outils pour lire, écrire et lister des fichiers dans le workspace. "
|
|
||||||
"Utilise-les pour accomplir les tâches concrètement, puis réponds de façon "
|
|
||||||
"concise en français. Après avoir écrit un fichier, propose un aperçu."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
session_id: str
|
session_id: str
|
||||||
content: str
|
content: str
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
options: dict | None = None
|
|
||||||
tools_enabled: bool = True
|
|
||||||
|
|
||||||
|
|
||||||
def _sse(event: str, data: dict) -> str:
|
def _sse(event: str, data: dict) -> str:
|
||||||
@@ -47,6 +38,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
raise HTTPException(404, "session introuvable")
|
raise HTTPException(404, "session introuvable")
|
||||||
|
|
||||||
model = req.model or session.get("model") or settings.default_model
|
model = req.model or session.get("model") or settings.default_model
|
||||||
|
cfg = agent_config.get_config()
|
||||||
|
|
||||||
# Premier message : titre la session avec un extrait.
|
# Premier message : titre la session avec un extrait.
|
||||||
if not db.list_messages(req.session_id):
|
if not db.list_messages(req.session_id):
|
||||||
@@ -55,7 +47,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
|
|
||||||
db.add_message(req.session_id, "user", req.content, None)
|
db.add_message(req.session_id, "user", req.content, None)
|
||||||
|
|
||||||
convo = [{"role": "system", "content": SYSTEM_PROMPT}]
|
convo = [{"role": "system", "content": cfg["system_prompt"]}]
|
||||||
convo += db.list_messages_for_model(req.session_id)
|
convo += db.list_messages_for_model(req.session_id)
|
||||||
|
|
||||||
async def event_stream():
|
async def event_stream():
|
||||||
@@ -64,7 +56,10 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
tools_meta: list[dict] = []
|
tools_meta: list[dict] = []
|
||||||
|
|
||||||
async for ev in run_agent(
|
async for ev in run_agent(
|
||||||
model, convo, options=req.options, tools_enabled=req.tools_enabled
|
model,
|
||||||
|
convo,
|
||||||
|
options=agent_config.ollama_options(cfg),
|
||||||
|
enabled_tools=agent_config.enabled_tool_names(cfg),
|
||||||
):
|
):
|
||||||
etype = ev.pop("type")
|
etype = ev.pop("type")
|
||||||
if etype == "token":
|
if etype == "token":
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Routes de configuration de l'agent (lecture / mise à jour)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .. import agent_config
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/config", tags=["config"])
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigPatch(BaseModel):
|
||||||
|
system_prompt: str | None = None
|
||||||
|
temperature: float | None = None
|
||||||
|
top_p: float | None = None
|
||||||
|
top_k: int | None = None
|
||||||
|
max_tokens: int | None = None
|
||||||
|
tools: dict[str, bool] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def get_config() -> dict:
|
||||||
|
return {
|
||||||
|
"config": agent_config.get_config(),
|
||||||
|
"available_tools": agent_config.AVAILABLE_TOOLS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("")
|
||||||
|
async def put_config(patch: ConfigPatch) -> dict:
|
||||||
|
cfg = agent_config.save_config(patch.model_dump(exclude_none=True))
|
||||||
|
return {"config": cfg}
|
||||||
@@ -9,15 +9,16 @@ import { useStore } from "./store/useStore";
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [view, setView] = useState<View>("chat");
|
const [view, setView] = useState<View>("chat");
|
||||||
const { refreshStatus, refreshModels } = useStore();
|
const { refreshStatus, refreshModels, refreshConfig } = useStore();
|
||||||
|
|
||||||
// Au démarrage : statut Ollama + liste des modèles. Poll du statut.
|
// Au démarrage : statut Ollama + modèles + config. Poll du statut.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
refreshModels();
|
refreshModels();
|
||||||
|
refreshConfig();
|
||||||
const id = setInterval(refreshStatus, 10000);
|
const id = setInterval(refreshStatus, 10000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [refreshStatus, refreshModels]);
|
}, [refreshStatus, refreshModels, refreshConfig]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-base text-ink">
|
<div className="flex h-full flex-col bg-base text-ink">
|
||||||
|
|||||||
@@ -55,6 +55,34 @@ export interface Message {
|
|||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AgentConfig {
|
||||||
|
system_prompt: string;
|
||||||
|
temperature: number;
|
||||||
|
top_p: number;
|
||||||
|
top_k: number;
|
||||||
|
max_tokens: number;
|
||||||
|
tools: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getConfig(): Promise<{
|
||||||
|
config: AgentConfig;
|
||||||
|
available_tools: string[];
|
||||||
|
}> {
|
||||||
|
const res = await fetch("/api/config");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveConfig(
|
||||||
|
patch: Partial<AgentConfig>
|
||||||
|
): Promise<AgentConfig> {
|
||||||
|
const res = await fetch("/api/config", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
return (await res.json()).config;
|
||||||
|
}
|
||||||
|
|
||||||
export interface FileNode {
|
export interface FileNode {
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
|
|||||||
@@ -14,8 +14,13 @@ export function ChatPanel() {
|
|||||||
streamTools,
|
streamTools,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
|
config,
|
||||||
} = useStore();
|
} = useStore();
|
||||||
|
|
||||||
|
const activeTools = config
|
||||||
|
? Object.values(config.tools).filter(Boolean).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -45,10 +50,15 @@ export function ChatPanel() {
|
|||||||
<div className="flex h-[42px] flex-none items-center gap-2 border-b border-line-soft px-[18px]">
|
<div className="flex h-[42px] flex-none items-center gap-2 border-b border-line-soft px-[18px]">
|
||||||
<Chip>Invite système</Chip>
|
<Chip>Invite système</Chip>
|
||||||
<Chip>
|
<Chip>
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-ok" />3 outils actifs
|
<span className="h-1.5 w-1.5 rounded-full bg-ok" />
|
||||||
|
{activeTools} outil{activeTools > 1 ? "s" : ""} actif
|
||||||
|
{activeTools > 1 ? "s" : ""}
|
||||||
</Chip>
|
</Chip>
|
||||||
<Chip>
|
<Chip>
|
||||||
Température <b className="font-semibold text-[#e7ddcd]">0.7</b>
|
Température{" "}
|
||||||
|
<b className="font-semibold text-[#e7ddcd]">
|
||||||
|
{config ? config.temperature.toFixed(2) : "—"}
|
||||||
|
</b>
|
||||||
</Chip>
|
</Chip>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<span className="font-mono text-[11px] text-muted-4">
|
<span className="font-mono text-[11px] text-muted-4">
|
||||||
|
|||||||
@@ -1,12 +1,39 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useStore } from "../store/useStore";
|
import { useStore } from "../store/useStore";
|
||||||
import { pullModel } from "../api/client";
|
import { pullModel } from "../api/client";
|
||||||
|
import type { AgentConfig } from "../api/client";
|
||||||
import { DownloadIcon, RefreshIcon } from "../components/Icon";
|
import { DownloadIcon, RefreshIcon } from "../components/Icon";
|
||||||
|
|
||||||
|
const TOOL_DESC: Record<string, string> = {
|
||||||
|
read_file: "Lire un fichier du projet",
|
||||||
|
write_file: "Créer / modifier un fichier",
|
||||||
|
list_dir: "Lister un répertoire",
|
||||||
|
};
|
||||||
|
|
||||||
/** Vue Configuration (Frame 2) — Modèle & génération, outils, invite système. */
|
/** Vue Configuration (Frame 2) — Modèle & génération, outils, invite système. */
|
||||||
export function SettingsView() {
|
export function SettingsView() {
|
||||||
const { models, selectedModel, setSelectedModel, refreshModels, status } =
|
const {
|
||||||
useStore();
|
models,
|
||||||
|
selectedModel,
|
||||||
|
setSelectedModel,
|
||||||
|
refreshModels,
|
||||||
|
status,
|
||||||
|
config,
|
||||||
|
availableTools,
|
||||||
|
refreshConfig,
|
||||||
|
updateConfig,
|
||||||
|
} = useStore();
|
||||||
|
|
||||||
|
// Brouillon local édité, synchronisé depuis la config serveur.
|
||||||
|
const [draft, setDraft] = useState<AgentConfig | null>(config);
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshConfig();
|
||||||
|
}, [refreshConfig]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (config) setDraft(config);
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
const [pullName, setPullName] = useState("");
|
const [pullName, setPullName] = useState("");
|
||||||
const [pullStatus, setPullStatus] = useState<string | null>(null);
|
const [pullStatus, setPullStatus] = useState<string | null>(null);
|
||||||
@@ -28,6 +55,23 @@ export function SettingsView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const dirty =
|
||||||
|
draft && config && JSON.stringify(draft) !== JSON.stringify(config);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
await updateConfig(draft);
|
||||||
|
setSaved(true);
|
||||||
|
setTimeout(() => setSaved(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = <K extends keyof AgentConfig>(key: K, value: AgentConfig[K]) =>
|
||||||
|
setDraft((d) => (d ? { ...d, [key]: value } : d));
|
||||||
|
|
||||||
|
const activeTools = draft
|
||||||
|
? Object.values(draft.tools).filter(Boolean).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scr flex-1 overflow-auto px-[30px] py-[26px]">
|
<div className="scr flex-1 overflow-auto px-[30px] py-[26px]">
|
||||||
<div className="mx-auto max-w-[1000px]">
|
<div className="mx-auto max-w-[1000px]">
|
||||||
@@ -41,199 +85,243 @@ export function SettingsView() {
|
|||||||
Choisis le modèle local et règle son comportement.
|
Choisis le modèle local et règle son comportement.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 font-mono text-[11.5px] text-ok">
|
<div className="flex items-center gap-3">
|
||||||
<span className="h-1.5 w-1.5 rounded-full bg-ok" />
|
<div className="flex items-center gap-1.5 font-mono text-[11.5px] text-ok">
|
||||||
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"}
|
<span className="h-1.5 w-1.5 rounded-full bg-ok" />
|
||||||
|
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={save}
|
||||||
|
disabled={!dirty}
|
||||||
|
className="flex h-[30px] items-center gap-1.5 rounded-lg bg-accent px-3.5 text-[12.5px] font-bold text-[#1f1b16] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{saved ? "Enregistré ✓" : dirty ? "Enregistrer" : "À jour"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 items-start gap-5">
|
{!draft ? (
|
||||||
{/* Modèle Ollama */}
|
<div className="py-20 text-center text-muted-3">
|
||||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
Chargement de la configuration…
|
||||||
<div className="mb-3.5 flex items-center justify-between">
|
</div>
|
||||||
<div className="text-sm font-bold">Modèle Ollama</div>
|
) : (
|
||||||
<button
|
<>
|
||||||
onClick={refreshModels}
|
<div className="grid grid-cols-2 items-start gap-5">
|
||||||
className="flex h-[26px] items-center gap-1.5 rounded-[7px] border border-line-strong bg-base px-2.5 text-[11px] text-muted"
|
{/* Modèle Ollama */}
|
||||||
>
|
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||||
<RefreshIcon size={12} />
|
<div className="mb-3.5 flex items-center justify-between">
|
||||||
Actualiser
|
<div className="text-sm font-bold">Modèle Ollama</div>
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
{models.length === 0 && (
|
|
||||||
<div className="rounded-[11px] border border-line bg-base p-4 text-center text-xs text-muted-3">
|
|
||||||
Aucun modèle installé. Télécharge-en un ci-dessous.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{models.map((m) => {
|
|
||||||
const on = m.name === selectedModel;
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={m.name}
|
onClick={refreshModels}
|
||||||
onClick={() => setSelectedModel(m.name)}
|
className="flex h-[26px] items-center gap-1.5 rounded-[7px] border border-line-strong bg-base px-2.5 text-[11px] text-muted"
|
||||||
className="flex items-center gap-3 rounded-[11px] border p-3 text-left"
|
|
||||||
style={{
|
|
||||||
background: on ? "rgba(240,161,92,.10)" : "#1a1714",
|
|
||||||
borderColor: on ? "rgba(240,161,92,.40)" : "#2c2720",
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span
|
<RefreshIcon size={12} />
|
||||||
className="flex h-[18px] w-[18px] items-center justify-center rounded-full border-2"
|
Actualiser
|
||||||
style={{ borderColor: on ? "#f0a15c" : "#4a443b" }}
|
|
||||||
>
|
|
||||||
{on && (
|
|
||||||
<span className="h-2 w-2 rounded-full bg-accent" />
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-[13px] font-semibold text-ink">
|
|
||||||
{m.name}
|
|
||||||
</span>
|
|
||||||
{m.name === status?.default_model && (
|
|
||||||
<span
|
|
||||||
className="rounded-[5px] px-1.5 py-px text-[10px] font-bold text-accent"
|
|
||||||
style={{ background: "rgba(240,161,92,.14)" }}
|
|
||||||
>
|
|
||||||
PAR DÉFAUT
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="mt-[3px] font-mono text-[10.5px] text-muted-2">
|
|
||||||
{[
|
|
||||||
m.size_go ? `${m.size_go} Go` : null,
|
|
||||||
m.quantization,
|
|
||||||
m.parameter_size,
|
|
||||||
m.family,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" · ")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="my-4 h-px bg-line" />
|
|
||||||
|
|
||||||
<div className="mb-2.5 text-[11px] font-bold uppercase tracking-[.05em] text-label">
|
|
||||||
Télécharger un modèle
|
|
||||||
</div>
|
|
||||||
<div className="mb-3 flex gap-2">
|
|
||||||
<input
|
|
||||||
value={pullName}
|
|
||||||
onChange={(e) => setPullName(e.target.value)}
|
|
||||||
placeholder="gemma2:9b"
|
|
||||||
className="h-[34px] flex-1 rounded-[9px] border border-line-strong bg-base px-3 font-mono text-xs text-ink outline-none placeholder:text-muted-3"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={doPull}
|
|
||||||
className="flex h-[34px] items-center gap-1.5 rounded-[9px] border border-line-strong bg-[#2a251f] px-3.5 text-[12.5px] font-semibold text-[#e7ddcd]"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
Récupérer
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pullStatus && (
|
|
||||||
<div className="rounded-[10px] border border-line bg-base p-3">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="font-mono text-xs text-ink-2">
|
|
||||||
{pullName}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-[11px] text-accent">
|
|
||||||
{pullStatus} {pullPct ? `· ${pullPct}%` : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 overflow-hidden rounded bg-line">
|
|
||||||
<div
|
<div className="flex flex-col gap-2">
|
||||||
className="h-full rounded bg-accent-grad transition-all"
|
{models.length === 0 && (
|
||||||
style={{ width: `${pullPct}%` }}
|
<div className="rounded-[11px] border border-line bg-base p-4 text-center text-xs text-muted-3">
|
||||||
|
Aucun modèle installé. Télécharge-en un ci-dessous.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{models.map((m) => {
|
||||||
|
const on = m.name === selectedModel;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={m.name}
|
||||||
|
onClick={() => setSelectedModel(m.name)}
|
||||||
|
className="flex items-center gap-3 rounded-[11px] border p-3 text-left"
|
||||||
|
style={{
|
||||||
|
background: on ? "rgba(240,161,92,.10)" : "#1a1714",
|
||||||
|
borderColor: on ? "rgba(240,161,92,.40)" : "#2c2720",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-[18px] w-[18px] items-center justify-center rounded-full border-2"
|
||||||
|
style={{ borderColor: on ? "#f0a15c" : "#4a443b" }}
|
||||||
|
>
|
||||||
|
{on && <span className="h-2 w-2 rounded-full bg-accent" />}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-[13px] font-semibold text-ink">
|
||||||
|
{m.name}
|
||||||
|
</span>
|
||||||
|
{m.name === status?.default_model && (
|
||||||
|
<span
|
||||||
|
className="rounded-[5px] px-1.5 py-px text-[10px] font-bold text-accent"
|
||||||
|
style={{ background: "rgba(240,161,92,.14)" }}
|
||||||
|
>
|
||||||
|
PAR DÉFAUT
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-[3px] font-mono text-[10.5px] text-muted-2">
|
||||||
|
{[
|
||||||
|
m.size_go ? `${m.size_go} Go` : null,
|
||||||
|
m.quantization,
|
||||||
|
m.parameter_size,
|
||||||
|
m.family,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="my-4 h-px bg-line" />
|
||||||
|
|
||||||
|
<div className="mb-2.5 text-[11px] font-bold uppercase tracking-[.05em] text-label">
|
||||||
|
Télécharger un modèle
|
||||||
|
</div>
|
||||||
|
<div className="mb-3 flex gap-2">
|
||||||
|
<input
|
||||||
|
value={pullName}
|
||||||
|
onChange={(e) => setPullName(e.target.value)}
|
||||||
|
placeholder="gemma2:9b"
|
||||||
|
className="h-[34px] flex-1 rounded-[9px] border border-line-strong bg-base px-3 font-mono text-xs text-ink outline-none placeholder:text-muted-3"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={doPull}
|
||||||
|
className="flex h-[34px] items-center gap-1.5 rounded-[9px] border border-line-strong bg-[#2a251f] px-3.5 text-[12.5px] font-semibold text-[#e7ddcd]"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
Récupérer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pullStatus && (
|
||||||
|
<div className="rounded-[10px] border border-line bg-base p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="font-mono text-xs text-ink-2">
|
||||||
|
{pullName}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] text-accent">
|
||||||
|
{pullStatus} {pullPct ? `· ${pullPct}%` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 overflow-hidden rounded bg-line">
|
||||||
|
<div
|
||||||
|
className="h-full rounded bg-accent-grad transition-all"
|
||||||
|
style={{ width: `${pullPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Génération + Outils */}
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||||
|
<div className="mb-4 text-sm font-bold">Génération</div>
|
||||||
|
<Slider
|
||||||
|
label="Température"
|
||||||
|
value={draft.temperature}
|
||||||
|
min={0}
|
||||||
|
max={2}
|
||||||
|
step={0.05}
|
||||||
|
fmt={(v) => v.toFixed(2)}
|
||||||
|
onChange={(v) => set("temperature", v)}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Top-P"
|
||||||
|
value={draft.top_p}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
fmt={(v) => v.toFixed(2)}
|
||||||
|
onChange={(v) => set("top_p", v)}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Top-K"
|
||||||
|
value={draft.top_k}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
fmt={(v) => String(Math.round(v))}
|
||||||
|
onChange={(v) => set("top_k", Math.round(v))}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
label="Jetons max"
|
||||||
|
value={draft.max_tokens}
|
||||||
|
min={256}
|
||||||
|
max={8192}
|
||||||
|
step={128}
|
||||||
|
fmt={(v) => String(Math.round(v))}
|
||||||
|
onChange={(v) => set("max_tokens", Math.round(v))}
|
||||||
|
last
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Génération + Outils */}
|
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||||
<div className="flex flex-col gap-5">
|
<div className="mb-3.5 flex items-center justify-between">
|
||||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
<div className="text-sm font-bold">Outils de l'agent</div>
|
||||||
<div className="mb-4 text-sm font-bold">Génération</div>
|
<span className="font-mono text-[11px] text-muted-2">
|
||||||
<Slider label="Température" value="0.7" pct={35} />
|
{activeTools} / {availableTools.length} actifs
|
||||||
<Slider label="Top-P" value="0.90" pct={90} />
|
</span>
|
||||||
<Slider label="Top-K" value="40" pct={40} />
|
</div>
|
||||||
<Slider label="Jetons max" value="2048" pct={25} last />
|
{availableTools.map((name, i) => {
|
||||||
|
const on = draft.tools[name] ?? false;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={name}
|
||||||
|
className={`flex items-center gap-3 py-[9px] ${
|
||||||
|
i > 0 ? "border-t border-line-soft" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="w-32 flex-none font-mono text-[12.5px] text-ink-2">
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 text-xs text-muted-2">
|
||||||
|
{TOOL_DESC[name] ?? ""}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
set("tools", { ...draft.tools, [name]: !on })
|
||||||
|
}
|
||||||
|
className="relative h-5 w-[34px] rounded-full transition-colors"
|
||||||
|
style={{ background: on ? "#f0a15c" : "#3a342c" }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="absolute top-0.5 h-4 w-4 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
background: on ? "#1f1b16" : "#6f675c",
|
||||||
|
left: on ? 16 : 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="mt-1 border-t border-line-soft pt-2.5 font-mono text-[11px] text-muted-3">
|
||||||
|
web_search · run_shell — arrivent bientôt (sensibles)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
{/* Invite système */}
|
||||||
<div className="mb-3.5 flex items-center justify-between">
|
<div className="mt-5 rounded-card border border-line bg-card p-[18px]">
|
||||||
<div className="text-sm font-bold">Outils de l'agent</div>
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="text-sm font-bold">Invite système</div>
|
||||||
<span className="font-mono text-[11px] text-muted-2">
|
<span className="font-mono text-[11px] text-muted-2">
|
||||||
bientôt
|
{draft.system_prompt.length} caractères
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{[
|
<textarea
|
||||||
["read_file", "Lire un fichier du projet", true],
|
value={draft.system_prompt}
|
||||||
["write_file", "Créer / modifier un fichier", true],
|
onChange={(e) => set("system_prompt", e.target.value)}
|
||||||
["list_dir", "Lister un répertoire", true],
|
rows={6}
|
||||||
["html_preview", "Aperçu HTML en direct", true],
|
className="scr w-full resize-y rounded-[10px] border border-line bg-sunken p-4 font-mono text-[12.5px] leading-[1.7] text-ink-3 outline-none focus:border-line-strong"
|
||||||
["web_search", "Recherche web", false],
|
/>
|
||||||
["run_shell", "Exécuter une commande · sensible", false],
|
|
||||||
].map(([name, desc, on], i) => (
|
|
||||||
<div
|
|
||||||
key={name as string}
|
|
||||||
className={`flex items-center gap-3 py-[9px] ${
|
|
||||||
i > 0 ? "border-t border-line-soft" : ""
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="w-32 flex-none font-mono text-[12.5px] text-ink-2">
|
|
||||||
{name}
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 text-xs text-muted-2">{desc}</span>
|
|
||||||
<span
|
|
||||||
className="relative h-5 w-[34px] rounded-full"
|
|
||||||
style={{ background: on ? "#f0a15c" : "#3a342c" }}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="absolute top-0.5 h-4 w-4 rounded-full"
|
|
||||||
style={{
|
|
||||||
background: on ? "#1f1b16" : "#6f675c",
|
|
||||||
left: on ? "auto" : "2px",
|
|
||||||
right: on ? "2px" : "auto",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Invite système */}
|
|
||||||
<div className="mt-5 rounded-card border border-line bg-card p-[18px]">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<div className="text-sm font-bold">Invite système</div>
|
|
||||||
<span className="font-mono text-[11px] text-muted-2">
|
|
||||||
éditeur à venir
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[10px] border border-line bg-sunken p-4 font-mono text-[12.5px] leading-[1.7] text-ink-3">
|
|
||||||
<span className="text-ok"># Rôle</span>
|
|
||||||
<br />
|
|
||||||
Tu es un assistant de développement local. Tu travailles dans le
|
|
||||||
dossier <span className="text-accent">workspace/</span> et tu écris
|
|
||||||
du code clair, commenté en français.
|
|
||||||
<br />
|
|
||||||
<br />
|
|
||||||
<span className="text-ok"># Règles</span>
|
|
||||||
<br />
|
|
||||||
— Demande confirmation avant toute commande shell.
|
|
||||||
<br />— Après chaque écriture de fichier, propose un aperçu.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -242,31 +330,52 @@ export function SettingsView() {
|
|||||||
function Slider({
|
function Slider({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
pct,
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
fmt,
|
||||||
|
onChange,
|
||||||
last,
|
last,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: number;
|
||||||
pct: number;
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step: number;
|
||||||
|
fmt: (v: number) => string;
|
||||||
|
onChange: (v: number) => void;
|
||||||
last?: boolean;
|
last?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const pct = ((value - min) / (max - min)) * 100;
|
||||||
return (
|
return (
|
||||||
<div className={last ? "" : "mb-4"}>
|
<div className={last ? "" : "mb-4"}>
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<span className="text-[13px] text-ink-2">{label}</span>
|
<span className="text-[13px] text-ink-2">{label}</span>
|
||||||
<span className="font-mono text-xs font-semibold text-accent">
|
<span className="font-mono text-xs font-semibold text-accent">
|
||||||
{value}
|
{fmt(value)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-1.5 rounded bg-line">
|
<div className="relative h-4">
|
||||||
|
{/* Piste + remplissage + pouce (visuel fidèle) */}
|
||||||
|
<div className="absolute top-1/2 h-1.5 w-full -translate-y-1/2 rounded bg-line" />
|
||||||
<div
|
<div
|
||||||
className="absolute left-0 top-0 h-1.5 rounded bg-accent"
|
className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded bg-accent"
|
||||||
style={{ width: `${pct}%` }}
|
style={{ width: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-card bg-accent"
|
className="absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-[3px] border-card bg-accent"
|
||||||
style={{ left: `${pct}%` }}
|
style={{ left: `${pct}%` }}
|
||||||
/>
|
/>
|
||||||
|
{/* Input réel transparent par-dessus */}
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(parseFloat(e.target.value))}
|
||||||
|
className="absolute inset-0 h-full w-full cursor-pointer opacity-0"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,13 +2,16 @@ import { create } from "zustand";
|
|||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
getConfig,
|
||||||
getModels,
|
getModels,
|
||||||
getSession,
|
getSession,
|
||||||
getStatus,
|
getStatus,
|
||||||
fileContent,
|
fileContent,
|
||||||
listFiles,
|
listFiles,
|
||||||
listSessions,
|
listSessions,
|
||||||
|
saveConfig,
|
||||||
streamChat,
|
streamChat,
|
||||||
|
type AgentConfig,
|
||||||
type FileNode,
|
type FileNode,
|
||||||
type Message,
|
type Message,
|
||||||
type OllamaModel,
|
type OllamaModel,
|
||||||
@@ -34,6 +37,11 @@ interface LokiState {
|
|||||||
previewPath: string | null;
|
previewPath: string | null;
|
||||||
previewContent: string;
|
previewContent: string;
|
||||||
|
|
||||||
|
config: AgentConfig | null;
|
||||||
|
availableTools: string[];
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
updateConfig: (patch: Partial<AgentConfig>) => Promise<void>;
|
||||||
|
|
||||||
openPreview: (path: string) => Promise<void>;
|
openPreview: (path: string) => Promise<void>;
|
||||||
setSelectedModel: (name: string) => void;
|
setSelectedModel: (name: string) => void;
|
||||||
refreshStatus: () => Promise<void>;
|
refreshStatus: () => Promise<void>;
|
||||||
@@ -62,6 +70,18 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
fileTree: [],
|
fileTree: [],
|
||||||
previewPath: null,
|
previewPath: null,
|
||||||
previewContent: "",
|
previewContent: "",
|
||||||
|
config: null,
|
||||||
|
availableTools: [],
|
||||||
|
|
||||||
|
refreshConfig: async () => {
|
||||||
|
const { config, available_tools } = await getConfig();
|
||||||
|
set({ config, availableTools: available_tools });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConfig: async (patch) => {
|
||||||
|
const config = await saveConfig(patch);
|
||||||
|
set({ config });
|
||||||
|
},
|
||||||
|
|
||||||
openPreview: async (path) => {
|
openPreview: async (path) => {
|
||||||
const content = await fileContent(path);
|
const content = await fileContent(path);
|
||||||
|
|||||||
Reference in new issue
Block a user