feat(dashboard): nous-blue theme, bulk sessions, schedule picker (#37383)
* 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 <cursoragent@cursor.com>
* refactor(dashboard): dedupe memory provider picker
The memory provider <Select> lived on both /system and /plugins,
writing the same config.yaml field through two different endpoints
with no cross-page refresh. Remove the picker from /system in favor
of a read-only status row + link to /plugins, where it pairs with
the context-engine picker under "Plugin providers".
/system retains the destructive admin controls (file sizes, Reset
MEMORY.md / USER.md / all). The api.setMemoryProvider client and
PUT /api/memory/provider backend endpoint are left in place for
CLI / script callers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(dashboard): address Copilot review on PR #37383
- Backdrop layer-stack comment claimed LENS_5I-style themes override
--component-backdrop-bg-blend-mode to multiply, but our only
LENS_5I-style theme (nous-blue) keeps the default difference.
Reword to describe what the code actually does and present the
var as a forward-looking extension hook.
- /api/sessions/bulk-delete docstring promised the response would
echo back the list of deleted IDs, but the implementation only
returns {ok, deleted}. Tighten the docstring to match the wire
format; the client already knows what it asked to delete, so the
IDs aren't needed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(dashboard): address copilot review on cron describe + bulk-select checkbox
- schedule.ts: restrict `describeCronExpression` to strictly 5-field cron
expressions. The backend `parse_schedule` also accepts the 6-field
`min hour dom month dow year` form, and humanising those by
destructuring only the first five fields would silently drop the year
(e.g. ``0 9 * * * 2099`` rendered as "Daily at 09:00"). 6+ field
expressions now fall through to the raw-string fallback so the user
sees what's actually scheduled.
- SessionsPage.tsx (SessionRow): wire the bulk-select Checkbox's
``onClick`` directly instead of attaching it to a parent ``<span>``
with a no-op ``onCheckedChange``. Radix forwards onClick to the
underlying ``<button role=checkbox>``, so the same handler now drives
both mouse clicks (preserving shift-key state for range select) and
keyboard activation (Space on the focused checkbox, which the browser
synthesises as a click on the <button>). Improves a11y / keyboard UX
without changing the controlled-selection model.
- SessionsPage.tsx: also extend ``SessionRowProps`` with the new
``onRename`` / ``onExport`` props introduced on main so the row's
destructured prop types resolve after the merge.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,11 +11,27 @@ import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
|
||||
* and the warm vignette both read theme-switchable CSS custom properties so
|
||||
* `ThemeProvider` can repaint the stack without remounting.
|
||||
*
|
||||
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
|
||||
* z-1 bg = `var(--background-base)`, mix-blend-mode driven by
|
||||
* `--component-backdrop-bg-blend-mode` (default `difference`).
|
||||
* Both LENS_0-style dark themes and the LENS_5I-style Nous Blue
|
||||
* light theme keep `difference` here — the canvas is flipped by
|
||||
* the z-200 FG inversion layer, not by changing this blend mode.
|
||||
* The CSS var is exposed as a hook so future presets can override
|
||||
* it (e.g. `multiply` to paint the bg as-is before inversion)
|
||||
* without touching this component.
|
||||
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
|
||||
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
|
||||
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
|
||||
* color-dodge) — gated on GPU tier
|
||||
* z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I,
|
||||
* alpha-0 in LENS_0), mix-blend-mode: difference. This is the
|
||||
* layer that flips the dashboard into "light mode" for inverted
|
||||
* themes; for normal dark themes its alpha is 0 so it's a no-op.
|
||||
* Deliberately placed above every UI overlay z-index (modals,
|
||||
* tooltips, and dropUp dropdowns all sit at z-[100]) so portaled
|
||||
* elements get inverted along with the rest of the page instead
|
||||
* of painting with pre-inversion colors on top of the lens.
|
||||
* z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
|
||||
* color-dodge) — gated on GPU tier. Sits above the inversion
|
||||
* layer by design so the grain is not flipped.
|
||||
*
|
||||
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
|
||||
* software rasterizer (SwiftShader/llvmpipe), or the user has
|
||||
@@ -31,10 +47,13 @@ export function Backdrop() {
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[1]"
|
||||
style={{
|
||||
backgroundColor: "var(--background-base)",
|
||||
mixBlendMode: "difference",
|
||||
}}
|
||||
style={
|
||||
{
|
||||
backgroundColor: "var(--background-base)",
|
||||
mixBlendMode:
|
||||
"var(--component-backdrop-bg-blend-mode, difference)",
|
||||
} as unknown as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -75,10 +94,35 @@ export function Backdrop() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity
|
||||
+ fgBlend: 'difference' in `design-language/src/ui/components/
|
||||
overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default)
|
||||
the layer is fully transparent and contributes nothing; with
|
||||
alpha 1 + opaque white it inverts the entire stack below it,
|
||||
producing the LENS_5I "light mode" look without altering any
|
||||
downstream component code.
|
||||
|
||||
z-200 (not 100) so it sits above every portaled UI overlay —
|
||||
sidebar tooltips, dropUp dropdowns, and modal dialogs all use
|
||||
z-[100], which is what the DS Lens picks too; portals append
|
||||
at the end of <body>, so equal z-index + later DOM order means
|
||||
they'd paint on top of the inversion and skip the flip. Inlined
|
||||
z-index for the same reason the DS does it — Tailwind's JIT
|
||||
scan sometimes drops non-default z utilities. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0"
|
||||
style={{
|
||||
backgroundColor: "var(--foreground)",
|
||||
mixBlendMode: "difference",
|
||||
zIndex: 200,
|
||||
}}
|
||||
/>
|
||||
|
||||
{gpuTier > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[101]"
|
||||
className="pointer-events-none fixed inset-0 z-[201]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
|
||||
|
||||
@@ -86,7 +86,7 @@ export function ModelInfoCard({
|
||||
{hasCaps && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{caps.supports_tools && (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<span className="inline-flex items-center gap-1 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
|
||||
<Wrench className="h-2.5 w-2.5" /> Tools
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useCallback } from "react";
|
||||
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 { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
buildScheduleString,
|
||||
DEFAULT_SCHEDULE_STATE,
|
||||
type IntervalUnit,
|
||||
type ScheduleBuilderState,
|
||||
type ScheduleMode,
|
||||
type Weekday,
|
||||
WEEKDAY_INDEXES,
|
||||
} from "@/lib/schedule";
|
||||
|
||||
/**
|
||||
* Human-readable schedule picker for cron job create/edit flows.
|
||||
*
|
||||
* Replaces the raw "type a cron expression" input that lived inline in
|
||||
* ``CronPage``. The picker still emits a single backend-compatible
|
||||
* schedule string (see ``cron/jobs.py::parse_schedule``), but the user
|
||||
* fills out shape-appropriate inputs (time picker, weekday toggles,
|
||||
* datetime-local field) per mode.
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* - The component is fully controlled. Parent owns the
|
||||
* ``ScheduleBuilderState`` and the derived schedule string (built
|
||||
* via ``buildScheduleString`` in render).
|
||||
* - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are
|
||||
* preserved across mode switches so flipping back to a previous mode
|
||||
* doesn't erase the user's work.
|
||||
* - The "Custom" mode is an escape hatch — surfacing it as a normal
|
||||
* option (instead of hiding it behind an "advanced" toggle) keeps
|
||||
* power-user workflows discoverable without making everyone scroll
|
||||
* past it.
|
||||
*/
|
||||
export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) {
|
||||
const { t } = useI18n();
|
||||
const cronStrings = t.cron;
|
||||
const modeStrings = cronStrings.scheduleModes;
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Partial<ScheduleBuilderState>) => {
|
||||
onChange({ ...value, ...patch });
|
||||
},
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
const toggleWeekday = useCallback(
|
||||
(day: Weekday) => {
|
||||
const present = value.weekdays.includes(day);
|
||||
update({
|
||||
weekdays: present
|
||||
? value.weekdays.filter((d) => d !== day)
|
||||
: [...value.weekdays, day],
|
||||
});
|
||||
},
|
||||
[update, value.weekdays],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-schedule-mode">
|
||||
{cronStrings.scheduleMode ?? "Schedule"}
|
||||
</Label>
|
||||
<Select
|
||||
id="cron-schedule-mode"
|
||||
value={value.mode}
|
||||
onValueChange={(v) => update({ mode: v as ScheduleMode })}
|
||||
>
|
||||
<SelectOption value="interval">{modeStrings.interval}</SelectOption>
|
||||
<SelectOption value="daily">{modeStrings.daily}</SelectOption>
|
||||
<SelectOption value="weekly">{modeStrings.weekly}</SelectOption>
|
||||
<SelectOption value="monthly">{modeStrings.monthly}</SelectOption>
|
||||
<SelectOption value="once">{modeStrings.once}</SelectOption>
|
||||
<SelectOption value="custom">{modeStrings.custom}</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{value.mode === "interval" && (
|
||||
<div className="grid grid-cols-[1fr_1.4fr] gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-interval-value">
|
||||
{modeStrings.intervalEvery}
|
||||
</Label>
|
||||
<Input
|
||||
id="cron-interval-value"
|
||||
type="number"
|
||||
min={1}
|
||||
max={9999}
|
||||
value={String(value.intervalValue)}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
update({
|
||||
intervalValue: Number.isFinite(n) && n > 0 ? n : 1,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-interval-unit">{modeStrings.intervalUnit}</Label>
|
||||
<Select
|
||||
id="cron-interval-unit"
|
||||
value={value.intervalUnit}
|
||||
onValueChange={(v) => update({ intervalUnit: v as IntervalUnit })}
|
||||
>
|
||||
<SelectOption value="minutes">
|
||||
{modeStrings.unitMinutes}
|
||||
</SelectOption>
|
||||
<SelectOption value="hours">{modeStrings.unitHours}</SelectOption>
|
||||
<SelectOption value="days">{modeStrings.unitDays}</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.mode === "daily" && (
|
||||
<TimeOfDayField
|
||||
id="cron-daily-time"
|
||||
label={modeStrings.timeOfDay}
|
||||
value={value.timeOfDay}
|
||||
onChange={(timeOfDay) => update({ timeOfDay })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{value.mode === "weekly" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>{modeStrings.weekdays}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={modeStrings.weekdays}
|
||||
>
|
||||
{WEEKDAY_INDEXES.map((d) => {
|
||||
const isOn = value.weekdays.includes(d);
|
||||
return (
|
||||
<Button
|
||||
key={d}
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined={!isOn}
|
||||
aria-pressed={isOn}
|
||||
onClick={() => toggleWeekday(d)}
|
||||
className="min-w-[2.5rem] font-mono-ui text-xs uppercase"
|
||||
>
|
||||
{modeStrings.weekdaysShort[d]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<TimeOfDayField
|
||||
id="cron-weekly-time"
|
||||
label={modeStrings.timeOfDay}
|
||||
value={value.timeOfDay}
|
||||
onChange={(timeOfDay) => update({ timeOfDay })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{value.mode === "monthly" && (
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-month-day">{modeStrings.dayOfMonth}</Label>
|
||||
<Input
|
||||
id="cron-month-day"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={String(value.dayOfMonth)}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
update({
|
||||
dayOfMonth:
|
||||
Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TimeOfDayField
|
||||
id="cron-monthly-time"
|
||||
label={modeStrings.timeOfDay}
|
||||
value={value.timeOfDay}
|
||||
onChange={(timeOfDay) => update({ timeOfDay })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.mode === "once" && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-once-at">{modeStrings.onceAt}</Label>
|
||||
{/* Native datetime-local — emits the exact "YYYY-MM-DDTHH:MM"
|
||||
shape ``parse_schedule`` accepts on the backend. */}
|
||||
<input
|
||||
id="cron-once-at"
|
||||
type="datetime-local"
|
||||
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
value={value.onceAt}
|
||||
onChange={(e) => update({ onceAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.mode === "custom" && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-custom-expr">{modeStrings.customLabel}</Label>
|
||||
<Input
|
||||
id="cron-custom-expr"
|
||||
placeholder={modeStrings.customPlaceholder}
|
||||
value={value.custom}
|
||||
onChange={(e) => update({ custom: e.target.value })}
|
||||
className="font-mono-ui"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{modeStrings.customHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="opacity-70">{modeStrings.preview}: </span>
|
||||
<span className="font-mono-ui text-foreground">
|
||||
{buildScheduleString(value) || modeStrings.previewEmpty}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeOfDayField({
|
||||
id,
|
||||
label,
|
||||
onChange,
|
||||
value,
|
||||
}: TimeOfDayFieldProps) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{/* 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. */}
|
||||
<input
|
||||
id={id}
|
||||
type="time"
|
||||
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DEFAULT_SCHEDULE_STATE };
|
||||
|
||||
interface ScheduleBuilderProps {
|
||||
onChange: (state: ScheduleBuilderState) => void;
|
||||
value: ScheduleBuilderState;
|
||||
}
|
||||
|
||||
interface TimeOfDayFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
|
||||
>
|
||||
<span className="flex-1" style={{ background: background.hex }} />
|
||||
<span className="flex-1" style={{ background: midground.hex }} />
|
||||
<span className="flex-1" style={{ background: warmGlow }} />
|
||||
<span className="flex-1" style={{ background: c1 }} />
|
||||
<span className="flex-1" style={{ background: c2 }} />
|
||||
<span className="flex-1" style={{ background: c3 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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 ジョブが設定されていません。上で作成してください。",
|
||||
|
||||
@@ -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 작업이 없습니다. 위에서 하나 만드세요.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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-задачи не настроены. Создайте задачу выше.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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-завдань не налаштовано. Створіть одне вище.",
|
||||
|
||||
@@ -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: "尚未設定排程任務。請於上方建立。",
|
||||
|
||||
@@ -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: "暂无定时任务。在上方创建一个。",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
|
||||
@@ -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 <input type=time>. */
|
||||
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 <input type=datetime-local>. */
|
||||
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<IntervalUnit, string> = {
|
||||
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 "";
|
||||
// <input type=datetime-local> 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`;
|
||||
}
|
||||
}
|
||||
@@ -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[] }) {
|
||||
</div>
|
||||
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
|
||||
<div
|
||||
className="h-2.5 w-2.5"
|
||||
style={{ backgroundColor: "var(--series-input-token)" }}
|
||||
/>
|
||||
{t.analytics.input}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 bg-emerald-500" />
|
||||
<div
|
||||
className="h-2.5 w-2.5"
|
||||
style={{ backgroundColor: "var(--series-output-token)" }}
|
||||
/>
|
||||
{t.analytics.output}
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,13 +197,19 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="w-full bg-[#ffe6cb]/70"
|
||||
style={{ height: Math.max(inputH, total > 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),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="w-full bg-emerald-500/70"
|
||||
className="w-full"
|
||||
style={{
|
||||
backgroundColor:
|
||||
"color-mix(in srgb, var(--series-output-token) 70%, transparent)",
|
||||
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
|
||||
}}
|
||||
/>
|
||||
@@ -261,12 +272,12 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
{d.sessions}
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<span className="text-[#ffe6cb]">
|
||||
<span style={{ color: "var(--series-input-token)" }}>
|
||||
{formatTokens(d.input_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-emerald-400">
|
||||
<span style={{ color: "var(--series-output-token)" }}>
|
||||
{formatTokens(d.output_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -319,11 +330,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||
{m.sessions}
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-[#ffe6cb]">
|
||||
<span style={{ color: "var(--series-input-token)" }}>
|
||||
{formatTokens(m.input_tokens)}
|
||||
</span>
|
||||
{" / "}
|
||||
<span className="text-emerald-400">
|
||||
<span style={{ color: "var(--series-output-token)" }}>
|
||||
{formatTokens(m.output_tokens)}
|
||||
</span>
|
||||
</td>
|
||||
@@ -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(
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{periodLabel}
|
||||
</Badge>
|
||||
{showTokens !== false && (
|
||||
showTokens === false ? null : (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined={days !== p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
@@ -446,28 +467,10 @@ export default function AnalyticsPage() {
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
)}
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
showTokens === false ? null : (
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined={days !== p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
setEnd(null);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
|
||||
+84
-46
@@ -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<ScheduleBuilderState>(
|
||||
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() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
|
||||
<Input
|
||||
id="cron-schedule"
|
||||
placeholder={t.cron.schedulePlaceholder}
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<ScheduleBuilder
|
||||
value={scheduleState}
|
||||
onChange={setScheduleState}
|
||||
/>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="local">
|
||||
{t.cron.delivery.local}
|
||||
</SelectOption>
|
||||
<SelectOption value="telegram">
|
||||
{t.cron.delivery.telegram}
|
||||
</SelectOption>
|
||||
<SelectOption value="discord">
|
||||
{t.cron.delivery.discord}
|
||||
</SelectOption>
|
||||
<SelectOption value="slack">
|
||||
{t.cron.delivery.slack}
|
||||
</SelectOption>
|
||||
<SelectOption value="email">
|
||||
{t.cron.delivery.email}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="local">
|
||||
{t.cron.delivery.local}
|
||||
</SelectOption>
|
||||
<SelectOption value="telegram">
|
||||
{t.cron.delivery.telegram}
|
||||
</SelectOption>
|
||||
<SelectOption value="discord">
|
||||
{t.cron.delivery.discord}
|
||||
</SelectOption>
|
||||
<SelectOption value="slack">
|
||||
{t.cron.delivery.slack}
|
||||
</SelectOption>
|
||||
<SelectOption value="email">
|
||||
{t.cron.delivery.email}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
@@ -617,7 +653,9 @@ export default function CronPage() {
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
|
||||
<span className="font-mono-ui">
|
||||
{getJobScheduleDisplay(job, scheduleDescribeStrings)}
|
||||
</span>
|
||||
<span>
|
||||
{t.cron.last}: {formatTime(job.last_run_at)}
|
||||
</span>
|
||||
|
||||
@@ -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) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${s.color} relative flex items-center transition-all duration-300`}
|
||||
style={{ width: `${(s.value / total) * 100}%` }}
|
||||
className="relative flex items-center transition-all duration-300"
|
||||
style={{
|
||||
backgroundColor: `color-mix(in srgb, ${s.color} 70%, transparent)`,
|
||||
width: `${(s.value / total) * 100}%`,
|
||||
}}
|
||||
>
|
||||
{/* Stepped fill pattern overlay */}
|
||||
<div
|
||||
@@ -128,7 +137,10 @@ function TokenBar({
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
|
||||
{segments.map((s, i) => (
|
||||
<span key={i} className="flex items-center gap-1">
|
||||
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
|
||||
<span
|
||||
className="inline-block h-1.5 w-1.5 rounded-full"
|
||||
style={{ backgroundColor: s.color }}
|
||||
/>
|
||||
{s.label} {formatTokens(s.value)}
|
||||
</span>
|
||||
))}
|
||||
@@ -152,7 +164,7 @@ function CapabilityBadges({
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{capabilities.supports_tools && (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<span className="inline-flex items-center gap-1 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success">
|
||||
<Wrench className="h-2.5 w-2.5" /> Tools
|
||||
</span>
|
||||
)}
|
||||
@@ -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(
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Badge tone="secondary" className="text-xs">
|
||||
{periodLabel}
|
||||
</Badge>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined={days !== p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
className="uppercase"
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
ghost
|
||||
@@ -836,26 +859,9 @@ export default function ModelsPage() {
|
||||
>
|
||||
{loading ? <Spinner /> : <RefreshCw />}
|
||||
</Button>
|
||||
</span>,
|
||||
);
|
||||
setEnd(
|
||||
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined={days !== p.days}
|
||||
onClick={() => setDays(p.days)}
|
||||
className="uppercase"
|
||||
>
|
||||
{p.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>,
|
||||
);
|
||||
setEnd(null);
|
||||
return () => {
|
||||
setAfterTitle(null);
|
||||
setEnd(null);
|
||||
|
||||
+384
-23
@@ -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<void>;
|
||||
onExport: (id: string) => void;
|
||||
resumeInChatEnabled: boolean;
|
||||
}) {
|
||||
}: SessionRowProps) {
|
||||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 ``<button
|
||||
// role=checkbox>``) so the event carries the real ``shiftKey`` state
|
||||
// for range-select AND so keyboard activation (Space on the focused
|
||||
// checkbox) toggles selection via the same code path — the browser
|
||||
// synthesises a click on <button> for Space, so one handler covers
|
||||
// mouse + keyboard cleanly.
|
||||
const handleSelectClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onSelectClick(e);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
|
||||
session.is_active
|
||||
? "border-success/30 bg-success/[0.03]"
|
||||
: "border-border"
|
||||
}`}
|
||||
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${containerClasses}`}
|
||||
>
|
||||
<div
|
||||
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="flex shrink-0 items-center pt-0.5">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onClick={handleSelectClick}
|
||||
aria-label={t.sessions.selectSession}
|
||||
/>
|
||||
</span>
|
||||
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
|
||||
<SourceIcon className="h-4 w-4" />
|
||||
</div>
|
||||
@@ -606,6 +626,30 @@ export default function SessionsPage() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
|
||||
const [view, setView] = useState<SessionsView>("overview");
|
||||
// Count of empty (no-message, ended, non-archived) sessions across the
|
||||
// entire DB, populated by /api/sessions/empty/count. Used to:
|
||||
// • hide the "Delete empty" button when there's nothing to clean up
|
||||
// • show "(N)" alongside the label
|
||||
// • surface the count in the confirm dialog body
|
||||
// Refreshed on mount, after single-session deletes, and after the bulk
|
||||
// delete itself — none of those code paths can update the global empty
|
||||
// count from local state alone (per-page list != global DB count).
|
||||
const [emptyCount, setEmptyCount] = useState(0);
|
||||
const [deleteEmptyOpen, setDeleteEmptyOpen] = useState(false);
|
||||
const [deletingEmpty, setDeletingEmpty] = useState(false);
|
||||
// Bulk-select-then-delete state. ``selectedIds`` is a Set so per-row
|
||||
// checkbox toggles and ``has()`` lookups are O(1); we wrap mutations
|
||||
// in a fresh Set so React notices the change (mutating in place
|
||||
// wouldn't trigger a re-render).
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
// Index of the last row whose checkbox was clicked WITHOUT shift,
|
||||
// resolved against the currently visible (post-search) ``filtered``
|
||||
// list. Used as the anchor for shift-click range select — matches the
|
||||
// Gmail / Notion / file-explorer convention. ``null`` means "no
|
||||
// anchor yet", in which case shift-click degrades to a plain toggle.
|
||||
const lastClickedIndexRef = useRef<number | null>(null);
|
||||
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false);
|
||||
const [deletingSelected, setDeletingSelected] = useState(false);
|
||||
const [stats, setStats] = useState<SessionStoreStats | null>(null);
|
||||
const [pruneOpen, setPruneOpen] = useState(false);
|
||||
const [pruneDays, setPruneDays] = useState("90");
|
||||
@@ -616,6 +660,18 @@ export default function SessionsPage() {
|
||||
const { activeAction, actionStatus, dismissLog } = useSystemActions();
|
||||
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
|
||||
|
||||
const refreshEmptyCount = useCallback(() => {
|
||||
api
|
||||
.getEmptySessionsCount()
|
||||
.then((r) => setEmptyCount(r.count))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
setSelectedIds(new Set());
|
||||
lastClickedIndexRef.current = null;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (loading) {
|
||||
setAfterTitle(null);
|
||||
@@ -673,7 +729,8 @@ export default function SessionsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions(page);
|
||||
}, [loadSessions, page]);
|
||||
refreshEmptyCount();
|
||||
}, [loadSessions, page, refreshEmptyCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadOverview = () => {
|
||||
@@ -696,6 +753,36 @@ export default function SessionsPage() {
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [actionStatus?.lines]);
|
||||
|
||||
// Wrapped setters that ALSO clear the bulk selection. The user's
|
||||
// mental model is "I'm selecting what I can see" — carrying a
|
||||
// selection across a page change, search input, or view switch
|
||||
// would arm invisible rows for deletion, which is the exact footgun
|
||||
// the confirm dialog can't catch. Doing this at the call sites
|
||||
// instead of in a ``useEffect`` keeps us out of the
|
||||
// react-hooks/set-state-in-effect lint trap and the cascading
|
||||
// re-render it warns about.
|
||||
const goToPage = useCallback(
|
||||
(p: number) => {
|
||||
setPage(p);
|
||||
clearSelection();
|
||||
},
|
||||
[clearSelection],
|
||||
);
|
||||
const updateSearch = useCallback(
|
||||
(value: string) => {
|
||||
setSearch(value);
|
||||
clearSelection();
|
||||
},
|
||||
[clearSelection],
|
||||
);
|
||||
const switchView = useCallback(
|
||||
(next: SessionsView) => {
|
||||
setView(next);
|
||||
clearSelection();
|
||||
},
|
||||
[clearSelection],
|
||||
);
|
||||
|
||||
// Debounced FTS search
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
@@ -728,6 +815,18 @@ export default function SessionsPage() {
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
setTotal((prev) => prev - 1);
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
// Drop the deleted ID from any active bulk-select set — it
|
||||
// can't bulk-delete a row that's already gone.
|
||||
setSelectedIds((prev) => {
|
||||
if (!prev.has(id)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
// A single-session delete might have been an empty one — re-fetch
|
||||
// the global empty count so the button hides itself / its badge
|
||||
// ticks down without waiting for the next page navigation.
|
||||
refreshEmptyCount();
|
||||
showToast(t.sessions.sessionDeleted, "success");
|
||||
loadStats();
|
||||
} catch {
|
||||
@@ -737,6 +836,7 @@ export default function SessionsPage() {
|
||||
},
|
||||
[
|
||||
expandedId,
|
||||
refreshEmptyCount,
|
||||
showToast,
|
||||
loadStats,
|
||||
t.sessions.sessionDeleted,
|
||||
@@ -745,6 +845,140 @@ export default function SessionsPage() {
|
||||
),
|
||||
});
|
||||
|
||||
/** Toggle one row's selection. When ``event.shiftKey`` is true AND we
|
||||
* have a previous anchor, every row between the anchor and the
|
||||
* current index (inclusive) is set to the current row's NEW state —
|
||||
* matches Gmail/Notion/file-explorer semantics. ``visibleList`` must
|
||||
* be the currently rendered list (post-search), since indices are
|
||||
* resolved against what the user is actually looking at.
|
||||
*/
|
||||
const handleSelectClick = useCallback(
|
||||
(event: React.MouseEvent, index: number, visibleList: SessionInfo[]) => {
|
||||
const id = visibleList[index]?.id;
|
||||
if (!id) return;
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
const wasSelected = next.has(id);
|
||||
const willSelect = !wasSelected;
|
||||
|
||||
const anchor = lastClickedIndexRef.current;
|
||||
// Shift-click extends the selection from the anchor to here.
|
||||
// Skip if there's no anchor or the anchor is outside the
|
||||
// visible list — in those cases fall through to a plain toggle
|
||||
// (the click also resets the anchor below).
|
||||
if (event.shiftKey && anchor !== null && anchor < visibleList.length) {
|
||||
const [lo, hi] =
|
||||
anchor <= index ? [anchor, index] : [index, anchor];
|
||||
for (let i = lo; i <= hi; i++) {
|
||||
const rowId = visibleList[i]?.id;
|
||||
if (!rowId) continue;
|
||||
if (willSelect) next.add(rowId);
|
||||
else next.delete(rowId);
|
||||
}
|
||||
} else if (willSelect) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Always update the anchor to the most recent click — even when
|
||||
// it was a shift-click that extended a range, the user's next
|
||||
// shift-click should anchor from here, not from two steps back.
|
||||
lastClickedIndexRef.current = index;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const selectAllOnPage = useCallback((visibleList: SessionInfo[]) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
for (const s of visibleList) next.add(s.id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDeleteSelected = useCallback(async () => {
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) {
|
||||
setDeleteSelectedOpen(false);
|
||||
return;
|
||||
}
|
||||
setDeletingSelected(true);
|
||||
try {
|
||||
const resp = await api.bulkDeleteSessions(ids);
|
||||
showToast(
|
||||
t.sessions.selectedSessionsDeleted.replace(
|
||||
"{count}",
|
||||
String(resp.deleted),
|
||||
),
|
||||
"success",
|
||||
);
|
||||
setDeleteSelectedOpen(false);
|
||||
// Drop deleted rows out of the visible list immediately rather
|
||||
// than waiting for the reload. The reload still runs so total /
|
||||
// pagination stays correct, and so any rows the reload pulls in
|
||||
// from later pages render in place.
|
||||
const deletedSet = new Set(ids);
|
||||
setSessions((prev) => prev.filter((s) => !deletedSet.has(s.id)));
|
||||
setTotal((prev) => Math.max(0, prev - resp.deleted));
|
||||
if (expandedId && deletedSet.has(expandedId)) setExpandedId(null);
|
||||
clearSelection();
|
||||
loadSessions(page);
|
||||
refreshEmptyCount();
|
||||
} catch {
|
||||
showToast(t.sessions.failedToDeleteSelected, "error");
|
||||
} finally {
|
||||
setDeletingSelected(false);
|
||||
}
|
||||
}, [
|
||||
clearSelection,
|
||||
expandedId,
|
||||
loadSessions,
|
||||
page,
|
||||
refreshEmptyCount,
|
||||
selectedIds,
|
||||
showToast,
|
||||
t.sessions.failedToDeleteSelected,
|
||||
t.sessions.selectedSessionsDeleted,
|
||||
]);
|
||||
|
||||
const handleDeleteEmpty = useCallback(async () => {
|
||||
setDeletingEmpty(true);
|
||||
try {
|
||||
const resp = await api.deleteEmptySessions();
|
||||
// Show count in the toast so users get confirmation of the actual
|
||||
// number removed (which may differ slightly from `emptyCount` if a
|
||||
// session entered/left the "empty" set between the count fetch and
|
||||
// the delete — e.g. an active session just ended without sending
|
||||
// any messages).
|
||||
showToast(
|
||||
t.sessions.emptySessionsDeleted.replace(
|
||||
"{count}",
|
||||
String(resp.deleted),
|
||||
),
|
||||
"success",
|
||||
);
|
||||
setDeleteEmptyOpen(false);
|
||||
// Reload the current page so any newly-vanished empty sessions
|
||||
// drop out of the visible list, and re-fetch the empty count so
|
||||
// the button hides itself.
|
||||
loadSessions(page);
|
||||
refreshEmptyCount();
|
||||
} catch {
|
||||
showToast(t.sessions.failedToDeleteEmpty, "error");
|
||||
} finally {
|
||||
setDeletingEmpty(false);
|
||||
}
|
||||
}, [
|
||||
loadSessions,
|
||||
page,
|
||||
refreshEmptyCount,
|
||||
showToast,
|
||||
t.sessions.emptySessionsDeleted,
|
||||
t.sessions.failedToDeleteEmpty,
|
||||
]);
|
||||
|
||||
const handleRename = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
try {
|
||||
@@ -898,6 +1132,33 @@ export default function SessionsPage() {
|
||||
loading={sessionDelete.isDeleting}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteEmptyOpen}
|
||||
onCancel={() => setDeleteEmptyOpen(false)}
|
||||
onConfirm={handleDeleteEmpty}
|
||||
title={t.sessions.deleteEmptyConfirmTitle}
|
||||
description={t.sessions.deleteEmptyConfirmMessage.replace(
|
||||
"{count}",
|
||||
String(emptyCount),
|
||||
)}
|
||||
loading={deletingEmpty}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteSelectedOpen}
|
||||
onCancel={() => setDeleteSelectedOpen(false)}
|
||||
onConfirm={handleDeleteSelected}
|
||||
title={t.sessions.deleteSelectedConfirmTitle.replace(
|
||||
"{count}",
|
||||
String(selectedIds.size),
|
||||
)}
|
||||
description={t.sessions.deleteSelectedConfirmMessage.replace(
|
||||
"{count}",
|
||||
String(selectedIds.size),
|
||||
)}
|
||||
loading={deletingSelected}
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
open={pruneOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -1084,7 +1345,7 @@ export default function SessionsPage() {
|
||||
className="w-fit shrink-0"
|
||||
size="md"
|
||||
value={view}
|
||||
onChange={setView}
|
||||
onChange={switchView}
|
||||
options={[
|
||||
{ value: "overview", label: t.sessions.overview },
|
||||
{ value: "list", label: t.sessions.history },
|
||||
@@ -1102,7 +1363,7 @@ export default function SessionsPage() {
|
||||
<Input
|
||||
placeholder={t.sessions.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => updateSearch(e.target.value)}
|
||||
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
|
||||
/>
|
||||
{search && (
|
||||
@@ -1110,7 +1371,7 @@ export default function SessionsPage() {
|
||||
ghost
|
||||
size="xs"
|
||||
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
onClick={() => updateSearch("")}
|
||||
aria-label={t.common.clear}
|
||||
>
|
||||
<X />
|
||||
@@ -1118,6 +1379,23 @@ export default function SessionsPage() {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showList && emptyCount > 0 && !isSearching && (
|
||||
<Button
|
||||
outlined
|
||||
destructive
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setDeleteEmptyOpen(true)}
|
||||
aria-label={t.sessions.deleteEmpty}
|
||||
title={t.sessions.deleteEmpty}
|
||||
>
|
||||
<Eraser className="h-3.5 w-3.5" />
|
||||
<span className="font-mondwest normal-case text-xs">
|
||||
{t.sessions.deleteEmpty} ({emptyCount})
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showPagination && (
|
||||
@@ -1126,12 +1404,77 @@ export default function SessionsPage() {
|
||||
className="shrink-0 sm:ml-auto"
|
||||
page={page}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showList && selectedIds.size > 0 && (
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-2 border border-primary/30 bg-primary/[0.06] px-3 py-2"
|
||||
role="region"
|
||||
aria-label={t.sessions.selectedCount.replace(
|
||||
"{count}",
|
||||
String(selectedIds.size),
|
||||
)}
|
||||
>
|
||||
<span className="font-mondwest normal-case text-xs text-primary tabular-nums">
|
||||
{t.sessions.selectedCount.replace(
|
||||
"{count}",
|
||||
String(selectedIds.size),
|
||||
)}
|
||||
</span>
|
||||
{filtered.some((s) => !selectedIds.has(s.id)) && (
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={() => selectAllOnPage(filtered)}
|
||||
aria-label={t.sessions.selectAllOnPage}
|
||||
title={t.sessions.selectAllOnPage}
|
||||
>
|
||||
<span className="font-mondwest normal-case text-xs">
|
||||
{t.sessions.selectAllOnPage}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
ghost
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
aria-label={t.sessions.clearSelection}
|
||||
title={t.sessions.clearSelection}
|
||||
>
|
||||
<span className="font-mondwest normal-case text-xs">
|
||||
{t.sessions.clearSelection}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
outlined
|
||||
destructive
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={() => setDeleteSelectedOpen(true)}
|
||||
aria-label={t.sessions.deleteSelected.replace(
|
||||
"{count}",
|
||||
String(selectedIds.size),
|
||||
)}
|
||||
title={t.sessions.deleteSelected.replace(
|
||||
"{count}",
|
||||
String(selectedIds.size),
|
||||
)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<span className="font-mondwest normal-case text-xs">
|
||||
{t.sessions.deleteSelected.replace(
|
||||
"{count}",
|
||||
String(selectedIds.size),
|
||||
)}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showList ? (
|
||||
filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
@@ -1148,16 +1491,20 @@ export default function SessionsPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className="flex min-w-0 flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
{filtered.map((s, index) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
isSelected={selectedIds.has(s.id)}
|
||||
onToggle={() =>
|
||||
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() {
|
||||
<SessionsPagination
|
||||
page={page}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageChange={goToPage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -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<void>;
|
||||
onSelectClick: (event: React.MouseEvent) => void;
|
||||
onToggle: () => void;
|
||||
resumeInChatEnabled: boolean;
|
||||
searchQuery?: string;
|
||||
session: SessionInfo;
|
||||
snippet?: string;
|
||||
}
|
||||
|
||||
interface SessionsPaginationProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
|
||||
@@ -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() {
|
||||
</H2>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-4 py-4">
|
||||
<div className="grid gap-2 max-w-sm">
|
||||
<Label htmlFor="mem-provider">External provider</Label>
|
||||
<Select
|
||||
id="mem-provider"
|
||||
value={memory?.active || ""}
|
||||
onValueChange={setMemoryProvider}
|
||||
>
|
||||
<SelectOption value="">Built-in only</SelectOption>
|
||||
{(memory?.providers ?? []).map((p) => (
|
||||
<SelectOption key={p.name} value={p.name}>
|
||||
{p.name}
|
||||
{p.configured ? " (configured)" : ""}
|
||||
</SelectOption>
|
||||
))}
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Set up a new provider's credentials with{" "}
|
||||
<span className="font-mono">hermes memory setup</span>.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>
|
||||
External provider:{" "}
|
||||
<span className="font-mono text-foreground">
|
||||
{memory?.active || "built-in only"}
|
||||
</span>
|
||||
</span>
|
||||
<Link to="/plugins" className="underline">
|
||||
Change in Plugins →
|
||||
</Link>
|
||||
<span className="ml-auto">
|
||||
New credentials:{" "}
|
||||
<span className="font-mono">hermes memory setup</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Built-in files — MEMORY.md:{" "}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
// 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 <link> tags. Keyed by URL. */
|
||||
const INJECTED_FONT_URLS = new Set<string>();
|
||||
@@ -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<keyof ThemeSeriesColors, string> = {
|
||||
inputTokenAccent: "--series-input-token",
|
||||
outputTokenAccent: "--series-output-token",
|
||||
};
|
||||
|
||||
const ALL_SERIES_VARS = Object.values(SERIES_KEY_TO_VAR);
|
||||
|
||||
function seriesColorVars(
|
||||
series: ThemeSeriesColors | undefined,
|
||||
): Record<string, string> {
|
||||
if (!series) return {};
|
||||
const out: Record<string, string> = {};
|
||||
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<string>(() => {
|
||||
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(() => {});
|
||||
|
||||
@@ -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 `<Backdrop />`'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 <body> 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<string, DashboardTheme> = {
|
||||
default: defaultTheme,
|
||||
"default-large": defaultLargeTheme,
|
||||
"nous-blue": nousBlueTheme,
|
||||
midnight: midnightTheme,
|
||||
ember: emberTheme,
|
||||
mono: monoTheme,
|
||||
|
||||
@@ -119,6 +119,25 @@ export interface ThemeComponentStyles {
|
||||
page?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
|
||||
Reference in New Issue
Block a user