From 6d14a24b798c6494eff0f0012ad093500ca59bd9 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 2 Jun 2026 12:37:40 -0400 Subject: [PATCH] feat(dashboard): nous-blue theme, bulk sessions, schedule picker (#37383) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(dashboard): nous-blue theme, bulk sessions, schedule picker Batch of related dashboard improvements gathered on austin/fix/dashboard-changes: * Nous Blue theme — faithful port of the LENS_5I overlay system onto the existing DashboardTheme. Lifts the foreground inversion layer to z-index 200 to fix the long-standing hover / loading visual artifact, adds an explicit swatchColors slot so the theme picker shows the post-inversion preview, and migrates the legacy "lens-5i" theme key from localStorage / API to "nous-blue" on first read. * Theme-aware series colors: new --series-input-token / --series-output-token CSS vars consumed by Analytics + Models charts; ToolCall + ModelInfoCard switched to semantic --color-success for diff lines and the Tools capability badge. * Analytics + Models headers: consolidate period selector + refresh next to the page title and drop the redundant period badge. * Bulk session management — "Delete empty (N)" button + per-row checkboxes with shift-click range select and a bulk-delete action bar. Backed by SessionDB.delete_sessions() / delete_empty_sessions() plus POST /api/sessions/bulk-delete and DELETE /api/sessions/empty (registered before the templated /api/sessions/{session_id} family so they don't get shadowed). Hard cap of 500 IDs per bulk request. Full pytest coverage. * Cron page — human-readable schedule picker (every-interval / daily / weekly / monthly / once / custom) replaces the raw cron expression input; the job list now renders "Weekly on Mon, Wed, Fri at 14:30" instead of "30 14 * * 1,3,5". English-only ordinals for monthly schedules so non-English locales don't get incorrect suffixes. * example-dashboard plugin moved from plugins/ to tests/fixtures/ so stock installs no longer ship the demo. Tests install it dynamically via a pytest fixture that also reorders the FastAPI routes. * i18n: 40+ new keys for the bulk-select UI and schedule picker/describer translated across all 16 locales. Co-authored-by: Cursor * refactor(dashboard): dedupe memory provider picker The memory provider update({ mode: v as ScheduleMode })} + > + {modeStrings.interval} + {modeStrings.daily} + {modeStrings.weekly} + {modeStrings.monthly} + {modeStrings.once} + {modeStrings.custom} + + + + {value.mode === "interval" && ( +
+
+ + { + const n = parseInt(e.target.value, 10); + update({ + intervalValue: Number.isFinite(n) && n > 0 ? n : 1, + }); + }} + /> +
+
+ + +
+
+ )} + + {value.mode === "daily" && ( + update({ timeOfDay })} + /> + )} + + {value.mode === "weekly" && ( + <> +
+ +
+ {WEEKDAY_INDEXES.map((d) => { + const isOn = value.weekdays.includes(d); + return ( + + ); + })} +
+
+ update({ timeOfDay })} + /> + + )} + + {value.mode === "monthly" && ( +
+
+ + { + const n = parseInt(e.target.value, 10); + update({ + dayOfMonth: + Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1, + }); + }} + /> +
+ update({ timeOfDay })} + /> +
+ )} + + {value.mode === "once" && ( +
+ + {/* Native datetime-local — emits the exact "YYYY-MM-DDTHH:MM" + shape ``parse_schedule`` accepts on the backend. */} + update({ onceAt: e.target.value })} + /> +
+ )} + + {value.mode === "custom" && ( +
+ + update({ custom: e.target.value })} + className="font-mono-ui" + /> +

+ {modeStrings.customHint} +

+
+ )} + + {/* Inline preview of what we'll send to the backend. Helps users + eyeball the result before hitting Create, and keeps the + schedule grammar discoverable for the custom mode. */} +

+ {modeStrings.preview}: + + {buildScheduleString(value) || modeStrings.previewEmpty} + +

