mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +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],
|
||||
*,
|
||||
options: dict | None = None,
|
||||
tools_enabled: bool = True,
|
||||
enabled_tools: list[str] | None = None,
|
||||
) -> 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] = []
|
||||
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)")}
|
||||
if "meta" not in cols:
|
||||
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:
|
||||
@@ -160,3 +164,22 @@ def list_messages_for_model(sid: str) -> list[dict]:
|
||||
{"role": m["role"], "content": m["content"]}
|
||||
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 .config import settings
|
||||
from .routes import chat, files, models, sessions
|
||||
from .routes import chat, config, files, models, sessions
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -40,6 +40,7 @@ app.include_router(models.router)
|
||||
app.include_router(sessions.router)
|
||||
app.include_router(chat.router)
|
||||
app.include_router(files.router)
|
||||
app.include_router(config.router)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
|
||||
@@ -14,26 +14,17 @@ from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .. import db
|
||||
from .. import agent_config, db
|
||||
from ..agent import run_agent
|
||||
from ..config import settings
|
||||
|
||||
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):
|
||||
session_id: str
|
||||
content: str
|
||||
model: str | None = None
|
||||
options: dict | None = None
|
||||
tools_enabled: bool = True
|
||||
|
||||
|
||||
def _sse(event: str, data: dict) -> str:
|
||||
@@ -47,6 +38,7 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
raise HTTPException(404, "session introuvable")
|
||||
|
||||
model = req.model or session.get("model") or settings.default_model
|
||||
cfg = agent_config.get_config()
|
||||
|
||||
# Premier message : titre la session avec un extrait.
|
||||
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)
|
||||
|
||||
convo = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||
convo = [{"role": "system", "content": cfg["system_prompt"]}]
|
||||
convo += db.list_messages_for_model(req.session_id)
|
||||
|
||||
async def event_stream():
|
||||
@@ -64,7 +56,10 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
||||
tools_meta: list[dict] = []
|
||||
|
||||
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")
|
||||
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() {
|
||||
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(() => {
|
||||
refreshStatus();
|
||||
refreshModels();
|
||||
refreshConfig();
|
||||
const id = setInterval(refreshStatus, 10000);
|
||||
return () => clearInterval(id);
|
||||
}, [refreshStatus, refreshModels]);
|
||||
}, [refreshStatus, refreshModels, refreshConfig]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-base text-ink">
|
||||
|
||||
@@ -55,6 +55,34 @@ export interface Message {
|
||||
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 {
|
||||
name: string;
|
||||
path: string;
|
||||
|
||||
@@ -14,8 +14,13 @@ export function ChatPanel() {
|
||||
streamTools,
|
||||
sendMessage,
|
||||
currentSessionId,
|
||||
config,
|
||||
} = useStore();
|
||||
|
||||
const activeTools = config
|
||||
? Object.values(config.tools).filter(Boolean).length
|
||||
: 0;
|
||||
|
||||
const [draft, setDraft] = useState("");
|
||||
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]">
|
||||
<Chip>Invite système</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>
|
||||
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>
|
||||
<div className="flex-1" />
|
||||
<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 { pullModel } from "../api/client";
|
||||
import type { AgentConfig } from "../api/client";
|
||||
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. */
|
||||
export function SettingsView() {
|
||||
const { models, selectedModel, setSelectedModel, refreshModels, status } =
|
||||
useStore();
|
||||
const {
|
||||
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 [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 (
|
||||
<div className="scr flex-1 overflow-auto px-[30px] py-[26px]">
|
||||
<div className="mx-auto max-w-[1000px]">
|
||||
@@ -41,199 +85,243 @@ export function SettingsView() {
|
||||
Choisis le modèle local et règle son comportement.
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11.5px] text-ok">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-ok" />
|
||||
{status?.connected ? "Ollama connecté" : "Ollama déconnecté"}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11.5px] text-ok">
|
||||
<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 className="grid grid-cols-2 items-start gap-5">
|
||||
{/* Modèle Ollama */}
|
||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||
<div className="mb-3.5 flex items-center justify-between">
|
||||
<div className="text-sm font-bold">Modèle Ollama</div>
|
||||
<button
|
||||
onClick={refreshModels}
|
||||
className="flex h-[26px] items-center gap-1.5 rounded-[7px] border border-line-strong bg-base px-2.5 text-[11px] text-muted"
|
||||
>
|
||||
<RefreshIcon size={12} />
|
||||
Actualiser
|
||||
</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 (
|
||||
{!draft ? (
|
||||
<div className="py-20 text-center text-muted-3">
|
||||
Chargement de la configuration…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 items-start gap-5">
|
||||
{/* Modèle Ollama */}
|
||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||
<div className="mb-3.5 flex items-center justify-between">
|
||||
<div className="text-sm font-bold">Modèle Ollama</div>
|
||||
<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",
|
||||
}}
|
||||
onClick={refreshModels}
|
||||
className="flex h-[26px] items-center gap-1.5 rounded-[7px] border border-line-strong bg-base px-2.5 text-[11px] text-muted"
|
||||
>
|
||||
<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>
|
||||
<RefreshIcon size={12} />
|
||||
Actualiser
|
||||
</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 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
|
||||
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>
|
||||
|
||||
{/* 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="0.7" pct={35} />
|
||||
<Slider label="Top-P" value="0.90" pct={90} />
|
||||
<Slider label="Top-K" value="40" pct={40} />
|
||||
<Slider label="Jetons max" value="2048" pct={25} last />
|
||||
<div className="rounded-card border border-line bg-card p-[18px]">
|
||||
<div className="mb-3.5 flex items-center justify-between">
|
||||
<div className="text-sm font-bold">Outils de l'agent</div>
|
||||
<span className="font-mono text-[11px] text-muted-2">
|
||||
{activeTools} / {availableTools.length} actifs
|
||||
</span>
|
||||
</div>
|
||||
{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 className="rounded-card border border-line bg-card p-[18px]">
|
||||
<div className="mb-3.5 flex items-center justify-between">
|
||||
<div className="text-sm font-bold">Outils de l'agent</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">
|
||||
bientôt
|
||||
{draft.system_prompt.length} caractères
|
||||
</span>
|
||||
</div>
|
||||
{[
|
||||
["read_file", "Lire un fichier du projet", true],
|
||||
["write_file", "Créer / modifier un fichier", true],
|
||||
["list_dir", "Lister un répertoire", true],
|
||||
["html_preview", "Aperçu HTML en direct", true],
|
||||
["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>
|
||||
))}
|
||||
<textarea
|
||||
value={draft.system_prompt}
|
||||
onChange={(e) => set("system_prompt", e.target.value)}
|
||||
rows={6}
|
||||
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"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
@@ -242,31 +330,52 @@ export function SettingsView() {
|
||||
function Slider({
|
||||
label,
|
||||
value,
|
||||
pct,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
fmt,
|
||||
onChange,
|
||||
last,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
pct: number;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
fmt: (v: number) => string;
|
||||
onChange: (v: number) => void;
|
||||
last?: boolean;
|
||||
}) {
|
||||
const pct = ((value - min) / (max - min)) * 100;
|
||||
return (
|
||||
<div className={last ? "" : "mb-4"}>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-[13px] text-ink-2">{label}</span>
|
||||
<span className="font-mono text-xs font-semibold text-accent">
|
||||
{value}
|
||||
{fmt(value)}
|
||||
</span>
|
||||
</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
|
||||
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}%` }}
|
||||
/>
|
||||
<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"
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,16 @@ import { create } from "zustand";
|
||||
import {
|
||||
createSession,
|
||||
deleteSession,
|
||||
getConfig,
|
||||
getModels,
|
||||
getSession,
|
||||
getStatus,
|
||||
fileContent,
|
||||
listFiles,
|
||||
listSessions,
|
||||
saveConfig,
|
||||
streamChat,
|
||||
type AgentConfig,
|
||||
type FileNode,
|
||||
type Message,
|
||||
type OllamaModel,
|
||||
@@ -34,6 +37,11 @@ interface LokiState {
|
||||
previewPath: string | null;
|
||||
previewContent: string;
|
||||
|
||||
config: AgentConfig | null;
|
||||
availableTools: string[];
|
||||
refreshConfig: () => Promise<void>;
|
||||
updateConfig: (patch: Partial<AgentConfig>) => Promise<void>;
|
||||
|
||||
openPreview: (path: string) => Promise<void>;
|
||||
setSelectedModel: (name: string) => void;
|
||||
refreshStatus: () => Promise<void>;
|
||||
@@ -62,6 +70,18 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
fileTree: [],
|
||||
previewPath: null,
|
||||
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) => {
|
||||
const content = await fileContent(path);
|
||||
|
||||
Reference in new issue
Block a user