mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Phase 7 : outils web_search et run_shell avec validation
Backend : - tools.py : web_search (DuckDuckGo HTML sans clé, ou SearxNG via SEARX_URL) et run_shell (confiné au workspace, sortie bornée, timeout) - agent.py : run_shell sensible -> émet tool_confirm et ne s'exécute pas tant que l'utilisateur n'a pas validé (confirm_shell) - agent_config.py : web_search/run_shell désactivés par défaut, flag confirm_shell - routes/shell.py : POST /api/shell/run exécute une commande validée - routes/chat.py : relaie l'événement tool_confirm, passe confirm_shell Frontend : - streamChat : événement tool_confirm - store : pendingShell + approveShell/rejectShell (réinjecte le résultat à l'agent) - ChatPanel : carte de validation de commande (Approuver / Refuser) - SettingsView : toggles web_search/run_shell, marqueur sensible, switch confirm_shell - ToolCard : glyphes web_search/run_shell, statut 'à valider', aperçu d'argument Tests : run_shell confiné, gate de confirmation (commande dangereuse non exécutée), route shell, parser DuckDuckGo. 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
2db6beea5c
commit
de2befe31e
12 files changed
+398
-26
No files matched your search
@@ -42,7 +42,7 @@ export interface ToolCall {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
summary?: string;
|
||||
status?: "ok" | "error" | "running";
|
||||
status?: "ok" | "error" | "running" | "pending";
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -62,6 +62,18 @@ export interface AgentConfig {
|
||||
top_k: number;
|
||||
max_tokens: number;
|
||||
tools: Record<string, boolean>;
|
||||
confirm_shell: boolean;
|
||||
}
|
||||
|
||||
export async function runShell(
|
||||
command: string
|
||||
): Promise<{ command: string; exit_code: number; output: string }> {
|
||||
const res = await fetch("/api/shell/run", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ command }),
|
||||
});
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function getConfig(): Promise<{
|
||||
@@ -134,6 +146,7 @@ export async function streamChat(
|
||||
onToken: (t: string) => void;
|
||||
onToolCall: (call: ToolCall) => void;
|
||||
onToolResult: (call: ToolCall) => void;
|
||||
onToolConfirm: (command: string) => void;
|
||||
onDone: (full: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
@@ -173,6 +186,7 @@ export async function streamChat(
|
||||
else if (event === "tool_call")
|
||||
handlers.onToolCall({ ...payload, status: "running" });
|
||||
else if (event === "tool_result") handlers.onToolResult(payload);
|
||||
else if (event === "tool_confirm") handlers.onToolConfirm(payload.command);
|
||||
else if (event === "done") handlers.onDone(payload.content);
|
||||
else if (event === "error") handlers.onError(payload.message);
|
||||
} catch {
|
||||
|
||||
@@ -4,11 +4,16 @@ import type { ToolCall } from "../api/client";
|
||||
export function ToolCard({ call }: { call: ToolCall }) {
|
||||
const running = call.status === "running";
|
||||
const error = call.status === "error";
|
||||
const pending = call.status === "pending";
|
||||
|
||||
// Argument principal affiché entre parenthèses (path le plus souvent).
|
||||
// Argument principal affiché entre parenthèses selon l'outil.
|
||||
const mainArg =
|
||||
(call.args?.path as string) ??
|
||||
(call.args?.query as string) ??
|
||||
(call.args?.command as string);
|
||||
const argPreview =
|
||||
typeof call.args?.path === "string"
|
||||
? `("${call.args.path}")`
|
||||
typeof mainArg === "string"
|
||||
? `("${mainArg.length > 48 ? mainArg.slice(0, 48) + "…" : mainArg}")`
|
||||
: Object.keys(call.args ?? {}).length
|
||||
? "(…)"
|
||||
: "()";
|
||||
@@ -32,6 +37,8 @@ export function ToolCard({ call }: { call: ToolCall }) {
|
||||
<span className="h-2.5 w-2.5 animate-spin rounded-full border-2 border-muted-3 border-t-accent" />
|
||||
en cours
|
||||
</span>
|
||||
) : pending ? (
|
||||
<span className="font-mono text-[11px] text-warn">⏸ à valider</span>
|
||||
) : (
|
||||
<span
|
||||
className={`flex items-center gap-1.5 font-mono text-[11px] ${
|
||||
@@ -74,6 +81,20 @@ function ToolGlyph({ name }: { name: string }) {
|
||||
<path d="M4 7h6l2 2h8v9H4z" />
|
||||
</svg>
|
||||
);
|
||||
if (name === "web_search")
|
||||
return (
|
||||
<svg {...common}>
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
if (name === "run_shell")
|
||||
return (
|
||||
<svg {...common}>
|
||||
<path d="m6 9 3 3-3 3M13 15h5" />
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
</svg>
|
||||
);
|
||||
// read_file (défaut)
|
||||
return (
|
||||
<svg {...common}>
|
||||
|
||||
@@ -15,6 +15,9 @@ export function ChatPanel() {
|
||||
sendMessage,
|
||||
currentSessionId,
|
||||
config,
|
||||
pendingShell,
|
||||
approveShell,
|
||||
rejectShell,
|
||||
} = useStore();
|
||||
|
||||
const activeTools = config
|
||||
@@ -100,6 +103,13 @@ export function ChatPanel() {
|
||||
pending
|
||||
/>
|
||||
)}
|
||||
{pendingShell && (
|
||||
<ShellConfirm
|
||||
command={pendingShell}
|
||||
onApprove={approveShell}
|
||||
onReject={rejectShell}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -201,6 +211,46 @@ function Bubble({ msg, pending }: { msg: Message; pending?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ShellConfirm({
|
||||
command,
|
||||
onApprove,
|
||||
onReject,
|
||||
}: {
|
||||
command: string;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="ml-[43px] overflow-hidden rounded-[11px] border border-[#4a3a2a] bg-card-deep">
|
||||
<div className="flex items-center gap-2 border-b border-line-soft px-[13px] py-2.5">
|
||||
<span className="font-mono text-[12.5px] font-semibold text-warn">
|
||||
run_shell
|
||||
</span>
|
||||
<span className="text-xs text-muted-2">· commande sensible à valider</span>
|
||||
</div>
|
||||
<div className="px-[13px] py-3">
|
||||
<pre className="m-0 mb-3 overflow-auto whitespace-pre-wrap rounded-lg bg-sunken px-3 py-2.5 font-mono text-[12px] text-ink-2">
|
||||
$ {command}
|
||||
</pre>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onApprove}
|
||||
className="flex h-[30px] items-center gap-1.5 rounded-lg bg-accent px-3.5 text-[12.5px] font-bold text-[#1f1b16]"
|
||||
>
|
||||
Approuver & exécuter
|
||||
</button>
|
||||
<button
|
||||
onClick={onReject}
|
||||
className="flex h-[30px] items-center rounded-lg border border-line-strong px-3.5 text-[12.5px] font-semibold text-ink-3"
|
||||
>
|
||||
Refuser
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex h-[25px] items-center gap-1.5 rounded-[7px] border border-line-strong bg-card-soft px-[9px] text-[11.5px] text-ink-3">
|
||||
|
||||
@@ -8,7 +8,10 @@ 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",
|
||||
web_search: "Recherche web",
|
||||
run_shell: "Exécuter une commande",
|
||||
};
|
||||
const SENSITIVE = new Set(["run_shell"]);
|
||||
|
||||
/** Vue Configuration (Frame 2) — Modèle & génération, outils, invite système. */
|
||||
export function SettingsView() {
|
||||
@@ -279,6 +282,9 @@ export function SettingsView() {
|
||||
</span>
|
||||
<span className="flex-1 text-xs text-muted-2">
|
||||
{TOOL_DESC[name] ?? ""}
|
||||
{SENSITIVE.has(name) && (
|
||||
<span className="text-warn"> · sensible</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
@@ -298,9 +304,28 @@ export function SettingsView() {
|
||||
</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>
|
||||
{draft.tools.run_shell && (
|
||||
<div className="mt-2 flex items-center gap-3 rounded-[10px] border border-line-soft bg-base px-3 py-2.5">
|
||||
<span className="flex-1 text-xs text-ink-3">
|
||||
Demander une validation avant chaque commande shell
|
||||
</span>
|
||||
<button
|
||||
onClick={() => set("confirm_shell", !draft.confirm_shell)}
|
||||
className="relative h-5 w-[34px] rounded-full transition-colors"
|
||||
style={{
|
||||
background: draft.confirm_shell ? "#f0a15c" : "#3a342c",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute top-0.5 h-4 w-4 rounded-full transition-all"
|
||||
style={{
|
||||
background: draft.confirm_shell ? "#1f1b16" : "#6f675c",
|
||||
left: draft.confirm_shell ? 16 : 2,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
fileContent,
|
||||
listFiles,
|
||||
listSessions,
|
||||
runShell,
|
||||
saveConfig,
|
||||
streamChat,
|
||||
type AgentConfig,
|
||||
@@ -42,6 +43,10 @@ interface LokiState {
|
||||
refreshConfig: () => Promise<void>;
|
||||
updateConfig: (patch: Partial<AgentConfig>) => Promise<void>;
|
||||
|
||||
pendingShell: string | null; // commande shell en attente de validation
|
||||
approveShell: () => Promise<void>;
|
||||
rejectShell: () => Promise<void>;
|
||||
|
||||
openPreview: (path: string) => Promise<void>;
|
||||
setSelectedModel: (name: string) => void;
|
||||
refreshStatus: () => Promise<void>;
|
||||
@@ -72,6 +77,32 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
previewContent: "",
|
||||
config: null,
|
||||
availableTools: [],
|
||||
pendingShell: null,
|
||||
|
||||
approveShell: async () => {
|
||||
const cmd = get().pendingShell;
|
||||
if (!cmd) return;
|
||||
set({ pendingShell: null });
|
||||
let report: string;
|
||||
try {
|
||||
const r = await runShell(cmd);
|
||||
report =
|
||||
`J'ai validé la commande \`${cmd}\` (code ${r.exit_code}).\n` +
|
||||
`Sortie :\n\`\`\`\n${r.output || "(vide)"}\n\`\`\``;
|
||||
} catch {
|
||||
report = `Échec de l'exécution de \`${cmd}\`.`;
|
||||
}
|
||||
await get().refreshFiles();
|
||||
// On renvoie le résultat à l'agent pour qu'il poursuive.
|
||||
await get().sendMessage(report);
|
||||
},
|
||||
|
||||
rejectShell: async () => {
|
||||
const cmd = get().pendingShell;
|
||||
if (!cmd) return;
|
||||
set({ pendingShell: null });
|
||||
await get().sendMessage(`J'ai refusé la commande \`${cmd}\`. N'exécute pas cette commande.`);
|
||||
},
|
||||
|
||||
refreshConfig: async () => {
|
||||
const { config, available_tools } = await getConfig();
|
||||
@@ -171,6 +202,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
streaming: true,
|
||||
streamContent: "",
|
||||
streamTools: [],
|
||||
pendingShell: null,
|
||||
});
|
||||
|
||||
await streamChat(
|
||||
@@ -190,6 +222,7 @@ export const useStore = create<LokiState>((set, get) => ({
|
||||
}
|
||||
set({ streamTools: tools });
|
||||
},
|
||||
onToolConfirm: (command) => set({ pendingShell: command }),
|
||||
onDone: async () => {
|
||||
// Repère un fichier HTML écrit pour l'afficher automatiquement.
|
||||
const writtenHtml = [...get().streamTools]
|
||||
|
||||
Reference in new issue
Block a user