Initial CodeCraft: web-based coding harness for AI models
Rust backend (axum/tokio): agent loop with streaming, tools (fs, bash, web_fetch, todo, ask_user, subagents, skills, craft snapshots/compare), permissions with modes and rules, Anthropic + OpenAI-compatible providers, MCP client (stdio/HTTP, elicitation), SQLite sessions, usage statistics, compaction, commented TOML config editable via toml_edit. React PWA: chat with live streaming, permission/question cards, modes (build/plan/craft), settings for providers/models/prices/MCP/permissions, raw config editor, statistics dashboard, i18n (en/de). Docker setup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
f3a2fe3816
62 files changed
+21835
No files matched your search
@@ -0,0 +1,228 @@
|
||||
import type { Messages } from "./en";
|
||||
|
||||
export const de: Messages = {
|
||||
"common.loading": "Lädt…",
|
||||
"common.save": "Speichern",
|
||||
"common.add": "Hinzufügen",
|
||||
"common.edit": "Bearbeiten",
|
||||
"common.delete": "Löschen",
|
||||
"common.remove": "Entfernen",
|
||||
"common.close": "Schließen",
|
||||
"common.collapse": "Einklappen",
|
||||
|
||||
"nav.menu": "Menü",
|
||||
"nav.settings": "Einstellungen",
|
||||
"nav.stats": "Statistik",
|
||||
|
||||
"login.prompt": "Gib das auf dem Server konfigurierte Zugriffstoken ein.",
|
||||
"login.token": "Zugriffstoken",
|
||||
"login.invalid": "Ungültiges Token.",
|
||||
"login.submit": "Anmelden",
|
||||
|
||||
"sidebar.newSession": "Neue Session",
|
||||
"sidebar.noSessions": "Noch keine Sessions",
|
||||
"sidebar.today": "Heute",
|
||||
"sidebar.yesterday": "Gestern",
|
||||
"sidebar.week": "Letzte 7 Tage",
|
||||
"sidebar.older": "Älter",
|
||||
"sidebar.connected": "Verbunden",
|
||||
"sidebar.disconnected": "Getrennt – verbinde neu…",
|
||||
"sidebar.needsInput": "Wartet auf deine Eingabe",
|
||||
|
||||
"session.untitled": "Neue Session",
|
||||
|
||||
"new.title": "Neue Session",
|
||||
"new.directory": "Projektverzeichnis",
|
||||
"new.up": "Hoch",
|
||||
"new.noSubdirs": "Keine Unterverzeichnisse",
|
||||
"new.model": "Modell",
|
||||
"new.mode": "Modus",
|
||||
"new.firstPrompt": "Was soll erledigt werden? (optional)",
|
||||
"new.start": "Starten in {dir}",
|
||||
|
||||
"chat.empty": "Schreib eine Nachricht, um zu starten.",
|
||||
"chat.reasoning": "Überlegungen",
|
||||
"chat.thinking": "Arbeitet…",
|
||||
"chat.compacted": "Unterhaltung komprimiert – Zusammenfassung",
|
||||
"chat.context": "Kontext",
|
||||
"chat.contextHint": "{tokens} Tokens in der letzten Anfrage",
|
||||
"chat.statsHint": "Tokens und Kosten dieser Session (Klick für Details)",
|
||||
"chat.renameHint": "Doppelklick zum Umbenennen",
|
||||
"chat.rewind": "Ab hier bearbeiten und neu senden",
|
||||
"chat.rewindConfirm": "Diese Nachricht und alles danach entfernen? Dateiänderungen werden nicht rückgängig gemacht.",
|
||||
"chat.delete": "Session löschen",
|
||||
"chat.deleteConfirm": "Diese Session inklusive Subagenten löschen? Die Statistik bleibt erhalten.",
|
||||
|
||||
"composer.placeholder": "Nachricht… (/ für Befehle, Bilder einfügen)",
|
||||
"composer.placeholderBusy": "Nachricht wird eingereiht und beim nächsten Schritt gesendet…",
|
||||
"composer.send": "Senden",
|
||||
"composer.queue": "Einreihen",
|
||||
"composer.stop": "Stopp",
|
||||
"composer.mode": "Modus",
|
||||
"composer.model": "Modell",
|
||||
"composer.permissionMode": "Berechtigungsmodus",
|
||||
"composer.attach": "Bilder anhängen",
|
||||
|
||||
"command.compact": "Unterhaltung zusammenfassen, um Kontext freizugeben",
|
||||
"command.new": "Neue Session starten",
|
||||
|
||||
"permissionMode.default": "Vor Änderungen fragen",
|
||||
"permissionMode.accept_edits": "Änderungen automatisch annehmen",
|
||||
"permissionMode.bypass": "Alles erlauben",
|
||||
|
||||
"plan.implement": "Zu Build wechseln und Plan umsetzen",
|
||||
"plan.implementPrompt": "Setze den Plan um.",
|
||||
|
||||
"tool.input": "Eingabe",
|
||||
"tool.output": "Ausgabe",
|
||||
"tool.todos": "Aufgaben",
|
||||
"tool.openSubagent": "Subagent-Session öffnen",
|
||||
|
||||
"interaction.fromSubagent": "Vom Subagenten:",
|
||||
|
||||
"permission.title": "Freigabe erforderlich",
|
||||
"permission.allowOnce": "Einmal erlauben",
|
||||
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
|
||||
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
|
||||
"permission.reject": "Ablehnen",
|
||||
"permission.rejectWithFeedback": "Mit Rückmeldung ablehnen",
|
||||
"permission.feedback": "Optional: Sag dem Agenten, was er stattdessen tun soll",
|
||||
|
||||
"question.title": "Frage an dich",
|
||||
"question.fromAgent": "Der Agent braucht eine Entscheidung",
|
||||
"question.fromMcp": "MCP-Server „{server}“ fragt",
|
||||
"question.other": "Andere Antwort (Freitext)",
|
||||
"question.answer": "Deine Antwort",
|
||||
"question.submit": "Antworten",
|
||||
"question.dismiss": "Verwerfen",
|
||||
|
||||
"settings.tab.general": "Allgemein",
|
||||
"settings.tab.providers": "Provider & Modelle",
|
||||
"settings.tab.mcp": "MCP",
|
||||
"settings.tab.permissions": "Berechtigungen",
|
||||
"settings.tab.agents": "Agenten & Befehle",
|
||||
"settings.tab.raw": "Config-Datei",
|
||||
"settings.saved": "Gespeichert – Änderungen sind aktiv.",
|
||||
"settings.interface": "Oberfläche (dieses Gerät)",
|
||||
"settings.language": "Sprache",
|
||||
"settings.theme": "Design",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeDark": "Dunkel",
|
||||
"settings.themeLight": "Hell",
|
||||
"settings.notifications": "Benachrichtigungen",
|
||||
"settings.notificationsEnable": "Aktivieren",
|
||||
"settings.notificationsOn": "Aktiv",
|
||||
"settings.notificationsHint": "Benachrichtigen, wenn ein Agent eine Frage stellt, während die App im Hintergrund ist.",
|
||||
"settings.server": "Server",
|
||||
"settings.workspace": "Workspace",
|
||||
"settings.defaultModel": "Standardmodell",
|
||||
"settings.smallModel": "Kleines Modell",
|
||||
"settings.smallModelHint": "Für Session-Titel. Die Komprimierung nutzt das Session-Modell.",
|
||||
"settings.currency": "Währung",
|
||||
"settings.autoCompact": "Auto-Komprimierung bei",
|
||||
"settings.autoCompactHint": "Anteil des Kontextfensters (0 = aus).",
|
||||
"settings.craft": "Craft-Modus",
|
||||
"settings.cleanRoom": "Clean Room",
|
||||
"settings.cleanRoomHint": "Nur nutzen, was ein Besucher sieht. Aus = Deep-Modus (Quelltext und Skripte dürfen gelesen werden).",
|
||||
"settings.chromium": "Chromium-Programm",
|
||||
"settings.widths": "Bildschirmbreiten (px)",
|
||||
"settings.kind": "Typ",
|
||||
"settings.baseUrl": "Basis-URL",
|
||||
"settings.apiKey": "API-Key",
|
||||
"settings.apiKeyStored": "Gespeichert – leer lassen zum Beibehalten",
|
||||
"settings.apiKeyHint": "Am besten ${ENV_VAR}-Verweise nutzen; direkte Keys stehen im Klartext in der Config-Datei.",
|
||||
"settings.authPrefix": "Auth-Präfix",
|
||||
"settings.enabled": "Aktiv",
|
||||
"settings.modelCount": "{n} Modelle",
|
||||
"settings.models": "Modelle & Preise",
|
||||
"settings.addModel": "Modell",
|
||||
"settings.addDiscovered": "{n} gefundene hinzufügen",
|
||||
"settings.pricesHint": "Preise pro 1 Mio. Tokens in der eingestellten Währung. Cache-Preise fallen auf den Input-Preis zurück.",
|
||||
"settings.modelsEmptyHint": "Keine Modelle eingetragen: Sie werden automatisch beim Provider abgefragt.",
|
||||
"settings.context": "Kontext",
|
||||
"settings.maxOutput": "Max. Ausgabe",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Bilder",
|
||||
"settings.effort": "Effort",
|
||||
"settings.priceIn": "Input",
|
||||
"settings.priceOut": "Output",
|
||||
"settings.priceCacheRead": "Cache lesen",
|
||||
"settings.priceCacheWrite": "Cache schreiben",
|
||||
"settings.addProvider": "Provider hinzufügen",
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.reloadProviders": "Provider & Modelle neu laden",
|
||||
"settings.deleteProviderConfirm": "Provider „{name}“ löschen?",
|
||||
"settings.invalidName": "Namen dürfen nur Buchstaben, Ziffern, - und _ enthalten.",
|
||||
"settings.name": "Name",
|
||||
"settings.addMcp": "MCP-Server hinzufügen",
|
||||
"settings.deleteMcpConfirm": "MCP-Server „{name}“ löschen?",
|
||||
"settings.rawHint": "Die komplette Konfigurationsdatei inklusive Kommentaren. Sie wird vor dem Speichern geprüft.",
|
||||
"settings.revert": "Zurücksetzen",
|
||||
|
||||
"mcp.reconnect": "Alle neu verbinden",
|
||||
"mcp.none": "Keine MCP-Server konfiguriert.",
|
||||
"mcp.tools": "{n} Tools",
|
||||
"mcp.command": "Befehl",
|
||||
"mcp.args": "Argumente (eins pro Zeile)",
|
||||
"mcp.env": "Umgebung (KEY=Wert)",
|
||||
"mcp.headers": "Header (Name: Wert)",
|
||||
"mcp.state.disabled": "deaktiviert",
|
||||
"mcp.state.connecting": "verbinde…",
|
||||
"mcp.state.connected": "verbunden",
|
||||
"mcp.state.failed": "Fehler",
|
||||
"mcp.state.unknown": "nicht gestartet",
|
||||
|
||||
"permissions.intro": "Regeln für die Tool-Nutzung. „Fragen“ zeigt eine Freigabekarte. MCP-Tools: mcp__<server>__<tool>, Platzhalter erlaubt (mcp__*).",
|
||||
"permissions.tools": "Tools",
|
||||
"permissions.bash": "Shell-Befehle",
|
||||
"permissions.bashHint": "Glob-Muster auf den ganzen Befehl. Zusammengesetzte Befehle (&&, ;, |) sind nur erlaubt, wenn jeder Teil erlaubt ist.",
|
||||
"permissions.tool": "Tool",
|
||||
"permissions.pattern": "Muster",
|
||||
"permissions.rule": "Regel",
|
||||
"permissions.allow": "Erlauben",
|
||||
"permissions.ask": "Fragen",
|
||||
"permissions.deny": "Verbieten",
|
||||
|
||||
"agents.intro": "Modi und Subagenten. Eigene in der Config-Datei ([agents.*]) oder als Markdown in .codecraft/agents bzw. .claude/agents anlegen.",
|
||||
"agents.title": "Agenten",
|
||||
"agents.readOnly": "nur lesen",
|
||||
"agents.commands": "Slash-Befehle",
|
||||
"agents.skills": "Skills",
|
||||
|
||||
"stats.range7": "7 Tage",
|
||||
"stats.range30": "30 Tage",
|
||||
"stats.range90": "90 Tage",
|
||||
"stats.range365": "1 Jahr",
|
||||
"stats.rangeAll": "Alles",
|
||||
"stats.tableView": "Tabellenansicht",
|
||||
"stats.chartView": "Diagrammansicht",
|
||||
"stats.cost": "Kosten",
|
||||
"stats.perRequest": "{v} pro Anfrage",
|
||||
"stats.requests": "Anfragen",
|
||||
"stats.errors": "{n} fehlgeschlagen",
|
||||
"stats.input": "Input",
|
||||
"stats.output": "Output",
|
||||
"stats.cacheRead": "Cache gelesen",
|
||||
"stats.cacheHit": "Cache-Trefferquote",
|
||||
"stats.tokens": "Tokens",
|
||||
"stats.ttft": "Zeit bis zum ersten Token",
|
||||
"stats.ttftHint": "Durchschnittliche Prefill-Wartezeit",
|
||||
"stats.outputSpeed": "Ausgabegeschwindigkeit",
|
||||
"stats.prefillSpeed": "Prefill-Geschwindigkeit",
|
||||
"stats.costPerDay": "Kosten pro Tag nach Modell",
|
||||
"stats.tokensPerDay": "Tokens pro Tag",
|
||||
"stats.heatmap": "Aktivität nach Wochentag und Uhrzeit",
|
||||
"stats.byModel": "Nach Modell",
|
||||
"stats.byProvider": "Nach Provider",
|
||||
"stats.byAgent": "Nach Modus",
|
||||
"stats.topSessions": "Teuerste Sessions",
|
||||
"stats.perDay": "Pro Tag",
|
||||
"stats.day": "Tag",
|
||||
"stats.model": "Modell",
|
||||
"stats.provider": "Provider",
|
||||
"stats.agent": "Modus",
|
||||
"stats.session": "Session",
|
||||
"stats.total": "Gesamt",
|
||||
"stats.other": "Sonstige",
|
||||
"stats.noData": "Keine Anfragen in diesem Zeitraum.",
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
// Reference language. Every other language must define the same keys.
|
||||
export const en = {
|
||||
"common.loading": "Loading…",
|
||||
"common.save": "Save",
|
||||
"common.add": "Add",
|
||||
"common.edit": "Edit",
|
||||
"common.delete": "Delete",
|
||||
"common.remove": "Remove",
|
||||
"common.close": "Close",
|
||||
"common.collapse": "Collapse",
|
||||
|
||||
"nav.menu": "Menu",
|
||||
"nav.settings": "Settings",
|
||||
"nav.stats": "Statistics",
|
||||
|
||||
"login.prompt": "Enter the access token configured on the server.",
|
||||
"login.token": "Access token",
|
||||
"login.invalid": "Invalid token.",
|
||||
"login.submit": "Sign in",
|
||||
|
||||
"sidebar.newSession": "New session",
|
||||
"sidebar.noSessions": "No sessions yet",
|
||||
"sidebar.today": "Today",
|
||||
"sidebar.yesterday": "Yesterday",
|
||||
"sidebar.week": "Last 7 days",
|
||||
"sidebar.older": "Older",
|
||||
"sidebar.connected": "Connected",
|
||||
"sidebar.disconnected": "Disconnected – reconnecting…",
|
||||
"sidebar.needsInput": "Waiting for your input",
|
||||
|
||||
"session.untitled": "New session",
|
||||
|
||||
"new.title": "New session",
|
||||
"new.directory": "Project directory",
|
||||
"new.up": "Up",
|
||||
"new.noSubdirs": "No subdirectories",
|
||||
"new.model": "Model",
|
||||
"new.mode": "Mode",
|
||||
"new.firstPrompt": "What should be done? (optional)",
|
||||
"new.start": "Start in {dir}",
|
||||
|
||||
"chat.empty": "Send a message to start.",
|
||||
"chat.reasoning": "Reasoning",
|
||||
"chat.thinking": "Working…",
|
||||
"chat.compacted": "Conversation compacted – summary",
|
||||
"chat.context": "Context",
|
||||
"chat.contextHint": "{tokens} tokens in the last request",
|
||||
"chat.statsHint": "Tokens and cost of this session (click for details)",
|
||||
"chat.renameHint": "Double-click to rename",
|
||||
"chat.rewind": "Edit and resend from here",
|
||||
"chat.rewindConfirm": "Remove this message and everything after it? File changes are not undone.",
|
||||
"chat.delete": "Delete session",
|
||||
"chat.deleteConfirm": "Delete this session including its subagents? Statistics are kept.",
|
||||
|
||||
"composer.placeholder": "Message… (/ for commands, paste images)",
|
||||
"composer.placeholderBusy": "Message is queued and sent at the next step…",
|
||||
"composer.send": "Send",
|
||||
"composer.queue": "Queue",
|
||||
"composer.stop": "Stop",
|
||||
"composer.mode": "Mode",
|
||||
"composer.model": "Model",
|
||||
"composer.permissionMode": "Permission mode",
|
||||
"composer.attach": "Attach images",
|
||||
|
||||
"command.compact": "Summarize the conversation to free up context",
|
||||
"command.new": "Start a new session",
|
||||
|
||||
"permissionMode.default": "Ask before changes",
|
||||
"permissionMode.accept_edits": "Auto-accept edits",
|
||||
"permissionMode.bypass": "Allow everything",
|
||||
|
||||
"plan.implement": "Switch to build and implement the plan",
|
||||
"plan.implementPrompt": "Implement the plan.",
|
||||
|
||||
"tool.input": "Input",
|
||||
"tool.output": "Output",
|
||||
"tool.todos": "Tasks",
|
||||
"tool.openSubagent": "Open subagent session",
|
||||
|
||||
"interaction.fromSubagent": "From subagent:",
|
||||
|
||||
"permission.title": "Permission required",
|
||||
"permission.allowOnce": "Allow once",
|
||||
"permission.alwaysTool": "Always allow {tool} in this session",
|
||||
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
|
||||
"permission.reject": "Reject",
|
||||
"permission.rejectWithFeedback": "Reject with feedback",
|
||||
"permission.feedback": "Optional: tell the agent what to do instead",
|
||||
|
||||
"question.title": "Question for you",
|
||||
"question.fromAgent": "The agent needs a decision",
|
||||
"question.fromMcp": "MCP server \"{server}\" asks",
|
||||
"question.other": "Other (free text)",
|
||||
"question.answer": "Your answer",
|
||||
"question.submit": "Answer",
|
||||
"question.dismiss": "Dismiss",
|
||||
|
||||
"settings.tab.general": "General",
|
||||
"settings.tab.providers": "Providers & models",
|
||||
"settings.tab.mcp": "MCP",
|
||||
"settings.tab.permissions": "Permissions",
|
||||
"settings.tab.agents": "Agents & commands",
|
||||
"settings.tab.raw": "Config file",
|
||||
"settings.saved": "Saved – changes are active.",
|
||||
"settings.interface": "Interface (this device)",
|
||||
"settings.language": "Language",
|
||||
"settings.theme": "Theme",
|
||||
"settings.themeSystem": "System",
|
||||
"settings.themeDark": "Dark",
|
||||
"settings.themeLight": "Light",
|
||||
"settings.notifications": "Notifications",
|
||||
"settings.notificationsEnable": "Enable",
|
||||
"settings.notificationsOn": "Enabled",
|
||||
"settings.notificationsHint": "Notify when an agent asks a question while the app is in the background.",
|
||||
"settings.server": "Server",
|
||||
"settings.workspace": "Workspace",
|
||||
"settings.defaultModel": "Default model",
|
||||
"settings.smallModel": "Small model",
|
||||
"settings.smallModelHint": "Used for session titles. Compaction uses the session model.",
|
||||
"settings.currency": "Currency",
|
||||
"settings.autoCompact": "Auto-compact at",
|
||||
"settings.autoCompactHint": "Fraction of the context window (0 = off).",
|
||||
"settings.craft": "Craft mode",
|
||||
"settings.cleanRoom": "Clean room",
|
||||
"settings.cleanRoomHint": "Only use what a visitor sees. Off = deep mode (page source and scripts may be read).",
|
||||
"settings.chromium": "Chromium binary",
|
||||
"settings.widths": "Screen widths (px)",
|
||||
"settings.kind": "Type",
|
||||
"settings.baseUrl": "Base URL",
|
||||
"settings.apiKey": "API key",
|
||||
"settings.apiKeyStored": "Stored – leave empty to keep",
|
||||
"settings.apiKeyHint": "Prefer ${ENV_VAR} references; literal keys are stored in plain text in the config file.",
|
||||
"settings.authPrefix": "Auth prefix",
|
||||
"settings.enabled": "Enabled",
|
||||
"settings.modelCount": "{n} models",
|
||||
"settings.models": "Models & prices",
|
||||
"settings.addModel": "Model",
|
||||
"settings.addDiscovered": "Add {n} discovered",
|
||||
"settings.pricesHint": "Prices per 1M tokens in the configured currency. Cache prices default to the input price.",
|
||||
"settings.modelsEmptyHint": "No models listed: they are discovered automatically from the provider.",
|
||||
"settings.context": "Context",
|
||||
"settings.maxOutput": "Max output",
|
||||
"settings.reasoning": "Reasoning",
|
||||
"settings.vision": "Vision",
|
||||
"settings.effort": "Effort",
|
||||
"settings.priceIn": "Input",
|
||||
"settings.priceOut": "Output",
|
||||
"settings.priceCacheRead": "Cache read",
|
||||
"settings.priceCacheWrite": "Cache write",
|
||||
"settings.addProvider": "Add provider",
|
||||
"settings.providerName": "Name (optional)",
|
||||
"settings.reloadProviders": "Reload providers & models",
|
||||
"settings.deleteProviderConfirm": "Delete provider \"{name}\"?",
|
||||
"settings.invalidName": "Names may only contain letters, digits, - and _.",
|
||||
"settings.name": "Name",
|
||||
"settings.addMcp": "Add MCP server",
|
||||
"settings.deleteMcpConfirm": "Delete MCP server \"{name}\"?",
|
||||
"settings.rawHint": "The complete configuration file including comments. It is validated before saving.",
|
||||
"settings.revert": "Revert",
|
||||
|
||||
"mcp.reconnect": "Reconnect all",
|
||||
"mcp.none": "No MCP servers configured.",
|
||||
"mcp.tools": "{n} tools",
|
||||
"mcp.command": "Command",
|
||||
"mcp.args": "Arguments (one per line)",
|
||||
"mcp.env": "Environment (KEY=value)",
|
||||
"mcp.headers": "Headers (Name: value)",
|
||||
"mcp.state.disabled": "disabled",
|
||||
"mcp.state.connecting": "connecting…",
|
||||
"mcp.state.connected": "connected",
|
||||
"mcp.state.failed": "failed",
|
||||
"mcp.state.unknown": "not started",
|
||||
|
||||
"permissions.intro": "Rules for tool use. \"Ask\" shows an approval card. MCP tools: mcp__<server>__<tool>, wildcards allowed (mcp__*).",
|
||||
"permissions.tools": "Tools",
|
||||
"permissions.bash": "Shell commands",
|
||||
"permissions.bashHint": "Glob patterns on the full command. Compound commands (&&, ;, |) are only allowed if every part is.",
|
||||
"permissions.tool": "Tool",
|
||||
"permissions.pattern": "Pattern",
|
||||
"permissions.rule": "Rule",
|
||||
"permissions.allow": "Allow",
|
||||
"permissions.ask": "Ask",
|
||||
"permissions.deny": "Deny",
|
||||
|
||||
"agents.intro": "Modes and subagents. Add your own in the config file ([agents.*]) or as Markdown in .codecraft/agents or .claude/agents.",
|
||||
"agents.title": "Agents",
|
||||
"agents.readOnly": "read-only",
|
||||
"agents.commands": "Slash commands",
|
||||
"agents.skills": "Skills",
|
||||
|
||||
"stats.range7": "7 days",
|
||||
"stats.range30": "30 days",
|
||||
"stats.range90": "90 days",
|
||||
"stats.range365": "1 year",
|
||||
"stats.rangeAll": "All",
|
||||
"stats.tableView": "Table view",
|
||||
"stats.chartView": "Chart view",
|
||||
"stats.cost": "Cost",
|
||||
"stats.perRequest": "{v} per request",
|
||||
"stats.requests": "Requests",
|
||||
"stats.errors": "{n} failed",
|
||||
"stats.input": "Input",
|
||||
"stats.output": "Output",
|
||||
"stats.cacheRead": "Cache read",
|
||||
"stats.cacheHit": "Cache hit rate",
|
||||
"stats.tokens": "Tokens",
|
||||
"stats.ttft": "Time to first token",
|
||||
"stats.ttftHint": "Average prefill wait",
|
||||
"stats.outputSpeed": "Output speed",
|
||||
"stats.prefillSpeed": "Prefill speed",
|
||||
"stats.costPerDay": "Cost per day by model",
|
||||
"stats.tokensPerDay": "Tokens per day",
|
||||
"stats.heatmap": "Activity by weekday and hour",
|
||||
"stats.byModel": "By model",
|
||||
"stats.byProvider": "By provider",
|
||||
"stats.byAgent": "By mode",
|
||||
"stats.topSessions": "Most expensive sessions",
|
||||
"stats.perDay": "Per day",
|
||||
"stats.day": "Day",
|
||||
"stats.model": "Model",
|
||||
"stats.provider": "Provider",
|
||||
"stats.agent": "Mode",
|
||||
"stats.session": "Session",
|
||||
"stats.total": "Total",
|
||||
"stats.other": "Other",
|
||||
"stats.noData": "No requests in this period.",
|
||||
};
|
||||
|
||||
export type Messages = Record<keyof typeof en, string>;
|
||||
@@ -0,0 +1,104 @@
|
||||
// Minimal i18n: dictionaries per language, `{name}` placeholders, persisted choice.
|
||||
// To add a language: copy en.ts, translate the values, register it in `languages`.
|
||||
// TypeScript enforces that every language defines every key of `en`.
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { en, type Messages } from "./en";
|
||||
import { de } from "./de";
|
||||
|
||||
export type Key = keyof typeof en;
|
||||
|
||||
export const languages: Record<string, { name: string; messages: Messages }> = {
|
||||
en: { name: "English", messages: en },
|
||||
de: { name: "Deutsch", messages: de },
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "codecraft.lang";
|
||||
|
||||
function detect(): string {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved && languages[saved]) return saved;
|
||||
const nav = navigator.language.slice(0, 2);
|
||||
return languages[nav] ? nav : "en";
|
||||
}
|
||||
|
||||
let current = detect();
|
||||
document.documentElement.lang = current;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function getLang() {
|
||||
return current;
|
||||
}
|
||||
|
||||
export function setLang(lang: string) {
|
||||
if (!languages[lang]) return;
|
||||
current = lang;
|
||||
localStorage.setItem(STORAGE_KEY, lang);
|
||||
document.documentElement.lang = lang;
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function t(key: Key, vars?: Record<string, string | number>): string {
|
||||
let s: string = languages[current].messages[key] ?? en[key] ?? key;
|
||||
if (vars) {
|
||||
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Re-render on language change. Returns `t` for convenience. */
|
||||
export function useT() {
|
||||
useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => current,
|
||||
);
|
||||
return t;
|
||||
}
|
||||
|
||||
// --- locale-aware formatting -------------------------------------------------
|
||||
|
||||
export function fmtNumber(n: number, digits = 0) {
|
||||
return new Intl.NumberFormat(current, { maximumFractionDigits: digits }).format(n);
|
||||
}
|
||||
|
||||
export function fmtCompact(n: number) {
|
||||
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
||||
}
|
||||
|
||||
export function fmtCost(n: number, currency: string) {
|
||||
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
|
||||
try {
|
||||
return new Intl.NumberFormat(current, { style: "currency", currency, maximumFractionDigits: digits }).format(n);
|
||||
} catch {
|
||||
return `${n.toFixed(digits)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
export function fmtPercent(n: number) {
|
||||
return new Intl.NumberFormat(current, { style: "percent", maximumFractionDigits: 1 }).format(n);
|
||||
}
|
||||
|
||||
export function fmtDate(ms: number, withTime = true) {
|
||||
return new Intl.DateTimeFormat(current, withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }).format(ms);
|
||||
}
|
||||
|
||||
export function fmtRelative(ms: number) {
|
||||
const diff = (ms - Date.now()) / 1000;
|
||||
const rtf = new Intl.RelativeTimeFormat(current, { numeric: "auto" });
|
||||
const abs = Math.abs(diff);
|
||||
if (abs < 60) return rtf.format(Math.round(diff), "second");
|
||||
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
|
||||
if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour");
|
||||
return rtf.format(Math.round(diff / 86400), "day");
|
||||
}
|
||||
|
||||
export function weekdayNames(): string[] {
|
||||
// Index 0 = Sunday, matching SQLite's %w.
|
||||
const base = new Date(Date.UTC(2024, 0, 7)); // a Sunday
|
||||
return Array.from({ length: 7 }, (_, i) =>
|
||||
new Intl.DateTimeFormat(current, { weekday: "short", timeZone: "UTC" }).format(new Date(base.getTime() + i * 86400000)),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user