mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: imports
This commit is contained in:
parent
60fd4b7d16
commit
823b6d08ed
11 changed files with 2127 additions and 146 deletions
1752
web/package-lock.json
generated
1752
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -14,9 +14,13 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@nous-research/ui": "^0.3.0",
|
||||
"@observablehq/plot": "^0.6.17",
|
||||
"@react-three/fiber": "^9.6.0",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"gsap": "^3.15.0",
|
||||
"leva": "^0.10.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
|
|
|
|||
|
|
@ -22,9 +22,7 @@ import {
|
|||
Code,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index";
|
||||
import { SelectionSwitcher } from "@nous-research/ui/ui/components/selection-switcher";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { Cell, Grid, SelectionSwitcher, Typography } from "@nous-research/ui";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Backdrop } from "@/components/Backdrop";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { Typography } from "@nous-research/ui";
|
||||
import { useI18n } from "@/i18n/context";
|
||||
|
||||
/**
|
||||
|
|
@ -19,7 +19,9 @@ export function LanguageSwitcher() {
|
|||
aria-label={t.language.switchTo}
|
||||
>
|
||||
{/* Show the *current* language's flag — tooltip advertises the click action */}
|
||||
<span className="text-base leading-none">{locale === "en" ? "🇬🇧" : "🇨🇳"}</span>
|
||||
<span className="text-base leading-none">
|
||||
{locale === "en" ? "🇬🇧" : "🇨🇳"}
|
||||
</span>
|
||||
<Typography
|
||||
mondwest
|
||||
className="hidden sm:inline tracking-wide uppercase text-[0.65rem]"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { Typography } from "@nous-research/ui";
|
||||
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Palette, Check } from "lucide-react";
|
||||
import { Typography } from "@nous-research/ui/ui/components/typography/index";
|
||||
import { Typography } from "@nous-research/ui";
|
||||
import { BUILTIN_THEMES, useTheme } from "@/themes";
|
||||
import { useI18n } from "@/i18n";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
|
@ -23,7 +23,10 @@ export function ThemeSwitcher() {
|
|||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
|
@ -104,7 +107,11 @@ export function ThemeSwitcher() {
|
|||
isActive ? "text-midground" : "text-midground/60",
|
||||
)}
|
||||
>
|
||||
{preset ? <ThemeSwatch theme={preset.name} /> : <PlaceholderSwatch />}
|
||||
{preset ? (
|
||||
<ThemeSwatch theme={preset.name} />
|
||||
) : (
|
||||
<PlaceholderSwatch />
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<Typography
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Clock, Pause, Play, Plus, Trash2, Zap } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CronJob } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -83,10 +83,16 @@ export default function CronPage() {
|
|||
const isPaused = job.state === "paused";
|
||||
if (isPaused) {
|
||||
await api.resumeCronJob(job.id);
|
||||
showToast(`${t.cron.resume}: "${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(`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(
|
||||
`${t.cron.pause}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
"success",
|
||||
);
|
||||
}
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
|
|
@ -97,7 +103,10 @@ export default function CronPage() {
|
|||
const handleTrigger = async (job: CronJob) => {
|
||||
try {
|
||||
await api.triggerCronJob(job.id);
|
||||
showToast(`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(
|
||||
`${t.cron.triggerNow}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
"success",
|
||||
);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
|
|
@ -107,7 +116,10 @@ export default function CronPage() {
|
|||
const handleDelete = async (job: CronJob) => {
|
||||
try {
|
||||
await api.deleteCronJob(job.id);
|
||||
showToast(`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`, "success");
|
||||
showToast(
|
||||
`${t.common.delete}: "${job.name || job.prompt.slice(0, 30)}"`,
|
||||
"success",
|
||||
);
|
||||
loadJobs();
|
||||
} catch (e) {
|
||||
showToast(`${t.status.error}: ${e}`, "error");
|
||||
|
|
@ -175,16 +187,30 @@ export default function CronPage() {
|
|||
value={deliver}
|
||||
onValueChange={(v) => setDeliver(v)}
|
||||
>
|
||||
<SelectOption value="local">{t.cron.delivery.local}</SelectOption>
|
||||
<SelectOption value="telegram">{t.cron.delivery.telegram}</SelectOption>
|
||||
<SelectOption value="discord">{t.cron.delivery.discord}</SelectOption>
|
||||
<SelectOption value="slack">{t.cron.delivery.slack}</SelectOption>
|
||||
<SelectOption value="email">{t.cron.delivery.email}</SelectOption>
|
||||
<SelectOption value="local">
|
||||
{t.cron.delivery.local}
|
||||
</SelectOption>
|
||||
<SelectOption value="telegram">
|
||||
{t.cron.delivery.telegram}
|
||||
</SelectOption>
|
||||
<SelectOption value="discord">
|
||||
{t.cron.delivery.discord}
|
||||
</SelectOption>
|
||||
<SelectOption value="slack">
|
||||
{t.cron.delivery.slack}
|
||||
</SelectOption>
|
||||
<SelectOption value="email">
|
||||
{t.cron.delivery.email}
|
||||
</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button onClick={handleCreate} disabled={creating} className="w-full">
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{creating ? t.common.creating : t.common.create}
|
||||
</Button>
|
||||
|
|
@ -196,7 +222,10 @@ export default function CronPage() {
|
|||
|
||||
{/* Jobs list */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
|
||||
<H2
|
||||
variant="sm"
|
||||
className="flex items-center gap-2 text-muted-foreground"
|
||||
>
|
||||
<Clock className="h-4 w-4" />
|
||||
{t.cron.scheduledJobs} ({jobs.length})
|
||||
</H2>
|
||||
|
|
@ -216,7 +245,9 @@ export default function CronPage() {
|
|||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{job.name || job.prompt.slice(0, 60) + (job.prompt.length > 60 ? "..." : "")}
|
||||
{job.name ||
|
||||
job.prompt.slice(0, 60) +
|
||||
(job.prompt.length > 60 ? "..." : "")}
|
||||
</span>
|
||||
<Badge variant={STATUS_VARIANT[job.state] ?? "secondary"}>
|
||||
{job.state}
|
||||
|
|
@ -227,16 +258,23 @@ export default function CronPage() {
|
|||
</div>
|
||||
{job.name && (
|
||||
<p className="text-xs text-muted-foreground truncate mb-1">
|
||||
{job.prompt.slice(0, 100)}{job.prompt.length > 100 ? "..." : ""}
|
||||
{job.prompt.slice(0, 100)}
|
||||
{job.prompt.length > 100 ? "..." : ""}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{job.schedule_display}</span>
|
||||
<span>{t.cron.last}: {formatTime(job.last_run_at)}</span>
|
||||
<span>{t.cron.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>
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{job.last_error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -246,7 +284,9 @@ export default function CronPage() {
|
|||
variant="ghost"
|
||||
size="icon"
|
||||
title={job.state === "paused" ? t.cron.resume : t.cron.pause}
|
||||
aria-label={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" ? (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { FileText, RefreshCw, ChevronRight } from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -16,7 +16,12 @@ const LINE_COUNTS = [50, 100, 200, 500] as const;
|
|||
|
||||
function classifyLine(line: string): "error" | "warning" | "info" | "debug" {
|
||||
const upper = line.toUpperCase();
|
||||
if (upper.includes("ERROR") || upper.includes("CRITICAL") || upper.includes("FATAL")) return "error";
|
||||
if (
|
||||
upper.includes("ERROR") ||
|
||||
upper.includes("CRITICAL") ||
|
||||
upper.includes("FATAL")
|
||||
)
|
||||
return "error";
|
||||
if (upper.includes("WARNING") || upper.includes("WARN")) return "warning";
|
||||
if (upper.includes("DEBUG")) return "debug";
|
||||
return "info";
|
||||
|
|
@ -55,7 +60,9 @@ function SidebarItem<T extends string>({
|
|||
}`}
|
||||
>
|
||||
<span className="flex-1 truncate">{label}</span>
|
||||
{isActive && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
|
||||
{isActive && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -63,7 +70,8 @@ function SidebarItem<T extends string>({
|
|||
export default function LogsPage() {
|
||||
const [file, setFile] = useState<(typeof FILES)[number]>("agent");
|
||||
const [level, setLevel] = useState<(typeof LEVELS)[number]>("ALL");
|
||||
const [component, setComponent] = useState<(typeof COMPONENTS)[number]>("all");
|
||||
const [component, setComponent] =
|
||||
useState<(typeof COMPONENTS)[number]>("all");
|
||||
const [lineCount, setLineCount] = useState<(typeof LINE_COUNTS)[number]>(100);
|
||||
const [autoRefresh, setAutoRefresh] = useState(false);
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
|
|
@ -124,7 +132,12 @@ export default function LogsPage() {
|
|||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={fetchLogs} className="text-xs h-7">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchLogs}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
{t.common.refresh}
|
||||
</Button>
|
||||
|
|
@ -132,23 +145,44 @@ export default function LogsPage() {
|
|||
</div>
|
||||
|
||||
{/* ═══════════════ Sidebar + Content ═══════════════ */}
|
||||
<div className="flex flex-col sm:flex-row gap-4" style={{ minHeight: "calc(100vh - 180px)" }}>
|
||||
<div
|
||||
className="flex flex-col sm:flex-row gap-4"
|
||||
style={{ minHeight: "calc(100vh - 180px)" }}
|
||||
>
|
||||
{/* ---- Sidebar ---- */}
|
||||
<div className="sm:w-44 sm:shrink-0">
|
||||
<div className="sm:sticky sm:top-[72px] flex flex-col gap-0.5">
|
||||
<SidebarHeading>{t.logs.file}</SidebarHeading>
|
||||
{FILES.map((f) => (
|
||||
<SidebarItem key={f} label={f} value={f} current={file} onChange={setFile} />
|
||||
<SidebarItem
|
||||
key={f}
|
||||
label={f}
|
||||
value={f}
|
||||
current={file}
|
||||
onChange={setFile}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SidebarHeading>{t.logs.level}</SidebarHeading>
|
||||
{LEVELS.map((l) => (
|
||||
<SidebarItem key={l} label={l} value={l} current={level} onChange={setLevel} />
|
||||
<SidebarItem
|
||||
key={l}
|
||||
label={l}
|
||||
value={l}
|
||||
current={level}
|
||||
onChange={setLevel}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SidebarHeading>{t.logs.component}</SidebarHeading>
|
||||
{COMPONENTS.map((c) => (
|
||||
<SidebarItem key={c} label={c} value={c} current={component} onChange={setComponent} />
|
||||
<SidebarItem
|
||||
key={c}
|
||||
label={c}
|
||||
value={c}
|
||||
current={component}
|
||||
onChange={setComponent}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SidebarHeading>{t.logs.lines}</SidebarHeading>
|
||||
|
|
@ -158,7 +192,9 @@ export default function LogsPage() {
|
|||
label={String(n)}
|
||||
value={String(n)}
|
||||
current={String(lineCount)}
|
||||
onChange={(v) => setLineCount(Number(v) as (typeof LINE_COUNTS)[number])}
|
||||
onChange={(v) =>
|
||||
setLineCount(Number(v) as (typeof LINE_COUNTS)[number])
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -185,12 +221,17 @@ export default function LogsPage() {
|
|||
className="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>
|
||||
<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`}>
|
||||
<div
|
||||
key={i}
|
||||
className={`${LINE_COLORS[cls]} hover:bg-secondary/20 px-1 -mx-1`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import {
|
|||
Hash,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SessionInfo, SessionMessage, SessionSearchResult } from "@/lib/api";
|
||||
import type {
|
||||
SessionInfo,
|
||||
SessionMessage,
|
||||
SessionSearchResult,
|
||||
} from "@/lib/api";
|
||||
import { timeAgo } from "@/lib/utils";
|
||||
import { Markdown } from "@/components/Markdown";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
|
@ -23,14 +27,15 @@ import { Button } from "@/components/ui/button";
|
|||
import { Input } from "@/components/ui/input";
|
||||
import { useI18n } from "@/i18n";
|
||||
|
||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> = {
|
||||
cli: { icon: Terminal, color: "text-primary" },
|
||||
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
|
||||
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
|
||||
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
|
||||
whatsapp: { icon: Globe, color: "text-success" },
|
||||
cron: { icon: Clock, color: "text-warning" },
|
||||
};
|
||||
const SOURCE_CONFIG: Record<string, { icon: typeof Terminal; color: string }> =
|
||||
{
|
||||
cli: { icon: Terminal, color: "text-primary" },
|
||||
telegram: { icon: MessageCircle, color: "text-[oklch(0.65_0.15_250)]" },
|
||||
discord: { icon: Hash, color: "text-[oklch(0.65_0.15_280)]" },
|
||||
slack: { icon: MessageSquare, color: "text-[oklch(0.7_0.15_155)]" },
|
||||
whatsapp: { icon: Globe, color: "text-success" },
|
||||
cron: { icon: Clock, color: "text-warning" },
|
||||
};
|
||||
|
||||
/** Render an FTS5 snippet with highlighted matches.
|
||||
* The backend wraps matches in >>> and <<< delimiters. */
|
||||
|
|
@ -47,7 +52,7 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
|||
parts.push(
|
||||
<mark key={i++} className="bg-warning/30 text-warning px-0.5">
|
||||
{match[1]}
|
||||
</mark>
|
||||
</mark>,
|
||||
);
|
||||
last = regex.lastIndex;
|
||||
}
|
||||
|
|
@ -61,7 +66,11 @@ function SnippetHighlight({ snippet }: { snippet: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name: string; arguments: string } } }) {
|
||||
function ToolCallBlock({
|
||||
toolCall,
|
||||
}: {
|
||||
toolCall: { id: string; function: { name: string; arguments: string } };
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useI18n();
|
||||
|
||||
|
|
@ -80,8 +89,14 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
|
|||
onClick={() => setOpen(!open)}
|
||||
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>
|
||||
{open ? (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
)}
|
||||
<span className="font-mono-ui font-medium">
|
||||
{toolCall.function.name}
|
||||
</span>
|
||||
<span className="text-warning/50 ml-auto">{toolCall.id}</span>
|
||||
</button>
|
||||
{open && (
|
||||
|
|
@ -93,18 +108,45 @@ function ToolCallBlock({ toolCall }: { toolCall: { id: string; function: { name:
|
|||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: string }) {
|
||||
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 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 ? `${t.sessions.roles.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 = (() => {
|
||||
|
|
@ -115,26 +157,35 @@ function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: st
|
|||
})();
|
||||
|
||||
// Split search query into terms for inline highlighting
|
||||
const highlightTerms = isHit && highlight
|
||||
? highlight.split(/\s+/).filter(Boolean)
|
||||
: undefined;
|
||||
const highlightTerms =
|
||||
isHit && highlight ? highlight.split(/\s+/).filter(Boolean) : undefined;
|
||||
|
||||
return (
|
||||
<div className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`} data-search-hit={isHit || undefined}>
|
||||
<div
|
||||
className={`${style.bg} p-3 ${isHit ? "ring-1 ring-warning/40" : ""}`}
|
||||
data-search-hit={isHit || undefined}
|
||||
>
|
||||
<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">{t.common.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>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{timeAgo(msg.timestamp)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{msg.content && (
|
||||
msg.role === "system"
|
||||
? <div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">{msg.content}</div>
|
||||
: <Markdown content={msg.content} highlightTerms={highlightTerms} />
|
||||
)}
|
||||
{msg.content &&
|
||||
(msg.role === "system" ? (
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap leading-relaxed">
|
||||
{msg.content}
|
||||
</div>
|
||||
) : (
|
||||
<Markdown content={msg.content} highlightTerms={highlightTerms} />
|
||||
))}
|
||||
{msg.tool_calls && msg.tool_calls.length > 0 && (
|
||||
<div className="mt-1">
|
||||
{msg.tool_calls.map((tc) => (
|
||||
|
|
@ -147,7 +198,13 @@ function MessageBubble({ msg, highlight }: { msg: SessionMessage; highlight?: st
|
|||
}
|
||||
|
||||
/** Message list with auto-scroll to first search hit. */
|
||||
function MessageList({ messages, highlight }: { messages: SessionMessage[]; highlight?: string }) {
|
||||
function MessageList({
|
||||
messages,
|
||||
highlight,
|
||||
}: {
|
||||
messages: SessionMessage[];
|
||||
highlight?: string;
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -163,7 +220,10 @@ function MessageList({ messages, highlight }: { messages: SessionMessage[]; high
|
|||
}, [messages, highlight]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex flex-col gap-3 max-h-[600px] overflow-y-auto pr-2"
|
||||
>
|
||||
{messages.map((msg, i) => (
|
||||
<MessageBubble key={i} msg={msg} highlight={highlight} />
|
||||
))}
|
||||
|
|
@ -202,16 +262,20 @@ function SessionRow({
|
|||
}
|
||||
}, [isExpanded, session.id, messages, loading]);
|
||||
|
||||
const sourceInfo = (session.source ? SOURCE_CONFIG[session.source] : null) ?? { icon: Globe, color: "text-muted-foreground" };
|
||||
const sourceInfo = (session.source
|
||||
? SOURCE_CONFIG[session.source]
|
||||
: null) ?? { icon: Globe, color: "text-muted-foreground" };
|
||||
const SourceIcon = sourceInfo.icon;
|
||||
const hasTitle = session.title && session.title !== "Untitled";
|
||||
|
||||
return (
|
||||
<div className={`border overflow-hidden transition-colors ${
|
||||
session.is_active
|
||||
? "border-success/30 bg-success/[0.03]"
|
||||
: "border-border"
|
||||
}`}>
|
||||
<div
|
||||
className={`border overflow-hidden transition-colors ${
|
||||
session.is_active
|
||||
? "border-success/30 bg-success/[0.03]"
|
||||
: "border-border"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-between p-3 cursor-pointer hover:bg-secondary/30 transition-colors"
|
||||
onClick={onToggle}
|
||||
|
|
@ -222,8 +286,14 @@ function SessionRow({
|
|||
</div>
|
||||
<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) : t.sessions.untitledSession)}
|
||||
<span
|
||||
className={`text-sm truncate pr-2 ${hasTitle ? "font-medium" : "text-muted-foreground italic"}`}
|
||||
>
|
||||
{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">
|
||||
|
|
@ -233,21 +303,25 @@ function SessionRow({
|
|||
)}
|
||||
</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 ?? t.common.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} {t.common.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} {t.common.tools}</span>
|
||||
<span>
|
||||
{session.tool_call_count} {t.common.tools}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-border">·</span>
|
||||
<span>{timeAgo(session.last_active)}</span>
|
||||
</div>
|
||||
{snippet && (
|
||||
<SnippetHighlight snippet={snippet} />
|
||||
)}
|
||||
{snippet && <SnippetHighlight snippet={snippet} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -281,7 +355,9 @@ 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">{t.sessions.noMessages}</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} />
|
||||
|
|
@ -300,7 +376,9 @@ export default function SessionsPage() {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SessionSearchResult[] | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<
|
||||
SessionSearchResult[] | null
|
||||
>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const { t } = useI18n();
|
||||
|
|
@ -420,7 +498,9 @@ export default function SessionsPage() {
|
|||
{search ? t.sessions.noMatch : t.sessions.noSessions}
|
||||
</p>
|
||||
{!search && (
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">{t.sessions.startConversation}</p>
|
||||
<p className="text-xs mt-1 text-muted-foreground/60">
|
||||
{t.sessions.startConversation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -445,7 +525,8 @@ 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)} {t.common.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
|
||||
|
|
@ -459,7 +540,8 @@ export default function SessionsPage() {
|
|||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
{t.common.page} {page + 1} {t.common.of} {Math.ceil(total / PAGE_SIZE)}
|
||||
{t.common.page} {page + 1} {t.common.of}{" "}
|
||||
{Math.ceil(total / PAGE_SIZE)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import {
|
|||
Code,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
|
||||
import { H2 } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
|
|
@ -47,7 +47,10 @@ const CATEGORY_LABELS: Record<string, string> = {
|
|||
ui: "UI",
|
||||
};
|
||||
|
||||
function prettyCategory(raw: string | null | undefined, generalLabel: string): string {
|
||||
function prettyCategory(
|
||||
raw: string | null | undefined,
|
||||
generalLabel: string,
|
||||
): string {
|
||||
if (!raw) return generalLabel;
|
||||
if (CATEGORY_LABELS[raw]) return CATEGORY_LABELS[raw];
|
||||
return raw
|
||||
|
|
@ -56,7 +59,10 @@ function prettyCategory(raw: string | null | undefined, generalLabel: string): s
|
|||
.join(" ");
|
||||
}
|
||||
|
||||
const TOOLSET_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
const TOOLSET_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
computer: Cpu,
|
||||
web: Globe,
|
||||
security: Shield,
|
||||
|
|
@ -68,7 +74,9 @@ const TOOLSET_ICONS: Record<string, React.ComponentType<{ className?: string }>>
|
|||
automation: Zap,
|
||||
};
|
||||
|
||||
function toolsetIcon(name: string): React.ComponentType<{ className?: string }> {
|
||||
function toolsetIcon(
|
||||
name: string,
|
||||
): React.ComponentType<{ className?: string }> {
|
||||
const lower = name.toLowerCase();
|
||||
for (const [key, icon] of Object.entries(TOOLSET_ICONS)) {
|
||||
if (lower.includes(key)) return icon;
|
||||
|
|
@ -108,12 +116,12 @@ export default function SkillsPage() {
|
|||
await api.toggleSkill(skill.name, !skill.enabled);
|
||||
setSkills((prev) =>
|
||||
prev.map((s) =>
|
||||
s.name === skill.name ? { ...s, enabled: !s.enabled } : s
|
||||
)
|
||||
s.name === skill.name ? { ...s, enabled: !s.enabled } : s,
|
||||
),
|
||||
);
|
||||
showToast(
|
||||
`${skill.name} ${skill.enabled ? t.common.disabled : t.common.enabled}`,
|
||||
"success"
|
||||
"success",
|
||||
);
|
||||
} catch {
|
||||
showToast(`${t.common.failedToToggle} ${skill.name}`, "error");
|
||||
|
|
@ -136,16 +144,19 @@ export default function SkillsPage() {
|
|||
(s) =>
|
||||
s.name.toLowerCase().includes(lowerSearch) ||
|
||||
s.description.toLowerCase().includes(lowerSearch) ||
|
||||
(s.category ?? "").toLowerCase().includes(lowerSearch)
|
||||
(s.category ?? "").toLowerCase().includes(lowerSearch),
|
||||
);
|
||||
}, [skills, isSearching, lowerSearch]);
|
||||
|
||||
const activeSkills = useMemo(() => {
|
||||
if (isSearching) return [];
|
||||
if (!activeCategory) return [...skills].sort((a, b) => a.name.localeCompare(b.name));
|
||||
if (!activeCategory)
|
||||
return [...skills].sort((a, b) => a.name.localeCompare(b.name));
|
||||
return skills
|
||||
.filter((s) =>
|
||||
activeCategory === "__none__" ? !s.category : s.category === activeCategory
|
||||
activeCategory === "__none__"
|
||||
? !s.category
|
||||
: s.category === activeCategory,
|
||||
)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}, [skills, activeCategory, isSearching]);
|
||||
|
|
@ -162,7 +173,11 @@ 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, t.common.general), count }));
|
||||
.map(([key, count]) => ({
|
||||
key,
|
||||
name: prettyCategory(key === "__none__" ? null : key, t.common.general),
|
||||
count,
|
||||
}));
|
||||
}, [skills, t]);
|
||||
|
||||
const enabledCount = skills.filter((s) => s.enabled).length;
|
||||
|
|
@ -173,7 +188,7 @@ export default function SkillsPage() {
|
|||
!search ||
|
||||
ts.name.toLowerCase().includes(lowerSearch) ||
|
||||
ts.label.toLowerCase().includes(lowerSearch) ||
|
||||
ts.description.toLowerCase().includes(lowerSearch)
|
||||
ts.description.toLowerCase().includes(lowerSearch),
|
||||
);
|
||||
}, [toolsets, search, lowerSearch]);
|
||||
|
||||
|
|
@ -196,13 +211,18 @@ export default function SkillsPage() {
|
|||
<Package className="h-5 w-5 text-muted-foreground" />
|
||||
<H2 variant="sm">{t.skills.title}</H2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.skills.enabledOf.replace("{enabled}", String(enabledCount)).replace("{total}", String(skills.length))}
|
||||
{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)" }}>
|
||||
<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">
|
||||
|
|
@ -230,7 +250,11 @@ export default function SkillsPage() {
|
|||
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setView("skills"); setActiveCategory(null); setSearch(""); }}
|
||||
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" && !isSearching
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
|
|
@ -238,35 +262,48 @@ export default function SkillsPage() {
|
|||
}`}
|
||||
>
|
||||
<Package className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{t.skills.all} ({skills.length})</span>
|
||||
{view === "skills" && !isSearching && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
|
||||
<span className="flex-1 truncate">
|
||||
{t.skills.all} ({skills.length})
|
||||
</span>
|
||||
{view === "skills" && !isSearching && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skill categories (nested under All Skills) */}
|
||||
{view === "skills" && !isSearching && allCategories.map(({ key, name, count }) => {
|
||||
const isActive = activeCategory === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => setActiveCategory(activeCategory === key ? null : key)}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{view === "skills" &&
|
||||
!isSearching &&
|
||||
allCategories.map(({ key, name, count }) => {
|
||||
const isActive = activeCategory === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setActiveCategory(activeCategory === key ? null : key)
|
||||
}
|
||||
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
|
||||
isActive
|
||||
? "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>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setView("toolsets"); setSearch(""); }}
|
||||
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"
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
|
|
@ -274,8 +311,12 @@ export default function SkillsPage() {
|
|||
}`}
|
||||
>
|
||||
<Wrench className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate">{t.skills.toolsets} ({toolsets.length})</span>
|
||||
{view === "toolsets" && <ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />}
|
||||
<span className="flex-1 truncate">
|
||||
{t.skills.toolsets} ({toolsets.length})
|
||||
</span>
|
||||
{view === "toolsets" && (
|
||||
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -293,7 +334,12 @@ export default function SkillsPage() {
|
|||
{t.skills.title}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t.skills.resultCount.replace("{count}", String(searchMatchedSkills.length)).replace("{s}", searchMatchedSkills.length !== 1 ? "s" : "")}
|
||||
{t.skills.resultCount
|
||||
.replace("{count}", String(searchMatchedSkills.length))
|
||||
.replace(
|
||||
"{s}",
|
||||
searchMatchedSkills.length !== 1 ? "s" : "",
|
||||
)}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
|
@ -325,18 +371,26 @@ export default function SkillsPage() {
|
|||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
{activeCategory
|
||||
? prettyCategory(activeCategory === "__none__" ? null : activeCategory, t.common.general)
|
||||
? prettyCategory(
|
||||
activeCategory === "__none__" ? null : activeCategory,
|
||||
t.common.general,
|
||||
)
|
||||
: t.skills.all}
|
||||
</CardTitle>
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{activeSkills.length} {t.skills.skillCount.replace("{count}", String(activeSkills.length)).replace("{s}", activeSkills.length !== 1 ? "s" : "")}
|
||||
{activeSkills.length}{" "}
|
||||
{t.skills.skillCount
|
||||
.replace("{count}", String(activeSkills.length))
|
||||
.replace("{s}", 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 ? t.skills.noSkills : t.skills.noSkillsMatch}
|
||||
{skills.length === 0
|
||||
? t.skills.noSkills
|
||||
: t.skills.noSkillsMatch}
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
|
|
@ -366,7 +420,9 @@ export default function SkillsPage() {
|
|||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredToolsets.map((ts) => {
|
||||
const TsIcon = toolsetIcon(ts.name);
|
||||
const labelText = ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() || ts.name;
|
||||
const labelText =
|
||||
ts.label.replace(/^[\p{Emoji}\s]+/u, "").trim() ||
|
||||
ts.name;
|
||||
|
||||
return (
|
||||
<Card key={ts.name} className="relative">
|
||||
|
|
@ -375,12 +431,16 @@ export default function SkillsPage() {
|
|||
<TsIcon className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-sm">{labelText}</span>
|
||||
<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}
|
||||
{ts.enabled
|
||||
? t.common.active
|
||||
: t.common.inactive}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
|
|
@ -406,7 +466,12 @@ export default function SkillsPage() {
|
|||
)}
|
||||
{ts.tools.length === 0 && (
|
||||
<span className="text-[10px] text-muted-foreground/60">
|
||||
{ts.enabled ? t.skills.toolsetLabel.replace("{name}", ts.name) : t.skills.disabledForCli}
|
||||
{ts.enabled
|
||||
? t.skills.toolsetLabel.replace(
|
||||
"{name}",
|
||||
ts.name,
|
||||
)
|
||||
: t.skills.disabledForCli}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Wifi,
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { Cell, Grid } from "@nous-research/ui/ui/components/grid/index";
|
||||
import { Cell, Grid } from "@nous-research/ui";
|
||||
import { api } from "@/lib/api";
|
||||
import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api";
|
||||
import { timeAgo, isoTimeAgo } from "@/lib/utils";
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue