mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-27 01:11:40 +00:00
feat: add internationalization (i18n) to web dashboard — English + Chinese (#9453)
Add a lightweight i18n system to the web dashboard with English (default) and Chinese language support. A language switcher with flag icons is placed in the header bar, allowing users to toggle between languages. The choice persists to localStorage. Implementation: - src/i18n/ — types, translation files (en.ts, zh.ts), React context + hook - LanguageSwitcher component shows the *other* language's flag as the toggle - I18nProvider wraps the app in main.tsx - All 8 pages + OAuth components updated to use t() translation calls - Zero new dependencies — pure React context + localStorage
This commit is contained in:
parent
19199cd38d
commit
a2ea237db2
19 changed files with 1715 additions and 977 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { formatTokenCount } from "@/lib/format";
|
||||
import {
|
||||
BarChart3,
|
||||
Cpu,
|
||||
|
|
@ -10,6 +9,7 @@ import { api } from "@/lib/api";
|
|||
import type { AnalyticsResponse, AnalyticsDailyEntry, AnalyticsModelEntry } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const PERIODS = [
|
||||
{ label: "7d", days: 7 },
|
||||
|
|
@ -19,7 +19,11 @@ const PERIODS = [
|
|||
|
||||
const CHART_HEIGHT_PX = 160;
|
||||
|
||||
const formatTokens = formatTokenCount;
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatDate(day: string): string {
|
||||
try {
|
||||
|
|
@ -56,6 +60,7 @@ function SummaryCard({
|
|||
}
|
||||
|
||||
function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
const maxTokens = Math.max(...daily.map((d) => d.input_tokens + d.output_tokens), 1);
|
||||
|
|
@ -65,16 +70,16 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Daily Token Usage</CardTitle>
|
||||
<CardTitle className="text-base">{t.analytics.dailyTokenUsage}</CardTitle>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
|
||||
Input
|
||||
<div className="h-2.5 w-2.5 rounded-sm bg-[#ffe6cb]" />
|
||||
{t.analytics.input}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="h-2.5 w-2.5 bg-emerald-500" />
|
||||
Output
|
||||
<div className="h-2.5 w-2.5 rounded-sm bg-emerald-500" />
|
||||
{t.analytics.output}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -92,11 +97,11 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
>
|
||||
{/* Tooltip */}
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block z-10 pointer-events-none">
|
||||
<div className="bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="rounded-md bg-card border border-border px-2.5 py-1.5 text-[10px] text-foreground shadow-lg whitespace-nowrap">
|
||||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>Input: {formatTokens(d.input_tokens)}</div>
|
||||
<div>Output: {formatTokens(d.output_tokens)}</div>
|
||||
<div>Total: {formatTokens(total)}</div>
|
||||
<div>{t.analytics.input}: {formatTokens(d.input_tokens)}</div>
|
||||
<div>{t.analytics.output}: {formatTokens(d.output_tokens)}</div>
|
||||
<div>{t.analytics.total}: {formatTokens(total)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Input bar */}
|
||||
|
|
@ -127,6 +132,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
}
|
||||
|
||||
function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
if (daily.length === 0) return null;
|
||||
|
||||
const sorted = [...daily].reverse();
|
||||
|
|
@ -136,7 +142,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Daily Breakdown</CardTitle>
|
||||
<CardTitle className="text-base">{t.analytics.dailyBreakdown}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -144,10 +150,10 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Date</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Input</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Output</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">{t.analytics.date}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.analytics.input}</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">{t.analytics.output}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -174,6 +180,7 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
}
|
||||
|
||||
function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
||||
const { t } = useI18n();
|
||||
if (models.length === 0) return null;
|
||||
|
||||
const sorted = [...models].sort(
|
||||
|
|
@ -185,7 +192,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Per-Model Breakdown</CardTitle>
|
||||
<CardTitle className="text-base">{t.analytics.perModelBreakdown}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
|
@ -193,9 +200,9 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Model</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Tokens</th>
|
||||
<th className="text-left py-2 pr-4 font-medium">{t.analytics.model}</th>
|
||||
<th className="text-right py-2 px-4 font-medium">{t.sessions.title}</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">{t.analytics.tokens}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -225,6 +232,7 @@ export default function AnalyticsPage() {
|
|||
const [data, setData] = useState<AnalyticsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const load = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
|
@ -244,7 +252,7 @@ export default function AnalyticsPage() {
|
|||
<div className="flex flex-col gap-6">
|
||||
{/* Period selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground font-medium">Period:</span>
|
||||
<span className="text-sm text-muted-foreground font-medium">{t.analytics.period}</span>
|
||||
{PERIODS.map((p) => (
|
||||
<Button
|
||||
key={p.label}
|
||||
|
|
@ -278,21 +286,21 @@ export default function AnalyticsPage() {
|
|||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SummaryCard
|
||||
icon={Hash}
|
||||
label="Total Tokens"
|
||||
label={t.analytics.totalTokens}
|
||||
value={formatTokens(data.totals.total_input + data.totals.total_output)}
|
||||
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
|
||||
sub={t.analytics.inOut.replace("{input}", formatTokens(data.totals.total_input)).replace("{output}", formatTokens(data.totals.total_output))}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={BarChart3}
|
||||
label="Total Sessions"
|
||||
label={t.analytics.totalSessions}
|
||||
value={String(data.totals.total_sessions)}
|
||||
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
|
||||
sub={`~${(data.totals.total_sessions / days).toFixed(1)}${t.analytics.perDayAvg}`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={TrendingUp}
|
||||
label="API Calls"
|
||||
label={t.analytics.apiCalls}
|
||||
value={String(data.daily.reduce((sum, d) => sum + d.sessions, 0))}
|
||||
sub={`across ${data.by_model.length} models`}
|
||||
sub={t.analytics.acrossModels.replace("{count}", String(data.by_model.length))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -310,8 +318,8 @@ export default function AnalyticsPage() {
|
|||
<CardContent className="py-12">
|
||||
<div className="flex flex-col items-center text-muted-foreground">
|
||||
<BarChart3 className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">No usage data for this period</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">Start a session to see analytics here</p>
|
||||
<p className="text-sm font-medium">{t.analytics.noUsageData}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">{t.analytics.startSession}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -1,76 +1,49 @@
|
|||
import { useEffect, useRef, useState, useMemo } from "react";
|
||||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
Code,
|
||||
Ear,
|
||||
Download,
|
||||
FileText,
|
||||
FormInput,
|
||||
Globe,
|
||||
Lock,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
Monitor,
|
||||
Package,
|
||||
Palette,
|
||||
RotateCcw,
|
||||
Save,
|
||||
ScrollText,
|
||||
Search,
|
||||
Settings,
|
||||
Settings2,
|
||||
Upload,
|
||||
Users,
|
||||
Volume2,
|
||||
Wrench,
|
||||
X,
|
||||
ChevronRight,
|
||||
Settings2,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { getNestedValue, setNestedValue } from "@/lib/nested";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { AutoField } from "@/components/AutoField";
|
||||
import { ModelInfoCard } from "@/components/ModelInfoCard";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const CATEGORY_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||
general: Settings,
|
||||
agent: Bot,
|
||||
terminal: Monitor,
|
||||
display: Palette,
|
||||
delegation: Users,
|
||||
memory: Package,
|
||||
compression: Package,
|
||||
security: Lock,
|
||||
browser: Globe,
|
||||
voice: Mic,
|
||||
tts: Volume2,
|
||||
stt: Ear,
|
||||
logging: ScrollText,
|
||||
discord: MessageSquare,
|
||||
auxiliary: Wrench,
|
||||
const CATEGORY_ICONS: Record<string, string> = {
|
||||
general: "⚙️",
|
||||
agent: "🤖",
|
||||
terminal: "💻",
|
||||
display: "🎨",
|
||||
delegation: "👥",
|
||||
memory: "🧠",
|
||||
compression: "📦",
|
||||
security: "🔒",
|
||||
browser: "🌐",
|
||||
voice: "🎙️",
|
||||
tts: "🔊",
|
||||
stt: "👂",
|
||||
logging: "📋",
|
||||
discord: "💬",
|
||||
auxiliary: "🔧",
|
||||
};
|
||||
const FallbackIcon = FileText;
|
||||
|
||||
function prettyCategoryName(cat: string): string {
|
||||
if (cat === "tts") return "Text-to-Speech";
|
||||
if (cat === "stt") return "Speech-to-Text";
|
||||
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||
}
|
||||
|
||||
function CategoryIcon({ cat, className }: { cat: string; className?: string }) {
|
||||
const Icon = CATEGORY_ICONS[cat] ?? FallbackIcon;
|
||||
return <Icon className={className} />;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
|
|
@ -88,9 +61,15 @@ export default function ConfigPage() {
|
|||
const [yamlLoading, setYamlLoading] = useState(false);
|
||||
const [yamlSaving, setYamlSaving] = useState(false);
|
||||
const [activeCategory, setActiveCategory] = useState<string>("");
|
||||
const [modelInfoRefreshKey, setModelInfoRefreshKey] = useState(0);
|
||||
const { toast, showToast } = useToast();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
function prettyCategoryName(cat: string): string {
|
||||
const key = cat as keyof typeof t.config.categories;
|
||||
if (t.config.categories[key]) return t.config.categories[key];
|
||||
return cat.charAt(0).toUpperCase() + cat.slice(1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
api.getConfig().then(setConfig).catch(() => {});
|
||||
|
|
@ -118,7 +97,7 @@ export default function ConfigPage() {
|
|||
api
|
||||
.getConfigRaw()
|
||||
.then((resp) => setYamlText(resp.yaml))
|
||||
.catch(() => showToast("Failed to load raw config", "error"))
|
||||
.catch(() => showToast(t.config.failedToLoadRaw, "error"))
|
||||
.finally(() => setYamlLoading(false));
|
||||
}
|
||||
}, [yamlMode]);
|
||||
|
|
@ -175,10 +154,9 @@ export default function ConfigPage() {
|
|||
setSaving(true);
|
||||
try {
|
||||
await api.saveConfig(config);
|
||||
showToast("Configuration saved", "success");
|
||||
setModelInfoRefreshKey((k) => k + 1);
|
||||
showToast(t.config.configSaved, "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to save: ${e}`, "error");
|
||||
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -188,11 +166,10 @@ export default function ConfigPage() {
|
|||
setYamlSaving(true);
|
||||
try {
|
||||
await api.saveConfigRaw(yamlText);
|
||||
showToast("YAML config saved", "success");
|
||||
setModelInfoRefreshKey((k) => k + 1);
|
||||
showToast(t.config.yamlConfigSaved, "success");
|
||||
api.getConfig().then(setConfig).catch(() => {});
|
||||
} catch (e) {
|
||||
showToast(`Failed to save YAML: ${e}`, "error");
|
||||
showToast(`${t.config.failedToSaveYaml}: ${e}`, "error");
|
||||
} finally {
|
||||
setYamlSaving(false);
|
||||
}
|
||||
|
|
@ -221,9 +198,9 @@ export default function ConfigPage() {
|
|||
try {
|
||||
const imported = JSON.parse(reader.result as string);
|
||||
setConfig(imported);
|
||||
showToast("Config imported — review and save", "success");
|
||||
showToast(t.config.configImported, "success");
|
||||
} catch {
|
||||
showToast("Invalid JSON file", "error");
|
||||
showToast(t.config.invalidJson, "error");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
|
@ -242,7 +219,6 @@ export default function ConfigPage() {
|
|||
const renderFields = (fields: [string, Record<string, unknown>][], showCategory = false) => {
|
||||
let lastSection = "";
|
||||
let lastCat = "";
|
||||
const currentModel = config ? String(getNestedValue(config, "model") ?? "") : "";
|
||||
return fields.map(([key, s]) => {
|
||||
const parts = key.split(".");
|
||||
const section = parts.length > 1 ? parts[0] : "";
|
||||
|
|
@ -256,7 +232,7 @@ export default function ConfigPage() {
|
|||
<div key={key}>
|
||||
{showCatBadge && (
|
||||
<div className="flex items-center gap-2 pt-4 pb-2 first:pt-0">
|
||||
<CategoryIcon cat={cat} className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-base">{CATEGORY_ICONS[cat] || "📄"}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{prettyCategoryName(cat)}
|
||||
</span>
|
||||
|
|
@ -279,12 +255,6 @@ export default function ConfigPage() {
|
|||
onChange={(v) => setConfig(setNestedValue(config, key, v))}
|
||||
/>
|
||||
</div>
|
||||
{/* Inject model info card right after the model field */}
|
||||
{key === "model" && currentModel && (
|
||||
<div className="py-1">
|
||||
<ModelInfoCard currentModel={currentModel} refreshKey={modelInfoRefreshKey} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -298,19 +268,19 @@ export default function ConfigPage() {
|
|||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 className="h-4 w-4 text-muted-foreground" />
|
||||
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5">
|
||||
~/.hermes/config.yaml
|
||||
<code className="text-xs text-muted-foreground bg-muted/50 px-2 py-0.5 rounded">
|
||||
{t.config.configPath}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button variant="ghost" size="sm" onClick={handleExport} title="Export config as JSON" aria-label="Export config">
|
||||
<Button variant="ghost" size="sm" onClick={handleExport} title={t.config.exportConfig} aria-label={t.config.exportConfig}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title="Import config from JSON" aria-label="Import config">
|
||||
<Button variant="ghost" size="sm" onClick={() => fileInputRef.current?.click()} title={t.config.importConfig} aria-label={t.config.importConfig}>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<input ref={fileInputRef} type="file" accept=".json" className="hidden" onChange={handleImport} />
|
||||
<Button variant="ghost" size="sm" onClick={handleReset} title="Reset to defaults" aria-label="Reset to defaults">
|
||||
<Button variant="ghost" size="sm" onClick={handleReset} title={t.config.resetDefaults} aria-label={t.config.resetDefaults}>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
|
|
@ -325,7 +295,7 @@ export default function ConfigPage() {
|
|||
{yamlMode ? (
|
||||
<>
|
||||
<FormInput className="h-3.5 w-3.5" />
|
||||
Form
|
||||
{t.common.form}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -338,12 +308,12 @@ export default function ConfigPage() {
|
|||
{yamlMode ? (
|
||||
<Button size="sm" onClick={handleYamlSave} disabled={yamlSaving} className="gap-1.5">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{yamlSaving ? "Saving..." : "Save"}
|
||||
{yamlSaving ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" onClick={handleSave} disabled={saving} className="gap-1.5">
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? "Saving..." : "Save"}
|
||||
{saving ? t.common.saving : t.common.save}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -355,7 +325,7 @@ export default function ConfigPage() {
|
|||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<FileText className="h-4 w-4" />
|
||||
Raw YAML Configuration
|
||||
{t.config.rawYaml}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
|
|
@ -384,7 +354,7 @@ export default function ConfigPage() {
|
|||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8 h-8 text-xs"
|
||||
placeholder="Search..."
|
||||
placeholder={t.common.search}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
|
|
@ -411,13 +381,13 @@ export default function ConfigPage() {
|
|||
setSearchQuery("");
|
||||
setActiveCategory(cat);
|
||||
}}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
className={`group flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<CategoryIcon cat={cat} className="h-4 w-4 shrink-0" />
|
||||
<span className="text-sm leading-none">{CATEGORY_ICONS[cat] || "📄"}</span>
|
||||
<span className="flex-1 truncate">{prettyCategoryName(cat)}</span>
|
||||
<span className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}>
|
||||
{categoryCounts[cat] || 0}
|
||||
|
|
@ -441,17 +411,17 @@ export default function ConfigPage() {
|
|||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Search Results
|
||||
{t.config.searchResults}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{searchMatchedFields.length} field{searchMatchedFields.length !== 1 ? "s" : ""}
|
||||
{searchMatchedFields.length} {t.config.fields.replace("{s}", searchMatchedFields.length !== 1 ? "s" : "")}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2 px-4 pb-4">
|
||||
{searchMatchedFields.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No fields match "<span className="text-foreground">{searchQuery}</span>"
|
||||
{t.config.noFieldsMatch.replace("{query}", searchQuery)}
|
||||
</p>
|
||||
) : (
|
||||
renderFields(searchMatchedFields, true)
|
||||
|
|
@ -464,11 +434,11 @@ export default function ConfigPage() {
|
|||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<CategoryIcon cat={activeCategory} className="h-4 w-4" />
|
||||
<span className="text-base">{CATEGORY_ICONS[activeCategory] || "📄"}</span>
|
||||
{prettyCategoryName(activeCategory)}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{activeFields.length} field{activeFields.length !== 1 ? "s" : ""}
|
||||
{activeFields.length} {t.config.fields.replace("{s}", activeFields.length !== 1 ? "s" : "")}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,8 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectOption } from "@/components/ui/select";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
function formatTime(iso?: string | null): string {
|
||||
if (!iso) return "—";
|
||||
|
|
@ -29,6 +30,7 @@ export default function CronPage() {
|
|||
const [jobs, setJobs] = useState<CronJob[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
// New job form state
|
||||
const [prompt, setPrompt] = useState("");
|
||||
|
|
@ -41,7 +43,7 @@ export default function CronPage() {
|
|||
api
|
||||
.getCronJobs()
|
||||
.then(setJobs)
|
||||
.catch(() => showToast("Failed to load cron jobs", "error"))
|
||||
.catch(() => showToast(t.common.loading, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
|
|
@ -51,7 +53,7 @@ export default function CronPage() {
|
|||
|
||||
const handleCreate = async () => {
|
||||
if (!prompt.trim() || !schedule.trim()) {
|
||||
showToast("Prompt and schedule are required", "error");
|
||||
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
|
|
@ -62,14 +64,14 @@ export default function CronPage() {
|
|||
name: name.trim() || undefined,
|
||||
deliver,
|
||||
});
|
||||
showToast("Cron job created", "success");
|
||||
showToast(t.common.create + " ✓", "success");
|
||||
setPrompt("");
|
||||
setSchedule("");
|
||||
setName("");
|
||||
setDeliver("local");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Failed to create job: ${e}`, "error");
|
||||
showToast(`${t.config.failedToSave}: ${e}`, "error");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
|
|
@ -80,34 +82,34 @@ export default function CronPage() {
|
|||
const isPaused = job.state === "paused";
|
||||
if (isPaused) {
|
||||
await api.resumeCronJob(job.id);
|
||||
showToast(`Resumed "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(`${t.cron.resume}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
} else {
|
||||
await api.pauseCronJob(job.id);
|
||||
showToast(`Paused "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
}
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Action failed: ${e}`, "error");
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleTrigger = async (job: CronJob) => {
|
||||
try {
|
||||
await api.triggerCronJob(job.id);
|
||||
showToast(`Triggered "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Trigger failed: ${e}`, "error");
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (job: CronJob) => {
|
||||
try {
|
||||
await api.deleteCronJob(job.id);
|
||||
showToast(`Deleted "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`Delete failed: ${e}`, "error");
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -128,27 +130,27 @@ export default function CronPage() {
|
|||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Plus className="h-4 w-4" />
|
||||
New Cron Job
|
||||
{t.cron.newJob}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-name">Name (optional)</Label>
|
||||
<Label htmlFor="cron-name">{t.cron.nameOptional}</Label>
|
||||
<Input
|
||||
id="cron-name"
|
||||
placeholder="e.g. Daily summary"
|
||||
placeholder={t.cron.namePlaceholder}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-prompt">Prompt</Label>
|
||||
<Label htmlFor="cron-prompt">{t.cron.prompt}</Label>
|
||||
<textarea
|
||||
id="cron-prompt"
|
||||
className="flex min-h-[80px] w-full border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder="What should the agent do on each run?"
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
placeholder={t.cron.promptPlaceholder}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
|
|
@ -156,34 +158,34 @@ export default function CronPage() {
|
|||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-schedule">Schedule (cron expression)</Label>
|
||||
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
|
||||
<Input
|
||||
id="cron-schedule"
|
||||
placeholder="0 9 * * *"
|
||||
placeholder={t.cron.schedulePlaceholder}
|
||||
value={schedule}
|
||||
onChange={(e) => setSchedule(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-deliver">Deliver to</Label>
|
||||
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
|
||||
<Select
|
||||
id="cron-deliver"
|
||||
value={deliver}
|
||||
onValueChange={setDeliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="local">Local</SelectOption>
|
||||
<SelectOption value="telegram">Telegram</SelectOption>
|
||||
<SelectOption value="discord">Discord</SelectOption>
|
||||
<SelectOption value="slack">Slack</SelectOption>
|
||||
<SelectOption value="email">Email</SelectOption>
|
||||
<option value="local">{t.cron.delivery.local}</option>
|
||||
<option value="telegram">{t.cron.delivery.telegram}</option>
|
||||
<option value="discord">{t.cron.delivery.discord}</option>
|
||||
<option value="slack">{t.cron.delivery.slack}</option>
|
||||
<option value="email">{t.cron.delivery.email}</option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleCreate} disabled={creating} className="w-full">
|
||||
<Plus className="h-3 w-3" />
|
||||
{creating ? "Creating..." : "Create"}
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -195,13 +197,13 @@ export default function CronPage() {
|
|||
<div className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Clock className="h-4 w-4" />
|
||||
Scheduled Jobs ({jobs.length})
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</h2>
|
||||
|
||||
{jobs.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No cron jobs configured. Create one above.
|
||||
{t.cron.noJobs}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
|
@ -229,8 +231,8 @@ export default function CronPage() {
|
|||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{job.schedule_display}</span>
|
||||
<span>Last: {formatTime(job.last_run_at)}</span>
|
||||
<span>Next: {formatTime(job.next_run_at)}</span>
|
||||
<span>{t.cron.last}: {formatTime(job.last_run_at)}</span>
|
||||
<span>{t.cron.next}: {formatTime(job.next_run_at)}</span>
|
||||
</div>
|
||||
{job.last_error && (
|
||||
<p className="text-xs text-destructive mt-1">{job.last_error}</p>
|
||||
|
|
@ -242,8 +244,8 @@ export default function CronPage() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title={job.state === "paused" ? "Resume" : "Pause"}
|
||||
aria-label={job.state === "paused" ? "Resume job" : "Pause job"}
|
||||
title={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
aria-label={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
onClick={() => handlePauseResume(job)}
|
||||
>
|
||||
{job.state === "paused" ? (
|
||||
|
|
@ -256,8 +258,8 @@ export default function CronPage() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Trigger now"
|
||||
aria-label="Trigger job now"
|
||||
title={t.cron.triggerNow}
|
||||
aria-label={t.cron.triggerNow}
|
||||
onClick={() => handleTrigger(job)}
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
|
|
@ -266,8 +268,8 @@ export default function CronPage() {
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Delete"
|
||||
aria-label="Delete job"
|
||||
title={t.common.delete}
|
||||
aria-label={t.common.delete}
|
||||
onClick={() => handleDelete(job)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { Badge } from "@/components/ui/badge";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Provider grouping */
|
||||
|
|
@ -72,11 +73,11 @@ interface ProviderGroup {
|
|||
hasAnySet: boolean;
|
||||
}
|
||||
|
||||
const CATEGORY_META: Record<string, { label: string; icon: typeof KeyRound }> = {
|
||||
provider: { label: "LLM Providers", icon: Zap },
|
||||
tool: { label: "Tool API Keys", icon: KeyRound },
|
||||
messaging: { label: "Messaging Platforms", icon: MessageSquare },
|
||||
setting: { label: "Agent Settings", icon: Settings },
|
||||
const CATEGORY_META_ICONS: Record<string, typeof KeyRound> = {
|
||||
provider: Zap,
|
||||
tool: KeyRound,
|
||||
messaging: MessageSquare,
|
||||
setting: Settings,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
|
@ -108,6 +109,7 @@ function EnvVarRow({
|
|||
onCancelEdit: (key: string) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isEditing = edits[varKey] !== undefined;
|
||||
const isRevealed = !!revealed[varKey];
|
||||
const displayValue = isRevealed ? revealed[varKey] : (info.redacted_value ?? "---");
|
||||
|
|
@ -124,13 +126,13 @@ function EnvVarRow({
|
|||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="h-6 text-[0.6rem] px-2"
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
Set
|
||||
{t.common.set}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -149,13 +151,13 @@ function EnvVarRow({
|
|||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" variant="outline" className="h-7 text-[0.6rem]"
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
Set
|
||||
{t.common.set}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -169,13 +171,13 @@ function EnvVarRow({
|
|||
<div className="flex items-center gap-2">
|
||||
<Label className="font-mono-ui text-[0.7rem]">{varKey}</Label>
|
||||
<Badge variant={info.is_set ? "success" : "outline"}>
|
||||
{info.is_set ? "Set" : "Not set"}
|
||||
{info.is_set ? t.common.set : t.env.notSet}
|
||||
</Badge>
|
||||
</div>
|
||||
{info.url && (
|
||||
<a href={info.url} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline">
|
||||
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -200,7 +202,7 @@ function EnvVarRow({
|
|||
|
||||
{info.is_set && (
|
||||
<Button size="sm" variant="ghost" onClick={() => onReveal(varKey)}
|
||||
title={isRevealed ? "Hide value" : "Show real value"}
|
||||
title={isRevealed ? t.env.hideValue : t.env.showValue}
|
||||
aria-label={isRevealed ? `Hide ${varKey}` : `Reveal ${varKey}`}>
|
||||
{isRevealed
|
||||
? <EyeOff className="h-4 w-4" />
|
||||
|
|
@ -211,7 +213,7 @@ function EnvVarRow({
|
|||
<Button size="sm" variant="outline"
|
||||
onClick={() => setEdits((prev) => ({ ...prev, [varKey]: "" }))}>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{info.is_set ? "Replace" : "Set"}
|
||||
{info.is_set ? t.common.replace : t.common.set}
|
||||
</Button>
|
||||
|
||||
{info.is_set && (
|
||||
|
|
@ -219,7 +221,7 @@ function EnvVarRow({
|
|||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => onClear(varKey)} disabled={saving === varKey}>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{saving === varKey ? "..." : "Clear"}
|
||||
{saving === varKey ? "..." : t.common.clear}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -229,15 +231,15 @@ function EnvVarRow({
|
|||
<div className="flex items-center gap-2">
|
||||
<Input autoFocus type="text" value={edits[varKey]}
|
||||
onChange={(e) => setEdits((prev) => ({ ...prev, [varKey]: e.target.value }))}
|
||||
placeholder={info.is_set ? `Replace current value (${info.redacted_value ?? "---"})` : "Enter value..."}
|
||||
placeholder={info.is_set ? t.env.replaceCurrentValue.replace("{preview}", info.redacted_value ?? "---") : t.env.enterValue}
|
||||
className="flex-1 font-mono-ui text-xs" />
|
||||
<Button size="sm" onClick={() => onSave(varKey)}
|
||||
disabled={saving === varKey || !edits[varKey]}>
|
||||
<Save className="h-3 w-3" />
|
||||
{saving === varKey ? "..." : "Save"}
|
||||
{saving === varKey ? "..." : t.common.save}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onCancelEdit(varKey)}>
|
||||
<X className="h-3 w-3" /> Cancel
|
||||
<X className="h-3 w-3" /> {t.common.cancel}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -271,6 +273,7 @@ function ProviderGroupCard({
|
|||
onCancelEdit: (key: string) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
// Separate API keys from base URLs and other settings
|
||||
const apiKeys = group.entries.filter(([k]) => k.endsWith("_API_KEY") || k.endsWith("_TOKEN"));
|
||||
|
|
@ -292,10 +295,10 @@ function ProviderGroupCard({
|
|||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{expanded ? <ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" /> : <ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
|
||||
<span className="font-semibold text-sm tracking-wide">{group.name}</span>
|
||||
<span className="font-semibold text-sm tracking-wide">{group.name === "Other" ? t.common.other : group.name}</span>
|
||||
{hasAnyConfigured && (
|
||||
<Badge variant="success" className="text-[0.6rem]">
|
||||
{configuredCount} set
|
||||
{configuredCount} {t.common.set.toLowerCase()}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -304,11 +307,11 @@ function ProviderGroupCard({
|
|||
<a href={keyUrl} target="_blank" rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[0.65rem] text-primary hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
Get key <ExternalLink className="h-2.5 w-2.5" />
|
||||
{t.env.getKey} <ExternalLink className="h-2.5 w-2.5" />
|
||||
</a>
|
||||
)}
|
||||
<span className="text-[0.65rem] text-muted-foreground/60">
|
||||
{group.entries.length} key{group.entries.length !== 1 ? "s" : ""}
|
||||
{t.env.keysCount.replace("{count}", String(group.entries.length)).replace("{s}", group.entries.length !== 1 ? "s" : "")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
|
@ -357,6 +360,7 @@ export default function EnvPage() {
|
|||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [showAdvanced, setShowAdvanced] = useState(true); // Show all providers by default
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
api.getEnvVars().then(setVars).catch(() => {});
|
||||
|
|
@ -378,9 +382,9 @@ export default function EnvPage() {
|
|||
);
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
showToast(`${key} saved`, "success");
|
||||
showToast(`${key} ${t.common.save.toLowerCase()}d`, "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to save ${key}: ${e}`, "error");
|
||||
showToast(`${t.config.failedToSave} ${key}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
|
|
@ -397,9 +401,9 @@ export default function EnvPage() {
|
|||
);
|
||||
setEdits((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
setRevealed((prev) => { const n = { ...prev }; delete n[key]; return n; });
|
||||
showToast(`${key} removed`, "success");
|
||||
showToast(`${key} ${t.common.removed}`, "success");
|
||||
} catch (e) {
|
||||
showToast(`Failed to remove ${key}: ${e}`, "error");
|
||||
showToast(`${t.common.failedToRemove} ${key}: ${e}`, "error");
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
|
|
@ -414,7 +418,7 @@ export default function EnvPage() {
|
|||
const resp = await api.revealEnvVar(key);
|
||||
setRevealed((prev) => ({ ...prev, [key]: resp.value }));
|
||||
} catch {
|
||||
showToast(`Failed to reveal ${key}`, "error");
|
||||
showToast(`${t.common.failedToReveal} ${key}`, "error");
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -447,7 +451,12 @@ export default function EnvPage() {
|
|||
}))
|
||||
.sort((a, b) => a.priority - b.priority);
|
||||
|
||||
// Non-provider categories
|
||||
// Non-provider categories — use translated labels
|
||||
const CATEGORY_META_LABELS: Record<string, string> = {
|
||||
tool: t.app.nav.keys,
|
||||
messaging: t.common.messaging,
|
||||
setting: t.app.nav.config,
|
||||
};
|
||||
const otherCategories = ["tool", "messaging", "setting"];
|
||||
const nonProvider = otherCategories.map((cat) => {
|
||||
const entries = Object.entries(vars).filter(
|
||||
|
|
@ -456,7 +465,8 @@ export default function EnvPage() {
|
|||
const setEntries = entries.filter(([, info]) => info.is_set);
|
||||
const unsetEntries = entries.filter(([, info]) => !info.is_set);
|
||||
return {
|
||||
...CATEGORY_META[cat],
|
||||
label: CATEGORY_META_LABELS[cat] ?? cat,
|
||||
icon: CATEGORY_META_ICONS[cat] ?? KeyRound,
|
||||
category: cat,
|
||||
setEntries,
|
||||
unsetEntries,
|
||||
|
|
@ -465,7 +475,7 @@ export default function EnvPage() {
|
|||
});
|
||||
|
||||
return { providerGroups: groups, nonProviderGrouped: nonProvider };
|
||||
}, [vars, showAdvanced]);
|
||||
}, [vars, showAdvanced, t]);
|
||||
|
||||
if (!vars) {
|
||||
return (
|
||||
|
|
@ -485,18 +495,18 @@ export default function EnvPage() {
|
|||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage API keys and secrets stored in <code>~/.hermes/.env</code>
|
||||
{t.env.description} <code>~/.hermes/.env</code>
|
||||
</p>
|
||||
<p className="text-[0.7rem] text-muted-foreground/70">
|
||||
Changes are saved to disk immediately. Active sessions pick up new keys automatically.
|
||||
{t.env.changesNote}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowAdvanced(!showAdvanced)}>
|
||||
{showAdvanced ? "Hide Advanced" : "Show Advanced"}
|
||||
{showAdvanced ? t.env.hideAdvanced : t.env.showAdvanced}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ OAuth Logins (sits above API keys — distinct auth mode) ══ */}
|
||||
{/* ═══════════════ OAuth Logins ══ */}
|
||||
<OAuthProvidersCard
|
||||
onError={(msg) => showToast(msg, "error")}
|
||||
onSuccess={(msg) => showToast(msg, "success")}
|
||||
|
|
@ -507,10 +517,10 @@ export default function EnvPage() {
|
|||
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">LLM Providers</CardTitle>
|
||||
<CardTitle className="text-base">{t.env.llmProviders}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{configuredProviders} of {totalProviders} providers configured
|
||||
{t.env.providersConfigured.replace("{configured}", String(configuredProviders)).replace("{total}", String(totalProviders))}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -538,7 +548,7 @@ export default function EnvPage() {
|
|||
<CardTitle className="text-base">{label}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{setEntries.length} of {totalEntries} configured
|
||||
{setEntries.length} {t.common.of} {totalEntries} {t.common.configured}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -595,6 +605,7 @@ function CollapsibleUnset({
|
|||
onCancelEdit: (key: string) => void;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -606,7 +617,7 @@ function CollapsibleUnset({
|
|||
{collapsed
|
||||
? <ChevronRight className="h-3 w-3" />
|
||||
: <ChevronDown className="h-3 w-3" />}
|
||||
<span>{unsetEntries.length} not configured</span>
|
||||
<span>{t.env.notConfigured.replace("{count}", String(unsetEntries.length))}</span>
|
||||
</button>
|
||||
|
||||
{!collapsed && unsetEntries.map(([key, info]) => (
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Bug,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Hash,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { FileText, RefreshCw } from "lucide-react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const FILES = ["agent", "errors", "gateway"] as const;
|
||||
const LEVELS = ["ALL", "DEBUG", "INFO", "WARNING", "ERROR"] as const;
|
||||
|
|
@ -35,6 +28,37 @@ const LINE_COLORS: Record<string, string> = {
|
|||
debug: "text-muted-foreground/60",
|
||||
};
|
||||
|
||||
function FilterBar<T extends string>({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
options: readonly T[];
|
||||
value: T;
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground font-medium w-20 shrink-0">{label}</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{options.map((opt) => (
|
||||
<Button
|
||||
key={opt}
|
||||
variant={value === opt ? "default" : "outline"}
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2.5"
|
||||
onClick={() => onChange(opt)}
|
||||
>
|
||||
{opt}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LogsPage() {
|
||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||
|
|
@ -45,6 +69,7 @@ export default function LogsPage() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const fetchLogs = useCallback(() => {
|
||||
setLoading(true);
|
||||
|
|
@ -53,6 +78,7 @@ export default function LogsPage() {
|
|||
.getLogs({ file, lines: lineCount, level, component })
|
||||
.then((resp) => {
|
||||
setLines(resp.lines);
|
||||
// Auto-scroll to bottom
|
||||
setTimeout(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
|
|
@ -63,10 +89,12 @@ export default function LogsPage() {
|
|||
.finally(() => setLoading(false));
|
||||
}, [file, lineCount, level, component]);
|
||||
|
||||
// Initial load + refetch on filter change
|
||||
useEffect(() => {
|
||||
fetchLogs();
|
||||
}, [fetchLogs]);
|
||||
|
||||
// Auto-refresh polling
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return;
|
||||
const interval = setInterval(fetchLogs, 5000);
|
||||
|
|
@ -74,176 +102,76 @@ export default function LogsPage() {
|
|||
}, [autoRefresh, fetchLogs]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* ═══════════════ Header ═══════════════ */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{file} / {level.toLowerCase()} / {component}
|
||||
</span>
|
||||
{loading && (
|
||||
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||
<Label className="text-xs">Auto-refresh</Label>
|
||||
{autoRefresh && (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
Live
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<div className="sm:w-52 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
||||
{/* File section */}
|
||||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
||||
<SidebarHeading icon={FileText} label="File" />
|
||||
{FILES.map((f) => (
|
||||
<SidebarItem
|
||||
key={f}
|
||||
label={f}
|
||||
active={file === f}
|
||||
indented
|
||||
onClick={() => setFile(f)}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">{t.logs.title}</CardTitle>
|
||||
{loading && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="hidden sm:block border-t border-border my-1" />
|
||||
|
||||
<SidebarHeading icon={AlertTriangle} label="Level" />
|
||||
{LEVELS.map((l) => (
|
||||
<SidebarItem
|
||||
key={l}
|
||||
label={l.toLowerCase()}
|
||||
active={level === l}
|
||||
indented
|
||||
onClick={() => setLevel(l)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="hidden sm:block border-t border-border my-1" />
|
||||
|
||||
<SidebarHeading icon={Layers} label="Component" />
|
||||
{COMPONENTS.map((c) => (
|
||||
<SidebarItem
|
||||
key={c}
|
||||
label={c}
|
||||
active={component === c}
|
||||
indented
|
||||
onClick={() => setComponent(c)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="hidden sm:block border-t border-border my-1" />
|
||||
|
||||
<SidebarHeading icon={Hash} label="Lines" />
|
||||
{LINE_COUNTS.map((n) => (
|
||||
<SidebarItem
|
||||
key={n}
|
||||
label={String(n)}
|
||||
active={lineCount === n}
|
||||
indented
|
||||
onClick={() => setLineCount(n)}
|
||||
/>
|
||||
))}
|
||||
<Label className="text-xs">{t.logs.autoRefresh}</Label>
|
||||
{autoRefresh && (
|
||||
<Badge variant="success" className="text-[10px]">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{/* ---- Content ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Bug className="h-4 w-4" />
|
||||
{file} logs
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{lines.length} line{lines.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
{error && (
|
||||
<div className="bg-destructive/10 border border-destructive/20 p-3 mb-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
<CardContent>
|
||||
<div className="flex flex-col gap-3 mb-4">
|
||||
<FilterBar label={t.logs.file} options={FILES} value={file} onChange={setFile} />
|
||||
<FilterBar label={t.logs.level} options={LEVELS} value={level} onChange={setLevel} />
|
||||
<FilterBar label={t.logs.component} options={COMPONENTS} value={component} onChange={setComponent} />
|
||||
<FilterBar
|
||||
label={t.logs.lines}
|
||||
options={LINE_COUNTS.map(String) as unknown as readonly string[]}
|
||||
value={String(lineCount)}
|
||||
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 p-3 mb-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
|
||||
>
|
||||
{lines.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-8">{t.logs.noLogLines}</p>
|
||||
)}
|
||||
{lines.map((line, i) => {
|
||||
const cls = classifyLine(line);
|
||||
return (
|
||||
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1 rounded`}>
|
||||
{line}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="border border-border bg-background p-4 font-mono-ui text-xs leading-5 overflow-auto max-h-[600px] min-h-[200px]"
|
||||
>
|
||||
{lines.length === 0 && !loading && (
|
||||
<p className="text-muted-foreground text-center py-8">No log lines found</p>
|
||||
)}
|
||||
{lines.map((line, i) => {
|
||||
const cls = classifyLine(line);
|
||||
return (
|
||||
<div key={i} className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarHeading({ icon: Icon, label }: SidebarHeadingProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarItem({ label, active, indented, onClick }: SidebarItemProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`group flex items-center gap-2 ${indented ? "sm:pl-6" : ""} px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
active
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{active && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
interface SidebarHeadingProps {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface SidebarItemProps {
|
||||
label: string;
|
||||
active: boolean;
|
||||
indented?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,13 +20,7 @@ import { Markdown } from "@/components/Markdown";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
user: { bg: "bg-primary/10", text: "text-primary", label: "User" },
|
||||
assistant: { bg: "bg-success/10", text: "text-success", label: "Assistant" },
|
||||
system: { bg: "bg-muted", text: "text-muted-foreground", label: "System" },
|
||||
tool: { bg: "bg-warning/10", text: "text-warning", label: "Tool" },
|
||||
};
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
|
||||
cli: { icon: Terminal, color: "text-primary" },
|
||||
|
|
@ -50,7 +44,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
|||
parts.push(snippet.slice(last, match.index));
|
||||
}
|
||||
parts.push(
|
||||
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
|
||||
<mark key={i++} className="bg-warning/30 text-warning rounded-sm px-0.5">
|
||||
{match[1]}
|
||||
</mark>
|
||||
);
|
||||
|
|
@ -68,6 +62,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
|||
|
||||
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
let args = toolCall.function.arguments;
|
||||
try {
|
||||
|
|
@ -77,12 +72,12 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="mt-2 border border-warning/20 bg-warning/5">
|
||||
<div className="mt-2 rounded-md border border-warning/20 bg-warning/5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-xs text-warning cursor-pointer hover:bg-warning/10 transition-colors"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-label={`${open ? "Collapse" : "Expand"} tool call ${toolCall.function.name}`}
|
||||
aria-label={`${open ? t.common.collapse : t.common.expand} tool call ${toolCall.function.name}`}
|
||||
>
|
||||
{open ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
<span className="font-mono-ui font-medium">{toolCall.function.name}</span>
|
||||
|
|
@ -98,8 +93,17 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
|
|||
}
|
||||
|
||||
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const ROLE_STYLES: Record<string, { bg: string; text: string; label: string }> = {
|
||||
user: { bg: "bg-primary/10", text: "text-primary", label: t.sessions.roles.user },
|
||||
assistant: { bg: "bg-success/10", text: "text-success", label: t.sessions.roles.assistant },
|
||||
system: { bg: "bg-muted", text: "text-muted-foreground", label: t.sessions.roles.system },
|
||||
tool: { bg: "bg-warning/10", text: "text-warning", label: t.sessions.roles.tool },
|
||||
};
|
||||
|
||||
const style = ROLE_STYLES[msg.role] ?? ROLE_STYLES.system;
|
||||
const label = msg.tool_name ? `Tool: ${msg.tool_name}` : style.label;
|
||||
const label = msg.tool_name ? `${t.sessions.roles.tool}: ${msg.tool_name}` : style.label;
|
||||
|
||||
// Check if any search term appears as a prefix of any word in content
|
||||
const isHit = (() => {
|
||||
|
|
@ -119,7 +123,7 @@ function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: st
|
|||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`text-xs font-semibold ${style.text}`}>{label}</span>
|
||||
{isHit && (
|
||||
<Badge variant="warning" className="text-[9px] py-0 px-1.5">match</Badge>
|
||||
<Badge variant="warning" className="text-[9px] py-0 px-1.5">{t.common.match}</Badge>
|
||||
)}
|
||||
{msg.timestamp && (
|
||||
<span className="text-[10px] text-muted-foreground">{timeAgo(msg.timestamp)}</span>
|
||||
|
|
@ -184,6 +188,7 @@ function SessionRow({
|
|||
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && messages === null && !loading) {
|
||||
|
|
@ -217,23 +222,23 @@ function SessionRow({
|
|||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}>
|
||||
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : "Untitled session")}
|
||||
{hasTitle ? session.title : (session.preview ? session.preview.slice(0, 60) : t.sessions.untitledSession)}
|
||||
</span>
|
||||
{session.is_active && (
|
||||
<Badge variant="success" className="text-[10px] shrink-0">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
Live
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span className="truncate max-w-[120px] sm:max-w-[180px]">{(session.model ?? "unknown").split("/").pop()}</span>
|
||||
<span className="truncate max-w-[120px] sm:max-w-[180px]">{(session.model ?? t.common.unknown).split("/").pop()}</span>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.message_count} msgs</span>
|
||||
<span>{session.message_count} {t.common.msgs}</span>
|
||||
{session.tool_call_count > 0 && (
|
||||
<>
|
||||
<span className="text-border">·</span>
|
||||
<span>{session.tool_call_count} tools</span>
|
||||
<span>{session.tool_call_count} {t.common.tools}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
|
|
@ -253,7 +258,7 @@ function SessionRow({
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-destructive"
|
||||
aria-label="Delete session"
|
||||
aria-label={t.sessions.deleteSession}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
|
|
@ -275,7 +280,7 @@ function SessionRow({
|
|||
<p className="text-sm text-destructive py-4 text-center">{error}</p>
|
||||
)}
|
||||
{messages && messages.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">No messages</p>
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">{t.sessions.noMessages}</p>
|
||||
)}
|
||||
{messages && messages.length > 0 && (
|
||||
<MessageList messages={messages} highlight={searchQuery} />
|
||||
|
|
@ -297,6 +302,7 @@ export default function SessionsPage() {
|
|||
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const loadSessions = useCallback((p: number) => {
|
||||
setLoading(true);
|
||||
|
|
@ -377,7 +383,7 @@ export default function SessionsPage() {
|
|||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">Sessions</h1>
|
||||
<h1 className="text-base font-semibold">{t.sessions.title}</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{total}
|
||||
</Badge>
|
||||
|
|
@ -389,7 +395,7 @@ export default function SessionsPage() {
|
|||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
<Input
|
||||
placeholder="Search message content..."
|
||||
placeholder={t.sessions.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-8 pr-7 h-8 text-xs"
|
||||
|
|
@ -410,10 +416,10 @@ export default function SessionsPage() {
|
|||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mb-3 opacity-40" />
|
||||
<p className="text-sm font-medium">
|
||||
{search ? "No sessions match your search" : "No sessions yet"}
|
||||
{search ? t.sessions.noMatch : t.sessions.noSessions}
|
||||
</p>
|
||||
{!search && (
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">Start a conversation to see it here</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">{t.sessions.startConversation}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -438,7 +444,7 @@ export default function SessionsPage() {
|
|||
{!searchResults && total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} {t.common.of} {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
|
|
@ -447,12 +453,12 @@ export default function SessionsPage() {
|
|||
className="h-7 w-7 p-0"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
aria-label="Previous page"
|
||||
aria-label={t.sessions.previousPage}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
Page {page + 1} of {Math.ceil(total / PAGE_SIZE)}
|
||||
{t.common.page} {page + 1} {t.common.of} {Math.ceil(total / PAGE_SIZE)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -460,7 +466,7 @@ export default function SessionsPage() {
|
|||
className="h-7 w-7 p-0"
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label="Next page"
|
||||
aria-label={t.sessions.nextPage}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,28 +1,13 @@
|
|||
import { useEffect, useState, useMemo } from "react";
|
||||
import {
|
||||
Blocks,
|
||||
Bot,
|
||||
BrainCircuit,
|
||||
ChevronRight,
|
||||
Code,
|
||||
Database,
|
||||
FileCode,
|
||||
FileSearch,
|
||||
Globe,
|
||||
Image,
|
||||
LayoutDashboard,
|
||||
Monitor,
|
||||
Package,
|
||||
Paintbrush,
|
||||
Search,
|
||||
Server,
|
||||
Shield,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Wrench,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { ComponentType } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -31,11 +16,19 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Types & helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
interface CategoryGroup {
|
||||
name: string; // display name
|
||||
key: string; // raw key (or "__none__")
|
||||
skills: SkillInfo[];
|
||||
enabledCount: number;
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
mlops: "MLOps",
|
||||
"mlops/cloud": "MLOps / Cloud",
|
||||
|
|
@ -53,8 +46,8 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||
ui: "UI",
|
||||
};
|
||||
|
||||
function prettyCategory(raw: string | null | undefined): string {
|
||||
if (!raw) return "General";
|
||||
function prettyCategory(raw: string | null | undefined, generalLabel: string): string {
|
||||
if (!raw) return generalLabel;
|
||||
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
|
||||
return raw
|
||||
.split(/[-_/]/)
|
||||
|
|
@ -62,63 +55,31 @@ function prettyCategory(raw: string | null | undefined): string {
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
const TOOLSET_ICONS: Record<string, ComponentType<{ className?: string }>> = {
|
||||
terminal: Terminal,
|
||||
shell: Terminal,
|
||||
browser: Globe,
|
||||
web: Globe,
|
||||
code: Code,
|
||||
coding: Code,
|
||||
python: FileCode,
|
||||
files: FileSearch,
|
||||
file: FileSearch,
|
||||
search: Search,
|
||||
image: Image,
|
||||
vision: Image,
|
||||
memory: BrainCircuit,
|
||||
database: Database,
|
||||
db: Database,
|
||||
mcp: Blocks,
|
||||
ai: Sparkles,
|
||||
agent: Bot,
|
||||
security: Shield,
|
||||
server: Server,
|
||||
deploy: Server,
|
||||
ui: Paintbrush,
|
||||
ux: LayoutDashboard,
|
||||
display: Monitor,
|
||||
};
|
||||
|
||||
function toolsetIcon(name: string, label: string): ComponentType<{ className?: string }> {
|
||||
const lower = name.toLowerCase();
|
||||
if (TOOLSET_ICONS[lower]) return TOOLSET_ICONS[lower];
|
||||
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
|
||||
if (lower.includes(key) || label.toLowerCase().includes(key)) return icon;
|
||||
}
|
||||
return Wrench;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Component */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export default function SkillsPage() {
|
||||
const [view, setView] = useState<"skills" | "toolsets">("skills");
|
||||
const [skills, setSkills] = useState<SkillInfo[]>([]);
|
||||
const [toolsets, setToolsets] = useState<ToolsetInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [activeCategory, setActiveCategory] = useState<string | null>(null);
|
||||
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
|
||||
// Start collapsed by default
|
||||
const [collapsedCategories, setCollapsedCategories] = useState<Set<string> | "all">("all");
|
||||
const { toast, showToast } = useToast();
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getSkills(), api.getToolsets()])
|
||||
.then(([s, t]) => {
|
||||
.then(([s, tsets]) => {
|
||||
setSkills(s);
|
||||
setToolsets(t);
|
||||
setToolsets(tsets);
|
||||
})
|
||||
.catch(() => showToast("Failed to load skills/toolsets", "error"))
|
||||
.catch(() => showToast(t.common.loading, "error"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
|
|
@ -133,11 +94,11 @@ export default function SkillsPage() {
|
|||
)
|
||||
);
|
||||
showToast(
|
||||
`${skill.name} ${skill.enabled ? "disabled" : "enabled"}`,
|
||||
`${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`,
|
||||
"success"
|
||||
);
|
||||
} catch {
|
||||
showToast(`Failed to toggle ${skill.name}`, "error");
|
||||
showToast(`${t.common.failedToToggle} ${skill.name}`, "error");
|
||||
} finally {
|
||||
setTogglingSkills((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
|
@ -164,6 +125,27 @@ export default function SkillsPage() {
|
|||
});
|
||||
}, [skills, search, lowerSearch, activeCategory]);
|
||||
|
||||
const categoryGroups: CategoryGroup[] = useMemo(() => {
|
||||
const map = new Map<string, SkillInfo[]>();
|
||||
for (const s of filteredSkills) {
|
||||
const key = s.category || "__none__";
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(s);
|
||||
}
|
||||
// Sort: General first, then alphabetical
|
||||
const entries = [...map.entries()].sort((a, b) => {
|
||||
if (a[0] === "__none__") return -1;
|
||||
if (b[0] === "__none__") return 1;
|
||||
return a[0].localeCompare(b[0]);
|
||||
});
|
||||
return entries.map(([key, list]) => ({
|
||||
key,
|
||||
name: prettyCategory(key === "__none__" ? null : key, t.common.general),
|
||||
skills: list.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
enabledCount: list.filter((s) => s.enabled).length,
|
||||
}));
|
||||
}, [filteredSkills]);
|
||||
|
||||
const allCategories = useMemo(() => {
|
||||
const cats = new Map<string, number>();
|
||||
for (const s of skills) {
|
||||
|
|
@ -176,39 +158,40 @@ export default function SkillsPage() {
|
|||
if (b[0] === "__none__") return 1;
|
||||
return a[0].localeCompare(b[0]);
|
||||
})
|
||||
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key), count }));
|
||||
.map(([key, count]) => ({ key, name: prettyCategory(key === "__none__" ? null : key, t.common.general), count }));
|
||||
}, [skills]);
|
||||
|
||||
const enabledCount = skills.filter((s) => s.enabled).length;
|
||||
|
||||
const filteredToolsets = useMemo(() => {
|
||||
return toolsets.filter(
|
||||
(t) =>
|
||||
(ts) =>
|
||||
!search ||
|
||||
t.name.toLowerCase().includes(lowerSearch) ||
|
||||
t.label.toLowerCase().includes(lowerSearch) ||
|
||||
t.description.toLowerCase().includes(lowerSearch)
|
||||
ts.name.toLowerCase().includes(lowerSearch) ||
|
||||
ts.label.toLowerCase().includes(lowerSearch) ||
|
||||
ts.description.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}, [toolsets, search, lowerSearch]);
|
||||
|
||||
const isSearching = search.trim().length > 0;
|
||||
const isCollapsed = (key: string): boolean => {
|
||||
if (collapsedCategories === "all") return true;
|
||||
return collapsedCategories.has(key);
|
||||
};
|
||||
|
||||
const activeToolsetCount = toolsets.filter((t) => t.enabled).length;
|
||||
|
||||
const searchMatchedSkills = useMemo(() => {
|
||||
if (!isSearching) return [];
|
||||
return skills.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(lowerSearch) ||
|
||||
s.description.toLowerCase().includes(lowerSearch) ||
|
||||
(s.category ?? "").toLowerCase().includes(lowerSearch),
|
||||
);
|
||||
}, [isSearching, skills, lowerSearch]);
|
||||
|
||||
const activeSkills = useMemo(() => {
|
||||
if (isSearching) return [];
|
||||
return [...filteredSkills].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [isSearching, filteredSkills]);
|
||||
const toggleCollapse = (key: string) => {
|
||||
setCollapsedCategories((prev) => {
|
||||
if (prev === "all") {
|
||||
// Switching from "all collapsed" → expand just this one
|
||||
const allKeys = new Set(categoryGroups.map((g) => g.key));
|
||||
allKeys.delete(key);
|
||||
return allKeys;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
/* ---- Loading ---- */
|
||||
if (loading) {
|
||||
|
|
@ -219,303 +202,240 @@ export default function SkillsPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const activeCategoryName = activeCategory
|
||||
? prettyCategory(activeCategory === "__none__" ? null : activeCategory)
|
||||
: "All Skills";
|
||||
|
||||
const renderSkillList = (list: SkillInfo[]) => (
|
||||
<div className="grid gap-1">
|
||||
{list.map((skill) => (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="group flex items-start gap-3 px-3 py-2.5 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<div className="pt-0.5 shrink-0">
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={() => handleToggleSkill(skill)}
|
||||
disabled={togglingSkills.has(skill.name)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
className={`font-mono-ui text-sm ${
|
||||
skill.enabled ? "text-foreground" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{skill.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{skill.description || "No description available."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-6">
|
||||
<Toast toast={toast} />
|
||||
|
||||
{/* ═══════════════ Header ═══════════════ */}
|
||||
<div className="flex items-center gap-3">
|
||||
{view === "skills" ? (
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Wrench className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{view === "skills"
|
||||
? `${enabledCount}/${skills.length} skills enabled`
|
||||
: `${activeToolsetCount}/${toolsets.length} toolsets active`}
|
||||
</span>
|
||||
{/* ═══════════════ Header + Search ═══════════════ */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">{t.skills.title}</h1>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<div className="sm:w-52 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
|
||||
{/* Search */}
|
||||
<div className="relative mb-2 hidden sm:block">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-8 h-8 text-xs"
|
||||
placeholder="Search..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav items */}
|
||||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
||||
{/* Skills top-level */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("skills");
|
||||
setActiveCategory(null);
|
||||
setSearch("");
|
||||
}}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
view === "skills" && !activeCategory && !isSearching
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Package className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">All Skills</span>
|
||||
<span className={`text-[10px] tabular-nums ${
|
||||
view === "skills" && !activeCategory && !isSearching
|
||||
? "text-primary/60"
|
||||
: "text-muted-foreground/50"
|
||||
}`}>
|
||||
{skills.length}
|
||||
</span>
|
||||
{view === "skills" && !activeCategory && !isSearching && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skill category sub-items */}
|
||||
{allCategories.map(({ key, name, count }) => {
|
||||
const isActive = view === "skills" && activeCategory === key && !isSearching;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("skills");
|
||||
setActiveCategory(key);
|
||||
setSearch("");
|
||||
}}
|
||||
className={`group flex items-center gap-2 sm:pl-6 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{name}</span>
|
||||
<span className={`text-[10px] tabular-nums ${
|
||||
isActive ? "text-primary/60" : "text-muted-foreground/50"
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
{isActive && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="hidden sm:block border-t border-border my-1" />
|
||||
|
||||
{/* Toolsets top-level */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setView("toolsets");
|
||||
setSearch("");
|
||||
}}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
|
||||
view === "toolsets" && !isSearching
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||
}`}
|
||||
>
|
||||
<Wrench className="h-4 w-4 shrink-0" />
|
||||
<span className="flex-1 truncate">Toolsets</span>
|
||||
<span className={`text-[10px] tabular-nums ${
|
||||
view === "toolsets" && !isSearching
|
||||
? "text-primary/60"
|
||||
: "text-muted-foreground/50"
|
||||
}`}>
|
||||
{toolsets.length}
|
||||
</span>
|
||||
{view === "toolsets" && !isSearching && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ---- Content ---- */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Search results (across both skills and toolsets) */}
|
||||
{isSearching ? (
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Search Results
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{searchMatchedSkills.length} skill{searchMatchedSkills.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
{searchMatchedSkills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No skills match “<span className="text-foreground">{search}</span>”
|
||||
</p>
|
||||
) : (
|
||||
renderSkillList(searchMatchedSkills)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
) : view === "skills" ? (
|
||||
/* ---- Skills view ---- */
|
||||
<Card>
|
||||
<CardHeader className="py-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
{activeCategoryName}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{activeSkills.length} skill{activeSkills.length !== 1 ? "s" : ""}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4">
|
||||
{activeSkills.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
{skills.length === 0
|
||||
? "No skills found. Skills are loaded from ~/.hermes/skills/"
|
||||
: "No skills in this category."}
|
||||
</p>
|
||||
) : (
|
||||
renderSkillList(activeSkills)
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
) : (
|
||||
/* ---- Toolsets view ---- */
|
||||
<>
|
||||
{filteredToolsets.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
No toolsets found.
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredToolsets.map((ts) => {
|
||||
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
|
||||
const TsIcon = toolsetIcon(ts.name, ts.label);
|
||||
|
||||
return (
|
||||
<Card key={ts.name}>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<TsIcon className="h-5 w-5 shrink-0 mt-0.5 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">{labelText}</span>
|
||||
<Badge
|
||||
variant={ts.enabled ? "success" : "outline"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{ts.enabled ? "active" : "inactive"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{ts.description}
|
||||
</p>
|
||||
{ts.enabled && !ts.configured && (
|
||||
<p className="text-[10px] text-amber-300/80 mb-2">
|
||||
Setup needed
|
||||
</p>
|
||||
)}
|
||||
{ts.tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ts.tools.map((tool) => (
|
||||
<Badge
|
||||
key={tool}
|
||||
variant="secondary"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ts.tools.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{ts.enabled ? `${ts.name} toolset` : "Disabled for CLI"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{/* ═══════════════ Search + Category Filter ═══════════════ */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-9"
|
||||
placeholder={t.skills.searchPlaceholder}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{search && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category pills */}
|
||||
{allCategories.length > 1 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Filter className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
!activeCategory
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
}`}
|
||||
onClick={() => setActiveCategory(null)}
|
||||
>
|
||||
{t.skills.all} ({skills.length})
|
||||
</button>
|
||||
{allCategories.map(({ key, name, count }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`inline-flex items-center px-3 py-1 text-xs font-medium transition-colors cursor-pointer ${
|
||||
activeCategory === key
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-secondary text-secondary-foreground hover:bg-secondary/80"
|
||||
}`}
|
||||
onClick={() =>
|
||||
setActiveCategory(activeCategory === key ? null : key)
|
||||
}
|
||||
>
|
||||
{name}
|
||||
<span className="ml-1 opacity-60">{count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ═══════════════ Skills by Category ═══════════════ */}
|
||||
<section className="flex flex-col gap-3">
|
||||
|
||||
{filteredSkills.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center text-sm text-muted-foreground">
|
||||
{skills.length === 0
|
||||
? t.skills.noSkills
|
||||
: t.skills.noSkillsMatch}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
categoryGroups.map(({ key, name, skills: catSkills, enabledCount: catEnabled }) => {
|
||||
const collapsed = isCollapsed(key);
|
||||
return (
|
||||
<Card key={key}>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none py-3 px-4"
|
||||
onClick={() => toggleCollapse(key)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<CardTitle className="text-sm font-medium">{name}</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px] font-normal">
|
||||
{t.skills.skillCount.replace("{count}", String(catSkills.length)).replace("{s}", catSkills.length !== 1 ? "s" : "")}
|
||||
</Badge>
|
||||
</div>
|
||||
<Badge
|
||||
variant={catEnabled === catSkills.length ? "success" : "outline"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{t.skills.enabledOf.replace("{enabled}", String(catEnabled)).replace("{total}", String(catSkills.length))}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{collapsed ? (
|
||||
/* Peek: show first few skill names so collapsed isn't blank */
|
||||
<div className="px-4 pb-3 flex items-center min-h-[28px]">
|
||||
<p className="text-xs text-muted-foreground/60 truncate leading-normal">
|
||||
{catSkills.slice(0, 4).map((s) => s.name).join(", ")}
|
||||
{catSkills.length > 4 && `, ${t.skills.more.replace("{count}", String(catSkills.length - 4))}`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<CardContent className="pt-0 px-4 pb-3">
|
||||
<div className="grid gap-1">
|
||||
{catSkills.map((skill) => (
|
||||
<div
|
||||
key={skill.name}
|
||||
className="group flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<div className="pt-0.5 shrink-0">
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
onCheckedChange={() => handleToggleSkill(skill)}
|
||||
disabled={togglingSkills.has(skill.name)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span
|
||||
className={`font-mono-ui text-sm ${
|
||||
skill.enabled
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{skill.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{skill.description || t.skills.noDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* ═══════════════ Toolsets ═══════════════ */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-sm font-medium text-muted-foreground flex items-center gap-2">
|
||||
<Wrench className="h-4 w-4" />
|
||||
{t.skills.toolsets} ({filteredToolsets.length})
|
||||
</h2>
|
||||
|
||||
{filteredToolsets.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-8 text-center text-sm text-muted-foreground">
|
||||
{t.skills.noToolsetsMatch}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredToolsets.map((ts) => {
|
||||
// Strip emoji prefix from label for cleaner display
|
||||
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
|
||||
const emoji = ts.label.match(/^[\p{Emoji}]+/u)?.[0] || "🔧";
|
||||
|
||||
return (
|
||||
<Card key={ts.name} className="relative overflow-hidden">
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="text-2xl shrink-0 leading-none mt-0.5">{emoji}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">{labelText}</span>
|
||||
<Badge
|
||||
variant={ts.enabled ? "success" : "outline"}
|
||||
className="text-[10px]"
|
||||
>
|
||||
{ts.enabled ? t.common.active : t.common.inactive}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{ts.description}
|
||||
</p>
|
||||
{ts.enabled && !ts.configured && (
|
||||
<p className="text-[10px] text-amber-300/80 mb-2">
|
||||
{t.skills.setupNeeded}
|
||||
</p>
|
||||
)}
|
||||
{ts.tools.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{ts.tools.map((tool) => (
|
||||
<Badge
|
||||
key={tool}
|
||||
variant="secondary"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{tool}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{ts.tools.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{ts.enabled ? `${ts.name} toolset` : t.skills.disabledForCli}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,37 +14,12 @@ import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
|
|||
import { timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
|
||||
connected: { variant: "success", label: "Connected" },
|
||||
disconnected: { variant: "warning", label: "Disconnected" },
|
||||
fatal: { variant: "destructive", label: "Error" },
|
||||
};
|
||||
|
||||
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
|
||||
running: { badge: "success", label: "Running" },
|
||||
starting: { badge: "warning", label: "Starting" },
|
||||
startup_failed: { badge: "destructive", label: "Failed" },
|
||||
stopped: { badge: "outline", label: "Stopped" },
|
||||
};
|
||||
|
||||
function gatewayValue(status: StatusResponse): string {
|
||||
if (status.gateway_running) return `PID ${status.gateway_pid}`;
|
||||
if (status.gateway_state === "startup_failed") return "Start failed";
|
||||
return "Not running";
|
||||
}
|
||||
|
||||
function gatewayBadge(status: StatusResponse) {
|
||||
const info = status.gateway_state ? GATEWAY_STATE_DISPLAY[status.gateway_state] : null;
|
||||
if (info) return info;
|
||||
return status.gateway_running
|
||||
? { badge: "success" as const, label: "Running" }
|
||||
: { badge: "outline" as const, label: "Off" };
|
||||
}
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
export default function StatusPage() {
|
||||
const [status, setStatus] = useState<StatusResponse | null>(null);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const { t } = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
const load = () => {
|
||||
|
|
@ -64,28 +39,55 @@ export default function StatusPage() {
|
|||
);
|
||||
}
|
||||
|
||||
const gwBadge = gatewayBadge(status);
|
||||
const PLATFORM_STATE_BADGE: Record<string, { variant: "success" | "warning" | "destructive"; label: string }> = {
|
||||
connected: { variant: "success", label: t.status.connected },
|
||||
disconnected: { variant: "warning", label: t.status.disconnected },
|
||||
fatal: { variant: "destructive", label: t.status.error },
|
||||
};
|
||||
|
||||
const GATEWAY_STATE_DISPLAY: Record<string, { badge: "success" | "warning" | "destructive" | "outline"; label: string }> = {
|
||||
running: { badge: "success", label: t.status.running },
|
||||
starting: { badge: "warning", label: t.status.starting },
|
||||
startup_failed: { badge: "destructive", label: t.status.failed },
|
||||
stopped: { badge: "outline", label: t.status.stopped },
|
||||
};
|
||||
|
||||
function gatewayValue(): string {
|
||||
if (status!.gateway_running) return `${t.status.pid} ${status!.gateway_pid}`;
|
||||
if (status!.gateway_state === "startup_failed") return t.status.startFailed;
|
||||
return t.status.notRunning;
|
||||
}
|
||||
|
||||
function gatewayBadge() {
|
||||
const info = status!.gateway_state ? GATEWAY_STATE_DISPLAY[status!.gateway_state] : null;
|
||||
if (info) return info;
|
||||
return status!.gateway_running
|
||||
? { badge: "success" as const, label: t.status.running }
|
||||
: { badge: "outline" as const, label: t.common.off };
|
||||
}
|
||||
|
||||
const gwBadge = gatewayBadge();
|
||||
|
||||
const items = [
|
||||
{
|
||||
icon: Cpu,
|
||||
label: "Agent",
|
||||
label: t.status.agent,
|
||||
value: `v${status.version}`,
|
||||
badgeText: "Live",
|
||||
badgeText: t.common.live,
|
||||
badgeVariant: "success" as const,
|
||||
},
|
||||
{
|
||||
icon: Radio,
|
||||
label: "Gateway",
|
||||
value: gatewayValue(status),
|
||||
label: t.status.gateway,
|
||||
value: gatewayValue(),
|
||||
badgeText: gwBadge.label,
|
||||
badgeVariant: gwBadge.badge,
|
||||
},
|
||||
{
|
||||
icon: Activity,
|
||||
label: "Active Sessions",
|
||||
value: status.active_sessions > 0 ? `${status.active_sessions} running` : "None",
|
||||
badgeText: status.active_sessions > 0 ? "Live" : "Off",
|
||||
label: t.status.activeSessions,
|
||||
value: status.active_sessions > 0 ? `${status.active_sessions} ${t.status.running.toLowerCase()}` : t.status.noneRunning,
|
||||
badgeText: status.active_sessions > 0 ? t.common.live : t.common.off,
|
||||
badgeVariant: (status.active_sessions > 0 ? "success" : "outline") as "success" | "outline",
|
||||
},
|
||||
];
|
||||
|
|
@ -98,19 +100,19 @@ export default function StatusPage() {
|
|||
const alerts: { message: string; detail?: string }[] = [];
|
||||
if (status.gateway_state === "startup_failed") {
|
||||
alerts.push({
|
||||
message: "Gateway failed to start",
|
||||
message: t.status.gatewayFailedToStart,
|
||||
detail: status.gateway_exit_reason ?? undefined,
|
||||
});
|
||||
}
|
||||
const failedPlatforms = platforms.filter(([, info]) => info.state === "fatal" || info.state === "disconnected");
|
||||
for (const [name, info] of failedPlatforms) {
|
||||
const stateLabel = info.state === "fatal" ? t.status.platformError : t.status.platformDisconnected;
|
||||
alerts.push({
|
||||
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${info.state === "fatal" ? "error" : "disconnected"}`,
|
||||
message: `${name.charAt(0).toUpperCase() + name.slice(1)} ${stateLabel}`,
|
||||
detail: info.error_message ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Alert banner — breaks grid monotony for critical states */}
|
||||
|
|
@ -157,7 +159,7 @@ export default function StatusPage() {
|
|||
</div>
|
||||
|
||||
{platforms.length > 0 && (
|
||||
<PlatformsCard platforms={platforms} />
|
||||
<PlatformsCard platforms={platforms} platformStateBadge={PLATFORM_STATE_BADGE} />
|
||||
)}
|
||||
|
||||
{activeSessions.length > 0 && (
|
||||
|
|
@ -165,7 +167,7 @@ export default function StatusPage() {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-success" />
|
||||
<CardTitle className="text-base">Active Sessions</CardTitle>
|
||||
<CardTitle className="text-base">{t.status.activeSessions}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -177,16 +179,16 @@ export default function StatusPage() {
|
|||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{s.title ?? "Untitled"}</span>
|
||||
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
|
||||
|
||||
<Badge variant="success" className="text-[10px] shrink-0">
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
|
||||
Live
|
||||
{t.common.live}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">{(s.model ?? "unknown").split("/").pop()}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
|
||||
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -200,7 +202,7 @@ export default function StatusPage() {
|
|||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Recent Sessions</CardTitle>
|
||||
<CardTitle className="text-base">{t.status.recentSessions}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
|
|
@ -211,10 +213,10 @@ export default function StatusPage() {
|
|||
className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border border-border p-3 w-full"
|
||||
>
|
||||
<div className="flex flex-col gap-1 min-w-0 w-full">
|
||||
<span className="font-medium text-sm truncate">{s.title ?? "Untitled"}</span>
|
||||
<span className="font-medium text-sm truncate">{s.title ?? t.common.untitled}</span>
|
||||
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
<span className="font-mono-ui">{(s.model ?? "unknown").split("/").pop()}</span> · {s.message_count} msgs · {timeAgo(s.last_active)}
|
||||
<span className="font-mono-ui">{(s.model ?? t.common.unknown).split("/").pop()}</span> · {s.message_count} {t.common.msgs} · {timeAgo(s.last_active)}
|
||||
</span>
|
||||
|
||||
{s.preview && (
|
||||
|
|
@ -237,19 +239,21 @@ export default function StatusPage() {
|
|||
);
|
||||
}
|
||||
|
||||
function PlatformsCard({ platforms }: PlatformsCardProps) {
|
||||
function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
|
||||
const { t } = useI18n();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Radio className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Connected Platforms</CardTitle>
|
||||
<CardTitle className="text-base">{t.status.connectedPlatforms}</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="grid gap-3">
|
||||
{platforms.map(([name, info]) => {
|
||||
const display = PLATFORM_STATE_BADGE[info.state] ?? {
|
||||
const display = platformStateBadge[info.state] ?? {
|
||||
variant: "outline" as const,
|
||||
label: info.state,
|
||||
};
|
||||
|
|
@ -278,7 +282,7 @@ function PlatformsCard({ platforms }: PlatformsCardProps) {
|
|||
|
||||
{info.updated_at && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Last update: {isoTimeAgo(info.updated_at)}
|
||||
{t.status.lastUpdate}: {isoTimeAgo(info.updated_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -300,4 +304,5 @@ function PlatformsCard({ platforms }: PlatformsCardProps) {
|
|||
|
||||
interface PlatformsCardProps {
|
||||
platforms: [string, PlatformStatus][];
|
||||
platformStateBadge: Record<string, { variant: "success" | "warning" | "destructive"; label: string }>;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue