mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-23 06:58:14 +02:00
Phase 4 : boucle agentique, outils fichiers et aperçu live
Backend : - tools.py : read_file / write_file / list_dir confinés au workspace (anti-traversal) - agent.py : boucle de tool-calling itérative au-dessus d'Ollama, génère des événements (token, tool_call, tool_result, final) - routes/chat.py : pilote run_agent, relaie les événements en SSE, persiste la réponse finale + le récap des outils (meta) - routes/files.py : arborescence et contenu du workspace (confiné) - db.py : colonne meta sur messages (+ migration douce), list_messages_for_model Frontend : - ToolCard : carte d'appel d'outil dans le fil (statut en cours/terminé/échec) - streamChat : événements tool_call / tool_result - store : streamTools, fileTree, preview (ouverture auto d'un HTML écrit) - LeftPanel : arborescence réelle du workspace, clic -> aperçu - PreviewPanel : rendu HTML en iframe + onglet Code - ChatPanel : rendu des outils dans les bulles agent (streaming + persisté) Tests manuels : outils + confinement, boucle agent (mock Ollama), chat HTTP de bout en bout (SSE, persistance meta, fichiers écrits, confinement route). 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
48cbcaafaf
commit
b6bf4edf97
13 files changed
+677
-80
No files matched your search
@@ -16,8 +16,9 @@ Frontend (React + Vite + TS + Tailwind)
|
|||||||
Backend (FastAPI, Python)
|
Backend (FastAPI, Python)
|
||||||
├── /api/status, /api/models, /api/models/pull (Ollama)
|
├── /api/status, /api/models, /api/models/pull (Ollama)
|
||||||
├── /api/sessions (CRUD sessions)
|
├── /api/sessions (CRUD sessions)
|
||||||
├── /api/chat (conversation streaming, SSE)
|
├── /api/chat (boucle agentique + outils, streaming SSE)
|
||||||
│ (outils agentiques : phases suivantes)
|
├── /api/files (arborescence + contenu du workspace)
|
||||||
|
│ Outils agent : read_file · write_file · list_dir (confinés au workspace)
|
||||||
│ httpx │ volume
|
│ httpx │ volume
|
||||||
Ollama (:11434) /workspace + /data (SQLite)
|
Ollama (:11434) /workspace + /data (SQLite)
|
||||||
```
|
```
|
||||||
@@ -70,8 +71,8 @@ npm run dev # http://localhost:5173
|
|||||||
- [x] **Phase 1** — Socle + design system fidèle au thème, layout 3 panneaux
|
- [x] **Phase 1** — Socle + design system fidèle au thème, layout 3 panneaux
|
||||||
- [x] **Phase 2** — Connexion Ollama : statut, liste des modèles, pull avec progression, sélecteur
|
- [x] **Phase 2** — Connexion Ollama : statut, liste des modèles, pull avec progression, sélecteur
|
||||||
- [x] **Phase 3** — Chat streaming (SSE) + persistance des sessions (SQLite)
|
- [x] **Phase 3** — Chat streaming (SSE) + persistance des sessions (SQLite)
|
||||||
- [ ] **Phase 4** — Boucle agentique & outils fichiers (read/write/list)
|
- [x] **Phase 4** — Boucle agentique & outils fichiers (read/write/list), confinés au workspace, rendu des appels d'outils dans le fil
|
||||||
- [ ] **Phase 5** — Aperçu HTML live + onglets Code/Logs + arborescence
|
- [~] **Phase 5** — Aperçu HTML live + onglet Code + arborescence du workspace (fait) · onglet Logs (à venir)
|
||||||
- [ ] **Phase 6** — Configuration complète (génération, toggles d'outils, invite système)
|
- [ ] **Phase 6** — Configuration complète (génération, toggles d'outils, invite système)
|
||||||
- [ ] **Phase 7** — Outils avancés (web_search, run_shell confirmé, html_preview)
|
- [ ] **Phase 7** — Outils avancés (web_search, run_shell confirmé, html_preview)
|
||||||
- [ ] **Phase 8** — Hardening & documentation Docker
|
- [ ] **Phase 8** — Hardening & documentation Docker
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Boucle agentique : tool-calling itératif au-dessus d'Ollama.
|
||||||
|
|
||||||
|
L'agent dialogue avec le modèle ; quand celui-ci demande un outil, on l'exécute,
|
||||||
|
on réinjecte le résultat, et on reboucle jusqu'à une réponse finale (ou la
|
||||||
|
limite d'itérations). La fonction est un générateur asynchrone d'événements
|
||||||
|
relayés tels quels au client via SSE.
|
||||||
|
|
||||||
|
Événements émis :
|
||||||
|
token {content} — fragment de texte de l'agent
|
||||||
|
tool_call {name, args} — début d'exécution d'un outil
|
||||||
|
tool_result {name, args, summary, status}
|
||||||
|
final {content, tools} — réponse complète + récap des outils
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import AsyncIterator
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from .ollama_client import ollama
|
||||||
|
from .tools import TOOL_DEFINITIONS, ToolError, run_tool
|
||||||
|
|
||||||
|
MAX_ITERATIONS = 6
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_args(raw) -> dict:
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
return raw
|
||||||
|
if isinstance(raw, str):
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return {}
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
async def run_agent(
|
||||||
|
model: str,
|
||||||
|
convo: list[dict],
|
||||||
|
*,
|
||||||
|
options: dict | None = None,
|
||||||
|
tools_enabled: bool = True,
|
||||||
|
) -> AsyncIterator[dict]:
|
||||||
|
tools = TOOL_DEFINITIONS if tools_enabled else None
|
||||||
|
collected: list[dict] = []
|
||||||
|
text_parts: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for _ in range(MAX_ITERATIONS):
|
||||||
|
content_buf = ""
|
||||||
|
tool_calls: list[dict] = []
|
||||||
|
|
||||||
|
async for chunk in ollama.chat(
|
||||||
|
model, convo, tools=tools, options=options, stream=True
|
||||||
|
):
|
||||||
|
msg = chunk.get("message", {})
|
||||||
|
token = msg.get("content", "")
|
||||||
|
if token:
|
||||||
|
content_buf += token
|
||||||
|
yield {"type": "token", "content": token}
|
||||||
|
if msg.get("tool_calls"):
|
||||||
|
tool_calls.extend(msg["tool_calls"])
|
||||||
|
if chunk.get("done"):
|
||||||
|
break
|
||||||
|
|
||||||
|
assistant_turn: dict = {"role": "assistant", "content": content_buf}
|
||||||
|
if tool_calls:
|
||||||
|
assistant_turn["tool_calls"] = tool_calls
|
||||||
|
convo.append(assistant_turn)
|
||||||
|
if content_buf.strip():
|
||||||
|
text_parts.append(content_buf.strip())
|
||||||
|
|
||||||
|
if not tool_calls:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Exécution des outils demandés, puis réinjection des résultats.
|
||||||
|
for tc in tool_calls:
|
||||||
|
fn = tc.get("function", {})
|
||||||
|
name = fn.get("name", "")
|
||||||
|
args = _parse_args(fn.get("arguments"))
|
||||||
|
|
||||||
|
yield {"type": "tool_call", "name": name, "args": args}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = run_tool(name, args)
|
||||||
|
summary = result.get("summary", "terminé")
|
||||||
|
status = "ok"
|
||||||
|
except ToolError as exc:
|
||||||
|
result = {"ok": False, "error": str(exc)}
|
||||||
|
summary = str(exc)
|
||||||
|
status = "error"
|
||||||
|
|
||||||
|
record = {"name": name, "args": args, "summary": summary, "status": status}
|
||||||
|
collected.append(record)
|
||||||
|
yield {"type": "tool_result", **record}
|
||||||
|
|
||||||
|
convo.append(
|
||||||
|
{
|
||||||
|
"role": "tool",
|
||||||
|
"name": name,
|
||||||
|
"content": json.dumps(result, ensure_ascii=False),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, OSError) as exc:
|
||||||
|
yield {"type": "error", "message": str(exc)}
|
||||||
|
return
|
||||||
|
|
||||||
|
yield {
|
||||||
|
"type": "final",
|
||||||
|
"content": "\n\n".join(text_parts).strip(),
|
||||||
|
"tools": collected,
|
||||||
|
}
|
||||||
+33
-6
@@ -5,6 +5,7 @@ sont rapides ; un verrou protège l'accès concurrent depuis les routes async.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import threading
|
import threading
|
||||||
@@ -42,12 +43,17 @@ def init_db() -> None:
|
|||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
model TEXT,
|
model TEXT,
|
||||||
|
meta TEXT,
|
||||||
created_at REAL NOT NULL
|
created_at REAL NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session
|
CREATE INDEX IF NOT EXISTS idx_messages_session
|
||||||
ON messages(session_id, created_at);
|
ON messages(session_id, created_at);
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
# Migration douce : ajoute la colonne meta aux bases antérieures.
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
def _now() -> float:
|
def _now() -> float:
|
||||||
@@ -110,20 +116,33 @@ def touch_session(sid: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
# ── Messages ─────────────────────────────────────────────────────────────
|
# ── Messages ─────────────────────────────────────────────────────────────
|
||||||
def add_message(sid: str, role: str, content: str, model: str | None) -> dict:
|
def add_message(
|
||||||
|
sid: str,
|
||||||
|
role: str,
|
||||||
|
content: str,
|
||||||
|
model: str | None,
|
||||||
|
meta: dict | None = None,
|
||||||
|
) -> dict:
|
||||||
mid = uuid.uuid4().hex
|
mid = uuid.uuid4().hex
|
||||||
now = _now()
|
now = _now()
|
||||||
|
meta_json = json.dumps(meta, ensure_ascii=False) if meta else None
|
||||||
with _LOCK, _connect() as conn:
|
with _LOCK, _connect() as conn:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO messages (id, session_id, role, content, model, created_at)"
|
"INSERT INTO messages (id, session_id, role, content, model, meta, created_at)"
|
||||||
" VALUES (?, ?, ?, ?, ?, ?)",
|
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||||
(mid, sid, role, content, model, now),
|
(mid, sid, role, content, model, meta_json, now),
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"UPDATE sessions SET updated_at = ? WHERE id = ?", (now, sid)
|
"UPDATE sessions SET updated_at = ? WHERE id = ?", (now, sid)
|
||||||
)
|
)
|
||||||
return {"id": mid, "session_id": sid, "role": role, "content": content,
|
return {"id": mid, "session_id": sid, "role": role, "content": content,
|
||||||
"model": model, "created_at": now}
|
"model": model, "meta": meta, "created_at": now}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_message(row: sqlite3.Row) -> dict:
|
||||||
|
msg = dict(row)
|
||||||
|
msg["meta"] = json.loads(msg["meta"]) if msg.get("meta") else None
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
def list_messages(sid: str) -> list[dict]:
|
def list_messages(sid: str) -> list[dict]:
|
||||||
@@ -132,4 +151,12 @@ def list_messages(sid: str) -> list[dict]:
|
|||||||
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at",
|
"SELECT * FROM messages WHERE session_id = ? ORDER BY created_at",
|
||||||
(sid,),
|
(sid,),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return [dict(r) for r in rows]
|
return [_row_to_message(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def list_messages_for_model(sid: str) -> list[dict]:
|
||||||
|
"""Historique épuré (role/content) destiné au contexte du modèle."""
|
||||||
|
return [
|
||||||
|
{"role": m["role"], "content": m["content"]}
|
||||||
|
for m in list_messages(sid)
|
||||||
|
]
|
||||||
+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, models, sessions
|
from .routes import chat, files, models, sessions
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -39,6 +39,7 @@ app.add_middleware(
|
|||||||
app.include_router(models.router)
|
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.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
+40
-32
@@ -1,29 +1,30 @@
|
|||||||
"""Route de conversation : streaming token par token depuis Ollama (SSE).
|
"""Route de conversation agentique : boucle d'outils + streaming SSE.
|
||||||
|
|
||||||
Flux :
|
Flux :
|
||||||
1. on enregistre le message utilisateur ;
|
1. on enregistre le message utilisateur ;
|
||||||
2. on rejoue l'historique de la session vers Ollama en streaming ;
|
2. on lance la boucle agentique (run_agent) sur l'historique de la session ;
|
||||||
3. on relaie chaque token au client (SSE) ;
|
3. on relaie tokens et événements d'outils au client (SSE) ;
|
||||||
4. on enregistre la réponse complète de l'assistant.
|
4. on enregistre la réponse finale de l'assistant + le récap des outils.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import httpx
|
|
||||||
from fastapi import APIRouter, HTTPException
|
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 db
|
||||||
|
from ..agent import run_agent
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..ollama_client import ollama
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["chat"])
|
router = APIRouter(prefix="/api", tags=["chat"])
|
||||||
|
|
||||||
SYSTEM_PROMPT = (
|
SYSTEM_PROMPT = (
|
||||||
"Tu es Loki, un assistant de développement local. Tu écris du code clair, "
|
"Tu es Loki, un assistant de développement local agentique. Tu disposes "
|
||||||
"commenté en français, et tu réponds de façon concise et utile."
|
"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."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ class ChatRequest(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
options: dict | None = None
|
options: dict | None = None
|
||||||
|
tools_enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
def _sse(event: str, data: dict) -> str:
|
def _sse(event: str, data: dict) -> str:
|
||||||
@@ -46,39 +48,45 @@ async def chat(req: ChatRequest) -> StreamingResponse:
|
|||||||
|
|
||||||
model = req.model or session.get("model") or settings.default_model
|
model = req.model or session.get("model") or settings.default_model
|
||||||
|
|
||||||
# Premier message : on titre la session avec un extrait.
|
# Premier message : titre la session avec un extrait.
|
||||||
history = db.list_messages(req.session_id)
|
if not db.list_messages(req.session_id):
|
||||||
if not history:
|
|
||||||
title = req.content.strip().split("\n")[0][:60] or "Nouvelle session"
|
title = req.content.strip().split("\n")[0][:60] or "Nouvelle session"
|
||||||
db.rename_session(req.session_id, title)
|
db.rename_session(req.session_id, title)
|
||||||
|
|
||||||
db.add_message(req.session_id, "user", req.content, None)
|
db.add_message(req.session_id, "user", req.content, None)
|
||||||
|
|
||||||
# Contexte envoyé à Ollama : invite système + historique complet.
|
|
||||||
convo = [{"role": "system", "content": SYSTEM_PROMPT}]
|
convo = [{"role": "system", "content": SYSTEM_PROMPT}]
|
||||||
convo += [
|
convo += db.list_messages_for_model(req.session_id)
|
||||||
{"role": m["role"], "content": m["content"]}
|
|
||||||
for m in db.list_messages(req.session_id)
|
|
||||||
]
|
|
||||||
|
|
||||||
async def event_stream():
|
async def event_stream():
|
||||||
yield _sse("start", {"model": model})
|
yield _sse("start", {"model": model})
|
||||||
full = ""
|
final_content = ""
|
||||||
try:
|
tools_meta: list[dict] = []
|
||||||
async for chunk in ollama.chat(
|
|
||||||
model, convo, options=req.options, stream=True
|
|
||||||
):
|
|
||||||
token = chunk.get("message", {}).get("content", "")
|
|
||||||
if token:
|
|
||||||
full += token
|
|
||||||
yield _sse("token", {"content": token})
|
|
||||||
if chunk.get("done"):
|
|
||||||
break
|
|
||||||
except (httpx.HTTPError, OSError) as exc:
|
|
||||||
yield _sse("error", {"message": str(exc)})
|
|
||||||
|
|
||||||
if full:
|
async for ev in run_agent(
|
||||||
db.add_message(req.session_id, "assistant", full, model)
|
model, convo, options=req.options, tools_enabled=req.tools_enabled
|
||||||
yield _sse("done", {"content": full, "model": model})
|
):
|
||||||
|
etype = ev.pop("type")
|
||||||
|
if etype == "token":
|
||||||
|
yield _sse("token", ev)
|
||||||
|
elif etype == "tool_call":
|
||||||
|
yield _sse("tool_call", ev)
|
||||||
|
elif etype == "tool_result":
|
||||||
|
yield _sse("tool_result", ev)
|
||||||
|
elif etype == "error":
|
||||||
|
yield _sse("error", ev)
|
||||||
|
elif etype == "final":
|
||||||
|
final_content = ev["content"]
|
||||||
|
tools_meta = ev["tools"]
|
||||||
|
|
||||||
|
if final_content or tools_meta:
|
||||||
|
db.add_message(
|
||||||
|
req.session_id,
|
||||||
|
"assistant",
|
||||||
|
final_content,
|
||||||
|
model,
|
||||||
|
meta={"tools": tools_meta} if tools_meta else None,
|
||||||
|
)
|
||||||
|
yield _sse("done", {"content": final_content, "tools": tools_meta})
|
||||||
|
|
||||||
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
return StreamingResponse(event_stream(), media_type="text/event-stream")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Routes de lecture du workspace (arborescence + contenu d'un fichier)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from ..config import settings
|
||||||
|
from ..tools import ToolError, _safe_path
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/files", tags=["files"])
|
||||||
|
|
||||||
|
|
||||||
|
def _tree(path: str) -> list[dict]:
|
||||||
|
"""Arborescence triée (dossiers d'abord) du workspace."""
|
||||||
|
items = []
|
||||||
|
for name in sorted(os.listdir(path)):
|
||||||
|
if name.startswith("."):
|
||||||
|
continue
|
||||||
|
full = os.path.join(path, name)
|
||||||
|
rel = os.path.relpath(full, os.path.abspath(settings.workspace_dir))
|
||||||
|
if os.path.isdir(full):
|
||||||
|
items.append({"name": name, "path": rel, "type": "dir",
|
||||||
|
"children": _tree(full)})
|
||||||
|
else:
|
||||||
|
items.append({"name": name, "path": rel, "type": "file",
|
||||||
|
"size": os.path.getsize(full)})
|
||||||
|
# Dossiers avant fichiers
|
||||||
|
items.sort(key=lambda x: (x["type"] != "dir", x["name"]))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_files() -> dict:
|
||||||
|
root = os.path.abspath(settings.workspace_dir)
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
return {"tree": _tree(root)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/content")
|
||||||
|
async def file_content(path: str) -> dict:
|
||||||
|
try:
|
||||||
|
target = _safe_path(path)
|
||||||
|
except ToolError as exc:
|
||||||
|
raise HTTPException(400, str(exc))
|
||||||
|
if not os.path.isfile(target):
|
||||||
|
raise HTTPException(404, "fichier introuvable")
|
||||||
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
return {"path": path, "content": f.read()}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
"""Outils de l'agent, exécutés côté serveur et confinés au workspace.
|
||||||
|
|
||||||
|
Chaque outil expose :
|
||||||
|
- une définition JSON (format function-calling Ollama/OpenAI) ;
|
||||||
|
- une implémentation Python qui renvoie un dict {ok, summary, ...}.
|
||||||
|
|
||||||
|
Toutes les opérations fichier sont strictement confinées à WORKSPACE_DIR :
|
||||||
|
toute tentative de sortie (../, chemin absolu hors workspace) est rejetée.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class ToolError(Exception):
|
||||||
|
"""Erreur d'exécution d'un outil (message destiné au modèle)."""
|
||||||
|
|
||||||
|
|
||||||
|
def _workspace_root() -> str:
|
||||||
|
root = os.path.abspath(settings.workspace_dir)
|
||||||
|
os.makedirs(root, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_path(rel: str) -> str:
|
||||||
|
"""Résout un chemin relatif en restant confiné au workspace."""
|
||||||
|
root = _workspace_root()
|
||||||
|
target = os.path.abspath(os.path.join(root, rel or "."))
|
||||||
|
if target != root and not target.startswith(root + os.sep):
|
||||||
|
raise ToolError(f"chemin hors du workspace refusé : {rel}")
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
# ── Implémentations ──────────────────────────────────────────────────────
|
||||||
|
def read_file(path: str) -> dict:
|
||||||
|
target = _safe_path(path)
|
||||||
|
if not os.path.isfile(target):
|
||||||
|
raise ToolError(f"fichier introuvable : {path}")
|
||||||
|
with open(target, "r", encoding="utf-8", errors="replace") as f:
|
||||||
|
content = f.read()
|
||||||
|
size = os.path.getsize(target)
|
||||||
|
summary = "fichier vide (0 octet)" if size == 0 else f"{len(content.splitlines())} lignes lues"
|
||||||
|
return {"ok": True, "content": content, "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(path: str, content: str) -> dict:
|
||||||
|
target = _safe_path(path)
|
||||||
|
os.makedirs(os.path.dirname(target) or _workspace_root(), exist_ok=True)
|
||||||
|
existed = os.path.isfile(target)
|
||||||
|
with open(target, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
lines = len(content.splitlines())
|
||||||
|
verb = "modifié" if existed else "écrit"
|
||||||
|
return {"ok": True, "summary": f"{verb} · {lines} lignes", "lines": lines}
|
||||||
|
|
||||||
|
|
||||||
|
def list_dir(path: str = ".") -> dict:
|
||||||
|
target = _safe_path(path)
|
||||||
|
if not os.path.isdir(target):
|
||||||
|
raise ToolError(f"répertoire introuvable : {path}")
|
||||||
|
entries = []
|
||||||
|
for name in sorted(os.listdir(target)):
|
||||||
|
full = os.path.join(target, name)
|
||||||
|
entries.append({"name": name, "type": "dir" if os.path.isdir(full) else "file"})
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"entries": entries,
|
||||||
|
"summary": f"{len(entries)} élément(s)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Registre & définitions exposées au modèle ────────────────────────────
|
||||||
|
TOOL_IMPL = {
|
||||||
|
"read_file": read_file,
|
||||||
|
"write_file": write_file,
|
||||||
|
"list_dir": list_dir,
|
||||||
|
}
|
||||||
|
|
||||||
|
TOOL_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "read_file",
|
||||||
|
"description": "Lire le contenu d'un fichier du workspace.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Chemin relatif au workspace"}
|
||||||
|
},
|
||||||
|
"required": ["path"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "write_file",
|
||||||
|
"description": "Créer ou modifier un fichier du workspace.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Chemin relatif au workspace"},
|
||||||
|
"content": {"type": "string", "description": "Contenu complet du fichier"},
|
||||||
|
},
|
||||||
|
"required": ["path", "content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": "list_dir",
|
||||||
|
"description": "Lister le contenu d'un répertoire du workspace.",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Répertoire (défaut : racine)"}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_tool(name: str, args: dict) -> dict:
|
||||||
|
"""Exécute un outil par son nom ; lève ToolError si inconnu/invalide."""
|
||||||
|
impl = TOOL_IMPL.get(name)
|
||||||
|
if impl is None:
|
||||||
|
raise ToolError(f"outil inconnu : {name}")
|
||||||
|
try:
|
||||||
|
return impl(**(args or {}))
|
||||||
|
except ToolError:
|
||||||
|
raise
|
||||||
|
except TypeError as exc:
|
||||||
|
raise ToolError(f"arguments invalides pour {name} : {exc}") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise ToolError(f"erreur système ({name}) : {exc}") from exc
|
||||||
@@ -38,15 +38,42 @@ export interface Session {
|
|||||||
message_count?: number;
|
message_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ToolCall {
|
||||||
|
name: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
summary?: string;
|
||||||
|
status?: "ok" | "error" | "running";
|
||||||
|
}
|
||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
session_id: string;
|
session_id: string;
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
meta?: { tools?: ToolCall[] } | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileNode {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
type: "dir" | "file";
|
||||||
|
size?: number;
|
||||||
|
children?: FileNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listFiles(): Promise<FileNode[]> {
|
||||||
|
const res = await fetch("/api/files");
|
||||||
|
return (await res.json()).tree;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fileContent(path: string): Promise<string> {
|
||||||
|
const res = await fetch(`/api/files/content?path=${encodeURIComponent(path)}`);
|
||||||
|
if (!res.ok) return "";
|
||||||
|
return (await res.json()).content;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listSessions(): Promise<Session[]> {
|
export async function listSessions(): Promise<Session[]> {
|
||||||
const res = await fetch("/api/sessions");
|
const res = await fetch("/api/sessions");
|
||||||
return (await res.json()).sessions;
|
return (await res.json()).sessions;
|
||||||
@@ -77,6 +104,8 @@ export async function streamChat(
|
|||||||
body: { session_id: string; content: string; model?: string },
|
body: { session_id: string; content: string; model?: string },
|
||||||
handlers: {
|
handlers: {
|
||||||
onToken: (t: string) => void;
|
onToken: (t: string) => void;
|
||||||
|
onToolCall: (call: ToolCall) => void;
|
||||||
|
onToolResult: (call: ToolCall) => void;
|
||||||
onDone: (full: string) => void;
|
onDone: (full: string) => void;
|
||||||
onError: (msg: string) => void;
|
onError: (msg: string) => void;
|
||||||
}
|
}
|
||||||
@@ -113,6 +142,9 @@ export async function streamChat(
|
|||||||
try {
|
try {
|
||||||
const payload = JSON.parse(data);
|
const payload = JSON.parse(data);
|
||||||
if (event === "token") handlers.onToken(payload.content);
|
if (event === "token") handlers.onToken(payload.content);
|
||||||
|
else if (event === "tool_call")
|
||||||
|
handlers.onToolCall({ ...payload, status: "running" });
|
||||||
|
else if (event === "tool_result") handlers.onToolResult(payload);
|
||||||
else if (event === "done") handlers.onDone(payload.content);
|
else if (event === "done") handlers.onDone(payload.content);
|
||||||
else if (event === "error") handlers.onError(payload.message);
|
else if (event === "error") handlers.onError(payload.message);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ToolCall } from "../api/client";
|
||||||
|
|
||||||
|
/** Carte d'appel d'outil rendue dans le fil (fidèle à la maquette). */
|
||||||
|
export function ToolCard({ call }: { call: ToolCall }) {
|
||||||
|
const running = call.status === "running";
|
||||||
|
const error = call.status === "error";
|
||||||
|
|
||||||
|
// Argument principal affiché entre parenthèses (path le plus souvent).
|
||||||
|
const argPreview =
|
||||||
|
typeof call.args?.path === "string"
|
||||||
|
? `("${call.args.path}")`
|
||||||
|
: Object.keys(call.args ?? {}).length
|
||||||
|
? "(…)"
|
||||||
|
: "()";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-[9px] overflow-hidden rounded-[11px] border border-line bg-card-deep">
|
||||||
|
<div className="flex items-center gap-2.5 px-[13px] py-2.5">
|
||||||
|
<span
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-[7px] text-accent"
|
||||||
|
style={{ background: "rgba(240,161,92,.13)" }}
|
||||||
|
>
|
||||||
|
<ToolGlyph name={call.name} />
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[12.5px] font-semibold text-[#e7ddcd]">
|
||||||
|
{call.name}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-muted-2">{argPreview}</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{running ? (
|
||||||
|
<span className="flex items-center gap-1.5 font-mono text-[11px] text-muted">
|
||||||
|
<span className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-muted-3 border-t-accent" />
|
||||||
|
en cours
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-1.5 font-mono text-[11px] ${
|
||||||
|
error ? "text-warn" : "text-ok"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{error ? "✕ échec" : "✓ terminé"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{call.summary && !running && (
|
||||||
|
<div className="border-t border-line-soft px-[13px] py-2.5 font-mono text-[11.5px] text-muted-3">
|
||||||
|
→ {call.summary}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolGlyph({ name }: { name: string }) {
|
||||||
|
const common = {
|
||||||
|
width: 13,
|
||||||
|
height: 13,
|
||||||
|
viewBox: "0 0 24 24",
|
||||||
|
fill: "none",
|
||||||
|
stroke: "currentColor",
|
||||||
|
strokeWidth: 1.9,
|
||||||
|
strokeLinecap: "round" as const,
|
||||||
|
strokeLinejoin: "round" as const,
|
||||||
|
};
|
||||||
|
if (name === "write_file")
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M5 19h14M7 14l9-9 3 3-9 9-4 1z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
if (name === "list_dir")
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M4 7h6l2 2h8v9H4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
// read_file (défaut)
|
||||||
|
return (
|
||||||
|
<svg {...common}>
|
||||||
|
<path d="M7 4h7l4 4v12H7z" />
|
||||||
|
<path d="M14 4v4h4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useStore } from "../store/useStore";
|
import { useStore } from "../store/useStore";
|
||||||
import { ClipIcon, LokiMark, SendIcon } from "../components/Icon";
|
import { ClipIcon, LokiMark, SendIcon } from "../components/Icon";
|
||||||
import type { Message } from "../api/client";
|
import { ToolCard } from "../components/ToolCard";
|
||||||
|
import type { Message, ToolCall } from "../api/client";
|
||||||
|
|
||||||
/** Panneau central : barre de contexte, fil de conversation, composer. */
|
/** Panneau central : barre de contexte, fil de conversation, composer. */
|
||||||
export function ChatPanel() {
|
export function ChatPanel() {
|
||||||
@@ -10,6 +11,7 @@ export function ChatPanel() {
|
|||||||
messages,
|
messages,
|
||||||
streaming,
|
streaming,
|
||||||
streamContent,
|
streamContent,
|
||||||
|
streamTools,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
} = useStore();
|
} = useStore();
|
||||||
@@ -20,7 +22,7 @@ export function ChatPanel() {
|
|||||||
// Auto-scroll vers le bas à chaque token / message.
|
// Auto-scroll vers le bas à chaque token / message.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
|
||||||
}, [messages, streamContent]);
|
}, [messages, streamContent, streamTools]);
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!draft.trim() || streaming) return;
|
if (!draft.trim() || streaming) return;
|
||||||
@@ -43,7 +45,7 @@ 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" />0 outil actif
|
<span className="h-1.5 w-1.5 rounded-full bg-ok" />3 outils actifs
|
||||||
</Chip>
|
</Chip>
|
||||||
<Chip>
|
<Chip>
|
||||||
Température <b className="font-semibold text-[#e7ddcd]">0.7</b>
|
Température <b className="font-semibold text-[#e7ddcd]">0.7</b>
|
||||||
@@ -82,6 +84,7 @@ export function ChatPanel() {
|
|||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: streamContent,
|
content: streamContent,
|
||||||
model: selectedModel,
|
model: selectedModel,
|
||||||
|
meta: { tools: streamTools },
|
||||||
created_at: Date.now() / 1000,
|
created_at: Date.now() / 1000,
|
||||||
}}
|
}}
|
||||||
pending
|
pending
|
||||||
@@ -172,12 +175,17 @@ function Bubble({ msg, pending }: { msg: Message; pending?: boolean }) {
|
|||||||
{time}
|
{time}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm leading-[1.65] text-ink-2 whitespace-pre-wrap">
|
{(msg.meta?.tools ?? []).map((t: ToolCall, i: number) => (
|
||||||
{msg.content}
|
<ToolCard key={i} call={t} />
|
||||||
{pending && (
|
))}
|
||||||
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-accent align-middle" />
|
{(msg.content || pending) && (
|
||||||
)}
|
<div className="text-sm leading-[1.65] text-ink-2 whitespace-pre-wrap">
|
||||||
</div>
|
{msg.content}
|
||||||
|
{pending && (
|
||||||
|
<span className="ml-0.5 inline-block h-3.5 w-[7px] animate-pulse bg-accent align-middle" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useStore } from "../store/useStore";
|
import { useStore } from "../store/useStore";
|
||||||
import { PlusIcon } from "../components/Icon";
|
import { PlusIcon } from "../components/Icon";
|
||||||
|
import type { FileNode } from "../api/client";
|
||||||
|
|
||||||
/** Panneau gauche : historique des sessions + arborescence de fichiers. */
|
/** Panneau gauche : historique des sessions + arborescence de fichiers. */
|
||||||
export function LeftPanel() {
|
export function LeftPanel() {
|
||||||
@@ -8,6 +9,8 @@ export function LeftPanel() {
|
|||||||
sessions,
|
sessions,
|
||||||
currentSessionId,
|
currentSessionId,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
|
refreshFiles,
|
||||||
|
fileTree,
|
||||||
newSession,
|
newSession,
|
||||||
openSession,
|
openSession,
|
||||||
removeSession,
|
removeSession,
|
||||||
@@ -15,7 +18,8 @@ export function LeftPanel() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshSessions();
|
refreshSessions();
|
||||||
}, [refreshSessions]);
|
refreshFiles();
|
||||||
|
}, [refreshSessions, refreshFiles]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-[268px] flex-none flex-col border-r border-line-soft bg-panel">
|
<div className="flex w-[268px] flex-none flex-col border-r border-line-soft bg-panel">
|
||||||
@@ -91,16 +95,60 @@ export function LeftPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="scr flex-1 overflow-auto px-2.5 pb-3.5 font-mono text-xs">
|
<div className="scr flex-1 overflow-auto px-2.5 pb-3.5 font-mono text-xs">
|
||||||
<div className="px-2 py-4 text-center text-muted-3">
|
{fileTree.length === 0 ? (
|
||||||
Aucun fichier pour l'instant.
|
<div className="px-2 py-4 text-center text-muted-3">
|
||||||
<br />
|
Aucun fichier pour l'instant.
|
||||||
L'agent créera des fichiers ici.
|
<br />
|
||||||
</div>
|
L'agent créera des fichiers ici.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FileTree nodes={fileTree} depth={0} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FileTree({ nodes, depth }: { nodes: FileNode[]; depth: number }) {
|
||||||
|
const openPreview = useStore((s) => s.openPreview);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{nodes.map((n) => (
|
||||||
|
<div key={n.path}>
|
||||||
|
<div
|
||||||
|
onClick={() => n.type === "file" && openPreview(n.path)}
|
||||||
|
className={`flex items-center gap-2 rounded-[7px] px-2 py-[5px] ${
|
||||||
|
n.type === "file"
|
||||||
|
? "cursor-pointer text-ink-2 hover:bg-[#221e18]"
|
||||||
|
: "text-muted"
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: 8 + depth * 14 }}
|
||||||
|
>
|
||||||
|
{n.type === "dir" ? (
|
||||||
|
<svg
|
||||||
|
width="11"
|
||||||
|
height="11"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.4"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<span className="h-1.5 w-1.5 rounded-[1.5px] bg-accent" />
|
||||||
|
)}
|
||||||
|
<span className="truncate">{n.name}</span>
|
||||||
|
</div>
|
||||||
|
{n.children && <FileTree nodes={n.children} depth={depth + 1} />}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function relTime(ts: number): string {
|
function relTime(ts: number): string {
|
||||||
const diff = Date.now() / 1000 - ts;
|
const diff = Date.now() / 1000 - ts;
|
||||||
if (diff < 60) return "à l'instant";
|
if (diff < 60) return "à l'instant";
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
/** Panneau droit : onglets Aperçu / Code / Logs + zone d'aperçu. */
|
import { useState } from "react";
|
||||||
|
import { useStore } from "../store/useStore";
|
||||||
|
|
||||||
|
type TabId = "preview" | "code";
|
||||||
|
|
||||||
|
/** Panneau droit : onglets Aperçu / Code + rendu du fichier sélectionné. */
|
||||||
export function PreviewPanel() {
|
export function PreviewPanel() {
|
||||||
|
const { previewPath, previewContent } = useStore();
|
||||||
|
const [tab, setTab] = useState<TabId>("preview");
|
||||||
|
|
||||||
|
const isHtml = previewPath ? /\.html?$/.test(previewPath) : false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-[452px] flex-none flex-col border-l border-line-soft bg-panel">
|
<div className="flex w-[452px] flex-none flex-col border-l border-line-soft bg-panel">
|
||||||
{/* Onglets */}
|
{/* Onglets */}
|
||||||
<div className="flex h-[42px] flex-none items-center gap-1 border-b border-line-soft px-3.5 pr-2">
|
<div className="flex h-[42px] flex-none items-center gap-1 border-b border-line-soft px-3.5 pr-2">
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<Tab active>Aperçu</Tab>
|
<Tab active={tab === "preview"} onClick={() => setTab("preview")}>
|
||||||
<Tab>Code</Tab>
|
Aperçu
|
||||||
<Tab>
|
</Tab>
|
||||||
Logs
|
<Tab active={tab === "code"} onClick={() => setTab("code")}>
|
||||||
<span className="ml-1.5 rounded-[5px] bg-line-strong px-1.5 py-px font-mono text-[10px] text-muted">
|
Code
|
||||||
0
|
|
||||||
</span>
|
|
||||||
</Tab>
|
</Tab>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -19,36 +27,61 @@ export function PreviewPanel() {
|
|||||||
{/* URL bar */}
|
{/* URL bar */}
|
||||||
<div className="flex flex-none items-center gap-2 px-3.5 py-[9px]">
|
<div className="flex flex-none items-center gap-2 px-3.5 py-[9px]">
|
||||||
<div className="flex h-[30px] flex-1 items-center gap-2 rounded-lg border border-line bg-sunken px-[11px]">
|
<div className="flex h-[30px] flex-1 items-center gap-2 rounded-lg border border-line bg-sunken px-[11px]">
|
||||||
<span className="font-mono text-[11.5px] text-muted-3">
|
<span className="truncate font-mono text-[11.5px] text-muted-3">
|
||||||
workspace/
|
{previewPath ? `workspace/${previewPath}` : "workspace/"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Viewport */}
|
{/* Viewport */}
|
||||||
<div className="scr mx-3.5 mb-3.5 flex-1 overflow-auto rounded-xl border border-line bg-[#faf6ef]">
|
<div className="scr mx-3.5 mb-3.5 flex-1 overflow-auto rounded-xl border border-line bg-[#faf6ef]">
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
{!previewPath ? (
|
||||||
<div className="font-mono text-[11px] text-[#a98b63]">
|
<Empty>
|
||||||
[ aucun aperçu ]
|
L'aperçu s'affichera ici dès que l'agent générera un fichier (clique
|
||||||
</div>
|
aussi un fichier à gauche).
|
||||||
<div className="max-w-[240px] text-xs text-[#8a7a66]">
|
</Empty>
|
||||||
L'aperçu HTML s'affichera ici dès que l'agent générera une page.
|
) : tab === "code" ? (
|
||||||
</div>
|
<pre className="m-0 whitespace-pre-wrap p-4 font-mono text-[11.5px] leading-relaxed text-[#2a2018]">
|
||||||
</div>
|
{previewContent}
|
||||||
|
</pre>
|
||||||
|
) : isHtml ? (
|
||||||
|
<iframe
|
||||||
|
title="aperçu"
|
||||||
|
srcDoc={previewContent}
|
||||||
|
className="h-full w-full border-0 bg-white"
|
||||||
|
sandbox="allow-same-origin"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="m-0 whitespace-pre-wrap p-4 font-mono text-[11.5px] leading-relaxed text-[#2a2018]">
|
||||||
|
{previewContent}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Empty({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||||
|
<div className="font-mono text-[11px] text-[#a98b63]">[ aucun aperçu ]</div>
|
||||||
|
<div className="max-w-[240px] text-xs text-[#8a7a66]">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Tab({
|
function Tab({
|
||||||
children,
|
children,
|
||||||
active,
|
active,
|
||||||
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
onClick?: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
|
onClick={onClick}
|
||||||
className={`flex h-7 items-center rounded-lg px-[13px] text-[12.5px] ${
|
className={`flex h-7 items-center rounded-lg px-[13px] text-[12.5px] ${
|
||||||
active
|
active
|
||||||
? "bg-[#252019] font-semibold text-ink"
|
? "bg-[#252019] font-semibold text-ink"
|
||||||
@@ -56,6 +89,6 @@ function Tab({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -5,12 +5,16 @@ import {
|
|||||||
getModels,
|
getModels,
|
||||||
getSession,
|
getSession,
|
||||||
getStatus,
|
getStatus,
|
||||||
|
fileContent,
|
||||||
|
listFiles,
|
||||||
listSessions,
|
listSessions,
|
||||||
streamChat,
|
streamChat,
|
||||||
|
type FileNode,
|
||||||
type Message,
|
type Message,
|
||||||
type OllamaModel,
|
type OllamaModel,
|
||||||
type OllamaStatus,
|
type OllamaStatus,
|
||||||
type Session,
|
type Session,
|
||||||
|
type ToolCall,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
interface LokiState {
|
interface LokiState {
|
||||||
@@ -24,10 +28,17 @@ interface LokiState {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
streaming: boolean;
|
streaming: boolean;
|
||||||
streamContent: string; // réponse de l'agent en cours de frappe
|
streamContent: string; // réponse de l'agent en cours de frappe
|
||||||
|
streamTools: ToolCall[]; // appels d'outils de la réponse en cours
|
||||||
|
|
||||||
|
fileTree: FileNode[];
|
||||||
|
previewPath: string | null;
|
||||||
|
previewContent: string;
|
||||||
|
|
||||||
|
openPreview: (path: string) => Promise<void>;
|
||||||
setSelectedModel: (name: string) => void;
|
setSelectedModel: (name: string) => void;
|
||||||
refreshStatus: () => Promise<void>;
|
refreshStatus: () => Promise<void>;
|
||||||
refreshModels: () => Promise<void>;
|
refreshModels: () => Promise<void>;
|
||||||
|
refreshFiles: () => Promise<void>;
|
||||||
|
|
||||||
refreshSessions: () => Promise<void>;
|
refreshSessions: () => Promise<void>;
|
||||||
newSession: () => Promise<void>;
|
newSession: () => Promise<void>;
|
||||||
@@ -47,9 +58,26 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
messages: [],
|
messages: [],
|
||||||
streaming: false,
|
streaming: false,
|
||||||
streamContent: "",
|
streamContent: "",
|
||||||
|
streamTools: [],
|
||||||
|
fileTree: [],
|
||||||
|
previewPath: null,
|
||||||
|
previewContent: "",
|
||||||
|
|
||||||
|
openPreview: async (path) => {
|
||||||
|
const content = await fileContent(path);
|
||||||
|
set({ previewPath: path, previewContent: content });
|
||||||
|
},
|
||||||
|
|
||||||
setSelectedModel: (name) => set({ selectedModel: name }),
|
setSelectedModel: (name) => set({ selectedModel: name }),
|
||||||
|
|
||||||
|
refreshFiles: async () => {
|
||||||
|
try {
|
||||||
|
set({ fileTree: await listFiles() });
|
||||||
|
} catch {
|
||||||
|
/* workspace indisponible */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
refreshStatus: async () => {
|
refreshStatus: async () => {
|
||||||
try {
|
try {
|
||||||
const status = await getStatus();
|
const status = await getStatus();
|
||||||
@@ -122,17 +150,42 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
messages: [...get().messages, userMsg],
|
messages: [...get().messages, userMsg],
|
||||||
streaming: true,
|
streaming: true,
|
||||||
streamContent: "",
|
streamContent: "",
|
||||||
|
streamTools: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await streamChat(
|
await streamChat(
|
||||||
{ session_id: sid, content, model: get().selectedModel || undefined },
|
{ session_id: sid, content, model: get().selectedModel || undefined },
|
||||||
{
|
{
|
||||||
onToken: (t) => set({ streamContent: get().streamContent + t }),
|
onToken: (t) => set({ streamContent: get().streamContent + t }),
|
||||||
|
onToolCall: (call) =>
|
||||||
|
set({ streamTools: [...get().streamTools, call] }),
|
||||||
|
onToolResult: (call) => {
|
||||||
|
// Met à jour le dernier outil correspondant (statut + résumé).
|
||||||
|
const tools = [...get().streamTools];
|
||||||
|
for (let i = tools.length - 1; i >= 0; i--) {
|
||||||
|
if (tools[i].name === call.name && tools[i].status === "running") {
|
||||||
|
tools[i] = { ...tools[i], ...call };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set({ streamTools: tools });
|
||||||
|
},
|
||||||
onDone: async () => {
|
onDone: async () => {
|
||||||
set({ streaming: false, streamContent: "" });
|
// Repère un fichier HTML écrit pour l'afficher automatiquement.
|
||||||
// Recharge depuis la base pour récupérer les ids/horodatages réels.
|
const writtenHtml = [...get().streamTools]
|
||||||
|
.reverse()
|
||||||
|
.find(
|
||||||
|
(t) =>
|
||||||
|
t.name === "write_file" &&
|
||||||
|
typeof t.args?.path === "string" &&
|
||||||
|
/\.html?$/.test(t.args.path as string)
|
||||||
|
);
|
||||||
|
set({ streaming: false, streamContent: "", streamTools: [] });
|
||||||
|
// Recharge depuis la base + l'arborescence (fichiers créés par l'agent).
|
||||||
if (get().currentSessionId === sid) await get().openSession(sid!);
|
if (get().currentSessionId === sid) await get().openSession(sid!);
|
||||||
await get().refreshSessions();
|
await get().refreshSessions();
|
||||||
|
await get().refreshFiles();
|
||||||
|
if (writtenHtml) await get().openPreview(writtenHtml.args.path as string);
|
||||||
},
|
},
|
||||||
onError: (msg) => {
|
onError: (msg) => {
|
||||||
const errMsg: Message = {
|
const errMsg: Message = {
|
||||||
@@ -145,6 +198,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
streaming: false,
|
streaming: false,
|
||||||
streamContent: "",
|
streamContent: "",
|
||||||
|
streamTools: [],
|
||||||
messages: [...get().messages, errMsg],
|
messages: [...get().messages, errMsg],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user