Modifications du projet
This commit is contained in:
parent
3bfd5cc629
commit
ad2cf661c3
29 changed files with 1279 additions and 56 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -3,4 +3,3 @@ dist
|
|||
*.log
|
||||
.env
|
||||
.maia
|
||||
generated_page.md
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -68,7 +68,7 @@ Le CLI garde une façade impérative (`InkUI` : `info`, `select`, `readInput`, `
|
|||
ce qui rend la logique testable (`store.test.ts`). Les composants (saisie, liste, confirmation, spinner) sont écrits à la main ; seuls les badges viennent de [`@inkjs/ui`](https://github.com/vadimdemedes/ink-ui).
|
||||
Un terminal interactif (TTY) est requis.
|
||||
|
||||
Présentation : la bannière d'accueil reprend le style « MAIA » : masque pixel-art à gauche (demi-blocs ▀▄█, 20 × 20 pixels), titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 46 colonnes pour la version complète ; entre 25 et 45 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
|
||||
Présentation : la bannière d'accueil reprend le style « MAIA » : image à gauche, affichée telle quelle en couleurs réelles (demi-blocs ▀▄█ : 32 × 32 pixels, soit 32 colonnes × 16 lignes ; sans pixelisation). Elle vient de `packages/ui/assets/mask.png` ; `python3 packages/ui/scripts/make-mask.py [taille]` (Pillow) régénère `src/mask-data.ts` à une autre taille, titre « Maia » en gros caractères pixel avec ombre et dégradé arc-en-ciel à droite, puis deux textes (vert : « Assistant IA en ligne de commande… », rose : « Un seul agent, plusieurs modèles »). Terminal d'au moins 58 colonnes pour la version complète ; entre 25 et 57 : sans le masque ; en dessous : cadre à double bordure compact, titre centré ;
|
||||
une ligne vide sépare chaque message.
|
||||
|
||||
Messages du journal, avec le composant `Badge` de `@inkjs/ui` :
|
||||
|
|
@ -80,8 +80,14 @@ Messages du journal, avec le composant `Badge` de `@inkjs/ui` :
|
|||
| `FAIL` (rouge) | erreur | `#DC8ADD` (`ERROR_TEXT`) |
|
||||
| `ASK` (vert) | question posée à l'utilisateur (liste de choix, confirmation o/n), à gauche de la question | gras |
|
||||
|
||||
Les réponses de l'agent sont rendues en markdown (titres, listes, code, tableaux, gras…) avec [`markdansi`](https://github.com/steipete/markdansi). Tout est isolé dans `packages/ui/src/markdown.tsx` : pour le retirer, supprimer ce fichier, la dépendance `markdansi` et remplacer `<Markdown />` par `<Text>` dans `components.tsx`. Le texte en cours de streaming reste brut ; la mise en forme apparaît quand la réponse est terminée. `MAIA_NO_MARKDOWN=1` désactive le rendu sans toucher au code, et un échec de `markdansi` retombe sur le texte brut.
|
||||
|
||||
Les couleurs sont dans `packages/ui/src/colors.ts`. Une fois la réponse validée, la question est remplacée par la ligne `◆ Question : choix` dans le journal.
|
||||
|
||||
Barre de contexte : sous la zone de saisie, en bas à droite, une barre de carrés ■ (de gauche à droite) montre la taille du contexte de la conversation par rapport à la taille maximale du modèle, à l'échelle **1 carré = 10 000 tokens** (200 000 tokens → 20 carrés), avec le libellé « 45.7k / 200k · 23% ». Les carrés sont verts, jaunes à partir de 60 %, rouges à partir de 85 %. Si la barre ne tient pas dans le terminal (très grands contextes), l'échelle est agrandie par paliers de 10 000 et indiquée (`1■ = 20k`).
|
||||
- **Taille maximale** : après le choix du modèle (au démarrage, avec `--model`/config, et avec `/model`), MAIA demande au LLM « Taille maximale du contexte pour ce modèle » (réponse en tokens, lue par `parseTokenCount` : `128000`, `128 000 tokens`, `200k`, `1,5 million`…). La valeur est annoncée (INFO) ; une réponse par modèle et par exécution. Si la réponse est illisible, ou si la question échoue ou dépasse 60 s (Ctrl+C l'abandonne), un avertissement s'affiche et la barre est masquée. Un LLM peut se tromper sur sa propre limite : la valeur affichée en INFO permet de la vérifier.
|
||||
- **Contexte utilisé** : mis à jour après chaque réponse du LLM (donc après chaque appel d'outil intermédiaire) avec la consommation réelle renvoyée par LiteLLM (`stream_options.include_usage` : tokens de la requête + de la réponse). Sans consommation renvoyée, une estimation (≈ 4 caractères par token : prompt système, outils, messages) est utilisée. L'estimation sert aussi au démarrage, après `/clear`, `/new`, `/load` et après un tour interrompu.
|
||||
|
||||
La zone de saisie est encadrée par une bordure en pointillés (┈) **au-dessus et au-dessous** seulement, en `greenBright`, sans bords gauche ni droit.
|
||||
À sa gauche, le spinner `arrow3` de [cli-spinners](https://github.com/sindresorhus/cli-spinners) s'anime en continu. Pendant l'attente de la réponse après validation de la saisie, « En réflexion » s'affiche avec le spinner `dots10` à sa gauche, en `yellowBright` (même spinner pour les autres attentes : chargement des modèles, exécution d'une commande `!`).
|
||||
|
||||
|
|
@ -267,6 +273,7 @@ Exemple pour éviter les confirmations sur les outils en lecture seule : voir `s
|
|||
- Éventuellement rétablir sous `.maia` ce que supprimait l'abandon de `.claude` : variables d'environnement et slash-commandes personnalisées (le code des commandes existe encore dans `packages/agent/src/commands.ts`, il n'est plus appelé).
|
||||
- `@fichier` dans le corps d'une commande personnalisée ; autocomplétion des chemins `@` à la saisie.
|
||||
- Liste à cocher (choix multiples) : n'existe pas encore ; elle recevrait le badge `ASK`.
|
||||
- Taille de contexte : lue en interrogeant le LLM (demande de l'utilisateur) ; la route `/model/info` de LiteLLM (`max_input_tokens`) serait plus fiable, et un réglage manuel dans `settings.json` pourrait corriger une réponse erronée.
|
||||
- Nerd Fonts (icônes) : proposé comme réglage optionnel dans `settings.json`, non réalisé.
|
||||
- Tests : les suites vitest et le typecheck sont à lancer après `pnpm install` (écrits sans avoir pu être exécutés dans l'environnement de développement).
|
||||
|
||||
|
|
@ -294,3 +301,6 @@ Journal des évolutions, dans l'ordre :
|
|||
18. **`/clear`** : supprime le contexte et recharge `MAIA.md` / `CLAUDE.md` (et les skills) comme au démarrage ; `/new` fait de même.
|
||||
19. **Permissions granulaires** : règles `allow` / `ask` / `deny` par outil (`Outil` ou `Outil(motif)`), évaluées dans l'ordre deny > ask > allow, `ask` par défaut ; définies dans `~/.maia/settings.json` et `./.maia/settings.json` (pas le dossier parent).
|
||||
20. **`/permissions`** : affiche les permissions chargées, par fichier et par type (badges ALLOW / ASK / DENY) ; les fichiers sont listés l'un sous l'autre au démarrage.
|
||||
21. **Rendu markdown** des réponses de l'agent via `markdansi`, dans un seul composant isolé (`packages/ui/src/markdown.tsx`), désactivable avec `MAIA_NO_MARKDOWN=1`.
|
||||
22. **Image de la bannière** : nouvelle image (visage à couronne), fond retiré, réduite à 32 × 32 en couleurs réelles (sans pixelisation ni réduction de palette), centrée verticalement à côté du titre ; données générées par `packages/ui/scripts/make-mask.py`.
|
||||
23. **Barre de progression du contexte** sous la zone de saisie (1 carré = 10 000 tokens) : taille maximale demandée au LLM après la sélection du modèle, contexte utilisé mis à jour après chaque réponse (consommation renvoyée par LiteLLM, sinon estimation) ; correction d'un import de type manquant dans `ink-ui.tsx`.
|
||||
|
|
|
|||
33
generated_page.md
Normal file
33
generated_page.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Les Mystères de l'Univers Numérique
|
||||
|
||||
## Architecture Décentralisée et Résilience des Systèmes
|
||||
|
||||
Dans le vaste paysage technologique moderne, les systèmes décentralisés représentent une avancée majeure. Ils permettent une meilleure distribution des charges et une résilience accrue face aux défaillances.
|
||||
|
||||
## Innovation Quantique et Calcul de Demain
|
||||
|
||||
La révolution quantique approche. Les ordinateurs quantiques promettent de résoudre des problèmes complexes en temps polynomial, transformant complètement nos capacités de calcul actuelles.
|
||||
|
||||
## Sécurité Cryptographique et Protection des Données
|
||||
|
||||
La cryptographie post-quantique devient essentielle. Avec l'émergence des ordinateurs quantiques, les algorithmes classiques pourraient être compromis, d'où la nécessité de nouvelles approches.
|
||||
|
||||
## Intelligence Artificielle et Apprentissage Profond
|
||||
|
||||
Les réseaux de neurones profonds continuent d'évoluer. L'IA générative ouvre des perspectives inédites dans la création, l'automatisation et l'assistance intelligente.
|
||||
|
||||
## Durabilité Écologique du Développement Logiciel
|
||||
|
||||
La conscience écologique grandit dans l'industrie tech. Optimiser les algorithmes et réduire la consommation énergétique devient une priorité stratégique.
|
||||
|
||||
## Collaboration Distribuée et Télétravail Technologique
|
||||
|
||||
Les outils collaboratifs modernes permettent des équipes vraiment distribuées. Le télétravail en tech s'accompagne d'une infrastructure logicielle sophistiquée et intuitive.
|
||||
|
||||
## Automatisation DevOps et Intégration Continue
|
||||
|
||||
Le DevOps transforme la façon dont nous livrons les applications. L'intégration continue et le déploiement continu deviennent la norme industrielle incontournable.
|
||||
|
||||
## Microservices et Architecture Modulaire
|
||||
|
||||
L'approche microservices rompt avec les monolithes. Elle offre une flexibilité accrue, une évolutivité meilleure et une maintenance simplifiée pour les grandes applications.
|
||||
|
|
@ -6,11 +6,13 @@ import {
|
|||
SessionStore,
|
||||
createSession,
|
||||
ensureMaiaHome,
|
||||
estimateSessionTokens,
|
||||
findSkill,
|
||||
normalizeSessionName,
|
||||
parseShellInvocation,
|
||||
resolveFileReferences,
|
||||
parseSkillInvocation,
|
||||
queryContextWindow,
|
||||
runTurn,
|
||||
type AgentSession,
|
||||
type Message,
|
||||
|
|
@ -24,6 +26,7 @@ import { runUserShell } from "./user-shell";
|
|||
|
||||
const REPLAY_RESULT_MAX = 600;
|
||||
const REPLAY_USER_MAX = 1500; // les messages peuvent contenir des fichiers ou sorties de commandes joints
|
||||
const CONTEXT_QUERY_TIMEOUT_MS = 60_000; // question « taille maximale du contexte » posée au LLM
|
||||
|
||||
/** Réaffiche le contenu d'une session rechargée (échanges, appels d'outils, résultats tronqués). */
|
||||
function replayMessages(ui: InkUI, session: AgentSession, messages: Message[]): void {
|
||||
|
|
@ -152,7 +155,51 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
log: (m) => ui.info(m),
|
||||
warn: (m) => ui.warn(m),
|
||||
});
|
||||
return createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd });
|
||||
const created = createSession({ provider: createProvider(modelSpec, settings), init, ui, cwd });
|
||||
refreshContext(created); // le contexte de départ (prompt système, MAIA.md, outils) occupe déjà des tokens
|
||||
return created;
|
||||
};
|
||||
|
||||
/** Recalcule (estimation) la taille du contexte et met à jour la barre : nouvelle conversation, session rechargée, tour interrompu. */
|
||||
const refreshContext = (target: AgentSession): void => {
|
||||
target.contextTokens = estimateSessionTokens(target);
|
||||
ui.onContextUsage(target.contextTokens);
|
||||
};
|
||||
|
||||
// Tailles maximales de contexte déjà demandées au LLM (une seule question par modèle et par exécution)
|
||||
const contextWindows = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Après la sélection d'un modèle : demande au LLM la taille maximale de son contexte (en tokens) et la donne à la barre
|
||||
* de progression. Si la réponse est introuvable ou illisible, la barre est masquée (l'application continue).
|
||||
*/
|
||||
const resolveContextWindow = async (spec: string): Promise<void> => {
|
||||
let max = contextWindows.get(spec) ?? null;
|
||||
if (max === null) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), CONTEXT_QUERY_TIMEOUT_MS);
|
||||
ui.setInterruptHandler(() => controller.abort()); // Ctrl+C abandonne la question
|
||||
ui.setBusy("Recherche de la taille maximale du contexte…");
|
||||
try {
|
||||
const { tokens, answer } = await queryContextWindow(createProvider(spec, settings), controller.signal);
|
||||
if (tokens === null) {
|
||||
const shown = answer.length > 120 ? `${answer.slice(0, 120)}…` : answer;
|
||||
ui.warn(`Taille maximale du contexte non déterminée (réponse du modèle : « ${shown} »). Barre de progression masquée.`);
|
||||
} else {
|
||||
max = tokens;
|
||||
contextWindows.set(spec, tokens);
|
||||
ui.info(`Taille maximale du contexte : ${tokens.toLocaleString("fr-FR")} tokens (réponse du modèle)`);
|
||||
}
|
||||
} catch (err) {
|
||||
const why = controller.signal.aborted ? "question abandonnée (délai dépassé ou Ctrl+C)" : (err as Error).message;
|
||||
ui.warn(`Taille maximale du contexte indisponible : ${why}. Barre de progression masquée.`);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
ui.setBusy(null);
|
||||
ui.setInterruptHandler(null);
|
||||
}
|
||||
}
|
||||
ui.setContextMax(max);
|
||||
};
|
||||
|
||||
ui.intro("Maia");
|
||||
|
|
@ -170,8 +217,10 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
}
|
||||
|
||||
// 3. Modèle de la session : explicite (flag, variable d'environnement, config) sinon choisi par l'utilisateur
|
||||
let modelSpec = config.model;
|
||||
if (!modelSpec) {
|
||||
let modelSpec: string;
|
||||
if (config.model) {
|
||||
modelSpec = config.model;
|
||||
} else {
|
||||
const picked = await pickModel();
|
||||
if (!picked) {
|
||||
ui.outro("Aucun modèle choisi");
|
||||
|
|
@ -190,6 +239,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
return;
|
||||
}
|
||||
ui.info(`Modèle : ${modelSpec}`);
|
||||
await resolveContextWindow(modelSpec);
|
||||
|
||||
// Sauvegarde de la session courante (silencieuse si elle est vide ou inchangée). Retourne son nom si écrite.
|
||||
const saveSession = (): string | null => {
|
||||
|
|
@ -351,6 +401,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
session.pendingContext.length = 0;
|
||||
session.pendingContext.push(...saved.pendingContext);
|
||||
sessions.restore(saved);
|
||||
refreshContext(session);
|
||||
ui.info(`Session chargée : ${saved.name} (${saved.messages.length} messages · modèle ${modelSpec})`);
|
||||
if (session.pendingContext.length > 0) {
|
||||
ui.info(`Contexte en attente (commandes « ! ») : ${session.pendingContext.length} élément(s), joint(s) à votre prochaine demande.`);
|
||||
|
|
@ -378,6 +429,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
session.provider = createProvider(choice, settings);
|
||||
modelSpec = choice;
|
||||
ui.info(`Modèle actif : ${modelSpec}`);
|
||||
await resolveContextWindow(modelSpec);
|
||||
} catch (err) {
|
||||
ui.error((err as Error).message);
|
||||
}
|
||||
|
|
@ -418,6 +470,7 @@ async function run(ui: InkUI, flags: { model?: string }): Promise<void> {
|
|||
if (controller.signal.aborted) {
|
||||
session.messages.length = mark; // évite un historique incohérent (appel d'outil sans résultat)
|
||||
session.pendingContext = contextBackup; // le contexte des commandes « ! » n'est pas perdu
|
||||
refreshContext(session);
|
||||
ui.warn("Interrompu (Ctrl+C) : la conversation est restaurée avant cette demande.");
|
||||
} else {
|
||||
ui.error((err as Error).message);
|
||||
|
|
|
|||
|
|
@ -122,3 +122,56 @@ describe("contexte en attente", () => {
|
|||
expect(session.pendingContext).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("taille du contexte", () => {
|
||||
const echo: Tool = {
|
||||
spec: { name: "echo", description: "echo", inputSchema: { type: "object" } },
|
||||
permission: "auto",
|
||||
execute: async () => "ok",
|
||||
};
|
||||
|
||||
it("utilise la consommation renvoyée par le serveur après chaque réponse", async () => {
|
||||
const seen: number[] = [];
|
||||
let call = 0;
|
||||
const provider: LLMProvider = {
|
||||
id: "fake",
|
||||
model: "fake",
|
||||
capabilities: { tools: true },
|
||||
async *chat(): AsyncIterable<StreamEvent> {
|
||||
call++;
|
||||
if (call === 1) {
|
||||
yield { type: "tool_use", id: "t1", name: "echo", input: {} };
|
||||
yield { type: "usage", inputTokens: 1000, outputTokens: 50 };
|
||||
} else {
|
||||
yield { type: "text_delta", text: "fin" };
|
||||
yield { type: "usage", inputTokens: 1200, outputTokens: 30 };
|
||||
}
|
||||
yield { type: "done", stopReason: call === 1 ? "tool_use" : "end_turn" };
|
||||
},
|
||||
};
|
||||
const session = createSession({
|
||||
provider,
|
||||
init: { systemPrompt: "t", tools: [echo] },
|
||||
ui: { ...silentUI, onContextUsage: (n) => seen.push(n) },
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
await runTurn(session, "go");
|
||||
expect(seen).toEqual([1050, 1230]);
|
||||
expect(session.contextTokens).toBe(1230);
|
||||
});
|
||||
|
||||
it("sans consommation renvoyée : estimation à partir de la conversation", async () => {
|
||||
const seen: number[] = [];
|
||||
const session = createSession({
|
||||
provider: fakeProvider("echo"),
|
||||
init: { systemPrompt: "x".repeat(4000), tools: [echo] },
|
||||
ui: { ...silentUI, onContextUsage: (n) => seen.push(n) },
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
await runTurn(session, "go");
|
||||
expect(seen).toHaveLength(2);
|
||||
expect(seen[0]).toBeGreaterThanOrEqual(1000); // prompt système de 4000 caractères
|
||||
expect(seen[1]).toBeGreaterThan(seen[0]!);
|
||||
expect(session.contextTokens).toBe(seen[1]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { estimateTokens } from "./context";
|
||||
import type {
|
||||
AgentSkill,
|
||||
AgentUI,
|
||||
|
|
@ -22,6 +23,8 @@ export interface AgentSession {
|
|||
permissionSources: PermissionSource[];
|
||||
env?: Record<string, string>;
|
||||
messages: Message[];
|
||||
/** Taille du contexte (tokens) après la dernière réponse du LLM : valeur renvoyée par le serveur, sinon estimation. */
|
||||
contextTokens: number;
|
||||
/** Contexte ajouté par l'utilisateur hors conversation (ex. résultat d'une commande "!"), joint à sa prochaine demande. */
|
||||
pendingContext: string[];
|
||||
ui: AgentUI;
|
||||
|
|
@ -41,9 +44,10 @@ export function createSession(opts: {
|
|||
commands: opts.init.commands ?? [],
|
||||
skills: opts.init.skills ?? [],
|
||||
permissions: opts.init.permissions,
|
||||
permissionSources: opts.init.permissionSources ?? [],
|
||||
permissionSources: opts.init.permissionSources ?? [],
|
||||
env: opts.init.env,
|
||||
messages: [],
|
||||
contextTokens: 0,
|
||||
pendingContext: [],
|
||||
ui: opts.ui,
|
||||
cwd: opts.cwd,
|
||||
|
|
@ -69,6 +73,7 @@ export async function runTurn(
|
|||
for (let i = 0; i < MAX_ITERATIONS; i++) {
|
||||
const assistant: ContentBlock[] = [];
|
||||
let text = "";
|
||||
let usedTokens: number | null = null;
|
||||
|
||||
ui.onThinking(true);
|
||||
let first = true;
|
||||
|
|
@ -90,6 +95,9 @@ export async function runTurn(
|
|||
ui.onText(ev.text);
|
||||
} else if (ev.type === "tool_use") {
|
||||
assistant.push({ type: "tool_use", id: ev.id, name: ev.name, input: ev.input });
|
||||
} else if (ev.type === "usage") {
|
||||
// Requête + réponse : c'est la taille du contexte une fois la réponse ajoutée à la conversation
|
||||
usedTokens = ev.inputTokens + ev.outputTokens;
|
||||
}
|
||||
// "done" : la fin du flux suffit, les appels d'outils décident de la suite
|
||||
}
|
||||
|
|
@ -102,6 +110,8 @@ export async function runTurn(
|
|||
assistant.unshift({ type: "text", text });
|
||||
}
|
||||
session.messages.push({ role: "assistant", content: assistant });
|
||||
session.contextTokens = usedTokens ?? estimateSessionTokens(session);
|
||||
ui.onContextUsage?.(session.contextTokens);
|
||||
|
||||
const calls = assistant.filter((b): b is ToolUseBlock => b.type === "tool_use");
|
||||
if (calls.length === 0) return;
|
||||
|
|
@ -161,3 +171,8 @@ async function executeToolCall(
|
|||
return result(msg, true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Estimation de la taille du contexte d'une session (sans réponse du serveur) : prompt système, outils et messages. */
|
||||
export function estimateSessionTokens(session: Pick<AgentSession, "system" | "messages" | "tools">): number {
|
||||
return estimateTokens(session.system, session.messages, [...session.tools.values()].map((t) => t.spec));
|
||||
}
|
||||
|
|
|
|||
72
packages/core/src/context.test.ts
Normal file
72
packages/core/src/context.test.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { CONTEXT_WINDOW_QUESTION, estimateTokens, parseTokenCount, queryContextWindow } from "./context";
|
||||
import type { LLMProvider, StreamEvent } from "./types";
|
||||
|
||||
describe("parseTokenCount", () => {
|
||||
it.each([
|
||||
["128000", 128_000],
|
||||
["128 000", 128_000],
|
||||
["128 000 tokens", 128_000],
|
||||
["131,072", 131_072],
|
||||
["1.000.000", 1_000_000],
|
||||
["200k", 200_000],
|
||||
["200K tokens", 200_000],
|
||||
["1M", 1_000_000],
|
||||
["1,5 million de tokens", 1_500_000],
|
||||
["2 millions", 2_000_000],
|
||||
["1.5M", 1_500_000],
|
||||
["Environ 128 000 tokens.", 128_000],
|
||||
])("%s → %d", (text, expected) => {
|
||||
expect(parseTokenCount(text)).toBe(expected);
|
||||
});
|
||||
|
||||
it("ignore les nombres du nom du modèle", () => {
|
||||
expect(parseTokenCount("GPT-4o a un contexte de 128 000 tokens")).toBe(128_000);
|
||||
expect(parseTokenCount("Claude 3.5 Sonnet : 200k tokens")).toBe(200_000);
|
||||
});
|
||||
|
||||
it("une valeur avec unité l'emporte sur une valeur plus grande sans unité", () => {
|
||||
expect(parseTokenCount("sortie 16 384 ; contexte 128k tokens ; ref 5000000")).toBe(128_000);
|
||||
});
|
||||
|
||||
it("sans valeur exploitable → null", () => {
|
||||
expect(parseTokenCount("Je ne sais pas.")).toBeNull();
|
||||
expect(parseTokenCount("")).toBeNull();
|
||||
expect(parseTokenCount("42")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateTokens", () => {
|
||||
it("≈ 4 caractères par token, outils compris", () => {
|
||||
expect(estimateTokens("a".repeat(400), [])).toBeGreaterThanOrEqual(100);
|
||||
const without = estimateTokens("s", []);
|
||||
const withTools = estimateTokens("s", [], [{ name: "T", description: "d".repeat(400), inputSchema: {} }]);
|
||||
expect(withTools).toBeGreaterThan(without + 90);
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryContextWindow", () => {
|
||||
const provider = (reply: string, seen: { text?: string } = {}): LLMProvider => ({
|
||||
id: "fake",
|
||||
model: "fake",
|
||||
capabilities: { tools: false },
|
||||
async *chat(req): AsyncIterable<StreamEvent> {
|
||||
seen.text = JSON.stringify(req.messages);
|
||||
yield { type: "text_delta", text: reply.slice(0, 3) };
|
||||
yield { type: "text_delta", text: reply.slice(3) };
|
||||
yield { type: "done", stopReason: "end_turn" };
|
||||
},
|
||||
});
|
||||
|
||||
it("pose la question et lit le nombre de tokens", async () => {
|
||||
const seen: { text?: string } = {};
|
||||
const r = await queryContextWindow(provider("200 000 tokens", seen));
|
||||
expect(r).toEqual({ tokens: 200_000, answer: "200 000 tokens" });
|
||||
expect(seen.text).toContain(CONTEXT_WINDOW_QUESTION);
|
||||
expect(CONTEXT_WINDOW_QUESTION).toMatch(/Taille maximale du contexte pour ce modèle/);
|
||||
});
|
||||
|
||||
it("réponse illisible : tokens null, réponse conservée", async () => {
|
||||
expect(await queryContextWindow(provider("Aucune idée"))).toEqual({ tokens: null, answer: "Aucune idée" });
|
||||
});
|
||||
});
|
||||
77
packages/core/src/context.ts
Normal file
77
packages/core/src/context.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Taille du contexte : estimation locale, lecture d'une taille en tokens, question posée au LLM.
|
||||
import type { LLMProvider, Message, ToolSpec } from "./types";
|
||||
|
||||
/** Estimation grossière (≈ 4 caractères par token) quand le serveur ne renvoie pas la consommation réelle. */
|
||||
export function estimateTokens(system: string, messages: Message[], tools: ToolSpec[] = []): number {
|
||||
const chars = system.length + JSON.stringify(messages).length + (tools.length > 0 ? JSON.stringify(tools).length : 0);
|
||||
return Math.ceil(chars / 4);
|
||||
}
|
||||
|
||||
/** Question posée au LLM après le choix du modèle (la consigne finale facilite la lecture de la réponse). */
|
||||
export const CONTEXT_WINDOW_QUESTION =
|
||||
"Taille maximale du contexte pour ce modèle, en tokens ? Réponds uniquement par un nombre entier, sans autre texte.";
|
||||
|
||||
const MIN_CONTEXT = 1_000;
|
||||
const MAX_CONTEXT = 100_000_000;
|
||||
|
||||
const MULTIPLIERS: Record<string, number> = {
|
||||
k: 1_000,
|
||||
mille: 1_000,
|
||||
m: 1_000_000,
|
||||
million: 1_000_000,
|
||||
millions: 1_000_000,
|
||||
milliard: 1_000_000_000,
|
||||
milliards: 1_000_000_000,
|
||||
};
|
||||
|
||||
// 128 000 · 128,000 · 128.000 · 1.5 · 1,5 · 200000 — suivi éventuel d'un multiplicateur (k, M, mille, million…) ou de « token »
|
||||
const NUMBER = /(\d{1,3}(?:[ ., ]\d{3})+(?!\d)|\d+(?:[.,]\d+)?)\s*(milliards?|millions?|mille|[kKmM](?![A-Za-zÀ-ÿ])|(?=tokens?\b))?/gi;
|
||||
|
||||
function toNumber(raw: string): number {
|
||||
if (/^\d{1,3}([ ., ]\d{3})+$/.test(raw)) return Number(raw.replace(/[ ., ]/g, ""));
|
||||
return Number(raw.replace(",", "."));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait une taille de contexte (en tokens) de la réponse d'un LLM : « 128000 », « 128 000 tokens », « 200k », « 1M »,
|
||||
* « 1,5 million »… Une valeur avec unité l'emporte ; sinon la plus grande valeur plausible (1 000 à 100 000 000).
|
||||
* Retourne null si rien d'exploitable.
|
||||
*/
|
||||
export function parseTokenCount(text: string): number | null {
|
||||
let best: { value: number; unit: boolean } | null = null;
|
||||
for (const m of text.matchAll(NUMBER)) {
|
||||
const base = toNumber(m[1] ?? "");
|
||||
const suffix = (m[2] ?? "").toLowerCase();
|
||||
const value = Math.round(base * (MULTIPLIERS[suffix] ?? 1));
|
||||
if (!Number.isFinite(value) || value < MIN_CONTEXT || value > MAX_CONTEXT) continue;
|
||||
const unit = suffix !== "" || /^\s*tokens?\b/i.test(text.slice((m.index ?? 0) + m[0].length));
|
||||
if (!best || (unit && !best.unit) || (unit === best.unit && value > best.value)) best = { value, unit };
|
||||
}
|
||||
return best ? best.value : null;
|
||||
}
|
||||
|
||||
export interface ContextWindowAnswer {
|
||||
/** Taille maximale en tokens, null si la réponse n'a pas pu être lue. */
|
||||
tokens: number | null;
|
||||
/** Réponse brute du LLM (utile pour informer l'utilisateur en cas d'échec). */
|
||||
answer: string;
|
||||
}
|
||||
|
||||
/** Demande au LLM la taille maximale de son contexte. Lève une erreur si l'appel échoue (réseau, HTTP, annulation). */
|
||||
export async function queryContextWindow(
|
||||
provider: LLMProvider,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ContextWindowAnswer> {
|
||||
let answer = "";
|
||||
for await (const ev of provider.chat(
|
||||
{
|
||||
system: "Tu réponds de façon brève et exacte.",
|
||||
messages: [{ role: "user", content: [{ type: "text", text: CONTEXT_WINDOW_QUESTION }] }],
|
||||
tools: [],
|
||||
},
|
||||
signal,
|
||||
)) {
|
||||
if (ev.type === "text_delta") answer += ev.text;
|
||||
}
|
||||
return { tokens: parseTokenCount(answer), answer: answer.trim() };
|
||||
}
|
||||
|
|
@ -4,3 +4,4 @@ export * from "./skills";
|
|||
export * from "./shell";
|
||||
export * from "./fileref";
|
||||
export * from "./sessions";
|
||||
export * from "./context";
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ export type StopReason = "end_turn" | "tool_use" | "max_tokens" | "other";
|
|||
export type StreamEvent =
|
||||
| { type: "text_delta"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
|
||||
/** Consommation de la requête, quand le serveur la renvoie (avant "done"). */
|
||||
| { type: "usage"; inputTokens: number; outputTokens: number }
|
||||
| { type: "done"; stopReason: StopReason };
|
||||
|
||||
export interface LLMProvider {
|
||||
|
|
@ -89,6 +91,8 @@ export interface AgentUI {
|
|||
onToolCall(name: string, summary: string): void;
|
||||
onToolResult(name: string, output: string, isError: boolean): void;
|
||||
confirm(message: string): Promise<boolean>;
|
||||
/** Taille courante du contexte (en tokens), après chaque réponse du LLM. */
|
||||
onContextUsage?(tokens: number): void;
|
||||
}
|
||||
|
||||
// ---------- Initialisation de l'agent ----------
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ describe("format OpenAI", () => {
|
|||
{ role: "user", content: [{ type: "text", text: "suite" }, { type: "tool_result", toolUseId: "c1", content: "R" }] },
|
||||
],
|
||||
});
|
||||
expect(body).toMatchObject({ model: "m", stream: true, max_tokens: 100 });
|
||||
expect(body).toMatchObject({ model: "m", stream: true, max_tokens: 100, stream_options: { include_usage: true } });
|
||||
const msgs = body.messages as { role: string }[];
|
||||
expect(msgs.map((m) => m.role)).toEqual(["system", "user", "assistant", "tool", "user"]);
|
||||
expect(msgs[2]).toMatchObject({ tool_calls: [{ id: "c1", function: { name: "Read", arguments: '{"a":1}' } }] });
|
||||
|
|
@ -95,6 +95,18 @@ describe("format OpenAI", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("streaming : consommation (tokens) lue dans le dernier morceau, avant « done »", async () => {
|
||||
const raw =
|
||||
sse({ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }) +
|
||||
sse({ choices: [], usage: { prompt_tokens: 1200, completion_tokens: 30, total_tokens: 1230 } }) +
|
||||
"data: [DONE]\n\n";
|
||||
expect(await collect(parseSse(chunks(raw)))).toEqual([
|
||||
{ type: "text_delta", text: "ok" },
|
||||
{ type: "usage", inputTokens: 1200, outputTokens: 30 },
|
||||
{ type: "done", stopReason: "end_turn" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("erreur renvoyée dans le flux", async () => {
|
||||
await expect(collect(parseSse(chunks(sse({ error: { message: "boom" } }))))).rejects.toThrow(/boom/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ export function buildChatBody(model: string, req: ChatRequest): Record<string, u
|
|||
}
|
||||
}
|
||||
|
||||
const body: Record<string, unknown> = { model, messages, stream: true };
|
||||
// include_usage : le serveur renvoie la consommation réelle (tokens) dans le dernier morceau du flux
|
||||
const body: Record<string, unknown> = { model, messages, stream: true, stream_options: { include_usage: true } };
|
||||
if (req.maxTokens) body.max_tokens = req.maxTokens;
|
||||
if (req.tools.length > 0) {
|
||||
body.tools = req.tools.map((t) => ({
|
||||
|
|
@ -39,6 +40,7 @@ export function buildChatBody(model: string, req: ChatRequest): Record<string, u
|
|||
|
||||
interface Chunk {
|
||||
error?: { message?: string };
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | null;
|
||||
choices?: {
|
||||
finish_reason?: string | null;
|
||||
delta?: {
|
||||
|
|
@ -60,6 +62,8 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
|
|||
const decoder = new TextDecoder();
|
||||
const calls = new Map<number, { id: string; name: string; args: string }>();
|
||||
let finish: string | null | undefined;
|
||||
// objet plutôt que variable : l'assignation se fait dans un générateur imbriqué (TypeScript ne la verrait pas)
|
||||
const seen: { usage: { inputTokens: number; outputTokens: number } | null } = { usage: null };
|
||||
let buffer = "";
|
||||
|
||||
function* handleLine(line: string): Generator<StreamEvent> {
|
||||
|
|
@ -73,6 +77,12 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
|
|||
return;
|
||||
}
|
||||
if (chunk.error) throw new Error(`Erreur du serveur LiteLLM : ${chunk.error.message ?? JSON.stringify(chunk.error)}`);
|
||||
// Le morceau d'usage arrive en dernier, avec une liste `choices` vide
|
||||
if (chunk.usage && typeof chunk.usage.prompt_tokens === "number") {
|
||||
const input = chunk.usage.prompt_tokens;
|
||||
const output = chunk.usage.completion_tokens ?? Math.max(0, (chunk.usage.total_tokens ?? input) - input);
|
||||
seen.usage = { inputTokens: input, outputTokens: output };
|
||||
}
|
||||
const choice = chunk.choices?.[0];
|
||||
if (!choice) return;
|
||||
const delta = choice.delta;
|
||||
|
|
@ -111,5 +121,6 @@ export async function* parseSse(body: AsyncIterable<Uint8Array>): AsyncGenerator
|
|||
}
|
||||
yield { type: "tool_use", id: call.id || `call_${++n}`, name: call.name, input };
|
||||
}
|
||||
if (seen.usage) yield { type: "usage", ...seen.usage };
|
||||
yield { type: "done", stopReason: stopReason(finish, calls.size > 0) };
|
||||
}
|
||||
|
|
|
|||
BIN
packages/ui/assets/mask.png
Normal file
BIN
packages/ui/assets/mask.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 45 KiB |
|
|
@ -12,6 +12,7 @@
|
|||
"@maia/core": "workspace:*",
|
||||
"cli-spinners": "^2.9.2",
|
||||
"ink": "^7.0.3",
|
||||
"markdansi": "^0.2.0",
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
65
packages/ui/scripts/make-mask.py
Normal file
65
packages/ui/scripts/make-mask.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Génère src/mask-data.ts à partir de assets/mask.png (nécessite Pillow : pip install pillow).
|
||||
|
||||
Usage : python3 scripts/make-mask.py [taille] (32 par défaut ; l'image affichée fait taille × taille pixels,
|
||||
soit taille colonnes × taille/2 lignes de terminal)
|
||||
|
||||
Le fond uni (couleur des coins) est rendu transparent, l'image est recadrée au carré puis réduite
|
||||
en moyennant les couleurs : aucune pixelisation par blocs, aucune réduction de palette.
|
||||
"""
|
||||
import sys
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
|
||||
N = int(sys.argv[1]) if len(sys.argv) > 1 else 32
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
im = Image.open(root / "assets" / "mask.png").convert("RGB")
|
||||
W, H = im.size
|
||||
px = im.load()
|
||||
bg = px[0, 0]
|
||||
near = lambda c: all(abs(c[i] - bg[i]) <= 12 for i in range(3))
|
||||
|
||||
# Fond = zone connectée aux coins et proche de la couleur du fond (le visage crème est isolé par son contour noir).
|
||||
transparent = [[False] * W for _ in range(H)]
|
||||
queue = deque([(0, 0), (W - 1, 0), (0, H - 1), (W - 1, H - 1)])
|
||||
while queue:
|
||||
x, y = queue.popleft()
|
||||
if x < 0 or y < 0 or x >= W or y >= H or transparent[y][x] or not near(px[x, y]):
|
||||
continue
|
||||
transparent[y][x] = True
|
||||
queue.extend([(x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)])
|
||||
|
||||
xs = [x for y in range(H) for x in range(W) if not transparent[y][x]]
|
||||
ys = [y for y in range(H) for x in range(W) if not transparent[y][x]]
|
||||
x0, x1, y0, y1 = min(xs), max(xs) + 1, min(ys), max(ys) + 1
|
||||
side = max(x1 - x0, y1 - y0)
|
||||
ox = x0 - (side - (x1 - x0)) // 2
|
||||
oy = y0 - (side - (y1 - y0)) // 2
|
||||
|
||||
rows = []
|
||||
for gy in range(N):
|
||||
row = ""
|
||||
for gx in range(N):
|
||||
total = opaque = 0
|
||||
acc = [0, 0, 0]
|
||||
for y in range(int(oy + gy * side / N), int(oy + (gy + 1) * side / N) + 1):
|
||||
for x in range(int(ox + gx * side / N), int(ox + (gx + 1) * side / N) + 1):
|
||||
total += 1
|
||||
if 0 <= x < W and 0 <= y < H and not transparent[y][x]:
|
||||
opaque += 1
|
||||
for i in range(3):
|
||||
acc[i] += px[x, y][i]
|
||||
# moyenne des pixels opaques uniquement (évite le liseré clair du fond) ; transparent si moins de la moitié est opaque
|
||||
row += "......" if opaque * 2 < total or opaque == 0 else "".join("%02x" % round(a / opaque) for a in acc)
|
||||
rows.append(row)
|
||||
|
||||
lines = "\n".join(f' "{r}",' for r in rows)
|
||||
(root / "src" / "mask-data.ts").write_text(
|
||||
"// Fichier généré par scripts/make-mask.py à partir de assets/mask.png : ne pas modifier à la main.\n"
|
||||
"// Une chaîne par ligne de pixels ; chaque pixel = 6 chiffres hexadécimaux rrggbb, \"......\" = transparent.\n"
|
||||
f"export const MASK_SIZE = {N};\n\n"
|
||||
f"export const MASK_DATA: string[] = [\n{lines}\n];\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"{N}x{N} écrit dans src/mask-data.ts")
|
||||
|
|
@ -90,7 +90,7 @@ export function Banner({ title }: { title: string }) {
|
|||
|
||||
if (columns < MASK_WIDTH + GAP + titleWidth(title)) return right;
|
||||
return (
|
||||
<Box>
|
||||
<Box alignItems="center">
|
||||
<PixelBlock lines={MASK_LINES} />
|
||||
<Box marginLeft={GAP}>{right}</Box>
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { Badge } from "@inkjs/ui";
|
|||
import { Box, Static, Text, useInput } from "ink";
|
||||
import { Banner } from "./banner";
|
||||
import { ERROR_TEXT, LIGHT_BLUE } from "./colors";
|
||||
import { ContextBar } from "./context-bar";
|
||||
import { Markdown } from "./markdown";
|
||||
import type {
|
||||
ConfirmPromptState,
|
||||
InputPromptState,
|
||||
|
|
@ -12,6 +14,7 @@ import type {
|
|||
PermissionFile,
|
||||
PromptState,
|
||||
SelectPromptState,
|
||||
UIState,
|
||||
UIStore,
|
||||
} from "./store";
|
||||
|
||||
|
|
@ -108,7 +111,8 @@ function LogLine({ item }: { item: LogItem }) {
|
|||
</Box>
|
||||
);
|
||||
case "assistant":
|
||||
return <Text>{item.text}</Text>;
|
||||
// Réponse finale rendue en markdown (voir markdown.tsx) ; le texte en cours de streaming reste brut.
|
||||
return <Markdown text={item.text} />;
|
||||
case "permissions":
|
||||
return <PermissionsView files={item.permissions ?? []} />;
|
||||
case "shell":
|
||||
|
|
@ -346,10 +350,15 @@ function ConfirmPrompt({ prompt }: { prompt: ConfirmPromptState }) {
|
|||
);
|
||||
}
|
||||
|
||||
function PromptView({ prompt }: { prompt: PromptState }) {
|
||||
function PromptView({ prompt, state }: { prompt: PromptState; state: UIState }) {
|
||||
switch (prompt.kind) {
|
||||
case "input":
|
||||
return <InputPrompt prompt={prompt} />;
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<InputPrompt prompt={prompt} />
|
||||
{state.contextMax !== null ? <ContextBar used={state.contextUsed} max={state.contextMax} /> : null}
|
||||
</Box>
|
||||
);
|
||||
case "select":
|
||||
return <SelectPrompt prompt={prompt} />;
|
||||
case "confirm":
|
||||
|
|
@ -390,7 +399,7 @@ export function App({ store }: { store: UIStore }) {
|
|||
<Spinner label={state.busy} />
|
||||
</Box>
|
||||
) : null}
|
||||
{state.prompt ? <PromptView prompt={state.prompt} /> : null}
|
||||
{state.prompt ? <PromptView prompt={state.prompt} state={state} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
24
packages/ui/src/context-bar.tsx
Normal file
24
packages/ui/src/context-bar.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
// Barre de progression du contexte (sous la zone de saisie, alignée à droite, de gauche à droite).
|
||||
import React from "react";
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import { contextBarModel, formatTokens } from "./context-model";
|
||||
|
||||
const COLORS = { ok: "green", warn: "yellow", danger: "red" } as const;
|
||||
const EMPTY_COLOR = "#555555";
|
||||
|
||||
export function ContextBar({ used, max }: { used: number; max: number }) {
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
const pct = Math.round(Math.min(1, used / Math.max(1, max)) * 100);
|
||||
const label = `${formatTokens(used)} / ${formatTokens(max)} · ${pct}% `;
|
||||
// Marge d'1 colonne de chaque côté ; le libellé passe avant les carrés
|
||||
const model = contextBarModel(used, max, Math.max(1, columns - label.length - 2));
|
||||
const scale = model.tokensPerSquare === 10_000 ? "" : ` (1■ = ${formatTokens(model.tokensPerSquare)})`;
|
||||
return (
|
||||
<Box justifyContent="flex-end" paddingRight={1}>
|
||||
<Text dimColor>{label.trimEnd() + scale + " "}</Text>
|
||||
<Text color={COLORS[model.level]}>{"■".repeat(model.filled)}</Text>
|
||||
<Text color={EMPTY_COLOR}>{"■".repeat(model.total - model.filled)}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
47
packages/ui/src/context-model.test.ts
Normal file
47
packages/ui/src/context-model.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { TOKENS_PER_SQUARE, contextBarModel, formatTokens } from "./context-model";
|
||||
|
||||
describe("contextBarModel", () => {
|
||||
it("1 carré = 10 000 tokens : 200 000 tokens → 20 carrés", () => {
|
||||
expect(TOKENS_PER_SQUARE).toBe(10_000);
|
||||
expect(contextBarModel(0, 200_000)).toMatchObject({ total: 20, filled: 0, tokensPerSquare: 10_000, level: "ok" });
|
||||
});
|
||||
|
||||
it("la barre se remplit de la gauche vers la droite selon l'usage", () => {
|
||||
expect(contextBarModel(50_000, 200_000).filled).toBe(5);
|
||||
expect(contextBarModel(100_000, 200_000)).toMatchObject({ filled: 10, ratio: 0.5, level: "ok" });
|
||||
expect(contextBarModel(130_000, 200_000)).toMatchObject({ filled: 13, level: "warn" });
|
||||
expect(contextBarModel(180_000, 200_000)).toMatchObject({ filled: 18, level: "danger" });
|
||||
});
|
||||
|
||||
it("un contexte non vide montre au moins un carré ; un contexte plein les remplit tous", () => {
|
||||
expect(contextBarModel(300, 200_000).filled).toBe(1);
|
||||
expect(contextBarModel(200_000, 200_000)).toMatchObject({ filled: 20, ratio: 1 });
|
||||
});
|
||||
|
||||
it("dépassement du maximum : plafonné", () => {
|
||||
expect(contextBarModel(250_000, 200_000)).toMatchObject({ filled: 20, ratio: 1, level: "danger" });
|
||||
});
|
||||
|
||||
it("taille non multiple de 10 000 : carré partiel arrondi vers le haut au total", () => {
|
||||
expect(contextBarModel(0, 128_000).total).toBe(13);
|
||||
});
|
||||
|
||||
it("barre trop large pour le terminal : échelle agrandie par paliers de 10 000", () => {
|
||||
const m = contextBarModel(500_000, 1_000_000, 60); // 100 carrés demandés, 60 disponibles
|
||||
expect(m.tokensPerSquare).toBe(20_000);
|
||||
expect(m.total).toBe(50);
|
||||
expect(m.filled).toBe(25);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokens", () => {
|
||||
it.each([
|
||||
[950, "950"],
|
||||
[12_345, "12.3k"],
|
||||
[128_000, "128k"],
|
||||
[200_000, "200k"],
|
||||
[1_048_576, "1.05M"],
|
||||
[1_000_000, "1M"],
|
||||
])("%d → %s", (n, expected) => expect(formatTokens(n)).toBe(expected));
|
||||
});
|
||||
47
packages/ui/src/context-model.ts
Normal file
47
packages/ui/src/context-model.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// Barre de progression du contexte : calculs purs (sans React), testables seuls.
|
||||
|
||||
/** Échelle par défaut : 1 carré = 10 000 tokens. */
|
||||
export const TOKENS_PER_SQUARE = 10_000;
|
||||
|
||||
export interface ContextBarModel {
|
||||
/** Nombre total de carrés. */
|
||||
total: number;
|
||||
/** Carrés remplis (au moins 1 dès que le contexte n'est pas vide). */
|
||||
filled: number;
|
||||
/** Tokens représentés par un carré (10 000, sauf si la barre est trop large pour le terminal). */
|
||||
tokensPerSquare: number;
|
||||
/** Part du contexte utilisée, de 0 à 1 (plafonnée). */
|
||||
ratio: number;
|
||||
/** vert : confortable · jaune : à surveiller · rouge : presque plein. */
|
||||
level: "ok" | "warn" | "danger";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param used tokens du contexte actuel
|
||||
* @param max taille maximale du contexte du modèle
|
||||
* @param maxSquares largeur disponible pour la barre ; au-delà, l'échelle est agrandie (1 carré = plus de 10 000 tokens)
|
||||
*/
|
||||
export function contextBarModel(used: number, max: number, maxSquares = Infinity): ContextBarModel {
|
||||
const safeMax = Math.max(1, max);
|
||||
const safeUsed = Math.max(0, used);
|
||||
let tokensPerSquare = TOKENS_PER_SQUARE;
|
||||
if (Math.ceil(safeMax / tokensPerSquare) > maxSquares) {
|
||||
// Échelle agrandie par paliers de 10 000 pour que la barre tienne dans la largeur disponible
|
||||
tokensPerSquare = Math.ceil(safeMax / Math.max(1, maxSquares) / TOKENS_PER_SQUARE) * TOKENS_PER_SQUARE;
|
||||
}
|
||||
const total = Math.max(1, Math.ceil(safeMax / tokensPerSquare));
|
||||
const ratio = Math.min(1, safeUsed / safeMax);
|
||||
const filled = safeUsed === 0 ? 0 : Math.min(total, Math.max(1, Math.round(safeUsed / tokensPerSquare)));
|
||||
const level = ratio >= 0.85 ? "danger" : ratio >= 0.6 ? "warn" : "ok";
|
||||
return { total, filled, tokensPerSquare, ratio, level };
|
||||
}
|
||||
|
||||
/** 950 → "950" · 12 345 → "12.3k" · 200 000 → "200k" · 1 048 576 → "1.05M". */
|
||||
export function formatTokens(n: number): string {
|
||||
if (n < 1_000) return String(Math.round(n));
|
||||
if (n < 1_000_000) {
|
||||
const k = n / 1_000;
|
||||
return `${k >= 100 ? Math.round(k) : Math.round(k * 10) / 10}k`;
|
||||
}
|
||||
return `${Math.round((n / 1_000_000) * 100) / 100}M`;
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@ import React from "react";
|
|||
import { render, type Instance } from "ink";
|
||||
import type { AgentUI } from "@maia/core";
|
||||
import { App } from "./components";
|
||||
import { UIStore, type Choice } from "./store";
|
||||
import { UIStore, type Choice, type PermissionFile } from "./store";
|
||||
|
||||
/**
|
||||
* Interface Ink (React pour CLI). Façade impérative au-dessus d'un UIStore : le CLI appelle des méthodes
|
||||
|
|
@ -116,4 +116,13 @@ export class InkUI implements AgentUI {
|
|||
onToolResult(name: string, output: string, isError: boolean): void {
|
||||
this.start().toolResult(name, output, isError);
|
||||
}
|
||||
/** Taille du contexte (tokens) après chaque réponse du LLM : met à jour la barre de progression. */
|
||||
onContextUsage(tokens: number): void {
|
||||
this.start().setContextUsed(tokens);
|
||||
}
|
||||
|
||||
/** Taille maximale du contexte du modèle (null : inconnue, la barre est masquée). */
|
||||
setContextMax(tokens: number | null): void {
|
||||
this.start().setContextMax(tokens);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
packages/ui/src/markdown.test.ts
Normal file
26
packages/ui/src/markdown.test.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { renderMarkdown } from "./markdown";
|
||||
|
||||
describe("renderMarkdown", () => {
|
||||
afterEach(() => {
|
||||
delete process.env.MAIA_NO_MARKDOWN;
|
||||
});
|
||||
|
||||
it("rend du markdown sans laisser les marqueurs de titre ni de gras", () => {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const plain = renderMarkdown("# Titre\n\nUn mot **important**.", 60).replace(/\u001b\[[0-9;]*m/g, "");
|
||||
expect(plain).toContain("Titre");
|
||||
expect(plain).toContain("important");
|
||||
expect(plain).not.toContain("**");
|
||||
expect(plain).not.toMatch(/^# /m);
|
||||
});
|
||||
|
||||
it("ne termine pas par un saut de ligne", () => {
|
||||
expect(renderMarkdown("Bonjour", 60).endsWith("\n")).toBe(false);
|
||||
});
|
||||
|
||||
it("renvoie le texte brut si MAIA_NO_MARKDOWN=1", () => {
|
||||
process.env.MAIA_NO_MARKDOWN = "1";
|
||||
expect(renderMarkdown("# Titre", 60)).toBe("# Titre");
|
||||
});
|
||||
});
|
||||
34
packages/ui/src/markdown.tsx
Normal file
34
packages/ui/src/markdown.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Rendu markdown des réponses de l'agent.
|
||||
//
|
||||
// Composant volontairement isolé : c'est le SEUL fichier qui connaît `markdansi`.
|
||||
// Pour le retirer : supprimer ce fichier, retirer `markdansi` de package.json et
|
||||
// remplacer `<Markdown text=… />` par `<Text>{…}</Text>` dans components.tsx (un seul endroit).
|
||||
import React from "react";
|
||||
import { Text } from "ink";
|
||||
import { render } from "markdansi";
|
||||
|
||||
/** Largeur de repli quand le terminal ne la fournit pas (sortie redirigée, etc.). */
|
||||
const DEFAULT_WIDTH = 80;
|
||||
|
||||
/**
|
||||
* Convertit du markdown en texte ANSI. Ne lève jamais d'exception :
|
||||
* en cas d'échec de markdansi (ou si MAIA_NO_MARKDOWN=1), renvoie le texte brut.
|
||||
*/
|
||||
export function renderMarkdown(text: string, width: number = terminalWidth()): string {
|
||||
if (process.env.MAIA_NO_MARKDOWN === "1") return text;
|
||||
try {
|
||||
// trimEnd : markdansi termine par un saut de ligne, qui ajouterait une ligne vide sous la réponse.
|
||||
return render(text, { width }).trimEnd();
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function terminalWidth(): number {
|
||||
const columns = process.stdout.columns;
|
||||
return typeof columns === "number" && columns > 0 ? columns : DEFAULT_WIDTH;
|
||||
}
|
||||
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
return <Text>{renderMarkdown(text)}</Text>;
|
||||
}
|
||||
38
packages/ui/src/mask-data.ts
Normal file
38
packages/ui/src/mask-data.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Fichier généré par scripts/make-mask.py à partir de assets/mask.png : ne pas modifier à la main.
|
||||
// Une chaîne par ligne de pixels ; chaque pixel = 6 chiffres hexadécimaux rrggbb, "......" = transparent.
|
||||
export const MASK_SIZE = 32;
|
||||
|
||||
export const MASK_DATA: string[] = [
|
||||
"..........................................................................................1514141d1c1c..........................................................................................",
|
||||
"................................................1515151d1a0b1c1c1c..................1b1b1b7868205f531a212121..................1a1a1a1c19091a1a1a................................................",
|
||||
"................................................222014ddbe3b5449181d1d1d............3e3613f7d442ebca3f2d2811............1818186a5c1dcbaf37161512................................................",
|
||||
"..................1a1a19171615..................433a13f7d442e6c63e403813......191919ac942ec4a835d3b5398f7b261e1e1e......564b18ebca3ff9d643312c16..................171615191919..................",
|
||||
"..................201c10baa132473e161b1b1b......594c18af972f72621fcbaf361d1c16282414f4d1417f6f239c872adebf3b1a1914242113e0c13c574b18cbaf373d3511......171716564b18ab942e0b0b0b..................",
|
||||
"..................211e14f2d041f8d5427565201a1a19534816e7c63e362f0ff9d643544917483e13ffda447f6f239c872afeda442b260c70611eeac93f352e0ff8d54337300f1f1e1a8e7b26fdd843d5b839111111..................",
|
||||
"..................151412e0c13ca48d2cf5d24267591c322b0dfeda44504516c2a6349b852a5b4e19ffda447f6f239c872affda443f3611b79d31a68e2d6c5d1df3d141161306837123ecca3faa922ec4a834141414..................",
|
||||
"..................161615c4a93562551ba38c2cedcb3f2a250bd4b6399782297a6921d5b739887525ffda44837224a08a2bffda44776720e9c83e5e511ab39a30b89e313c3410f8d5428774247e6d22a8902d191919..................",
|
||||
"..................1a1a1a9d872ad0b2383d3411f9d542b49b30d4b639e5c43da08a2bffda44ffda44ffda44eecc40f4d041ffda44ffda44ffda44968129f3d041c4a835c5a935eccb3f332c0ee5c43d806e22........................",
|
||||
"........................66591cffda445e511aa78f2dffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda448a77257a6921ffda444b4115........................",
|
||||
"........................282414eac93fcdaf37534717ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44fdd944443a13e3c23dd7b93a1f1d16........................",
|
||||
"........................222222786720ffda44f0ce40ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44f1ce41feda445c5019..............................",
|
||||
"..............................1c1a11d1b438ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44ffda44b99f32161512..............................",
|
||||
"..............................181818322b0e64581b64581b64581b64581b64581b64581b64581b64581b64581b64581b695b1de1cd45b8a63764581ba1912ff0dd4b7d6e2364581b27220b1c1c1c..............................",
|
||||
"........................1b15144a2c261f25108aa74489a74489a74489a74489a74489a74489a74489a74489a74489a7448daa46b9e15cabcf5589a744a2c551bde55e95b44a88a4431b180d4d2e281a1615........................",
|
||||
"..................1b1b1b693e368c53482b3315b6dd5b53652a4c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c264c5c26607430bbe25d2622139b5c5058342d1c1c1c..................",
|
||||
"..................18131294584c8c53482b3315abcf552a260cb79f32b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b79e31b49c312a2c11bae15d2622139b5c50834e430f0f0f..................",
|
||||
"..................171110975a4e8c53482b3315abcf5527230ba9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da9912da68f2d282b10bae15d2622139b5c50864f450a0a0a..................",
|
||||
"..................171110975a4e8c53482b3315abcf551b1b19464444241512462a242415124543431a14146f2d2d6228281d1c1c3937372c1a17462a241c110f514f4f232717bae15d2622139b5c50864f450a0a0a..................",
|
||||
"..................0e0b0b36201c351f1b2a3215abcf55393836a7a2a24127229b5c50422722a5a0a0392c2cf76363db5858413e3e89858553312b9a5c50301d19bfb9b9313524bae15d1b1c0e3a221e311d190a0a0a..................",
|
||||
"............2d17178d3939bf4d4da743432b3215abcf55393836eee6e6484545241614484444eee5e5392c2cf76363db5858413e3ee0d8d83a35352316145c5959f5ecec313524bae15d271f12ba4b4bbc4c4c823434251616............",
|
||||
"......391a1ae25b5ba643435e26265c25252b3215abcf55393836fff6f6fbf1f1e0d8d8fbf1f1e9e1e1352020fa6565e65d5d322b2bf6ededf8eeeee1d9d9fdf4f4fbf2f2313524bae15d221d10632828622828b74949d556562b1616......",
|
||||
"151313c04d4d88373742091f9e164a86133f2a3115abcf55393836fff6f6fff5f5fff5f5fff5f5847f7f803434ff6666ff66666429299f9a9afff5f5fff5f5fff5f5fbf2f2313524bae15d21191097154798154838091aa54343a24141151515",
|
||||
"301818f161612e0d14ca1c5fd81e66c31b5c2c3116abcf55393836fff6f6fff5f5fff5f5fff5f53e3c3cbb4b4bff6666ff66669f4040595757fff5f5fff5f5fff5f5fbf2f2313524bae15d2d1a16d71e66d81e66be1a5aa53645ec5f5f170e0e",
|
||||
"371717e75d5d2e0815d81e66d81e66c81c5f2d2e16b0d558373833fff6f6fff5f5fff5f5cdc6c6413f3f260f0f5d25255d25251c0b0b525050d9d1d1fff5f5fff5f5f6eded303621b8de5b2f1617d81e66d81e66d71e66e74363f964641c0c0c",
|
||||
"221717e85d5d481c1d931445d81e66d21d63281a13b7de5b2f3421f3eaeafff5f5c9c2c237311ba48e2cbfa433bfa433bfa433bea433968128322f22ddd5d5fff5f5d9d1d1333d1ba6c9532f0a16d81e66d81e667e113b7d3133ce5353151313",
|
||||
"1918187c3232d7565640181b3a081b32091712080898b84c435121bab4b4fff5f5676464ae962fceb037ad942ead942ead942ead942ed7b83a887525837f7ffff5f59e9999586b2c839e4116080936081937081a4e1f20e45c5c5e2626......",
|
||||
"......151111823535ec5f5fd45555df5a5a8034346378317f9a3f5f5c5cfff5f55b5958c0a533a28b2c6e5e1e6e5e1e6e5e1e6e5e1eb299309a8429787474fcf3f345444294b44a4e5e279a3f3fda5858d85757e85d5d6c2c2c161515......",
|
||||
"............1b1b1b2817174a1e1e431c1c1f1616242a15afd457333c1dbdb7b7a7a1a14c4216e2c33dfdd843fdd843fdd843fcd843d4b639383114c1bbbba29d9d424f21a8cb53181b11221515461d1d471d1d2316161e1e1e............",
|
||||
"..........................................1f1f1f62773197b74b2a2f1a303424181c0d21240e3132123132123132123132121d200d1b1f103135252e361ba4c7524d5d26262626..........................................",
|
||||
"................................................181a138aa845bbe35dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe25dbbe35d78923c171815................................................",
|
||||
"......................................................14160f181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c181d0c151712......................................................",
|
||||
];
|
||||
|
|
@ -45,15 +45,24 @@ describe("pixelsToRuns", () => {
|
|||
});
|
||||
|
||||
describe("masque", () => {
|
||||
it("fait 20 × 20 pixels, que des couleurs de la palette ou du transparent", () => {
|
||||
it("fait 32 × 32 pixels, soit 16 lignes de terminal", () => {
|
||||
const grid = buildMaskGrid();
|
||||
expect(grid).toHaveLength(20);
|
||||
expect(MASK_WIDTH).toBe(32);
|
||||
expect(grid).toHaveLength(MASK_WIDTH);
|
||||
for (const row of grid) expect(row).toHaveLength(MASK_WIDTH);
|
||||
expect(pixelsToRuns(grid)).toHaveLength(10); // 10 lignes de terminal
|
||||
expect(pixelsToRuns(grid)).toHaveLength(16);
|
||||
});
|
||||
|
||||
it("est symétrique gauche/droite", () => {
|
||||
for (const row of buildMaskGrid()) expect([...row].reverse()).toEqual(row);
|
||||
it("n'a que des couleurs #rrggbb ou du transparent, avec fond transparent et image au centre", () => {
|
||||
const grid = buildMaskGrid();
|
||||
for (const row of grid) for (const p of row) expect(p === null || /^#[0-9a-f]{6}$/.test(p)).toBe(true);
|
||||
expect(grid[0]![0]).toBeNull();
|
||||
expect(grid[16]![16]).not.toBeNull();
|
||||
});
|
||||
|
||||
it("n'est pas pixelisé en aplats : beaucoup de couleurs différentes (couleurs réelles)", () => {
|
||||
const colors = new Set(buildMaskGrid().flat().filter((p) => p !== null));
|
||||
expect(colors.size).toBeGreaterThan(30);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Pixel-art pour le terminal, sans dépendance à React : chaque caractère « demi-bloc » (▀ ▄ █) affiche
|
||||
// deux pixels superposés, ce qui donne des pixels carrés (une cellule de terminal est ~2 fois plus haute que large).
|
||||
import { hslToHex } from "./colors";
|
||||
import { MASK_DATA, MASK_SIZE } from "./mask-data";
|
||||
|
||||
/** Couleur "#rrggbb", ou null pour un pixel transparent. */
|
||||
export type Pixel = string | null;
|
||||
|
|
@ -49,45 +50,22 @@ export function pixelsToRuns(grid: Pixel[][]): Run[][] {
|
|||
return lines;
|
||||
}
|
||||
|
||||
// ───────────── Masque (20 × 20 pixels → 10 lignes de terminal) ─────────────
|
||||
// ───────────── Masque : image affichée telle quelle (couleurs réelles, sans pixelisation) ─────────────
|
||||
// Les pixels viennent de mask-data.ts, généré depuis assets/mask.png par scripts/make-mask.py.
|
||||
|
||||
const PALETTE: Record<string, string> = {
|
||||
Y: "#F7C61B", // jaune (couronne, mâchoire)
|
||||
O: "#E8962A", // orange (bandeau)
|
||||
G: "#3DDC4B", // vert (contour du visage)
|
||||
K: "#111418", // noir (traits)
|
||||
W: "#F6E7D2", // crème (visage)
|
||||
R: "#E0364F", // rouge (nez, oreilles)
|
||||
B: "#8A4B2D", // brun (yeux, côtés)
|
||||
};
|
||||
|
||||
const MASK = [
|
||||
".........YY.........",
|
||||
"...Y.....YY.....Y...",
|
||||
"...YY...YYYY...YY...",
|
||||
"..YYY...YYYY...YYY..",
|
||||
"..YYYY.YYYYYY.YYYY..",
|
||||
"..YYYYYYYYYYYYYYYY..",
|
||||
"...OOOOOOOOOOOOOO...",
|
||||
"...GGGGGGGGGGGGGG...",
|
||||
".BBGKKKKKKKKKKKKGBB.",
|
||||
".BBGKYYYYYYYYYYKGBB.",
|
||||
".BBGKWWWWWWWWWWKGBB.",
|
||||
"..RGKWBBWWWWBBWKGR..",
|
||||
"RRRGKWBBWWWWBBWKGRRR",
|
||||
"RRRGKWWWWRRWWWWKGRRR",
|
||||
"RRRGKWWWRRRRWWWKGRRR",
|
||||
"RRRGKWWWWRRWWWWKGRRR",
|
||||
".RRGKWWYYYYYYWWKGRR.",
|
||||
"..RGKWWWYYYYWWWKGR..",
|
||||
"...GKKKKKKKKKKKKG...",
|
||||
"...GGGGGGGGGGGGGG...",
|
||||
];
|
||||
|
||||
export const MASK_WIDTH = 20;
|
||||
/** Largeur du masque en colonnes (= nombre de pixels par ligne ; 1 pixel = 1 colonne de terminal). */
|
||||
export const MASK_WIDTH = MASK_SIZE;
|
||||
|
||||
/** Décode les lignes de mask-data.ts : 6 chiffres hexadécimaux par pixel, "......" = transparent. */
|
||||
export function buildMaskGrid(): Pixel[][] {
|
||||
return MASK.map((row) => [...row].map((ch) => PALETTE[ch] ?? null));
|
||||
return MASK_DATA.map((row) => {
|
||||
const pixels: Pixel[] = [];
|
||||
for (let i = 0; i + 6 <= row.length; i += 6) {
|
||||
const hex = row.slice(i, i + 6);
|
||||
pixels.push(hex === "......" ? null : `#${hex}`);
|
||||
}
|
||||
return pixels;
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────── Titre en gros caractères (police 5 × 7) ─────────────
|
||||
|
|
|
|||
|
|
@ -120,4 +120,16 @@ describe("UIStore", () => {
|
|||
expect(ko).toMatchObject({ detail: "(aucune sortie)\n[code de sortie : 1]", failed: true });
|
||||
expect(big?.detail).toContain("caractères omis à l'affichage");
|
||||
});
|
||||
|
||||
it("contexte : maximum inconnu au départ (barre masquée), puis usage et maximum renseignés", () => {
|
||||
const s = new UIStore();
|
||||
expect(s.getSnapshot()).toMatchObject({ contextUsed: 0, contextMax: null });
|
||||
s.setContextMax(200_000);
|
||||
s.setContextUsed(45_678.4);
|
||||
expect(s.getSnapshot()).toMatchObject({ contextUsed: 45_678, contextMax: 200_000 });
|
||||
s.setContextMax(null);
|
||||
expect(s.getSnapshot().contextMax).toBeNull();
|
||||
s.setContextMax(0);
|
||||
expect(s.getSnapshot().contextMax).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -74,13 +74,17 @@ export interface UIState {
|
|||
/** Libellé du spinner, ou null. */
|
||||
busy: string | null;
|
||||
prompt: PromptState | null;
|
||||
/** Tokens du contexte de la conversation (mis à jour après chaque réponse du LLM). */
|
||||
contextUsed: number;
|
||||
/** Taille maximale du contexte du modèle, null tant qu'elle n'est pas connue (la barre est alors masquée). */
|
||||
contextMax: number | null;
|
||||
}
|
||||
|
||||
const RESULT_PREVIEW_MAX = 600;
|
||||
const SHELL_PREVIEW_MAX = 4000;
|
||||
|
||||
export class UIStore {
|
||||
private state: UIState = { items: [], streaming: "", busy: null, prompt: null };
|
||||
private state: UIState = { items: [], streaming: "", busy: null, prompt: null, contextUsed: 0, contextMax: null };
|
||||
private listeners = new Set<() => void>();
|
||||
private nextId = 1;
|
||||
private history: string[] = [];
|
||||
|
|
@ -136,6 +140,16 @@ export class UIStore {
|
|||
this.commit({ items: this.withItem("permissions", "Permissions", { permissions: files }) });
|
||||
}
|
||||
|
||||
/** Contexte actuel de la conversation, en tokens. */
|
||||
setContextUsed(tokens: number): void {
|
||||
this.commit({ contextUsed: Math.max(0, Math.round(tokens)) });
|
||||
}
|
||||
|
||||
/** Taille maximale du contexte du modèle (null : inconnue, barre masquée). */
|
||||
setContextMax(tokens: number | null): void {
|
||||
this.commit({ contextMax: tokens !== null && tokens > 0 ? Math.round(tokens) : null });
|
||||
}
|
||||
|
||||
setBusy(label: string | null): void {
|
||||
this.commit({ busy: label });
|
||||
}
|
||||
|
|
|
|||
570
pnpm-lock.yaml
570
pnpm-lock.yaml
|
|
@ -111,6 +111,9 @@ importers:
|
|||
ink:
|
||||
specifier: ^7.0.3
|
||||
version: 7.1.1(@types/react@19.3.0)(react@19.3.1)
|
||||
markdansi:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.1
|
||||
react:
|
||||
specifier: ^19.2.0
|
||||
version: 19.3.1
|
||||
|
|
@ -728,9 +731,18 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||
|
||||
'@types/ms@2.1.0':
|
||||
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
|
||||
|
||||
'@types/node@22.20.5':
|
||||
resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==}
|
||||
|
||||
|
|
@ -743,6 +755,9 @@ packages:
|
|||
'@types/turndown@5.0.6':
|
||||
resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==}
|
||||
|
||||
|
|
@ -810,6 +825,9 @@ packages:
|
|||
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ccount@2.0.1:
|
||||
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
|
||||
|
||||
chai@5.3.3:
|
||||
resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -818,6 +836,9 @@ packages:
|
|||
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
|
||||
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
|
||||
|
||||
character-entities@2.0.2:
|
||||
resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==}
|
||||
|
||||
check-error@2.1.3:
|
||||
resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==}
|
||||
engines: {node: '>= 16'}
|
||||
|
|
@ -877,6 +898,9 @@ packages:
|
|||
supports-color:
|
||||
optional: true
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
|
||||
|
||||
deep-eql@5.0.2:
|
||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -885,6 +909,13 @@ packages:
|
|||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
devlop@1.1.0:
|
||||
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
|
||||
|
||||
environment@1.1.0:
|
||||
resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -914,6 +945,10 @@ packages:
|
|||
resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
escape-string-regexp@5.0.0:
|
||||
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
|
|
@ -946,6 +981,10 @@ packages:
|
|||
resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
has-flag@5.0.1:
|
||||
resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ignore@6.0.2:
|
||||
resolution: {integrity: sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A==}
|
||||
engines: {node: '>= 4'}
|
||||
|
|
@ -995,12 +1034,143 @@ packages:
|
|||
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
loupe@3.2.1:
|
||||
resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
markdansi@0.2.1:
|
||||
resolution: {integrity: sha512-ri9ce2cmpcbL0A0FJ5a3WtIxMBRE8fPNEnSAz1CZGCfZ7MazmBEGmreOpBhXiaQZ31FXT+GYUi+86bxYpCTo8w==}
|
||||
engines: {node: '>=22'}
|
||||
hasBin: true
|
||||
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
mdast-util-find-and-replace@3.0.3:
|
||||
resolution: {integrity: sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==}
|
||||
|
||||
mdast-util-from-markdown@2.1.0:
|
||||
resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==}
|
||||
|
||||
mdast-util-gfm-autolink-literal@2.0.1:
|
||||
resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==}
|
||||
|
||||
mdast-util-gfm-footnote@2.1.0:
|
||||
resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==}
|
||||
|
||||
mdast-util-gfm-strikethrough@2.0.1:
|
||||
resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==}
|
||||
|
||||
mdast-util-gfm-table@2.0.0:
|
||||
resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==}
|
||||
|
||||
mdast-util-gfm-task-list-item@2.0.0:
|
||||
resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==}
|
||||
|
||||
mdast-util-gfm@3.1.0:
|
||||
resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==}
|
||||
|
||||
mdast-util-phrasing@4.1.0:
|
||||
resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==}
|
||||
|
||||
mdast-util-to-markdown@2.2.0:
|
||||
resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==}
|
||||
|
||||
mdast-util-to-string@4.0.0:
|
||||
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
|
||||
|
||||
micromark-core-commonmark@2.0.4:
|
||||
resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==}
|
||||
|
||||
micromark-extension-gfm-autolink-literal@2.1.0:
|
||||
resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==}
|
||||
|
||||
micromark-extension-gfm-footnote@2.1.0:
|
||||
resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==}
|
||||
|
||||
micromark-extension-gfm-strikethrough@2.1.0:
|
||||
resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==}
|
||||
|
||||
micromark-extension-gfm-table@2.1.2:
|
||||
resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==}
|
||||
|
||||
micromark-extension-gfm-tagfilter@2.0.0:
|
||||
resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==}
|
||||
|
||||
micromark-extension-gfm-task-list-item@2.1.0:
|
||||
resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==}
|
||||
|
||||
micromark-extension-gfm@3.0.0:
|
||||
resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==}
|
||||
|
||||
micromark-factory-destination@2.0.1:
|
||||
resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==}
|
||||
|
||||
micromark-factory-label@2.0.1:
|
||||
resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==}
|
||||
|
||||
micromark-factory-space@2.1.0:
|
||||
resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==}
|
||||
|
||||
micromark-factory-title@2.0.1:
|
||||
resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==}
|
||||
|
||||
micromark-factory-whitespace@2.0.1:
|
||||
resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==}
|
||||
|
||||
micromark-util-character@2.1.1:
|
||||
resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==}
|
||||
|
||||
micromark-util-chunked@2.0.1:
|
||||
resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==}
|
||||
|
||||
micromark-util-classify-character@2.0.1:
|
||||
resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==}
|
||||
|
||||
micromark-util-combine-extensions@2.0.1:
|
||||
resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==}
|
||||
|
||||
micromark-util-decode-numeric-character-reference@2.0.2:
|
||||
resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==}
|
||||
|
||||
micromark-util-decode-string@2.0.1:
|
||||
resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==}
|
||||
|
||||
micromark-util-edit-map@1.0.0:
|
||||
resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==}
|
||||
|
||||
micromark-util-encode@2.0.1:
|
||||
resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==}
|
||||
|
||||
micromark-util-html-tag-name@2.0.1:
|
||||
resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==}
|
||||
|
||||
micromark-util-normalize-identifier@2.0.1:
|
||||
resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==}
|
||||
|
||||
micromark-util-resolve-all@2.0.1:
|
||||
resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==}
|
||||
|
||||
micromark-util-sanitize-uri@2.0.1:
|
||||
resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==}
|
||||
|
||||
micromark-util-subtokenize@2.1.0:
|
||||
resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==}
|
||||
|
||||
micromark-util-symbol@2.0.1:
|
||||
resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==}
|
||||
|
||||
micromark-util-types@2.0.3:
|
||||
resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==}
|
||||
|
||||
micromark@4.0.3:
|
||||
resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==}
|
||||
|
||||
mimic-fn@2.1.0:
|
||||
resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -1152,6 +1322,14 @@ packages:
|
|||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
supports-color@10.2.2:
|
||||
resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
supports-hyperlinks@4.6.0:
|
||||
resolution: {integrity: sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tagged-tag@1.0.0:
|
||||
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
|
||||
engines: {node: '>=20'}
|
||||
|
|
@ -1239,6 +1417,18 @@ packages:
|
|||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
|
||||
|
||||
unist-util-stringify-position@4.0.0:
|
||||
resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==}
|
||||
|
||||
unist-util-visit-parents@6.0.2:
|
||||
resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==}
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==}
|
||||
|
||||
vite-node@2.1.9:
|
||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
|
|
@ -1328,6 +1518,9 @@ packages:
|
|||
yoga-layout@3.2.1:
|
||||
resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==}
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alcalzone/ansi-tokenize@0.3.1':
|
||||
|
|
@ -1662,8 +1855,18 @@ snapshots:
|
|||
'@rollup/rollup-win32-x64-msvc@4.64.6':
|
||||
optional: true
|
||||
|
||||
'@types/debug@4.1.13':
|
||||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/mdast@4.0.4':
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
'@types/ms@2.1.0': {}
|
||||
|
||||
'@types/node@22.20.5':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
|
@ -1676,6 +1879,8 @@ snapshots:
|
|||
|
||||
'@types/turndown@5.0.6': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@vitest/expect@2.1.9':
|
||||
dependencies:
|
||||
'@vitest/spy': 2.1.9
|
||||
|
|
@ -1739,6 +1944,8 @@ snapshots:
|
|||
|
||||
cac@6.7.14: {}
|
||||
|
||||
ccount@2.0.1: {}
|
||||
|
||||
chai@5.3.3:
|
||||
dependencies:
|
||||
assertion-error: 2.0.1
|
||||
|
|
@ -1749,6 +1956,8 @@ snapshots:
|
|||
|
||||
chalk@5.6.2: {}
|
||||
|
||||
character-entities@2.0.2: {}
|
||||
|
||||
check-error@2.1.3: {}
|
||||
|
||||
chokidar@4.0.3:
|
||||
|
|
@ -1788,10 +1997,20 @@ snapshots:
|
|||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decode-named-character-reference@1.3.0:
|
||||
dependencies:
|
||||
character-entities: 2.0.2
|
||||
|
||||
deep-eql@5.0.2: {}
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
devlop@1.1.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
environment@1.1.0: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
|
@ -1884,6 +2103,8 @@ snapshots:
|
|||
|
||||
escape-string-regexp@2.0.0: {}
|
||||
|
||||
escape-string-regexp@5.0.0: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
|
@ -1909,6 +2130,8 @@ snapshots:
|
|||
|
||||
get-east-asian-width@1.7.0: {}
|
||||
|
||||
has-flag@5.0.1: {}
|
||||
|
||||
ignore@6.0.2: {}
|
||||
|
||||
indent-string@5.0.0: {}
|
||||
|
|
@ -1963,12 +2186,331 @@ snapshots:
|
|||
|
||||
load-tsconfig@0.2.5: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loupe@3.2.1: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.6.0
|
||||
|
||||
markdansi@0.2.1:
|
||||
dependencies:
|
||||
chalk: 5.6.2
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-gfm: 3.1.0
|
||||
micromark: 4.0.3
|
||||
micromark-extension-gfm: 3.0.0
|
||||
micromark-util-combine-extensions: 2.0.1
|
||||
string-width: 8.3.0
|
||||
strip-ansi: 7.2.0
|
||||
supports-hyperlinks: 4.6.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
mdast-util-find-and-replace@3.0.3:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
escape-string-regexp: 5.0.0
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
mdast-util-from-markdown@2.1.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 3.0.3
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
mdast-util-to-string: 4.0.0
|
||||
micromark: 4.0.3
|
||||
micromark-util-decode-numeric-character-reference: 2.0.2
|
||||
micromark-util-decode-string: 2.0.1
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
unist-util-stringify-position: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-autolink-literal@2.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
ccount: 2.0.1
|
||||
devlop: 1.1.0
|
||||
mdast-util-find-and-replace: 3.0.3
|
||||
micromark-util-character: 2.1.1
|
||||
|
||||
mdast-util-gfm-footnote@2.1.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-strikethrough@2.0.1:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-table@2.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
markdown-table: 3.0.4
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm-task-list-item@2.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
devlop: 1.1.0
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-gfm@3.1.0:
|
||||
dependencies:
|
||||
mdast-util-from-markdown: 2.1.0
|
||||
mdast-util-gfm-autolink-literal: 2.0.1
|
||||
mdast-util-gfm-footnote: 2.1.0
|
||||
mdast-util-gfm-strikethrough: 2.0.1
|
||||
mdast-util-gfm-table: 2.0.0
|
||||
mdast-util-gfm-task-list-item: 2.0.0
|
||||
mdast-util-to-markdown: 2.2.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mdast-util-phrasing@4.1.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
mdast-util-to-markdown@2.2.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
'@types/unist': 3.0.3
|
||||
longest-streak: 3.1.0
|
||||
mdast-util-phrasing: 4.1.0
|
||||
mdast-util-to-string: 4.0.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-classify-character: 2.0.1
|
||||
micromark-util-decode-string: 2.0.1
|
||||
micromark-util-html-tag-name: 2.0.1
|
||||
unist-util-visit: 5.1.0
|
||||
zwitch: 2.0.4
|
||||
|
||||
mdast-util-to-string@4.0.0:
|
||||
dependencies:
|
||||
'@types/mdast': 4.0.4
|
||||
|
||||
micromark-core-commonmark@2.0.4:
|
||||
dependencies:
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-factory-destination: 2.0.1
|
||||
micromark-factory-label: 2.0.1
|
||||
micromark-factory-space: 2.1.0
|
||||
micromark-factory-title: 2.0.1
|
||||
micromark-factory-whitespace: 2.0.1
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-chunked: 2.0.1
|
||||
micromark-util-classify-character: 2.0.1
|
||||
micromark-util-edit-map: 1.0.0
|
||||
micromark-util-html-tag-name: 2.0.1
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
micromark-util-resolve-all: 2.0.1
|
||||
micromark-util-subtokenize: 2.1.0
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-extension-gfm-autolink-literal@2.1.0:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-extension-gfm-footnote@2.1.0:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.4
|
||||
micromark-factory-space: 2.1.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-extension-gfm-strikethrough@2.1.0:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-util-chunked: 2.0.1
|
||||
micromark-util-classify-character: 2.0.1
|
||||
micromark-util-resolve-all: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-extension-gfm-table@2.1.2:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-factory-space: 2.1.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-extension-gfm-tagfilter@2.0.0:
|
||||
dependencies:
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-extension-gfm-task-list-item@2.1.0:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-factory-space: 2.1.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-extension-gfm@3.0.0:
|
||||
dependencies:
|
||||
micromark-extension-gfm-autolink-literal: 2.1.0
|
||||
micromark-extension-gfm-footnote: 2.1.0
|
||||
micromark-extension-gfm-strikethrough: 2.1.0
|
||||
micromark-extension-gfm-table: 2.1.2
|
||||
micromark-extension-gfm-tagfilter: 2.0.0
|
||||
micromark-extension-gfm-task-list-item: 2.1.0
|
||||
micromark-util-combine-extensions: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-factory-destination@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-factory-label@2.0.1:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-factory-space@2.1.0:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-factory-title@2.0.1:
|
||||
dependencies:
|
||||
micromark-factory-space: 2.1.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-factory-whitespace@2.0.1:
|
||||
dependencies:
|
||||
micromark-factory-space: 2.1.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-util-character@2.1.1:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-util-chunked@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
micromark-util-classify-character@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-util-combine-extensions@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-chunked: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-util-decode-numeric-character-reference@2.0.2:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
micromark-util-decode-string@2.0.1:
|
||||
dependencies:
|
||||
decode-named-character-reference: 1.3.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-decode-numeric-character-reference: 2.0.2
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
micromark-util-edit-map@1.0.0:
|
||||
dependencies:
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-util-encode@2.0.1: {}
|
||||
|
||||
micromark-util-html-tag-name@2.0.1: {}
|
||||
|
||||
micromark-util-normalize-identifier@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
micromark-util-resolve-all@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-util-sanitize-uri@2.0.1:
|
||||
dependencies:
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-encode: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
|
||||
micromark-util-subtokenize@2.1.0:
|
||||
dependencies:
|
||||
devlop: 1.1.0
|
||||
micromark-util-chunked: 2.0.1
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
|
||||
micromark-util-symbol@2.0.1: {}
|
||||
|
||||
micromark-util-types@2.0.3: {}
|
||||
|
||||
micromark@4.0.3:
|
||||
dependencies:
|
||||
'@types/debug': 4.1.13
|
||||
debug: 4.4.3
|
||||
decode-named-character-reference: 1.3.0
|
||||
devlop: 1.1.0
|
||||
micromark-core-commonmark: 2.0.4
|
||||
micromark-factory-space: 2.1.0
|
||||
micromark-util-character: 2.1.1
|
||||
micromark-util-chunked: 2.0.1
|
||||
micromark-util-combine-extensions: 2.0.1
|
||||
micromark-util-decode-numeric-character-reference: 2.0.2
|
||||
micromark-util-edit-map: 1.0.0
|
||||
micromark-util-encode: 2.0.1
|
||||
micromark-util-normalize-identifier: 2.0.1
|
||||
micromark-util-resolve-all: 2.0.1
|
||||
micromark-util-sanitize-uri: 2.0.1
|
||||
micromark-util-subtokenize: 2.1.0
|
||||
micromark-util-symbol: 2.0.1
|
||||
micromark-util-types: 2.0.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
mimic-fn@2.1.0: {}
|
||||
|
||||
mlly@1.8.2:
|
||||
|
|
@ -2119,6 +2661,13 @@ snapshots:
|
|||
tinyglobby: 0.2.17
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
supports-color@10.2.2: {}
|
||||
|
||||
supports-hyperlinks@4.6.0:
|
||||
dependencies:
|
||||
has-flag: 5.0.1
|
||||
supports-color: 10.2.2
|
||||
|
||||
tagged-tag@1.0.0: {}
|
||||
|
||||
terminal-size@4.0.1: {}
|
||||
|
|
@ -2198,6 +2747,25 @@ snapshots:
|
|||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
unist-util-is@6.0.1:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-stringify-position@4.0.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
|
||||
unist-util-visit-parents@6.0.2:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
|
||||
unist-util-visit@5.1.0:
|
||||
dependencies:
|
||||
'@types/unist': 3.0.3
|
||||
unist-util-is: 6.0.1
|
||||
unist-util-visit-parents: 6.0.2
|
||||
|
||||
vite-node@2.1.9(@types/node@22.20.5):
|
||||
dependencies:
|
||||
cac: 6.7.14
|
||||
|
|
@ -2277,3 +2845,5 @@ snapshots:
|
|||
ws@8.22.0: {}
|
||||
|
||||
yoga-layout@3.2.1: {}
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
|
|
|||
Loading…
Reference in a new issue