+ + ); +} + +function TimeOfDayField({ + id, + label, + onChange, + value, +}: TimeOfDayFieldProps) { + return ( +
+ + {/* Native time picker is the right tool for "HH:MM" — saves us + two separate hour/minute selects, respects user locale's + AM/PM preference, and round-trips with ``buildScheduleString`` + without parsing. */} + onChange(e.target.value)} + /> +
+ ); +} + +export { DEFAULT_SCHEDULE_STATE }; + +interface ScheduleBuilderProps { + onChange: (state: ScheduleBuilderState) => void; + value: ScheduleBuilderState; +} + +interface TimeOfDayFieldProps { + id: string; + label: string; + onChange: (value: string) => void; + value: string; +} diff --git a/web/src/components/ThemeSwitcher.tsx b/web/src/components/ThemeSwitcher.tsx index 66cf9b0c5..161175bbf 100644 --- a/web/src/components/ThemeSwitcher.tsx +++ b/web/src/components/ThemeSwitcher.tsx @@ -208,15 +208,25 @@ function ThemeSwitcherOptions({ } function ThemeSwatch({ theme }: { theme: DashboardTheme }) { - const { background, midground, warmGlow } = theme.palette; + // Inverted themes (Nous Blue / future lens themes) author their palette + // pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground- + // difference layer flips the page. The picker can't replay that math + // cheaply, so themes opt-in to an explicit `swatchColors` triplet that + // mirrors the on-screen result. Falls back to the raw palette hexes for + // every other theme so existing dark-theme swatches are untouched. + const [c1, c2, c3] = theme.swatchColors ?? [ + theme.palette.background.hex, + theme.palette.midground.hex, + theme.palette.warmGlow, + ]; return (
- - - + + +
); } diff --git a/web/src/components/ToolCall.tsx b/web/src/components/ToolCall.tsx index 0c599d2d6..c17a60d8e 100644 --- a/web/src/components/ToolCall.tsx +++ b/web/src/components/ToolCall.tsx @@ -220,7 +220,7 @@ function colorizeDiff(diff: string): React.ReactNode { function diffLineClass(line: string): string { if (line.startsWith("+") && !line.startsWith("+++")) - return "text-emerald-500 dark:text-emerald-400"; + return "text-success"; if (line.startsWith("-") && !line.startsWith("---")) return "text-destructive"; if (line.startsWith("@@")) return "text-primary"; diff --git a/web/src/i18n/af.ts b/web/src/i18n/af.ts index c3d6312aa..5d5dc0042 100644 --- a/web/src/i18n/af.ts +++ b/web/src/i18n/af.ts @@ -141,6 +141,22 @@ export const af: Translations = { "Dit verwyder die gesprek en al sy boodskappe permanent. Dit kan nie ongedaan gemaak word nie.", sessionDeleted: "Sessie geskrap", failedToDelete: "Kon nie sessie skrap nie", + deleteEmpty: "Skrap leë", + deleteEmptyConfirmTitle: "Skrap leë sessies?", + deleteEmptyConfirmMessage: + "Dit verwyder permanent {count} sessies wat geen boodskappe het nie. Aktiewe en geargiveerde sessies word oorgeslaan. Dit kan nie ongedaan gemaak word nie.", + emptySessionsDeleted: "{count} leë sessies geskrap", + failedToDeleteEmpty: "Kon nie leë sessies skrap nie", + selectSession: "Kies sessie", + selectAllOnPage: "Kies alles op hierdie bladsy", + clearSelection: "Maak keuse skoon", + selectedCount: "{count} gekies", + deleteSelected: "Skrap {count}", + deleteSelectedConfirmTitle: "Skrap {count} sessies?", + deleteSelectedConfirmMessage: + "Dit verwyder {count} gekose sessies en al hul boodskappe permanent. Dit kan nie ongedaan gemaak word nie.", + selectedSessionsDeleted: "{count} sessies geskrap", + failedToDeleteSelected: "Kon nie gekose sessies skrap nie", resumeInChat: "Hervat in Klets", previousPage: "Vorige bladsy", nextPage: "Volgende bladsy", @@ -211,6 +227,41 @@ export const af: Translations = { promptPlaceholder: "Wat moet die agent met elke uitvoering doen?", schedule: "Skedule (cron-uitdrukking)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Skedule", + scheduleModes: { + interval: "Herhalende interval", + daily: "Daagliks", + weekly: "Weekliks", + monthly: "Maandeliks", + once: "Een keer", + custom: "Pasgemaak (cron-uitdrukking)", + intervalEvery: "Elke", + intervalUnit: "Eenheid", + unitMinutes: "minute", + unitHours: "ure", + unitDays: "dae", + timeOfDay: "Tyd van die dag", + weekdays: "Dae van die week", + weekdaysShort: ["Son", "Maa", "Din", "Woe", "Don", "Vry", "Sat"], + dayOfMonth: "Dag van die maand", + onceAt: "Hardloop op", + customLabel: "Cron-uitdrukking", + customPlaceholder: "0 9 * * *", + customHint: + "Cron-uitdrukking met vyf velde (minuut, uur, dag, maand, weekdag).", + preview: "Word gestuur as", + previewEmpty: "(onvolledig)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Elke {n} min", + everyHours: "Elke {n} u", + everyDays: "Elke {n} d", + dailyAt: "Daagliks om {time}", + weeklyAt: "Weekliks op {days} om {time}", + monthlyAt: "Maandeliks op die {day} om {time}", + onceAt: "Een keer op {time}", + }, deliverTo: "Lewer aan", scheduledJobs: "Geskeduleerde Take", noJobs: "Geen cron-take gekonfigureer nie. Skep een hierbo.", diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index d6fdfe645..e2eb1429c 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -141,6 +141,22 @@ export const de: Translations = { "Dies entfernt die Unterhaltung und alle Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.", sessionDeleted: "Sitzung gelöscht", failedToDelete: "Sitzung konnte nicht gelöscht werden", + deleteEmpty: "Leere löschen", + deleteEmptyConfirmTitle: "Leere Sitzungen löschen?", + deleteEmptyConfirmMessage: + "Dies entfernt dauerhaft {count} Sitzungen ohne Nachrichten. Aktive und archivierte Sitzungen werden übersprungen. Dies kann nicht rückgängig gemacht werden.", + emptySessionsDeleted: "{count} leere Sitzungen gelöscht", + failedToDeleteEmpty: "Leere Sitzungen konnten nicht gelöscht werden", + selectSession: "Sitzung auswählen", + selectAllOnPage: "Alle auf dieser Seite auswählen", + clearSelection: "Auswahl aufheben", + selectedCount: "{count} ausgewählt", + deleteSelected: "{count} löschen", + deleteSelectedConfirmTitle: "{count} Sitzungen löschen?", + deleteSelectedConfirmMessage: + "Dies entfernt {count} ausgewählte Sitzungen und alle zugehörigen Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.", + selectedSessionsDeleted: "{count} Sitzungen gelöscht", + failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden", resumeInChat: "Im Chat fortsetzen", previousPage: "Vorherige Seite", nextPage: "Nächste Seite", @@ -211,6 +227,41 @@ export const de: Translations = { promptPlaceholder: "Was soll der Agent bei jedem Lauf tun?", schedule: "Zeitplan (Cron-Ausdruck)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Zeitplan", + scheduleModes: { + interval: "Wiederkehrendes Intervall", + daily: "Täglich", + weekly: "Wöchentlich", + monthly: "Monatlich", + once: "Einmalig", + custom: "Benutzerdefiniert (Cron-Ausdruck)", + intervalEvery: "Alle", + intervalUnit: "Einheit", + unitMinutes: "Minuten", + unitHours: "Stunden", + unitDays: "Tage", + timeOfDay: "Uhrzeit", + weekdays: "Wochentage", + weekdaysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"], + dayOfMonth: "Tag des Monats", + onceAt: "Ausführen am", + customLabel: "Cron-Ausdruck", + customPlaceholder: "0 9 * * *", + customHint: + "Cron-Ausdruck mit fünf Feldern (Minute, Stunde, Tag, Monat, Wochentag).", + preview: "Wird gesendet als", + previewEmpty: "(unvollständig)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Alle {n} Min.", + everyHours: "Alle {n} Std.", + everyDays: "Alle {n} Tage", + dailyAt: "Täglich um {time}", + weeklyAt: "Wöchentlich am {days} um {time}", + monthlyAt: "Monatlich am {day} um {time}", + onceAt: "Einmal am {time}", + }, deliverTo: "Zustellen an", scheduledJobs: "Geplante Aufgaben", noJobs: "Keine Cron-Aufgaben konfiguriert. Erstelle oben eine.", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index f792bf4dc..423876f3d 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -141,6 +141,22 @@ export const en: Translations = { "This permanently removes the conversation and all of its messages. This cannot be undone.", sessionDeleted: "Session deleted", failedToDelete: "Failed to delete session", + deleteEmpty: "Delete empty", + deleteEmptyConfirmTitle: "Delete empty sessions?", + deleteEmptyConfirmMessage: + "This permanently removes {count} sessions that have no messages. Active and archived sessions are skipped. This cannot be undone.", + emptySessionsDeleted: "{count} empty sessions deleted", + failedToDeleteEmpty: "Failed to delete empty sessions", + selectSession: "Select session", + selectAllOnPage: "Select all on this page", + clearSelection: "Clear selection", + selectedCount: "{count} selected", + deleteSelected: "Delete {count}", + deleteSelectedConfirmTitle: "Delete {count} sessions?", + deleteSelectedConfirmMessage: + "This permanently removes {count} selected sessions and all their messages. This cannot be undone.", + selectedSessionsDeleted: "{count} sessions deleted", + failedToDeleteSelected: "Failed to delete selected sessions", resumeInChat: "Resume in Chat", previousPage: "Previous page", nextPage: "Next page", @@ -211,6 +227,41 @@ export const en: Translations = { promptPlaceholder: "What should the agent do on each run?", schedule: "Schedule (cron expression)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Schedule", + scheduleModes: { + interval: "Every interval", + daily: "Daily", + weekly: "Weekly", + monthly: "Monthly", + once: "Once", + custom: "Custom (cron expression)", + intervalEvery: "Every", + intervalUnit: "Unit", + unitMinutes: "minutes", + unitHours: "hours", + unitDays: "days", + timeOfDay: "Time of day", + weekdays: "Days of week", + weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"], + dayOfMonth: "Day of month", + onceAt: "Run at", + customLabel: "Cron expression", + customPlaceholder: "0 9 * * *", + customHint: + "Five-field cron expression (minute, hour, day, month, weekday).", + preview: "Sends as", + previewEmpty: "(incomplete)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Every {n} min", + everyHours: "Every {n} h", + everyDays: "Every {n} d", + dailyAt: "Daily at {time}", + weeklyAt: "Weekly on {days} at {time}", + monthlyAt: "Monthly on the {day} at {time}", + onceAt: "Once at {time}", + }, deliverTo: "Deliver to", scheduledJobs: "Scheduled Jobs", noJobs: "No cron jobs configured. Create one above.", diff --git a/web/src/i18n/es.ts b/web/src/i18n/es.ts index 84a1501e9..421837007 100644 --- a/web/src/i18n/es.ts +++ b/web/src/i18n/es.ts @@ -141,6 +141,22 @@ export const es: Translations = { "Esto elimina permanentemente la conversación y todos sus mensajes. No se puede deshacer.", sessionDeleted: "Sesión eliminada", failedToDelete: "No se pudo eliminar la sesión", + deleteEmpty: "Eliminar vacías", + deleteEmptyConfirmTitle: "¿Eliminar sesiones vacías?", + deleteEmptyConfirmMessage: + "Esto elimina permanentemente {count} sesiones que no tienen mensajes. Se omiten las sesiones activas y archivadas. Esta acción no se puede deshacer.", + emptySessionsDeleted: "{count} sesiones vacías eliminadas", + failedToDeleteEmpty: "No se pudieron eliminar las sesiones vacías", + selectSession: "Seleccionar sesión", + selectAllOnPage: "Seleccionar todas en esta página", + clearSelection: "Limpiar selección", + selectedCount: "{count} seleccionadas", + deleteSelected: "Eliminar {count}", + deleteSelectedConfirmTitle: "¿Eliminar {count} sesiones?", + deleteSelectedConfirmMessage: + "Esto elimina permanentemente {count} sesiones seleccionadas y todos sus mensajes. No se puede deshacer.", + selectedSessionsDeleted: "{count} sesiones eliminadas", + failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas", resumeInChat: "Reanudar en el chat", previousPage: "Página anterior", nextPage: "Página siguiente", @@ -211,6 +227,41 @@ export const es: Translations = { promptPlaceholder: "¿Qué debe hacer el agente en cada ejecución?", schedule: "Programación (expresión cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Programación", + scheduleModes: { + interval: "Cada intervalo", + daily: "Diariamente", + weekly: "Semanalmente", + monthly: "Mensualmente", + once: "Una vez", + custom: "Personalizado (expresión cron)", + intervalEvery: "Cada", + intervalUnit: "Unidad", + unitMinutes: "minutos", + unitHours: "horas", + unitDays: "días", + timeOfDay: "Hora del día", + weekdays: "Días de la semana", + weekdaysShort: ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"], + dayOfMonth: "Día del mes", + onceAt: "Ejecutar el", + customLabel: "Expresión cron", + customPlaceholder: "0 9 * * *", + customHint: + "Expresión cron de cinco campos (minuto, hora, día, mes, día de la semana).", + preview: "Se envía como", + previewEmpty: "(incompleta)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Cada {n} min", + everyHours: "Cada {n} h", + everyDays: "Cada {n} d", + dailyAt: "Diariamente a las {time}", + weeklyAt: "Semanalmente los {days} a las {time}", + monthlyAt: "Mensualmente el {day} a las {time}", + onceAt: "Una vez el {time}", + }, deliverTo: "Entregar a", scheduledJobs: "Tareas programadas", noJobs: "No hay tareas cron configuradas. Crea una arriba.", diff --git a/web/src/i18n/fr.ts b/web/src/i18n/fr.ts index 409c0a1e3..4887dc9c0 100644 --- a/web/src/i18n/fr.ts +++ b/web/src/i18n/fr.ts @@ -141,6 +141,22 @@ export const fr: Translations = { "Cela supprime définitivement la conversation et tous ses messages. Cette action est irréversible.", sessionDeleted: "Session supprimée", failedToDelete: "Échec de la suppression de la session", + deleteEmpty: "Supprimer les vides", + deleteEmptyConfirmTitle: "Supprimer les sessions vides ?", + deleteEmptyConfirmMessage: + "Cela supprime définitivement {count} sessions sans messages. Les sessions actives et archivées sont ignorées. Cette action est irréversible.", + emptySessionsDeleted: "{count} sessions vides supprimées", + failedToDeleteEmpty: "Échec de la suppression des sessions vides", + selectSession: "Sélectionner la session", + selectAllOnPage: "Tout sélectionner sur cette page", + clearSelection: "Effacer la sélection", + selectedCount: "{count} sélectionnée(s)", + deleteSelected: "Supprimer {count}", + deleteSelectedConfirmTitle: "Supprimer {count} sessions ?", + deleteSelectedConfirmMessage: + "Cela supprime définitivement {count} sessions sélectionnées et tous leurs messages. Cette action est irréversible.", + selectedSessionsDeleted: "{count} sessions supprimées", + failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées", resumeInChat: "Reprendre dans le chat", previousPage: "Page précédente", nextPage: "Page suivante", @@ -211,6 +227,41 @@ export const fr: Translations = { promptPlaceholder: "Que doit faire l'agent à chaque exécution ?", schedule: "Planning (expression cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Planification", + scheduleModes: { + interval: "Intervalle récurrent", + daily: "Quotidien", + weekly: "Hebdomadaire", + monthly: "Mensuel", + once: "Une fois", + custom: "Personnalisé (expression cron)", + intervalEvery: "Toutes les", + intervalUnit: "Unité", + unitMinutes: "minutes", + unitHours: "heures", + unitDays: "jours", + timeOfDay: "Heure de la journée", + weekdays: "Jours de la semaine", + weekdaysShort: ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"], + dayOfMonth: "Jour du mois", + onceAt: "Exécuter le", + customLabel: "Expression cron", + customPlaceholder: "0 9 * * *", + customHint: + "Expression cron à cinq champs (minute, heure, jour, mois, jour de la semaine).", + preview: "Envoyé sous la forme", + previewEmpty: "(incomplet)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Toutes les {n} min", + everyHours: "Toutes les {n} h", + everyDays: "Tous les {n} j", + dailyAt: "Tous les jours à {time}", + weeklyAt: "Chaque {days} à {time}", + monthlyAt: "Le {day} de chaque mois à {time}", + onceAt: "Une fois le {time}", + }, deliverTo: "Livrer à", scheduledJobs: "Tâches planifiées", noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.", diff --git a/web/src/i18n/ga.ts b/web/src/i18n/ga.ts index a4d41e303..6f71635b3 100644 --- a/web/src/i18n/ga.ts +++ b/web/src/i18n/ga.ts @@ -141,6 +141,22 @@ export const ga: Translations = { "Baineann sé seo an comhrá agus a chuid teachtaireachtaí ar fad go buan. Ní féidir é seo a chealú.", sessionDeleted: "Seisiún scriosta", failedToDelete: "Theip ar scriosadh an tseisiúin", + deleteEmpty: "Scrios folamh", + deleteEmptyConfirmTitle: "Scrios seisiúin fholmha?", + deleteEmptyConfirmMessage: + "Baintear {count} seisiúin gan teachtaireachtaí ar bhealach buan. Ní scriostar seisiúin ghníomhacha agus seisiúin chartlainne. Ní féidir é seo a chealú.", + emptySessionsDeleted: "{count} seisiúin fholmha scriosta", + failedToDeleteEmpty: "Theip ar scriosadh na seisiún folmha", + selectSession: "Roghnaigh seisiún", + selectAllOnPage: "Roghnaigh gach ceann ar an leathanach seo", + clearSelection: "Glan an rogha", + selectedCount: "{count} roghnaithe", + deleteSelected: "Scrios {count}", + deleteSelectedConfirmTitle: "Scrios {count} seisiún?", + deleteSelectedConfirmMessage: + "Bainfear {count} seisiún roghnaithe agus a dteachtaireachtaí go léir go buan. Ní féidir é seo a chur ar ais.", + selectedSessionsDeleted: "Scriosadh {count} seisiún", + failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe", resumeInChat: "Lean ar aghaidh sa chomhrá", previousPage: "Leathanach roimhe seo", nextPage: "An chéad leathanach eile", @@ -211,6 +227,49 @@ export const ga: Translations = { promptPlaceholder: "Cad ba chóir don agent a dhéanamh ag gach rith?", schedule: "Sceideal (slonn cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Sceideal", + scheduleModes: { + interval: "Eatramh athfhillteach", + daily: "Go laethúil", + weekly: "Go seachtainiúil", + monthly: "Go míosúil", + once: "Uair amháin", + custom: "Saincheaptha (slonn cron)", + intervalEvery: "Gach", + intervalUnit: "Aonad", + unitMinutes: "nóiméad", + unitHours: "uair", + unitDays: "lá", + timeOfDay: "Am an lae", + weekdays: "Laethanta na seachtaine", + weekdaysShort: [ + "Domh", + "Luan", + "Máirt", + "Céad", + "Déar", + "Aoine", + "Sath", + ], + dayOfMonth: "Lá den mhí", + onceAt: "Rith ag", + customLabel: "Slonn cron", + customPlaceholder: "0 9 * * *", + customHint: + "Slonn cron cúig réimse (nóiméad, uair, lá, mí, lá den tseachtain).", + preview: "Seoltar mar", + previewEmpty: "(neamhiomlán)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Gach {n} nóim", + everyHours: "Gach {n} u", + everyDays: "Gach {n} lá", + dailyAt: "Go laethúil ag {time}", + weeklyAt: "Gach {days} ag {time}", + monthlyAt: "An {day} de gach mí ag {time}", + onceAt: "Uair amháin ag {time}", + }, deliverTo: "Seachadadh chuig", scheduledJobs: "Poist sceidealta", noJobs: "Níl poist cron cumraithe. Cruthaigh ceann thuas.", diff --git a/web/src/i18n/hu.ts b/web/src/i18n/hu.ts index 7814aff86..a41382074 100644 --- a/web/src/i18n/hu.ts +++ b/web/src/i18n/hu.ts @@ -141,6 +141,22 @@ export const hu: Translations = { "Ez véglegesen eltávolítja a beszélgetést és minden üzenetét. A művelet nem vonható vissza.", sessionDeleted: "Munkamenet törölve", failedToDelete: "Nem sikerült törölni a munkamenetet", + deleteEmpty: "Üresek törlése", + deleteEmptyConfirmTitle: "Üres munkamenetek törlése?", + deleteEmptyConfirmMessage: + "Ez véglegesen eltávolít {count} olyan munkamenetet, amely nem tartalmaz üzenetet. Az aktív és archivált munkameneteket kihagyja. Ez nem vonható vissza.", + emptySessionsDeleted: "{count} üres munkamenet törölve", + failedToDeleteEmpty: "Nem sikerült törölni az üres munkameneteket", + selectSession: "Munkamenet kijelölése", + selectAllOnPage: "Az oldalon mindegyik kijelölése", + clearSelection: "Kijelölés törlése", + selectedCount: "{count} kijelölve", + deleteSelected: "{count} törlése", + deleteSelectedConfirmTitle: "{count} munkamenet törlése?", + deleteSelectedConfirmMessage: + "Ez véglegesen eltávolítja a kijelölt {count} munkamenetet és minden üzenetüket. A művelet nem vonható vissza.", + selectedSessionsDeleted: "{count} munkamenet törölve", + failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket", resumeInChat: "Folytatás a csevegésben", previousPage: "Előző oldal", nextPage: "Következő oldal", @@ -211,6 +227,41 @@ export const hu: Translations = { promptPlaceholder: "Mit tegyen az ügynök minden futtatáskor?", schedule: "Ütemezés (cron-kifejezés)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Ütemezés", + scheduleModes: { + interval: "Ismétlődő intervallum", + daily: "Naponta", + weekly: "Hetente", + monthly: "Havonta", + once: "Egyszer", + custom: "Egyéni (cron kifejezés)", + intervalEvery: "Minden", + intervalUnit: "Egység", + unitMinutes: "perc", + unitHours: "óra", + unitDays: "nap", + timeOfDay: "Napszak", + weekdays: "Hét napjai", + weekdaysShort: ["V", "H", "K", "Sze", "Cs", "P", "Szo"], + dayOfMonth: "Hónap napja", + onceAt: "Futtatás ekkor", + customLabel: "Cron kifejezés", + customPlaceholder: "0 9 * * *", + customHint: + "Öt mezős cron kifejezés (perc, óra, nap, hónap, hét napja).", + preview: "Elküldve mint", + previewEmpty: "(hiányos)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "{n} percenként", + everyHours: "{n} óránként", + everyDays: "{n} naponta", + dailyAt: "Naponta {time}-kor", + weeklyAt: "Hetente {days} {time}-kor", + monthlyAt: "Havonta {day} {time}-kor", + onceAt: "Egyszer {time}-kor", + }, deliverTo: "Kézbesítés ide", scheduledJobs: "Ütemezett feladatok", noJobs: "Nincs beállított cron-feladat. Hozzon létre egyet fent.", diff --git a/web/src/i18n/it.ts b/web/src/i18n/it.ts index 1485cb687..61ca8b7bb 100644 --- a/web/src/i18n/it.ts +++ b/web/src/i18n/it.ts @@ -141,6 +141,22 @@ export const it: Translations = { "Questa operazione rimuove definitivamente la conversazione e tutti i suoi messaggi. Non può essere annullata.", sessionDeleted: "Sessione eliminata", failedToDelete: "Eliminazione della sessione non riuscita", + deleteEmpty: "Elimina vuote", + deleteEmptyConfirmTitle: "Eliminare le sessioni vuote?", + deleteEmptyConfirmMessage: + "Questa azione rimuove in modo permanente {count} sessioni senza messaggi. Le sessioni attive e archiviate vengono ignorate. L'azione non può essere annullata.", + emptySessionsDeleted: "{count} sessioni vuote eliminate", + failedToDeleteEmpty: "Impossibile eliminare le sessioni vuote", + selectSession: "Seleziona sessione", + selectAllOnPage: "Seleziona tutte in questa pagina", + clearSelection: "Annulla selezione", + selectedCount: "{count} selezionate", + deleteSelected: "Elimina {count}", + deleteSelectedConfirmTitle: "Eliminare {count} sessioni?", + deleteSelectedConfirmMessage: + "Verranno eliminate definitivamente {count} sessioni selezionate e tutti i loro messaggi. L'operazione non può essere annullata.", + selectedSessionsDeleted: "{count} sessioni eliminate", + failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate", resumeInChat: "Riprendi nella chat", previousPage: "Pagina precedente", nextPage: "Pagina successiva", @@ -211,6 +227,41 @@ export const it: Translations = { promptPlaceholder: "Cosa deve fare l'agente a ogni esecuzione?", schedule: "Pianificazione (espressione cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Pianificazione", + scheduleModes: { + interval: "Intervallo ricorrente", + daily: "Giornaliero", + weekly: "Settimanale", + monthly: "Mensile", + once: "Una volta", + custom: "Personalizzato (espressione cron)", + intervalEvery: "Ogni", + intervalUnit: "Unità", + unitMinutes: "minuti", + unitHours: "ore", + unitDays: "giorni", + timeOfDay: "Ora del giorno", + weekdays: "Giorni della settimana", + weekdaysShort: ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"], + dayOfMonth: "Giorno del mese", + onceAt: "Esegui il", + customLabel: "Espressione cron", + customPlaceholder: "0 9 * * *", + customHint: + "Espressione cron a cinque campi (minuto, ora, giorno, mese, giorno della settimana).", + preview: "Inviato come", + previewEmpty: "(incompleta)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Ogni {n} min", + everyHours: "Ogni {n} h", + everyDays: "Ogni {n} g", + dailyAt: "Tutti i giorni alle {time}", + weeklyAt: "Ogni {days} alle {time}", + monthlyAt: "Il {day} di ogni mese alle {time}", + onceAt: "Una volta il {time}", + }, deliverTo: "Consegna a", scheduledJobs: "Attività pianificate", noJobs: "Nessuna attività cron configurata. Creane una sopra.", diff --git a/web/src/i18n/ja.ts b/web/src/i18n/ja.ts index 1b9ad88ea..e3db6b9a2 100644 --- a/web/src/i18n/ja.ts +++ b/web/src/i18n/ja.ts @@ -141,6 +141,22 @@ export const ja: Translations = { "会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。", sessionDeleted: "セッションを削除しました", failedToDelete: "セッションの削除に失敗しました", + deleteEmpty: "空を削除", + deleteEmptyConfirmTitle: "空のセッションを削除しますか?", + deleteEmptyConfirmMessage: + "メッセージのない {count} 件のセッションを完全に削除します。アクティブおよびアーカイブされたセッションはスキップされます。この操作は元に戻せません。", + emptySessionsDeleted: "{count} 件の空のセッションを削除しました", + failedToDeleteEmpty: "空のセッションの削除に失敗しました", + selectSession: "セッションを選択", + selectAllOnPage: "このページの全てを選択", + clearSelection: "選択を解除", + selectedCount: "{count}件選択中", + deleteSelected: "{count}件削除", + deleteSelectedConfirmTitle: "{count}件のセッションを削除しますか?", + deleteSelectedConfirmMessage: + "選択した{count}件のセッションとそのすべてのメッセージが完全に削除されます。この操作は取り消せません。", + selectedSessionsDeleted: "{count}件のセッションを削除しました", + failedToDeleteSelected: "選択したセッションの削除に失敗しました", resumeInChat: "チャットで再開", previousPage: "前のページ", nextPage: "次のページ", @@ -211,6 +227,40 @@ export const ja: Translations = { promptPlaceholder: "実行ごとにエージェントが行う内容は?", schedule: "スケジュール (cron 式)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "スケジュール", + scheduleModes: { + interval: "繰り返し間隔", + daily: "毎日", + weekly: "毎週", + monthly: "毎月", + once: "1回のみ", + custom: "カスタム(cron式)", + intervalEvery: "実行間隔", + intervalUnit: "単位", + unitMinutes: "分", + unitHours: "時間", + unitDays: "日", + timeOfDay: "時刻", + weekdays: "曜日", + weekdaysShort: ["日", "月", "火", "水", "木", "金", "土"], + dayOfMonth: "日付", + onceAt: "実行日時", + customLabel: "cron式", + customPlaceholder: "0 9 * * *", + customHint: "5フィールドのcron式(分、時、日、月、曜日)。", + preview: "送信形式", + previewEmpty: "(未入力)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "{n}分ごと", + everyHours: "{n}時間ごと", + everyDays: "{n}日ごと", + dailyAt: "毎日 {time}", + weeklyAt: "毎週 {days} {time}", + monthlyAt: "毎月{day} {time}", + onceAt: "{time} に1回", + }, deliverTo: "配信先", scheduledJobs: "スケジュール済みジョブ", noJobs: "Cron ジョブが設定されていません。上で作成してください。", diff --git a/web/src/i18n/ko.ts b/web/src/i18n/ko.ts index 4fcb6f001..b624938f8 100644 --- a/web/src/i18n/ko.ts +++ b/web/src/i18n/ko.ts @@ -141,6 +141,22 @@ export const ko: Translations = { "이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.", sessionDeleted: "세션이 삭제되었습니다", failedToDelete: "세션 삭제에 실패했습니다", + deleteEmpty: "빈 세션 삭제", + deleteEmptyConfirmTitle: "빈 세션을 삭제하시겠습니까?", + deleteEmptyConfirmMessage: + "메시지가 없는 {count}개의 세션을 영구적으로 삭제합니다. 활성 및 보관된 세션은 건너뜁니다. 이 작업은 되돌릴 수 없습니다.", + emptySessionsDeleted: "빈 세션 {count}개 삭제됨", + failedToDeleteEmpty: "빈 세션 삭제에 실패했습니다", + selectSession: "세션 선택", + selectAllOnPage: "이 페이지 전체 선택", + clearSelection: "선택 해제", + selectedCount: "{count}개 선택됨", + deleteSelected: "{count}개 삭제", + deleteSelectedConfirmTitle: "{count}개 세션을 삭제하시겠습니까?", + deleteSelectedConfirmMessage: + "선택한 {count}개 세션과 모든 메시지가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.", + selectedSessionsDeleted: "{count}개 세션이 삭제되었습니다", + failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다", resumeInChat: "채팅에서 다시 시작", previousPage: "이전 페이지", nextPage: "다음 페이지", @@ -211,6 +227,40 @@ export const ko: Translations = { promptPlaceholder: "에이전트가 매 실행 시 무엇을 해야 합니까?", schedule: "스케줄 (cron 표현식)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "일정", + scheduleModes: { + interval: "반복 간격", + daily: "매일", + weekly: "매주", + monthly: "매월", + once: "한 번", + custom: "사용자 지정 (cron 표현식)", + intervalEvery: "실행 간격", + intervalUnit: "단위", + unitMinutes: "분", + unitHours: "시간", + unitDays: "일", + timeOfDay: "시각", + weekdays: "요일", + weekdaysShort: ["일", "월", "화", "수", "목", "금", "토"], + dayOfMonth: "날짜", + onceAt: "실행 시각", + customLabel: "cron 표현식", + customPlaceholder: "0 9 * * *", + customHint: "5개 필드의 cron 표현식 (분, 시, 일, 월, 요일).", + preview: "전송 형식", + previewEmpty: "(미완성)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "{n}분마다", + everyHours: "{n}시간마다", + everyDays: "{n}일마다", + dailyAt: "매일 {time}", + weeklyAt: "매주 {days} {time}", + monthlyAt: "매월 {day} {time}", + onceAt: "{time}에 한 번", + }, deliverTo: "전달 대상", scheduledJobs: "예약된 작업", noJobs: "구성된 cron 작업이 없습니다. 위에서 하나 만드세요.", diff --git a/web/src/i18n/pt.ts b/web/src/i18n/pt.ts index b84c99b67..109027bc7 100644 --- a/web/src/i18n/pt.ts +++ b/web/src/i18n/pt.ts @@ -141,6 +141,22 @@ export const pt: Translations = { "Esta ação remove permanentemente a conversa e todas as suas mensagens. Não é possível anular.", sessionDeleted: "Sessão eliminada", failedToDelete: "Falha ao eliminar a sessão", + deleteEmpty: "Eliminar vazias", + deleteEmptyConfirmTitle: "Eliminar sessões vazias?", + deleteEmptyConfirmMessage: + "Isto remove permanentemente {count} sessões sem mensagens. As sessões ativas e arquivadas são ignoradas. Esta ação não pode ser desfeita.", + emptySessionsDeleted: "{count} sessões vazias eliminadas", + failedToDeleteEmpty: "Falha ao eliminar sessões vazias", + selectSession: "Selecionar sessão", + selectAllOnPage: "Selecionar todas nesta página", + clearSelection: "Limpar seleção", + selectedCount: "{count} selecionadas", + deleteSelected: "Eliminar {count}", + deleteSelectedConfirmTitle: "Eliminar {count} sessões?", + deleteSelectedConfirmMessage: + "Isto remove permanentemente {count} sessões selecionadas e todas as suas mensagens. Não pode ser desfeito.", + selectedSessionsDeleted: "{count} sessões eliminadas", + failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas", resumeInChat: "Retomar no Chat", previousPage: "Página anterior", nextPage: "Página seguinte", @@ -211,6 +227,41 @@ export const pt: Translations = { promptPlaceholder: "O que deve o agente fazer em cada execução?", schedule: "Agendamento (expressão cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Agendamento", + scheduleModes: { + interval: "Intervalo recorrente", + daily: "Diariamente", + weekly: "Semanalmente", + monthly: "Mensalmente", + once: "Uma vez", + custom: "Personalizado (expressão cron)", + intervalEvery: "A cada", + intervalUnit: "Unidade", + unitMinutes: "minutos", + unitHours: "horas", + unitDays: "dias", + timeOfDay: "Hora do dia", + weekdays: "Dias da semana", + weekdaysShort: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"], + dayOfMonth: "Dia do mês", + onceAt: "Executar em", + customLabel: "Expressão cron", + customPlaceholder: "0 9 * * *", + customHint: + "Expressão cron de cinco campos (minuto, hora, dia, mês, dia da semana).", + preview: "Enviado como", + previewEmpty: "(incompleta)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "A cada {n} min", + everyHours: "A cada {n} h", + everyDays: "A cada {n} d", + dailyAt: "Diariamente às {time}", + weeklyAt: "Semanalmente {days} às {time}", + monthlyAt: "Mensalmente no dia {day} às {time}", + onceAt: "Uma vez em {time}", + }, deliverTo: "Entregar a", scheduledJobs: "Tarefas agendadas", noJobs: "Sem tarefas cron configuradas. Crie uma acima.", diff --git a/web/src/i18n/ru.ts b/web/src/i18n/ru.ts index e9b5e2cb8..51eaf774c 100644 --- a/web/src/i18n/ru.ts +++ b/web/src/i18n/ru.ts @@ -141,6 +141,22 @@ export const ru: Translations = { "Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.", sessionDeleted: "Сессия удалена", failedToDelete: "Не удалось удалить сессию", + deleteEmpty: "Удалить пустые", + deleteEmptyConfirmTitle: "Удалить пустые сессии?", + deleteEmptyConfirmMessage: + "Это безвозвратно удалит {count} сессий без сообщений. Активные и архивные сессии будут пропущены. Это действие нельзя отменить.", + emptySessionsDeleted: "Удалено пустых сессий: {count}", + failedToDeleteEmpty: "Не удалось удалить пустые сессии", + selectSession: "Выбрать сессию", + selectAllOnPage: "Выбрать все на этой странице", + clearSelection: "Снять выделение", + selectedCount: "Выбрано: {count}", + deleteSelected: "Удалить {count}", + deleteSelectedConfirmTitle: "Удалить {count} сессий?", + deleteSelectedConfirmMessage: + "Это безвозвратно удалит {count} выбранных сессий и все их сообщения. Это действие нельзя отменить.", + selectedSessionsDeleted: "Удалено сессий: {count}", + failedToDeleteSelected: "Не удалось удалить выбранные сессии", resumeInChat: "Продолжить в чате", previousPage: "Предыдущая страница", nextPage: "Следующая страница", @@ -211,6 +227,41 @@ export const ru: Translations = { promptPlaceholder: "Что должен делать агент при каждом запуске?", schedule: "Расписание (cron-выражение)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Расписание", + scheduleModes: { + interval: "Повторяющийся интервал", + daily: "Ежедневно", + weekly: "Еженедельно", + monthly: "Ежемесячно", + once: "Один раз", + custom: "Произвольное (cron-выражение)", + intervalEvery: "Каждые", + intervalUnit: "Единицы", + unitMinutes: "минут", + unitHours: "часов", + unitDays: "дней", + timeOfDay: "Время суток", + weekdays: "Дни недели", + weekdaysShort: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"], + dayOfMonth: "День месяца", + onceAt: "Выполнить в", + customLabel: "Cron-выражение", + customPlaceholder: "0 9 * * *", + customHint: + "Cron-выражение из пяти полей (минута, час, день, месяц, день недели).", + preview: "Отправляется как", + previewEmpty: "(не заполнено)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Каждые {n} мин", + everyHours: "Каждые {n} ч", + everyDays: "Каждые {n} дн", + dailyAt: "Ежедневно в {time}", + weeklyAt: "Еженедельно в {days} в {time}", + monthlyAt: "Ежемесячно {day} числа в {time}", + onceAt: "Один раз {time}", + }, deliverTo: "Доставить в", scheduledJobs: "Запланированные задачи", noJobs: "Cron-задачи не настроены. Создайте задачу выше.", diff --git a/web/src/i18n/tr.ts b/web/src/i18n/tr.ts index f9aaa14d4..85910c88e 100644 --- a/web/src/i18n/tr.ts +++ b/web/src/i18n/tr.ts @@ -141,6 +141,22 @@ export const tr: Translations = { "Bu, konuşmayı ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.", sessionDeleted: "Oturum silindi", failedToDelete: "Oturum silinemedi", + deleteEmpty: "Boşları sil", + deleteEmptyConfirmTitle: "Boş oturumlar silinsin mi?", + deleteEmptyConfirmMessage: + "Bu işlem, mesaj içermeyen {count} oturumu kalıcı olarak siler. Aktif ve arşivlenmiş oturumlar atlanır. Bu işlem geri alınamaz.", + emptySessionsDeleted: "{count} boş oturum silindi", + failedToDeleteEmpty: "Boş oturumlar silinemedi", + selectSession: "Oturumu seç", + selectAllOnPage: "Bu sayfadakilerin tümünü seç", + clearSelection: "Seçimi temizle", + selectedCount: "{count} seçildi", + deleteSelected: "{count} sil", + deleteSelectedConfirmTitle: "{count} oturum silinsin mi?", + deleteSelectedConfirmMessage: + "Bu, seçilen {count} oturumu ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.", + selectedSessionsDeleted: "{count} oturum silindi", + failedToDeleteSelected: "Seçilen oturumlar silinemedi", resumeInChat: "Sohbette Devam Et", previousPage: "Önceki sayfa", nextPage: "Sonraki sayfa", @@ -211,6 +227,41 @@ export const tr: Translations = { promptPlaceholder: "Agent her çalıştırmada ne yapmalı?", schedule: "Zamanlama (cron ifadesi)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Zamanlama", + scheduleModes: { + interval: "Tekrarlanan aralık", + daily: "Günlük", + weekly: "Haftalık", + monthly: "Aylık", + once: "Bir kez", + custom: "Özel (cron ifadesi)", + intervalEvery: "Her", + intervalUnit: "Birim", + unitMinutes: "dakika", + unitHours: "saat", + unitDays: "gün", + timeOfDay: "Günün saati", + weekdays: "Haftanın günleri", + weekdaysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"], + dayOfMonth: "Ayın günü", + onceAt: "Çalıştırma zamanı", + customLabel: "Cron ifadesi", + customPlaceholder: "0 9 * * *", + customHint: + "Beş alanlı cron ifadesi (dakika, saat, gün, ay, haftanın günü).", + preview: "Gönderilecek olan", + previewEmpty: "(eksik)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Her {n} dk", + everyHours: "Her {n} sa", + everyDays: "Her {n} gün", + dailyAt: "Her gün {time}", + weeklyAt: "Her hafta {days} {time}", + monthlyAt: "Her ayın {day} günü {time}", + onceAt: "{time} bir kez", + }, deliverTo: "Şuraya teslim et", scheduledJobs: "Zamanlanmış Görevler", noJobs: "Yapılandırılmış cron görevi yok. Yukarıdan bir tane oluşturun.", diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 15f2f1a0c..a35fcb898 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -158,6 +158,20 @@ export interface Translations { confirmDeleteMessage: string; sessionDeleted: string; failedToDelete: string; + deleteEmpty: string; + deleteEmptyConfirmTitle: string; + deleteEmptyConfirmMessage: string; + emptySessionsDeleted: string; + failedToDeleteEmpty: string; + selectSession: string; + selectAllOnPage: string; + clearSelection: string; + selectedCount: string; + deleteSelected: string; + deleteSelectedConfirmTitle: string; + deleteSelectedConfirmMessage: string; + selectedSessionsDeleted: string; + failedToDeleteSelected: string; resumeInChat: string; previousPage: string; nextPage: string; @@ -231,6 +245,40 @@ export interface Translations { promptPlaceholder: string; schedule: string; schedulePlaceholder: string; + scheduleMode: string; + scheduleModes: { + interval: string; + daily: string; + weekly: string; + monthly: string; + once: string; + custom: string; + intervalEvery: string; + intervalUnit: string; + unitMinutes: string; + unitHours: string; + unitDays: string; + timeOfDay: string; + weekdays: string; + weekdaysShort: [string, string, string, string, string, string, string]; + dayOfMonth: string; + onceAt: string; + customLabel: string; + customPlaceholder: string; + customHint: string; + preview: string; + previewEmpty: string; + }; + scheduleDescribe: { + none: string; + everyMinutes: string; + everyHours: string; + everyDays: string; + dailyAt: string; + weeklyAt: string; + monthlyAt: string; + onceAt: string; + }; deliverTo: string; scheduledJobs: string; noJobs: string; diff --git a/web/src/i18n/uk.ts b/web/src/i18n/uk.ts index 8d67f58ec..ce1a4babf 100644 --- a/web/src/i18n/uk.ts +++ b/web/src/i18n/uk.ts @@ -141,6 +141,22 @@ export const uk: Translations = { "Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.", sessionDeleted: "Сесію видалено", failedToDelete: "Не вдалося видалити сесію", + deleteEmpty: "Видалити порожні", + deleteEmptyConfirmTitle: "Видалити порожні сесії?", + deleteEmptyConfirmMessage: + "Це остаточно видалить {count} сесій без повідомлень. Активні та архівні сесії пропускаються. Цю дію неможливо скасувати.", + emptySessionsDeleted: "Видалено порожніх сесій: {count}", + failedToDeleteEmpty: "Не вдалося видалити порожні сесії", + selectSession: "Вибрати сесію", + selectAllOnPage: "Вибрати всі на цій сторінці", + clearSelection: "Скинути вибір", + selectedCount: "Вибрано: {count}", + deleteSelected: "Видалити {count}", + deleteSelectedConfirmTitle: "Видалити {count} сесій?", + deleteSelectedConfirmMessage: + "Це назавжди видалить {count} вибраних сесій і всі їхні повідомлення. Цю дію неможливо скасувати.", + selectedSessionsDeleted: "Видалено сесій: {count}", + failedToDeleteSelected: "Не вдалося видалити вибрані сесії", resumeInChat: "Продовжити в чаті", previousPage: "Попередня сторінка", nextPage: "Наступна сторінка", @@ -211,6 +227,41 @@ export const uk: Translations = { promptPlaceholder: "Що агент має робити при кожному запуску?", schedule: "Розклад (cron-вираз)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "Розклад", + scheduleModes: { + interval: "Повторюваний інтервал", + daily: "Щодня", + weekly: "Щотижня", + monthly: "Щомісяця", + once: "Один раз", + custom: "Користувацьке (cron-вираз)", + intervalEvery: "Кожні", + intervalUnit: "Одиниці", + unitMinutes: "хвилин", + unitHours: "годин", + unitDays: "днів", + timeOfDay: "Час доби", + weekdays: "Дні тижня", + weekdaysShort: ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"], + dayOfMonth: "День місяця", + onceAt: "Виконати о", + customLabel: "Cron-вираз", + customPlaceholder: "0 9 * * *", + customHint: + "П'ятиполевий cron-вираз (хвилина, година, день, місяць, день тижня).", + preview: "Надсилається як", + previewEmpty: "(не заповнено)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "Кожні {n} хв", + everyHours: "Кожні {n} год", + everyDays: "Кожні {n} дн", + dailyAt: "Щодня о {time}", + weeklyAt: "Щотижня у {days} о {time}", + monthlyAt: "Щомісяця {day} числа о {time}", + onceAt: "Один раз {time}", + }, deliverTo: "Надіслати на", scheduledJobs: "Заплановані завдання", noJobs: "Cron-завдань не налаштовано. Створіть одне вище.", diff --git a/web/src/i18n/zh-hant.ts b/web/src/i18n/zh-hant.ts index e569b27a4..e2c4ff725 100644 --- a/web/src/i18n/zh-hant.ts +++ b/web/src/i18n/zh-hant.ts @@ -141,6 +141,22 @@ export const zhHant: Translations = { "此操作將永久移除對話及其所有訊息,無法復原。", sessionDeleted: "工作階段已刪除", failedToDelete: "刪除工作階段失敗", + deleteEmpty: "刪除空工作階段", + deleteEmptyConfirmTitle: "刪除空工作階段?", + deleteEmptyConfirmMessage: + "這將永久刪除 {count} 個沒有訊息的工作階段。活動中與已封存的工作階段將被略過。此動作無法復原。", + emptySessionsDeleted: "已刪除 {count} 個空工作階段", + failedToDeleteEmpty: "刪除空工作階段失敗", + selectSession: "選擇工作階段", + selectAllOnPage: "全選本頁", + clearSelection: "清除選擇", + selectedCount: "已選擇 {count} 個", + deleteSelected: "刪除 {count} 個", + deleteSelectedConfirmTitle: "刪除 {count} 個工作階段?", + deleteSelectedConfirmMessage: + "此操作將永久刪除所選的 {count} 個工作階段及其所有訊息。無法復原。", + selectedSessionsDeleted: "已刪除 {count} 個工作階段", + failedToDeleteSelected: "刪除所選工作階段失敗", resumeInChat: "在對話中繼續", previousPage: "上一頁", nextPage: "下一頁", @@ -211,6 +227,40 @@ export const zhHant: Translations = { promptPlaceholder: "代理每次執行時應做什麼?", schedule: "排程(cron 運算式)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "排程", + scheduleModes: { + interval: "重複間隔", + daily: "每日", + weekly: "每週", + monthly: "每月", + once: "僅一次", + custom: "自訂(cron 運算式)", + intervalEvery: "每", + intervalUnit: "單位", + unitMinutes: "分鐘", + unitHours: "小時", + unitDays: "天", + timeOfDay: "時間", + weekdays: "星期", + weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"], + dayOfMonth: "日期", + onceAt: "執行時間", + customLabel: "cron 運算式", + customPlaceholder: "0 9 * * *", + customHint: "五欄位 cron 運算式(分、時、日、月、星期)。", + preview: "傳送為", + previewEmpty: "(未完成)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "每 {n} 分鐘", + everyHours: "每 {n} 小時", + everyDays: "每 {n} 天", + dailyAt: "每天 {time}", + weeklyAt: "每週 {days} {time}", + monthlyAt: "每月{day} {time}", + onceAt: "{time} 執行一次", + }, deliverTo: "傳送至", scheduledJobs: "已排程任務", noJobs: "尚未設定排程任務。請於上方建立。", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 5bc5ae493..d60dea816 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -139,6 +139,22 @@ export const zh: Translations = { confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。", sessionDeleted: "会话已删除", failedToDelete: "删除会话失败", + deleteEmpty: "删除空会话", + deleteEmptyConfirmTitle: "删除空会话?", + deleteEmptyConfirmMessage: + "这将永久删除 {count} 个没有消息的会话。活动和已归档的会话将被跳过。此操作无法撤销。", + emptySessionsDeleted: "已删除 {count} 个空会话", + failedToDeleteEmpty: "删除空会话失败", + selectSession: "选择会话", + selectAllOnPage: "全选本页", + clearSelection: "清除选择", + selectedCount: "已选择 {count} 个", + deleteSelected: "删除 {count} 个", + deleteSelectedConfirmTitle: "删除 {count} 个会话?", + deleteSelectedConfirmMessage: + "此操作将永久删除所选的 {count} 个会话及其所有消息。无法撤销。", + selectedSessionsDeleted: "已删除 {count} 个会话", + failedToDeleteSelected: "删除所选会话失败", resumeInChat: "在对话中继续", previousPage: "上一页", nextPage: "下一页", @@ -208,6 +224,40 @@ export const zh: Translations = { promptPlaceholder: "代理每次运行时应执行什么操作?", schedule: "调度表达式(cron)", schedulePlaceholder: "0 9 * * *", + scheduleMode: "调度", + scheduleModes: { + interval: "重复间隔", + daily: "每天", + weekly: "每周", + monthly: "每月", + once: "仅一次", + custom: "自定义(cron 表达式)", + intervalEvery: "每", + intervalUnit: "单位", + unitMinutes: "分钟", + unitHours: "小时", + unitDays: "天", + timeOfDay: "时间", + weekdays: "星期", + weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"], + dayOfMonth: "日期", + onceAt: "执行时间", + customLabel: "cron 表达式", + customPlaceholder: "0 9 * * *", + customHint: "五字段 cron 表达式(分、时、日、月、星期)。", + preview: "发送为", + previewEmpty: "(未完成)", + }, + scheduleDescribe: { + none: "—", + everyMinutes: "每 {n} 分钟", + everyHours: "每 {n} 小时", + everyDays: "每 {n} 天", + dailyAt: "每天 {time}", + weeklyAt: "每周 {days} {time}", + monthlyAt: "每月{day} {time}", + onceAt: "{time} 执行一次", + }, deliverTo: "投递至", scheduledJobs: "已调度任务", noJobs: "暂无定时任务。在上方创建一个。", diff --git a/web/src/index.css b/web/src/index.css index 342a4856f..1bbb9c4dd 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -83,6 +83,15 @@ --theme-radius: 0.5rem; --theme-spacing-mul: 1; --theme-density: comfortable; + + /* Data-series accents — consumed by Analytics + Models pages for the + input-vs-output token visualisations (chart bars, table values, + legend swatches). Defaults are tuned for the Hermes-teal LENS_0 + look: cream input + emerald-400 output read as warm/cool against + the dark canvas. Themes override via ThemeProvider, which emits + these as `--series-input-token` / `--series-output-token`. */ + --series-input-token: #ffe6cb; + --series-output-token: #34d399; } /* Theme tokens cascade into the document root so every descendant inherits diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6202b9f28..f22a39613 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -238,6 +238,18 @@ export const api = { fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, { method: "DELETE", }), + getEmptySessionsCount: () => + fetchJSON<{ count: number }>("/api/sessions/empty/count"), + deleteEmptySessions: () => + fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", { + method: "DELETE", + }), + bulkDeleteSessions: (ids: string[]) => + fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids }), + }), renameSession: (id: string, title: string) => fetchJSON<{ ok: boolean; title: string }>( `/api/sessions/${encodeURIComponent(id)}`, diff --git a/web/src/lib/schedule.ts b/web/src/lib/schedule.ts new file mode 100644 index 000000000..d36fa52c2 --- /dev/null +++ b/web/src/lib/schedule.ts @@ -0,0 +1,382 @@ +/** + * Schedule builder helpers for the cron page. + * + * The hermes-agent backend (cron/jobs.py::parse_schedule) accepts a + * surprisingly broad set of string formats: + * + * - Duration (one-shot): "30m", "2h", "1d" + * - Interval (recurring): "every 30m", "every 2h", "every 1d" + * - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5" + * - ISO timestamp (one-shot): "2026-02-03T14:00:00" + * + * Power users can hand-type any of those, but for everyone else the + * dashboard now offers a human-readable picker. This module is the + * pure logic layer behind that picker: + * + * - {@link buildScheduleString} turns the picker's structured state + * into one of the strings above. + * - {@link describeSchedule} goes the other way: takes the structured + * schedule shape the API returns (``CronJob.schedule``) and produces + * a human-readable sentence for the job list. It recognises common + * cron-expression shapes (daily/weekly/monthly) so users don't have + * to parse "30 14 * * 1,3,5" by eye. + * + * Kept dependency-free and locale-string-driven so it tree-shakes + * cleanly and is testable in isolation if we ever wire up vitest here. + */ + +/** Picker modes — each renders a different set of inputs in the UI but + * all funnel through {@link buildScheduleString} to a backend-compatible + * string. ``custom`` is the escape hatch for power users who still want + * to type a raw cron expression. */ +export type ScheduleMode = + | "interval" + | "daily" + | "weekly" + | "monthly" + | "once" + | "custom"; + +/** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */ +export type IntervalUnit = "minutes" | "hours" | "days"; + +/** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what + * croniter expects on the backend (no need to remap on submit). */ +export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const; +export type Weekday = (typeof WEEKDAY_INDEXES)[number]; + +export interface ScheduleBuilderState { + /** Index of which "custom" radio is selected. */ + mode: ScheduleMode; + + /** Interval mode: positive integer, paired with ``intervalUnit``. */ + intervalValue: number; + intervalUnit: IntervalUnit; + + /** Daily/weekly/monthly mode: "HH:MM" 24h format from . */ + timeOfDay: string; + + /** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is + * still valid — we send "*" for the day-of-week cron field. */ + weekdays: Weekday[]; + + /** Monthly mode: 1..31 (no support for "last day of month" sugar — the + * croniter ``L`` extension isn't enabled in the parse_schedule regex). */ + dayOfMonth: number; + + /** Once mode: ``YYYY-MM-DDTHH:MM`` from . */ + onceAt: string; + + /** Custom mode: raw user-typed cron expression. Stored separately so + * flipping between modes doesn't erase the user's work. */ + custom: string; +} + +/** Default state — "every 30 minutes" is the most-common-cron-pattern + * starting point and avoids forcing the user to pick everything from + * scratch. */ +export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = { + mode: "interval", + intervalValue: 30, + intervalUnit: "minutes", + timeOfDay: "09:00", + weekdays: [1, 2, 3, 4, 5], + dayOfMonth: 1, + onceAt: "", + custom: "", +}; + +const UNIT_SUFFIX: Record = { + minutes: "m", + hours: "h", + days: "d", +}; + +/** Build the schedule string from picker state. Returns ``""`` when the + * state is incomplete enough that the backend would 400 — the caller + * uses that to disable the Submit button. + * + * Why we lean on the broad parse_schedule grammar instead of always + * emitting cron expressions: interval syntax ("every 30m") survives a + * backend without ``croniter`` installed and renders more readably in + * the job list. We only emit raw cron when the picker truly needs the + * cron field expressiveness (specific weekdays, specific day-of-month). */ +export function buildScheduleString(state: ScheduleBuilderState): string { + switch (state.mode) { + case "interval": { + const n = Math.floor(state.intervalValue); + if (!Number.isFinite(n) || n < 1) return ""; + return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`; + } + case "daily": { + const parsed = parseTimeOfDay(state.timeOfDay); + if (!parsed) return ""; + return `${parsed.minute} ${parsed.hour} * * *`; + } + case "weekly": { + const parsed = parseTimeOfDay(state.timeOfDay); + if (!parsed) return ""; + // Empty weekday selection → "*" (every day) rather than a backend + // 400. The Daily mode is the cleaner choice for that, but if the + // user toggles all days off in Weekly mode we still emit a valid + // expression instead of breaking the submit. + const days = + state.weekdays.length === 0 + ? "*" + : [...state.weekdays].sort((a, b) => a - b).join(","); + return `${parsed.minute} ${parsed.hour} * * ${days}`; + } + case "monthly": { + const parsed = parseTimeOfDay(state.timeOfDay); + if (!parsed) return ""; + const dom = Math.floor(state.dayOfMonth); + if (!Number.isFinite(dom) || dom < 1 || dom > 31) return ""; + return `${parsed.minute} ${parsed.hour} ${dom} * *`; + } + case "once": { + const v = state.onceAt.trim(); + if (!v) return ""; + // already emits the + // "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly. + // Append ":00" so the backend's regex hits the "T" branch and + // the seconds component lines up with isoformat() output. + return v.length === 16 ? `${v}:00` : v; + } + case "custom": + return state.custom.trim(); + } +} + +function parseTimeOfDay(value: string): { hour: number; minute: number } | null { + if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null; + const [hh, mm] = value.split(":"); + const hour = parseInt(hh, 10); + const minute = parseInt(mm, 10); + if ( + !Number.isFinite(hour) || + !Number.isFinite(minute) || + hour < 0 || + hour > 23 || + minute < 0 || + minute > 59 + ) { + return null; + } + return { hour, minute }; +} + +/** Translation surface the human-readable describer needs. Passing it + * in (instead of importing ``useI18n``) keeps the helper pure and + * testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */ +export interface ScheduleDescribeStrings { + /** Display when no schedule can be resolved (e.g. legacy/blank job). */ + none: string; + /** "Every {n} minute(s)" — caller pluralises via {n}. */ + everyMinutes: string; + everyHours: string; + everyDays: string; + /** "Daily at {time}" */ + dailyAt: string; + /** "Weekly on {days} at {time}" */ + weeklyAt: string; + /** "Monthly on the {day} at {time}" */ + monthlyAt: string; + /** "Once at {time}" */ + onceAt: string; + /** Weekday short names indexed 0..6 (Sunday-first). */ + weekdaysShort: [string, string, string, string, string, string, string]; + /** Ordinal suffix builder, e.g. "1st", "22nd". For locales that + * don't use English ordinals, just return ``String(day)``. */ + ordinal: (day: number) => string; +} + +/** Schedule shape stored on a ``CronJob`` row (see api.ts). */ +export interface ScheduleLike { + kind?: string; + expr?: string; + minutes?: number; + run_at?: string; + display?: string; +} + +/** Human-readable description of a stored schedule. + * + * Prefers a structured render over the raw ``display`` string so cron + * expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed, + * Fri at 14:30" instead of the raw five-field gibberish. Falls back to + * ``display`` / ``expr`` / ``none`` in that order if we can't make sense + * of the schedule (e.g. exotic cron with ranges, step values, or @reboot + * macros that we'd misrepresent if we tried to "humanize"). */ +export function describeSchedule( + schedule: ScheduleLike | undefined, + fallbackDisplay: string | undefined, + strings: ScheduleDescribeStrings, +): string { + if (!schedule) return fallbackDisplay || strings.none; + + if (schedule.kind === "interval" && typeof schedule.minutes === "number") { + return describeInterval(schedule.minutes, strings); + } + + if (schedule.kind === "once" && schedule.run_at) { + return strings.onceAt.replace( + "{time}", + formatIsoLocal(schedule.run_at, false), + ); + } + + if (schedule.kind === "cron" && schedule.expr) { + const cronDesc = describeCronExpression(schedule.expr, strings); + if (cronDesc) return cronDesc; + } + + // Try the raw expression as a last attempt — for legacy jobs stored + // without ``kind``, the ``schedule_display`` field often *is* the cron + // expression. + if (fallbackDisplay) { + const cronDesc = describeCronExpression(fallbackDisplay, strings); + if (cronDesc) return cronDesc; + return fallbackDisplay; + } + if (schedule.display) return schedule.display; + if (schedule.expr) return schedule.expr; + return strings.none; +} + +function describeInterval( + minutes: number, + strings: ScheduleDescribeStrings, +): string { + if (minutes <= 0) return strings.none; + if (minutes % 1440 === 0) { + return strings.everyDays.replace("{n}", String(minutes / 1440)); + } + if (minutes % 60 === 0) { + return strings.everyHours.replace("{n}", String(minutes / 60)); + } + return strings.everyMinutes.replace("{n}", String(minutes)); +} + +/** Recognise the common, well-shaped cron patterns and return a + * human sentence for them. Returns ``null`` when the expression has any + * ranges, steps, or other complexity that would be misleading to + * "humanize" — caller falls back to displaying the raw expression so + * the user sees what's actually scheduled. + * + * Strictly 5-field only: the backend ``parse_schedule`` also accepts the + * 6-field ``minute hour dom month dow year`` form, but humanising those + * by destructuring only the first five fields would silently drop the + * year and mislead the user (e.g. ``0 9 * * * 2099`` would read as + * "Daily at 09:00"). 6+ field expressions intentionally fall through to + * the raw-string fallback in {@link describeSchedule}. */ +function describeCronExpression( + expr: string, + strings: ScheduleDescribeStrings, +): string | null { + const parts = expr.trim().split(/\s+/); + if (parts.length !== 5) return null; + const [minField, hourField, domField, monField, dowField] = parts; + + const month = monField === "*"; + if (!month) return null; // we don't try to humanize per-month rules + + const isLiteralOrList = (f: string) => + /^\d+(,\d+)*$/.test(f) || /^\*$/.test(f); + if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null; + if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null; + + // Star minutes/hours would mean "every minute" / "every hour" — we'd + // need a step-value handler ("*/15") to describe that cleanly, and + // that path is power-user territory. Bail to raw display. + if (minField === "*" || hourField === "*") return null; + + const minutes = minField.split(",").map((n) => parseInt(n, 10)); + const hours = hourField.split(",").map((n) => parseInt(n, 10)); + if (minutes.length !== 1 || hours.length !== 1) return null; + if ( + !Number.isFinite(minutes[0]) || + !Number.isFinite(hours[0]) || + hours[0] < 0 || + hours[0] > 23 || + minutes[0] < 0 || + minutes[0] > 59 + ) { + return null; + } + const time = `${pad2(hours[0])}:${pad2(minutes[0])}`; + + const domAll = domField === "*"; + const dowAll = dowField === "*"; + + if (domAll && dowAll) { + return strings.dailyAt.replace("{time}", time); + } + + if (domAll && !dowAll) { + const days = dowField + .split(",") + .map((n) => parseInt(n, 10)) + .filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[]; + if (days.length === 0) return null; + const labels = days + .map((d) => strings.weekdaysShort[d]) + .filter(Boolean) + .join(", "); + return strings.weeklyAt + .replace("{days}", labels) + .replace("{time}", time); + } + + if (!domAll && dowAll) { + const dom = parseInt(domField, 10); + if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null; + return strings.monthlyAt + .replace("{day}", strings.ordinal(dom)) + .replace("{time}", time); + } + + // Both day-of-month AND day-of-week set is unusual and cron's + // OR-semantics for that combo are confusing — fall back to raw. + return null; +} + +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); +} + +/** Format an ISO date for inline display. Drops the seconds + TZ + * suffix so the cron list stays compact. Falls back to the raw string + * if Date parsing fails. */ +function formatIsoLocal(iso: string, includeSeconds: boolean): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return iso; + const yyyy = d.getFullYear(); + const mm = pad2(d.getMonth() + 1); + const dd = pad2(d.getDate()); + const hh = pad2(d.getHours()); + const mi = pad2(d.getMinutes()); + if (includeSeconds) { + return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`; + } + return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; +} + +/** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd"). + * Most non-English locales should just return ``String(day)`` from + * their ``ordinal`` override. */ +export function englishOrdinal(day: number): string { + const d = Math.floor(day); + if (!Number.isFinite(d) || d < 1) return String(day); + const lastTwo = d % 100; + if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`; + switch (d % 10) { + case 1: + return `${d}st`; + case 2: + return `${d}nd`; + case 3: + return `${d}rd`; + default: + return `${d}th`; + } +} diff --git a/web/src/pages/AnalyticsPage.tsx b/web/src/pages/AnalyticsPage.tsx index b9851c227..2583be969 100644 --- a/web/src/pages/AnalyticsPage.tsx +++ b/web/src/pages/AnalyticsPage.tsx @@ -21,7 +21,6 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { Stats } from "@nous-research/ui/ui/components/stats"; import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card"; -import { Badge } from "@nous-research/ui/ui/components/badge"; import { usePageHeader } from "@/contexts/usePageHeader"; import { useI18n } from "@/i18n"; import { PluginSlot } from "@/plugins"; @@ -148,11 +147,17 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
-
+
{t.analytics.input}
-
+
{t.analytics.output}
@@ -192,13 +197,19 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
0 ? 1 : 0) }} + className="w-full" + style={{ + backgroundColor: + "color-mix(in srgb, var(--series-input-token) 70%, transparent)", + height: Math.max(inputH, total > 0 ? 1 : 0), + }} />
0 ? 1 : 0), }} /> @@ -261,12 +272,12 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) { {d.sessions} - + {formatTokens(d.input_tokens)} - + {formatTokens(d.output_tokens)} @@ -319,11 +330,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) { {m.sessions} - + {formatTokens(m.input_tokens)} {" / "} - + {formatTokens(m.output_tokens)} @@ -427,14 +438,24 @@ export default function AnalyticsPage() { }, [days, showTokens]); useLayoutEffect(() => { - const periodLabel = - PERIODS.find((p) => p.days === days)?.label ?? `${days}d`; + // Period selector + refresh both live in afterTitle so the controls + // sit immediately next to the page title instead of being pinned to + // the far-right `end` slot. The active period is conveyed by the + // filled (non-outlined) button — no redundant period badge. setAfterTitle( - - - {periodLabel} - - {showTokens !== false && ( + showTokens === false ? null : ( +
+ {PERIODS.map((p) => ( + + ))} - )} - , - ); - setEnd( - showTokens === false ? null : ( -
-
- {PERIODS.map((p) => ( - - ))} -
), ); + setEnd(null); return () => { setAfterTitle(null); setEnd(null); diff --git a/web/src/pages/CronPage.tsx b/web/src/pages/CronPage.tsx index 741d6d5be..47c6ee843 100644 --- a/web/src/pages/CronPage.tsx +++ b/web/src/pages/CronPage.tsx @@ -8,6 +8,17 @@ import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { api } from "@/lib/api"; import type { CronJob, ProfileInfo } from "@/lib/api"; import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog"; +import { + DEFAULT_SCHEDULE_STATE, + ScheduleBuilder, +} from "@/components/ScheduleBuilder"; +import { + buildScheduleString, + describeSchedule, + englishOrdinal, + type ScheduleBuilderState, + type ScheduleDescribeStrings, +} from "@/lib/schedule"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; import { useModalBehavior } from "@/hooks/useModalBehavior"; @@ -57,12 +68,20 @@ function getJobTitle(job: CronJob): string { return job.id || "Cron job"; } -function getJobScheduleDisplay(job: CronJob): string { - return ( - asText(job.schedule_display) || - asText(job.schedule?.display) || - asText(job.schedule?.expr) || - "—" +function getJobScheduleDisplay( + job: CronJob, + strings: ScheduleDescribeStrings, +): string { + // Prefer a structured render so cron expressions like + // ``30 14 * * 1,3,5`` surface as "Weekly on Mon, Wed, Fri at 14:30" + // in the list instead of the raw five-field gibberish. Falls back + // through the existing chain (``schedule_display`` from the backend, + // then the structured ``display`` field, then the raw ``expr``) so + // legacy job rows still render *something* meaningful. + return describeSchedule( + job.schedule, + asText(job.schedule_display) || asText(job.schedule?.display), + strings, ); } @@ -102,13 +121,35 @@ export default function CronPage() { const [selectedProfile, setSelectedProfile] = useState("all"); const [loading, setLoading] = useState(true); const { toast, showToast } = useToast(); - const { t } = useI18n(); + const { t, locale } = useI18n(); const { setEnd } = usePageHeader(); + // Translation surface for the human-readable schedule describer. + // English ordinals are a special case ("1st", "2nd", "23rd"); every + // other locale falls back to the plain numeric form, which avoids + // shipping incorrect grammar (e.g. naive "1th"/"2th" suffixes that + // don't exist in most languages). + // + // Built inline (not memoized) — the cron page renders a small job + // list, this is single-digit microseconds, and a useMemo here would + // just add boilerplate. + const scheduleDescribeStrings: ScheduleDescribeStrings = { + ...t.cron.scheduleDescribe, + weekdaysShort: t.cron.scheduleModes.weekdaysShort, + ordinal: locale === "en" ? englishOrdinal : (n: number) => String(n), + }; + // New job modal state const [createModalOpen, setCreateModalOpen] = useState(false); const [prompt, setPrompt] = useState(""); - const [schedule, setSchedule] = useState(""); + // The schedule is now constructed via the ScheduleBuilder; we keep + // the full builder state so flipping between modes during edit + // doesn't erase the user's intermediate inputs. The actual string + // sent to the backend is derived via ``buildScheduleString`` at + // submit time. + const [scheduleState, setScheduleState] = useState( + DEFAULT_SCHEDULE_STATE, + ); const [name, setName] = useState(""); const closeCreateModal = useCallback(() => setCreateModalOpen(false), []); const createModalRef = useModalBehavior({ @@ -161,8 +202,10 @@ export default function CronPage() { loadJobs(); }, [loadJobs]); + const scheduleString = buildScheduleString(scheduleState); + const handleCreate = async () => { - if (!prompt.trim() || !schedule.trim()) { + if (!prompt.trim() || !scheduleString) { showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error"); return; } @@ -171,7 +214,7 @@ export default function CronPage() { await api.createCronJob( { prompt: prompt.trim(), - schedule: schedule.trim(), + schedule: scheduleString, name: name.trim() || undefined, deliver, }, @@ -179,7 +222,7 @@ export default function CronPage() { ); showToast(t.common.create + " ✓", "success"); setPrompt(""); - setSchedule(""); + setScheduleState(DEFAULT_SCHEDULE_STATE); setName(""); setDeliver("local"); setCreateModalOpen(false); @@ -392,41 +435,34 @@ export default function CronPage() { />
-
-
- - setSchedule(e.target.value)} - /> -
+ -
- - -
+
+ +
@@ -617,7 +653,9 @@ export default function CronPage() {

)}
- {getJobScheduleDisplay(job)} + + {getJobScheduleDisplay(job, scheduleDescribeStrings)} + {t.cron.last}: {formatTime(job.last_run_at)} diff --git a/web/src/pages/ModelsPage.tsx b/web/src/pages/ModelsPage.tsx index f0e81d0f0..50cd69515 100644 --- a/web/src/pages/ModelsPage.tsx +++ b/web/src/pages/ModelsPage.tsx @@ -95,11 +95,17 @@ function TokenBar({ const total = input + output + cacheRead + reasoning; if (total === 0) return null; - const segments = [ - { value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" }, - { value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" }, - { value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" }, - { value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" }, + // Segments carry a CSS color value (hex or `var(--token)`) rather than + // a Tailwind class so the input/output series can pick up the active + // theme's `--series-*-token` vars — see `themes/types.ts` + // `ThemeSeriesColors`. The /60–/70 fade on the bar is applied via + // color-mix on the same value so themes don't need to ship two + // separate hex literals. + const segments: Array<{ color: string; label: string; value: number }> = [ + { value: cacheRead, color: "#60a5fa", label: "Cache Read" }, // tailwind blue-400 + { value: reasoning, color: "#c084fc", label: "Reasoning" }, // tailwind purple-400 + { value: input, color: "var(--series-input-token)", label: "Input" }, + { value: output, color: "var(--series-output-token)", label: "Output" }, ].filter((s) => s.value > 0); return ( @@ -109,8 +115,11 @@ function TokenBar({ {segments.map((s, i) => (
{/* Stepped fill pattern overlay */}
{segments.map((s, i) => ( - + {s.label} {formatTokens(s.value)} ))} @@ -152,7 +164,7 @@ function CapabilityBadges({ return (
{capabilities.supports_tools && ( - + Tools )} @@ -818,13 +830,24 @@ export default function ModelsPage() { }, []); useLayoutEffect(() => { - const periodLabel = - PERIODS.find((p) => p.days === days)?.label ?? `${days}d`; + // Period selector + refresh both live in afterTitle so the controls + // sit immediately next to the page title instead of being pinned to + // the far-right `end` slot. The active period is conveyed by the + // filled (non-outlined) button — no redundant period badge. setAfterTitle( - - - {periodLabel} - +
+ {PERIODS.map((p) => ( + + ))} - , - ); - setEnd( -
-
- {PERIODS.map((p) => ( - - ))} -
, ); + setEnd(null); return () => { setAfterTitle(null); setEnd(null); diff --git a/web/src/pages/SessionsPage.tsx b/web/src/pages/SessionsPage.tsx index f9cefd337..34a68800d 100644 --- a/web/src/pages/SessionsPage.tsx +++ b/web/src/pages/SessionsPage.tsx @@ -23,6 +23,7 @@ import { Hash, X, Play, + Eraser, Download, Pencil, Check, @@ -41,6 +42,7 @@ import { Markdown } from "@/components/Markdown"; import { PlatformsCard } from "@/components/PlatformsCard"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { Button } from "@nous-research/ui/ui/components/button"; +import { Checkbox } from "@nous-research/ui/ui/components/checkbox"; import { ListItem } from "@nous-research/ui/ui/components/list-item"; import { Segmented } from "@nous-research/ui/ui/components/segmented"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; @@ -273,22 +275,14 @@ function SessionRow({ snippet, searchQuery, isExpanded, + isSelected, onToggle, + onSelectClick, onDelete, onRename, onExport, resumeInChatEnabled, -}: { - session: SessionInfo; - snippet?: string; - searchQuery?: string; - isExpanded: boolean; - onToggle: () => void; - onDelete: () => void; - onRename: (id: string, title: string) => Promise; - onExport: (id: string) => void; - resumeInChatEnabled: boolean; -}) { +}: SessionRowProps) { const [messages, setMessages] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -400,18 +394,44 @@ function SessionRow({ ); + // Selected rows get a stronger left-edge accent + tinted background so the + // selection state is unambiguous even when scrolling past the bulk-action + // bar at the top. Beat the is_active styling — explicit user selection + // takes priority over "this session is live". + const containerClasses = isSelected + ? "border-primary/40 bg-primary/[0.06]" + : session.is_active + ? "border-success/30 bg-success/[0.03]" + : "border-border"; + + // Clicking the checkbox must NOT toggle row expansion; selection and + // expansion are independent gestures. We bind ``onClick`` directly on + // the Checkbox (which Radix forwards to its underlying `` + )}
{showPagination && ( @@ -1126,12 +1404,77 @@ export default function SessionsPage() { className="shrink-0 sm:ml-auto" page={page} total={total} - onPageChange={setPage} + onPageChange={goToPage} /> )}
) : null} + {showList && selectedIds.size > 0 && ( +
+ + {t.sessions.selectedCount.replace( + "{count}", + String(selectedIds.size), + )} + + {filtered.some((s) => !selectedIds.has(s.id)) && ( + + )} + + +
+ )} + {showList ? ( filtered.length === 0 ? (
@@ -1148,16 +1491,20 @@ export default function SessionsPage() { ) : ( <>
- {filtered.map((s) => ( + {filtered.map((s, index) => ( setExpandedId((prev) => (prev === s.id ? null : s.id)) } + onSelectClick={(event) => + handleSelectClick(event, index, filtered) + } onDelete={() => sessionDelete.requestDelete(s.id)} onRename={handleRename} onExport={handleExport} @@ -1170,7 +1517,7 @@ export default function SessionsPage() { )} @@ -1238,6 +1585,20 @@ export default function SessionsPage() { ); } +interface SessionRowProps { + isExpanded: boolean; + isSelected: boolean; + onDelete: () => void; + onExport: (id: string) => void; + onRename: (id: string, title: string) => Promise; + onSelectClick: (event: React.MouseEvent) => void; + onToggle: () => void; + resumeInChatEnabled: boolean; + searchQuery?: string; + session: SessionInfo; + snippet?: string; +} + interface SessionsPaginationProps { className?: string; compact?: boolean; diff --git a/web/src/pages/SystemPage.tsx b/web/src/pages/SystemPage.tsx index d8a5b0e15..4fc09c71b 100644 --- a/web/src/pages/SystemPage.tsx +++ b/web/src/pages/SystemPage.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; import { Activity, Brain, @@ -21,12 +22,12 @@ import { } from "lucide-react"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Button } from "@nous-research/ui/ui/components/button"; -import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Spinner } from "@nous-research/ui/ui/components/spinner"; import { H2 } from "@nous-research/ui/ui/components/typography/h2"; import { Card, CardContent } from "@nous-research/ui/ui/components/card"; import { Input } from "@nous-research/ui/ui/components/input"; import { Label } from "@nous-research/ui/ui/components/label"; +import { Select, SelectOption } from "@nous-research/ui/ui/components/select"; import { Toast } from "@nous-research/ui/ui/components/toast"; import { useToast } from "@nous-research/ui/hooks/use-toast"; import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete"; @@ -236,16 +237,9 @@ export default function SystemPage() { }; // ── Memory ───────────────────────────────────────────────────────── - const setMemoryProvider = async (provider: string) => { - try { - await api.setMemoryProvider(provider); - showToast(`Memory provider: ${provider || "built-in only"}`, "success"); - loadAll(); - } catch (e) { - showToast(`Failed to set provider: ${e}`, "error"); - } - }; - + // Memory provider selection lives on the /plugins page now (see the + // read-only display + link below); the dropdown was intentionally + // dropped from this card during the admin-panel refresh. const memoryReset = useConfirmDelete({ onDelete: useCallback( async (target: string) => { @@ -748,26 +742,22 @@ export default function SystemPage() { -
- - -

- Set up a new provider's credentials with{" "} - hermes memory setup. -

+
+ + External provider:{" "} + + {memory?.active || "built-in only"} + + + + Change in Plugins → + + + New credentials:{" "} + hermes memory setup +
+
Built-in files — MEMORY.md:{" "} diff --git a/web/src/themes/context.tsx b/web/src/themes/context.tsx index 7af100206..9f3161b41 100644 --- a/web/src/themes/context.tsx +++ b/web/src/themes/context.tsx @@ -19,6 +19,7 @@ import type { ThemeLayoutVariant, ThemeListEntry, ThemePalette, + ThemeSeriesColors, ThemeTypography, } from "./types"; import { api } from "@/lib/api"; @@ -27,6 +28,21 @@ import { api } from "@/lib/api"; * a visible flash of the default palette on theme-overridden installs. */ const STORAGE_KEY = "hermes-dashboard-theme"; +/** Renames of built-in theme keys we've shipped previously. Without this, + * users who saved one of the old names in localStorage (or had it + * persisted server-side) would silently fall back to `defaultTheme` + * because the lookup in `resolveTheme` no longer finds the stale key. + * Keep entries here until enough release cycles have passed that we can + * reasonably assume nobody still has the old value persisted. */ +const THEME_NAME_ALIASES: Record = { + // Renamed during the LENS_5I port + Nous-blue rebrand. + "lens-5i": "nous-blue", +}; + +function migrateThemeName(name: string): string { + return THEME_NAME_ALIASES[name] ?? name; +} + /** Tracks fontUrls we've already injected so multiple theme switches don't * pile up tags. Keyed by URL. */ const INJECTED_FONT_URLS = new Set(); @@ -126,6 +142,30 @@ function overrideVars( return out; } +/** Map data-series accents to their CSS vars. Themes omit either field to + * inherit the `:root` default from `index.css`; when omitted we also + * proactively clear any leftover value from a previous theme so switches + * don't carry stale colors. */ +const SERIES_KEY_TO_VAR: Record = { + inputTokenAccent: "--series-input-token", + outputTokenAccent: "--series-output-token", +}; + +const ALL_SERIES_VARS = Object.values(SERIES_KEY_TO_VAR); + +function seriesColorVars( + series: ThemeSeriesColors | undefined, +): Record { + if (!series) return {}; + const out: Record = {}; + for (const [key, value] of Object.entries(series)) { + if (!value) continue; + const cssVar = SERIES_KEY_TO_VAR[key as keyof ThemeSeriesColors]; + if (cssVar) out[cssVar] = value; + } + return out; +} + // --------------------------------------------------------------------------- // Asset + component-style + layout variant vars // --------------------------------------------------------------------------- @@ -268,6 +308,12 @@ function applyTheme(theme: DashboardTheme) { for (const cssVar of ALL_OVERRIDE_VARS) { root.style.removeProperty(cssVar); } + // Same clear-then-set for series colors so a theme that defines them + // (e.g. Nous Blue) doesn't leave its values behind when the user + // switches to a theme that inherits the `:root` defaults. + for (const cssVar of ALL_SERIES_VARS) { + root.style.removeProperty(cssVar); + } // Clear dynamic (asset/component) vars from the previous theme so the // new one starts clean — otherwise stale notched clip-paths, hero URLs, // etc. would bleed across theme switches. @@ -287,6 +333,7 @@ function applyTheme(theme: DashboardTheme) { ...typographyVars(theme.typography), ...layoutVars(theme.layout), ...overrideVars(theme.colorOverrides), + ...seriesColorVars(theme.seriesColors), ...assetMap, ...componentMap, }; @@ -313,7 +360,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) { /** Name of the currently active theme (built-in id or user YAML name). */ const [themeName, setThemeName] = useState(() => { if (typeof window === "undefined") return "default"; - return window.localStorage.getItem(STORAGE_KEY) ?? "default"; + const stored = window.localStorage.getItem(STORAGE_KEY) ?? "default"; + const migrated = migrateThemeName(stored); + // Write the migrated name back so future reads converge on the new + // key and we eventually retire the alias entry. + if (migrated !== stored) { + window.localStorage.setItem(STORAGE_KEY, migrated); + } + return migrated; }); /** All selectable themes (shown in the picker). Starts with just the @@ -377,9 +431,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) { } if (Object.keys(defs).length > 0) setUserThemeDefs(defs); } - if (resp.active && resp.active !== themeName) { - setThemeName(resp.active); - window.localStorage.setItem(STORAGE_KEY, resp.active); + if (resp.active) { + const migratedActive = migrateThemeName(resp.active); + if (migratedActive !== themeName) { + setThemeName(migratedActive); + window.localStorage.setItem(STORAGE_KEY, migratedActive); + } + // If the server is still persisting the stale key, push the + // migrated value back so it converges too — otherwise every + // future page load would re-trigger this branch. + if (migratedActive !== resp.active) { + api.setTheme(migratedActive).catch(() => {}); + } } }) .catch(() => {}); diff --git a/web/src/themes/presets.ts b/web/src/themes/presets.ts index 35f5e028b..6e1b05384 100644 --- a/web/src/themes/presets.ts +++ b/web/src/themes/presets.ts @@ -184,6 +184,100 @@ export const roseTheme: DashboardTheme = { }, }; +/** + * Nous Blue — the inverted "light mode" Hermes look, ported from the + * LENS_5I overlay preset in `@nous-research/ui`. + * + * Unlike the other built-ins (which paint dark color directly on the + * canvas), this theme relies on ``'s foreground inversion + * layer: an opaque white sheet at z-200 with `mix-blend-mode: difference` + * that flips the entire stack below it. Authoring colors stay dark + * (`#170d02` brown background, `#FFAC02` orange midground), and the + * inversion converts them to their visual complements at paint time — + * the orange midground reads as #0053FD Nous-blue on screen, against a + * cream `#E8F2FD` canvas. + * + * Note on bg blend mode: the DS Lens uses `multiply` for LENS_5I because + * nousnet-web's is white; hermes-agent's App root is `bg-black`, + * so we leave the bg layer's blend mode at the `difference` default — + * `difference(#170d02, #000)` passes the bg through unchanged, and the + * subsequent FG-difference layer then inverts it to cream. Using + * `multiply` here would collapse the bg to pure black against the + * `bg-black` root and produce a plain-white canvas instead of the + * intended cream-blue. + * + * Source of truth for the palette: `design-language/src/ui/components/ + * overlays/lens.ts` (LENS_5I export). + */ +export const nousBlueTheme: DashboardTheme = { + name: "nous-blue", + label: "Nous Blue", + description: "Light mode — vivid Nous-blue accents on cream canvas", + palette: { + background: { hex: "#170d02", alpha: 1 }, + midground: { hex: "#FFAC02", alpha: 1 }, + foreground: { hex: "#FFFFFF", alpha: 1 }, + // Same warm-amber as nousnet-web's overlay glow; after the FG + // inversion it reads as a cool ultraviolet vignette in the top-left. + warmGlow: "rgba(255, 172, 2, 0.18)", + // Noise sits above the FG inversion and is NOT flipped, so a softer + // multiplier keeps it from speckling over the bright post-inversion + // canvas. + noiseOpacity: 0.4, + }, + typography: DEFAULT_TYPOGRAPHY, + layout: DEFAULT_LAYOUT, + // Inverted page: the embedded terminal is below the FG layer too, so + // a `#000000` source paints as visual white — i.e. a proper light-mode + // terminal pane. xterm picks lighter palette colors against the "black" + // canvas, which then read as dark text on screen post-inversion. + terminalBackground: "#000000", + componentStyles: { + backdrop: { + // Lower than LENS_5I.Lens.fillerOpacity (0.06). The filler texture + // gets amplified post-inversion: small variations against the deep + // `#170d02` source bg are barely visible, but those same variations + // against the bright `#E8F2FD` post-inversion canvas read as a + // heavy cloud/marble pattern — especially on near-empty pages + // (loading spinners, blank states). 0.02 keeps subtle grain + // without overwhelming the canvas. + fillerOpacity: "0.02", + }, + }, + // Pre-invert absolute-hex tokens so they read as their familiar colors + // through the FG difference layer. e.g. source #04D3C9 (cyan) is what + // gets painted, and `255 - channel` flips it to #FB2C36 (red) on screen. + // Without these, the default destructive/success/warning tokens would + // appear as their unintuitive complements. + colorOverrides: { + destructive: "#04d3c9", + destructiveForeground: "#000000", + success: "#b5217f", + warning: "#0042c7", + }, + // Pre-inverted data-series accents for the Analytics/Models token + // charts. The defaults (#ffe6cb cream + #34d399 emerald) would render + // through the FG difference layer as dark navy + hot-coral on the + // bright Nous-blue canvas — the coral is the "red" users see for + // Output values without these overrides. Source → on-screen: + // Input: #ffe6cb → #001934 (dark navy) ← unchanged + // Output: #ffac02 → #0053fd (vivid Nous-blue) ← brand accent + // Input keeps the cream source so it stays a neutral, low-contrast + // dark-blue against the cream canvas; output paints as the brand + // Nous-blue so the "primary" series in token-flow charts reads as + // the highlight color, matching the rest of the inverted UI chrome. + seriesColors: { + inputTokenAccent: "#ffe6cb", + outputTokenAccent: "#ffac02", + }, + // Explicit picker swatch — the raw palette hex (`#170d02`, `#FFAC02`, + // amber rgba) doesn't reflect what users see after the FG inversion, + // so we paint the post-inversion visual triplet directly: + // white → vivid Nous-blue → cream/light-blue + // matching the actual on-screen rendering of the theme. + swatchColors: ["#FFFFFF", "#0053FD", "#E8F2FD"], +}; + /** * Same look as ``defaultTheme`` but with a larger root font size, looser * line-height, and ``spacious`` density so every rem-based size in the @@ -208,6 +302,7 @@ export const defaultLargeTheme: DashboardTheme = { export const BUILTIN_THEMES: Record = { default: defaultTheme, "default-large": defaultLargeTheme, + "nous-blue": nousBlueTheme, midnight: midnightTheme, ember: emberTheme, mono: monoTheme, diff --git a/web/src/themes/types.ts b/web/src/themes/types.ts index d4333f2ad..811d67c3c 100644 --- a/web/src/themes/types.ts +++ b/web/src/themes/types.ts @@ -119,6 +119,25 @@ export interface ThemeComponentStyles { page?: Record; } +/** Data-series accent colors for chart + table visualisations (Analytics, + * Models, etc.). Themes provide hex strings; the provider emits them as + * `--series-input-token` / `--series-output-token` CSS vars consumed + * inline by pages that render input-vs-output token flows. Themes can + * omit either field to inherit the default token defined in + * `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output). + * + * Inverted-lens themes (e.g. Nous Blue) must pre-invert these hex + * values so they read as their intended visual color after the FG + * difference layer flips them (`out = 255 − channel`). E.g. to make + * output paint as Nous-blue `#0053FD` on screen, set + * `outputTokenAccent: "#FFAC02"` — the difference math reverses it. */ +export interface ThemeSeriesColors { + /** Input-tokens series accent (Analytics chart bars + table values). */ + inputTokenAccent?: string; + /** Output-tokens series accent. */ + outputTokenAccent?: string; +} + /** Optional hex overrides keyed by shadcn-compat token name (without the * `--color-` prefix). Any key set here wins over the DS cascade. */ export interface ThemeColorOverrides { @@ -162,6 +181,15 @@ export interface DashboardTheme { /** Per-component CSS-var overrides. See `ThemeComponentStyles`. */ componentStyles?: ThemeComponentStyles; colorOverrides?: ThemeColorOverrides; + /** Data-series accent colors for Analytics/Models token charts. + * See `ThemeSeriesColors` for inversion-aware values. */ + seriesColors?: ThemeSeriesColors; + /** Explicit 3-color swatch override for the theme picker. Use when the + * palette's raw hex values don't reflect what users see on screen — + * e.g. inverted "lens" themes whose foreground-difference layer flips + * the authored colors to their visual complements. Order matches the + * default swatch cells: [background, midground, warmGlow]. */ + swatchColors?: [string, string, string]; /** Background color for the embedded terminal pane (xterm.js). * Hex string. Defaults to `"#000000"` when absent. */ terminalBackground?: string;