@castlekit/castle 0.1.6 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/drizzle.config.ts +7 -0
- package/install.sh +20 -1
- package/next.config.ts +1 -0
- package/package.json +35 -3
- package/src/app/api/avatars/[id]/route.ts +57 -7
- package/src/app/api/openclaw/agents/route.ts +7 -1
- package/src/app/api/openclaw/agents/status/route.ts +55 -0
- package/src/app/api/openclaw/chat/attachments/route.ts +230 -0
- package/src/app/api/openclaw/chat/channels/route.ts +217 -0
- package/src/app/api/openclaw/chat/route.ts +283 -0
- package/src/app/api/openclaw/chat/search/route.ts +150 -0
- package/src/app/api/openclaw/chat/storage/route.ts +75 -0
- package/src/app/api/openclaw/config/route.ts +2 -0
- package/src/app/api/openclaw/events/route.ts +23 -8
- package/src/app/api/openclaw/logs/route.ts +17 -3
- package/src/app/api/openclaw/ping/route.ts +5 -0
- package/src/app/api/openclaw/restart/route.ts +6 -1
- package/src/app/api/openclaw/session/context/route.ts +163 -0
- package/src/app/api/openclaw/session/status/route.ts +210 -0
- package/src/app/api/openclaw/sessions/route.ts +2 -0
- package/src/app/api/settings/avatar/route.ts +190 -0
- package/src/app/api/settings/route.ts +88 -0
- package/src/app/chat/[channelId]/error-boundary.tsx +64 -0
- package/src/app/chat/[channelId]/page.tsx +385 -0
- package/src/app/chat/layout.tsx +96 -0
- package/src/app/chat/page.tsx +52 -0
- package/src/app/globals.css +99 -2
- package/src/app/layout.tsx +7 -1
- package/src/app/page.tsx +59 -25
- package/src/app/settings/page.tsx +300 -0
- package/src/components/chat/agent-mention-popup.tsx +89 -0
- package/src/components/chat/archived-channels.tsx +190 -0
- package/src/components/chat/channel-list.tsx +140 -0
- package/src/components/chat/chat-input.tsx +328 -0
- package/src/components/chat/create-channel-dialog.tsx +171 -0
- package/src/components/chat/markdown-content.tsx +205 -0
- package/src/components/chat/message-bubble.tsx +168 -0
- package/src/components/chat/message-list.tsx +666 -0
- package/src/components/chat/message-queue.tsx +68 -0
- package/src/components/chat/session-divider.tsx +61 -0
- package/src/components/chat/session-stats-panel.tsx +444 -0
- package/src/components/chat/storage-indicator.tsx +76 -0
- package/src/components/layout/sidebar.tsx +126 -45
- package/src/components/layout/user-menu.tsx +29 -4
- package/src/components/providers/presence-provider.tsx +8 -0
- package/src/components/providers/search-provider.tsx +110 -0
- package/src/components/search/search-dialog.tsx +269 -0
- package/src/components/ui/avatar.tsx +11 -9
- package/src/components/ui/dialog.tsx +10 -4
- package/src/components/ui/tooltip.tsx +25 -8
- package/src/components/ui/twemoji-text.tsx +37 -0
- package/src/lib/api-security.ts +125 -0
- package/src/lib/date-utils.ts +79 -0
- package/src/lib/db/index.ts +652 -0
- package/src/lib/db/queries.ts +1144 -0
- package/src/lib/db/schema.ts +164 -0
- package/src/lib/gateway-connection.ts +24 -3
- package/src/lib/hooks/use-agent-status.ts +251 -0
- package/src/lib/hooks/use-chat.ts +753 -0
- package/src/lib/hooks/use-compaction-events.ts +132 -0
- package/src/lib/hooks/use-context-boundary.ts +82 -0
- package/src/lib/hooks/use-openclaw.ts +122 -100
- package/src/lib/hooks/use-search.ts +114 -0
- package/src/lib/hooks/use-session-stats.ts +60 -0
- package/src/lib/hooks/use-user-settings.ts +46 -0
- package/src/lib/sse-singleton.ts +184 -0
- package/src/lib/types/chat.ts +202 -0
- package/src/lib/types/search.ts +60 -0
- package/src/middleware.ts +52 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { X, Send, Loader2 } from "lucide-react";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
interface MessageQueueProps {
|
|
8
|
+
messages: string[];
|
|
9
|
+
onRemove: (index: number) => void;
|
|
10
|
+
onSendAll: () => void;
|
|
11
|
+
sending?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function MessageQueue({ messages, onRemove, onSendAll, sending, className }: MessageQueueProps) {
|
|
16
|
+
if (messages.length === 0) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className={cn("space-y-3", className)}>
|
|
22
|
+
{/* Header */}
|
|
23
|
+
<div className="flex items-center justify-between">
|
|
24
|
+
<span className="text-sm font-medium text-foreground-secondary">
|
|
25
|
+
Queued ({messages.length})
|
|
26
|
+
</span>
|
|
27
|
+
<Button
|
|
28
|
+
variant="primary"
|
|
29
|
+
size="sm"
|
|
30
|
+
onClick={onSendAll}
|
|
31
|
+
disabled={sending}
|
|
32
|
+
className="h-7"
|
|
33
|
+
>
|
|
34
|
+
{sending ? (
|
|
35
|
+
<>
|
|
36
|
+
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
|
37
|
+
Sending...
|
|
38
|
+
</>
|
|
39
|
+
) : (
|
|
40
|
+
<>
|
|
41
|
+
<Send className="h-3 w-3 mr-1" />
|
|
42
|
+
Send All
|
|
43
|
+
</>
|
|
44
|
+
)}
|
|
45
|
+
</Button>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Queued messages */}
|
|
49
|
+
<div className="space-y-2">
|
|
50
|
+
{messages.map((message, index) => (
|
|
51
|
+
<div
|
|
52
|
+
key={index}
|
|
53
|
+
className="relative p-3 rounded-xl border border-dashed border-border bg-surface-hover/50"
|
|
54
|
+
>
|
|
55
|
+
<button
|
|
56
|
+
onClick={() => onRemove(index)}
|
|
57
|
+
className="absolute top-2 right-2 p-1 rounded-full hover:bg-surface-hover text-foreground-secondary hover:text-foreground"
|
|
58
|
+
disabled={sending}
|
|
59
|
+
>
|
|
60
|
+
<X className="h-3 w-3" />
|
|
61
|
+
</button>
|
|
62
|
+
<p className="text-sm pr-6 line-clamp-2">{message}</p>
|
|
63
|
+
</div>
|
|
64
|
+
))}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
import { formatDateTime } from "@/lib/date-utils";
|
|
7
|
+
import type { ChannelSession } from "@/lib/types/chat";
|
|
8
|
+
|
|
9
|
+
interface SessionDividerProps {
|
|
10
|
+
session: ChannelSession;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SessionDivider({ session, className }: SessionDividerProps) {
|
|
15
|
+
const [expanded, setExpanded] = useState(false);
|
|
16
|
+
|
|
17
|
+
const endedDate = session.endedAt
|
|
18
|
+
? formatDateTime(session.endedAt)
|
|
19
|
+
: "Ongoing";
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className={cn("relative py-4", className)}>
|
|
23
|
+
{/* Divider line */}
|
|
24
|
+
<div className="absolute inset-x-0 top-1/2 h-px bg-border" />
|
|
25
|
+
|
|
26
|
+
{/* Content */}
|
|
27
|
+
<div className="relative flex items-center justify-center">
|
|
28
|
+
<div className="bg-surface px-4 py-2 rounded-full border border-border">
|
|
29
|
+
<button
|
|
30
|
+
onClick={() => setExpanded(!expanded)}
|
|
31
|
+
className="flex items-center gap-2 text-xs text-foreground-secondary hover:text-foreground transition-colors"
|
|
32
|
+
>
|
|
33
|
+
<span>Session ended {endedDate}</span>
|
|
34
|
+
{session.summary && (
|
|
35
|
+
expanded ? (
|
|
36
|
+
<ChevronUp className="h-3 w-3" />
|
|
37
|
+
) : (
|
|
38
|
+
<ChevronDown className="h-3 w-3" />
|
|
39
|
+
)
|
|
40
|
+
)}
|
|
41
|
+
</button>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
{/* Expanded summary */}
|
|
46
|
+
{expanded && session.summary && (
|
|
47
|
+
<div className="mt-3 mx-auto max-w-xl p-4 rounded-xl bg-surface-hover border border-border">
|
|
48
|
+
<p className="text-sm text-foreground-secondary leading-relaxed">
|
|
49
|
+
<span className="font-medium text-foreground">Summary:</span>{" "}
|
|
50
|
+
{session.summary}
|
|
51
|
+
</p>
|
|
52
|
+
<div className="mt-2 flex items-center gap-4 text-xs text-foreground-secondary/60">
|
|
53
|
+
<span>
|
|
54
|
+
{session.totalInputTokens.toLocaleString()} in / {session.totalOutputTokens.toLocaleString()} out
|
|
55
|
+
</span>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, useLayoutEffect } from "react";
|
|
4
|
+
import {
|
|
5
|
+
Brain,
|
|
6
|
+
Minimize2,
|
|
7
|
+
Loader2,
|
|
8
|
+
Eye,
|
|
9
|
+
FileText,
|
|
10
|
+
Wrench,
|
|
11
|
+
Sparkles,
|
|
12
|
+
FolderOpen,
|
|
13
|
+
Activity,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
import { Dialog, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
17
|
+
import type { SessionStatus } from "@/lib/types/chat";
|
|
18
|
+
|
|
19
|
+
interface SessionStatsPanelProps {
|
|
20
|
+
stats: SessionStatus | null;
|
|
21
|
+
isLoading?: boolean;
|
|
22
|
+
className?: string;
|
|
23
|
+
/** Whether a compaction is currently in progress */
|
|
24
|
+
isCompacting?: boolean;
|
|
25
|
+
/** Live compaction count observed via SSE (since mount) */
|
|
26
|
+
liveCompactionCount?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Simple relative time helper */
|
|
30
|
+
function timeAgo(timestamp: number): string {
|
|
31
|
+
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
|
32
|
+
if (seconds < 5) return "just now";
|
|
33
|
+
if (seconds < 60) return `${seconds}s ago`;
|
|
34
|
+
const minutes = Math.floor(seconds / 60);
|
|
35
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
36
|
+
const hours = Math.floor(minutes / 60);
|
|
37
|
+
if (hours < 24) return `${hours}h ago`;
|
|
38
|
+
const days = Math.floor(hours / 24);
|
|
39
|
+
return `${days}d ago`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Get context dot/bar color based on usage percentage */
|
|
43
|
+
function getContextColor(percentage: number): string {
|
|
44
|
+
if (percentage >= 90) return "bg-error";
|
|
45
|
+
if (percentage >= 80) return "bg-orange-500";
|
|
46
|
+
if (percentage >= 60) return "bg-yellow-500";
|
|
47
|
+
return "bg-success";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getContextTextColor(percentage: number): string {
|
|
51
|
+
if (percentage >= 90) return "text-error/60 group-hover:text-error";
|
|
52
|
+
if (percentage >= 80) return "text-orange-500/55 group-hover:text-orange-500";
|
|
53
|
+
if (percentage >= 60) return "text-yellow-500/55 group-hover:text-yellow-500";
|
|
54
|
+
return "text-foreground-secondary/60 group-hover:text-foreground-secondary";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Returns a raw CSS color for inline styles */
|
|
58
|
+
function getContextColorValue(percentage: number): string {
|
|
59
|
+
if (percentage >= 90) return "#ef4444";
|
|
60
|
+
if (percentage >= 80) return "#f97316";
|
|
61
|
+
if (percentage >= 60) return "#eab308";
|
|
62
|
+
return "#22c55e";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatTokens(n: number): string {
|
|
66
|
+
if (n >= 1_000_000) {
|
|
67
|
+
const v = n / 1_000_000;
|
|
68
|
+
return `${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}M`;
|
|
69
|
+
}
|
|
70
|
+
if (n >= 1_000) {
|
|
71
|
+
const v = n / 1_000;
|
|
72
|
+
return `${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}k`;
|
|
73
|
+
}
|
|
74
|
+
return String(n);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function formatChars(n: number): string {
|
|
78
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M chars`;
|
|
79
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k chars`;
|
|
80
|
+
return `${n} chars`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// ============================================================================
|
|
84
|
+
// Compact stats indicator — sits between text input and send button
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
export function SessionStatsIndicator({
|
|
88
|
+
stats,
|
|
89
|
+
isLoading,
|
|
90
|
+
isCompacting,
|
|
91
|
+
}: SessionStatsPanelProps) {
|
|
92
|
+
const [modalOpen, setModalOpen] = useState(false);
|
|
93
|
+
const [boxSize, setBoxSize] = useState({ w: 0, h: 0 });
|
|
94
|
+
const boxElRef = useRef<HTMLDivElement | null>(null);
|
|
95
|
+
const roRef = useRef<ResizeObserver | null>(null);
|
|
96
|
+
|
|
97
|
+
const boxRef = useCallback((el: HTMLDivElement | null) => {
|
|
98
|
+
// Cleanup previous observer
|
|
99
|
+
if (roRef.current) {
|
|
100
|
+
roRef.current.disconnect();
|
|
101
|
+
roRef.current = null;
|
|
102
|
+
}
|
|
103
|
+
boxElRef.current = el;
|
|
104
|
+
if (el) {
|
|
105
|
+
setBoxSize({ w: el.offsetWidth, h: el.offsetHeight });
|
|
106
|
+
const ro = new ResizeObserver(() => {
|
|
107
|
+
setBoxSize({ w: el.offsetWidth, h: el.offsetHeight });
|
|
108
|
+
});
|
|
109
|
+
ro.observe(el);
|
|
110
|
+
roRef.current = ro;
|
|
111
|
+
}
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
// Cleanup on unmount
|
|
115
|
+
useLayoutEffect(() => {
|
|
116
|
+
return () => roRef.current?.disconnect();
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
const percentage = stats?.context?.percentage ?? 0;
|
|
120
|
+
const textColor = getContextTextColor(percentage);
|
|
121
|
+
const progressColor = getContextColorValue(percentage);
|
|
122
|
+
const displayCompactions = stats?.compactions ?? 0;
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
// Build CCW path starting from a point on the top edge (offset from TL),
|
|
126
|
+
// then left to TL corner → down left → bottom → up right → TR → top edge → Z
|
|
127
|
+
const r = 6;
|
|
128
|
+
const s = 0.75; // inset = half stroke width
|
|
129
|
+
const { w, h } = boxSize;
|
|
130
|
+
// Start point: offset along the top edge so initial fill is visible there
|
|
131
|
+
const startX = w > 0 ? Math.min(r + s + 5, w * 0.2) : 0;
|
|
132
|
+
const pathD =
|
|
133
|
+
w > 0 && h > 0
|
|
134
|
+
? [
|
|
135
|
+
`M ${startX} ${s}`, // start: top edge, offset from left
|
|
136
|
+
`L ${r + s} ${s}`, // left along top to TL corner
|
|
137
|
+
`A ${r} ${r} 0 0 0 ${s} ${r + s}`, // TL corner arc (CCW)
|
|
138
|
+
`L ${s} ${h - r - s}`, // down left edge
|
|
139
|
+
`A ${r} ${r} 0 0 0 ${r + s} ${h - s}`, // BL corner arc
|
|
140
|
+
`L ${w - r - s} ${h - s}`, // right along bottom
|
|
141
|
+
`A ${r} ${r} 0 0 0 ${w - s} ${h - r - s}`, // BR corner arc
|
|
142
|
+
`L ${w - s} ${r + s}`, // up right edge
|
|
143
|
+
`A ${r} ${r} 0 0 0 ${w - r - s} ${s}`, // TR corner arc
|
|
144
|
+
`Z`, // top edge back to start
|
|
145
|
+
].join(" ")
|
|
146
|
+
: "";
|
|
147
|
+
|
|
148
|
+
// Always fill at least a visible initial segment on the top edge
|
|
149
|
+
const minPct = stats ? Math.max(percentage, 3) : 0;
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<>
|
|
153
|
+
{/* Stats box with SVG border progress */}
|
|
154
|
+
<div
|
|
155
|
+
ref={boxRef}
|
|
156
|
+
className="group relative h-[38px] min-w-[146px] shrink-0 rounded-[var(--radius-sm)] cursor-pointer"
|
|
157
|
+
onClick={() => setModalOpen(true)}
|
|
158
|
+
title="View session details"
|
|
159
|
+
>
|
|
160
|
+
{/* Track border */}
|
|
161
|
+
<div className="absolute inset-0 rounded-[var(--radius-sm)] border border-border" />
|
|
162
|
+
{/* Progress border — CCW from top-left */}
|
|
163
|
+
{pathD && (
|
|
164
|
+
<svg className="absolute inset-0 w-full h-full opacity-45 group-hover:opacity-100 transition-opacity" fill="none">
|
|
165
|
+
<path
|
|
166
|
+
d={pathD}
|
|
167
|
+
stroke={progressColor}
|
|
168
|
+
strokeWidth="1.5"
|
|
169
|
+
pathLength="100"
|
|
170
|
+
strokeDasharray={`${minPct} ${100 - minPct}`}
|
|
171
|
+
/>
|
|
172
|
+
</svg>
|
|
173
|
+
)}
|
|
174
|
+
{/* Content */}
|
|
175
|
+
<button
|
|
176
|
+
type="button"
|
|
177
|
+
className="relative h-full w-full rounded-[var(--radius-sm)] bg-transparent flex items-center justify-center gap-2.5 px-3 text-xs text-foreground-secondary hover:text-foreground transition-colors cursor-pointer"
|
|
178
|
+
>
|
|
179
|
+
{stats ? (
|
|
180
|
+
<>
|
|
181
|
+
<span className={cn("tabular-nums whitespace-nowrap transition-colors", textColor)}>
|
|
182
|
+
{formatTokens(stats.context.used)} · {percentage}%
|
|
183
|
+
</span>
|
|
184
|
+
{isCompacting && (
|
|
185
|
+
<Loader2 className="h-3 w-3 animate-spin text-yellow-500" />
|
|
186
|
+
)}
|
|
187
|
+
<span className="w-px h-4 bg-border shrink-0" />
|
|
188
|
+
<span className="flex items-center gap-0.5 text-foreground-secondary/60 group-hover:text-foreground-secondary transition-colors">
|
|
189
|
+
<Minimize2 className="h-3.5 w-3.5" />
|
|
190
|
+
{displayCompactions}
|
|
191
|
+
</span>
|
|
192
|
+
</>
|
|
193
|
+
) : (
|
|
194
|
+
<>
|
|
195
|
+
<span className="skeleton h-3 w-[72px] rounded" />
|
|
196
|
+
<span className="w-px h-4 bg-border/50 shrink-0" />
|
|
197
|
+
<span className="skeleton h-3 w-[28px] rounded" />
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
</button>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Full stats modal */}
|
|
204
|
+
<SessionStatsModal
|
|
205
|
+
open={modalOpen}
|
|
206
|
+
onClose={() => setModalOpen(false)}
|
|
207
|
+
stats={stats}
|
|
208
|
+
isCompacting={isCompacting}
|
|
209
|
+
/>
|
|
210
|
+
</>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ============================================================================
|
|
215
|
+
// Full stats modal — beautiful breakdown of session state
|
|
216
|
+
// ============================================================================
|
|
217
|
+
|
|
218
|
+
function SessionStatsModal({
|
|
219
|
+
open,
|
|
220
|
+
onClose,
|
|
221
|
+
stats,
|
|
222
|
+
isCompacting,
|
|
223
|
+
}: {
|
|
224
|
+
open: boolean;
|
|
225
|
+
onClose: () => void;
|
|
226
|
+
stats: SessionStatus | null;
|
|
227
|
+
isCompacting?: boolean;
|
|
228
|
+
}) {
|
|
229
|
+
if (!stats) return null;
|
|
230
|
+
|
|
231
|
+
const percentage = stats.context.percentage;
|
|
232
|
+
const contextColor = getContextColor(percentage);
|
|
233
|
+
const headroom = stats.context.limit - stats.context.used;
|
|
234
|
+
const headroomPct = Math.max(0, 100 - percentage);
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<Dialog open={open} onClose={onClose} className="max-w-md">
|
|
238
|
+
<DialogHeader>
|
|
239
|
+
<DialogTitle className="flex items-center gap-2">
|
|
240
|
+
<Activity className="h-5 w-5 text-foreground-secondary" />
|
|
241
|
+
Session
|
|
242
|
+
</DialogTitle>
|
|
243
|
+
<p className="text-sm text-foreground-secondary mt-1">
|
|
244
|
+
{stats.model} · {stats.modelProvider}
|
|
245
|
+
</p>
|
|
246
|
+
</DialogHeader>
|
|
247
|
+
|
|
248
|
+
<div className="space-y-5">
|
|
249
|
+
{/* Context window — hero section */}
|
|
250
|
+
<div className="space-y-2">
|
|
251
|
+
<div className="flex items-center justify-between text-sm">
|
|
252
|
+
<span className="text-foreground-secondary flex items-center gap-1.5">
|
|
253
|
+
<Eye className="h-4 w-4" />
|
|
254
|
+
Context Window
|
|
255
|
+
</span>
|
|
256
|
+
<span className="font-mono font-medium">
|
|
257
|
+
{percentage}%
|
|
258
|
+
</span>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{/* Progress bar */}
|
|
262
|
+
<div className="w-full h-4 bg-surface-hover rounded-full overflow-hidden">
|
|
263
|
+
<div
|
|
264
|
+
className={cn("h-full rounded-full transition-all duration-500", contextColor)}
|
|
265
|
+
style={{ width: `${Math.min(percentage, 100)}%` }}
|
|
266
|
+
/>
|
|
267
|
+
</div>
|
|
268
|
+
|
|
269
|
+
{/* Token counts */}
|
|
270
|
+
<div className="flex items-center justify-between text-xs text-foreground-secondary">
|
|
271
|
+
<span>
|
|
272
|
+
<span className="font-medium text-foreground">{formatTokens(stats.context.used)}</span> used
|
|
273
|
+
</span>
|
|
274
|
+
<span>
|
|
275
|
+
<span className="font-medium text-foreground">{formatTokens(headroom)}</span> headroom
|
|
276
|
+
</span>
|
|
277
|
+
<span>
|
|
278
|
+
<span className="font-medium text-foreground">{formatTokens(stats.context.limit)}</span> limit
|
|
279
|
+
</span>
|
|
280
|
+
</div>
|
|
281
|
+
|
|
282
|
+
{stats.context.modelMax > stats.context.limit && (
|
|
283
|
+
<p className="text-[11px] text-foreground-secondary/50">
|
|
284
|
+
Model supports {formatTokens(stats.context.modelMax)} — limit set to {formatTokens(stats.context.limit)} in config
|
|
285
|
+
</p>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
|
|
289
|
+
{/* Stats grid */}
|
|
290
|
+
<div className="grid grid-cols-2 gap-4">
|
|
291
|
+
<StatCard
|
|
292
|
+
icon={<Brain className="h-4 w-4" />}
|
|
293
|
+
label="Thinking"
|
|
294
|
+
value={stats.thinkingLevel || "off"}
|
|
295
|
+
/>
|
|
296
|
+
<StatCard
|
|
297
|
+
icon={<Minimize2 className="h-4 w-4" />}
|
|
298
|
+
label="Compactions"
|
|
299
|
+
value={String(stats.compactions)}
|
|
300
|
+
highlight={stats.compactions > 0}
|
|
301
|
+
/>
|
|
302
|
+
<StatCard
|
|
303
|
+
label="Input tokens"
|
|
304
|
+
value={stats.tokens.input.toLocaleString()}
|
|
305
|
+
/>
|
|
306
|
+
<StatCard
|
|
307
|
+
label="Output tokens"
|
|
308
|
+
value={stats.tokens.output.toLocaleString()}
|
|
309
|
+
/>
|
|
310
|
+
</div>
|
|
311
|
+
|
|
312
|
+
{/* Compaction status */}
|
|
313
|
+
{isCompacting && (
|
|
314
|
+
<div className="flex items-center gap-2 text-sm text-yellow-500 py-2 px-3 rounded-lg bg-yellow-500/10 border border-yellow-500/20">
|
|
315
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
316
|
+
<span>Context compaction in progress...</span>
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
|
|
320
|
+
{/* System prompt breakdown */}
|
|
321
|
+
{stats.systemPrompt && (
|
|
322
|
+
<div className="space-y-3 pt-3 border-t border-border">
|
|
323
|
+
<h3 className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
|
324
|
+
<FileText className="h-4 w-4 text-foreground-secondary" />
|
|
325
|
+
System Prompt
|
|
326
|
+
</h3>
|
|
327
|
+
|
|
328
|
+
<div className="grid grid-cols-2 gap-3">
|
|
329
|
+
<MiniStat
|
|
330
|
+
icon={<FolderOpen className="h-3.5 w-3.5" />}
|
|
331
|
+
label="Project context"
|
|
332
|
+
value={formatChars(stats.systemPrompt.projectContextChars)}
|
|
333
|
+
/>
|
|
334
|
+
<MiniStat
|
|
335
|
+
label="Non-project"
|
|
336
|
+
value={formatChars(stats.systemPrompt.nonProjectContextChars)}
|
|
337
|
+
/>
|
|
338
|
+
<MiniStat
|
|
339
|
+
icon={<Sparkles className="h-3.5 w-3.5" />}
|
|
340
|
+
label="Skills"
|
|
341
|
+
value={`${stats.systemPrompt.skills.count} (${formatChars(stats.systemPrompt.skills.promptChars)})`}
|
|
342
|
+
/>
|
|
343
|
+
<MiniStat
|
|
344
|
+
icon={<Wrench className="h-3.5 w-3.5" />}
|
|
345
|
+
label="Tools"
|
|
346
|
+
value={`${stats.systemPrompt.tools.count} (${formatChars(stats.systemPrompt.tools.schemaChars)})`}
|
|
347
|
+
/>
|
|
348
|
+
</div>
|
|
349
|
+
|
|
350
|
+
{/* Workspace files */}
|
|
351
|
+
{stats.systemPrompt.workspaceFiles.length > 0 && (
|
|
352
|
+
<div className="space-y-1.5">
|
|
353
|
+
<p className="text-[11px] text-foreground-secondary/60 uppercase tracking-wider font-medium">
|
|
354
|
+
Workspace Files
|
|
355
|
+
</p>
|
|
356
|
+
<div className="flex flex-wrap gap-1.5">
|
|
357
|
+
{stats.systemPrompt.workspaceFiles.map((f) => (
|
|
358
|
+
<span
|
|
359
|
+
key={f.name}
|
|
360
|
+
className={cn(
|
|
361
|
+
"inline-flex items-center gap-1 px-2 py-1 rounded-md text-[11px] bg-surface-hover border border-border/50",
|
|
362
|
+
f.truncated && "border-yellow-500/40"
|
|
363
|
+
)}
|
|
364
|
+
>
|
|
365
|
+
<FileText className="h-2.5 w-2.5 text-foreground-secondary/60" />
|
|
366
|
+
<span className="font-medium">{f.name}</span>
|
|
367
|
+
<span className="text-foreground-secondary/50">
|
|
368
|
+
{formatChars(f.injectedChars)}
|
|
369
|
+
</span>
|
|
370
|
+
{f.truncated && (
|
|
371
|
+
<span className="text-yellow-500 text-[9px] font-medium">truncated</span>
|
|
372
|
+
)}
|
|
373
|
+
</span>
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
</div>
|
|
379
|
+
)}
|
|
380
|
+
|
|
381
|
+
{/* Footer */}
|
|
382
|
+
{stats.updatedAt > 0 && (
|
|
383
|
+
<p className="text-[11px] text-foreground-secondary/40 text-right pt-1">
|
|
384
|
+
Session updated {timeAgo(stats.updatedAt)}
|
|
385
|
+
</p>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
</Dialog>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ============================================================================
|
|
393
|
+
// Small helper components
|
|
394
|
+
// ============================================================================
|
|
395
|
+
|
|
396
|
+
function StatCard({
|
|
397
|
+
icon,
|
|
398
|
+
label,
|
|
399
|
+
value,
|
|
400
|
+
highlight,
|
|
401
|
+
}: {
|
|
402
|
+
icon?: React.ReactNode;
|
|
403
|
+
label: string;
|
|
404
|
+
value: string;
|
|
405
|
+
highlight?: boolean;
|
|
406
|
+
}) {
|
|
407
|
+
return (
|
|
408
|
+
<div className="flex flex-col gap-1 p-3 rounded-lg bg-surface-hover/50 border border-border/30">
|
|
409
|
+
<span className="text-[11px] text-foreground-secondary flex items-center gap-1">
|
|
410
|
+
{icon}
|
|
411
|
+
{label}
|
|
412
|
+
</span>
|
|
413
|
+
<span className={cn("text-sm font-medium", highlight && "text-yellow-500")}>
|
|
414
|
+
{value}
|
|
415
|
+
</span>
|
|
416
|
+
</div>
|
|
417
|
+
);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function MiniStat({
|
|
421
|
+
icon,
|
|
422
|
+
label,
|
|
423
|
+
value,
|
|
424
|
+
}: {
|
|
425
|
+
icon?: React.ReactNode;
|
|
426
|
+
label: string;
|
|
427
|
+
value: string;
|
|
428
|
+
}) {
|
|
429
|
+
return (
|
|
430
|
+
<div className="flex items-center gap-2 text-xs">
|
|
431
|
+
{icon && <span className="text-foreground-secondary/60">{icon}</span>}
|
|
432
|
+
<span className="text-foreground-secondary">{label}</span>
|
|
433
|
+
<span className="font-medium ml-auto">{value}</span>
|
|
434
|
+
</div>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// ============================================================================
|
|
439
|
+
// Keep the old export name for backwards compat (but it's now unused)
|
|
440
|
+
// ============================================================================
|
|
441
|
+
|
|
442
|
+
export function SessionStatsPanel(props: SessionStatsPanelProps) {
|
|
443
|
+
return <SessionStatsIndicator {...props} />;
|
|
444
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { HardDrive, AlertTriangle } from "lucide-react";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
interface StorageData {
|
|
8
|
+
messages: number;
|
|
9
|
+
channels: number;
|
|
10
|
+
attachments: number;
|
|
11
|
+
totalAttachmentBytes: number;
|
|
12
|
+
dbSizeBytes: number;
|
|
13
|
+
attachmentsDirBytes: number;
|
|
14
|
+
warnings: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function formatBytes(bytes: number): string {
|
|
18
|
+
if (bytes === 0) return "0 B";
|
|
19
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
20
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
21
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
22
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface StorageIndicatorProps {
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function StorageIndicator({ className }: StorageIndicatorProps) {
|
|
30
|
+
const [data, setData] = useState<StorageData | null>(null);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
async function fetchStats() {
|
|
34
|
+
try {
|
|
35
|
+
const res = await fetch("/api/openclaw/chat/storage");
|
|
36
|
+
if (res.ok) {
|
|
37
|
+
setData(await res.json());
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Silently fail — storage indicator is non-critical
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fetchStats();
|
|
45
|
+
// Refresh every 5 minutes
|
|
46
|
+
const interval = setInterval(fetchStats, 300000);
|
|
47
|
+
return () => clearInterval(interval);
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
if (!data) return null;
|
|
51
|
+
|
|
52
|
+
const hasWarnings = data.warnings && data.warnings.length > 0;
|
|
53
|
+
const totalSize = (data.dbSizeBytes || 0) + (data.attachmentsDirBytes || 0);
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className={cn("px-4 py-2 border-t border-border", className)}>
|
|
57
|
+
<div className="flex items-center justify-between text-xs text-foreground-secondary">
|
|
58
|
+
<div className="flex items-center gap-2">
|
|
59
|
+
{hasWarnings ? (
|
|
60
|
+
<AlertTriangle className="h-3 w-3 text-warning" />
|
|
61
|
+
) : (
|
|
62
|
+
<HardDrive className="h-3 w-3" />
|
|
63
|
+
)}
|
|
64
|
+
<span>
|
|
65
|
+
{data.messages.toLocaleString()} messages · {formatBytes(totalSize)}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
{hasWarnings && (
|
|
69
|
+
<span className="text-warning text-xs truncate ml-2 max-w-[200px]" title={data.warnings.join("; ")}>
|
|
70
|
+
{data.warnings[0]}
|
|
71
|
+
</span>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|