@nextclaw/ui 0.3.14 → 0.3.16
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/CHANGELOG.md +12 -0
- package/dist/assets/index-DuW0OWcM.js +298 -0
- package/dist/assets/index-xwCviEXg.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/App.tsx +12 -21
- package/src/api/config.ts +3 -2
- package/src/components/config/ModelConfig.tsx +1 -1
- package/src/components/config/SessionsConfig.tsx +315 -175
- package/src/components/layout/Sidebar.tsx +22 -20
- package/src/components/ui/select.tsx +135 -0
- package/src/lib/i18n.ts +37 -0
- package/src/main.tsx +4 -1
- package/src/stores/ui.store.ts +0 -7
- package/dist/assets/index-DMFVPFFm.js +0 -255
- package/dist/assets/index-uqpsLADF.css +0 -1
|
@@ -2,9 +2,13 @@ import { useEffect, useMemo, useState } from 'react';
|
|
|
2
2
|
import type { SessionEntryView, SessionMessageView } from '@/api/types';
|
|
3
3
|
import { useDeleteSession, useSessionHistory, useSessions, useUpdateSession } from '@/hooks/useConfig';
|
|
4
4
|
import { Button } from '@/components/ui/button';
|
|
5
|
-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
6
5
|
import { Input } from '@/components/ui/input';
|
|
7
|
-
import {
|
|
6
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
7
|
+
import { cn } from '@/lib/utils';
|
|
8
|
+
import { t } from '@/lib/i18n';
|
|
9
|
+
import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle } from 'lucide-react';
|
|
10
|
+
|
|
11
|
+
const UNKNOWN_CHANNEL_KEY = '__unknown_channel__';
|
|
8
12
|
|
|
9
13
|
function formatDate(value?: string): string {
|
|
10
14
|
if (!value) {
|
|
@@ -17,230 +21,366 @@ function formatDate(value?: string): string {
|
|
|
17
21
|
return date.toLocaleString();
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
function resolveChannelFromSessionKey(key: string): string {
|
|
25
|
+
const separator = key.indexOf(':');
|
|
26
|
+
if (separator <= 0) {
|
|
27
|
+
return UNKNOWN_CHANNEL_KEY;
|
|
28
|
+
}
|
|
29
|
+
const channel = key.slice(0, separator).trim();
|
|
30
|
+
return channel || UNKNOWN_CHANNEL_KEY;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function displayChannelName(channel: string): string {
|
|
34
|
+
if (channel === UNKNOWN_CHANNEL_KEY) {
|
|
35
|
+
return t('sessionsUnknownChannel');
|
|
36
|
+
}
|
|
37
|
+
return channel;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ============================================================================
|
|
41
|
+
// COMPONENT: Left Sidebar Session Item
|
|
42
|
+
// ============================================================================
|
|
43
|
+
|
|
44
|
+
type SessionListItemProps = {
|
|
21
45
|
session: SessionEntryView;
|
|
46
|
+
channel: string;
|
|
22
47
|
isSelected: boolean;
|
|
23
|
-
|
|
24
|
-
modelValue: string;
|
|
25
|
-
onToggleHistory: () => void;
|
|
26
|
-
onLabelChange: (value: string) => void;
|
|
27
|
-
onModelChange: (value: string) => void;
|
|
28
|
-
onSave: () => void;
|
|
29
|
-
onClear: () => void;
|
|
30
|
-
onDelete: () => void;
|
|
48
|
+
onSelect: () => void;
|
|
31
49
|
};
|
|
32
50
|
|
|
33
|
-
function
|
|
34
|
-
const
|
|
51
|
+
function SessionListItem({ session, channel, isSelected, onSelect }: SessionListItemProps) {
|
|
52
|
+
const channelDisplay = displayChannelName(channel);
|
|
53
|
+
const displayName = session.label || session.key.split(':').pop() || session.key;
|
|
54
|
+
|
|
35
55
|
return (
|
|
36
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
</div>
|
|
47
|
-
<div>
|
|
48
|
-
|
|
56
|
+
<button
|
|
57
|
+
onClick={onSelect}
|
|
58
|
+
className={cn(
|
|
59
|
+
"w-full text-left p-3 rounded-xl border transition-all duration-200 focus:outline-none",
|
|
60
|
+
isSelected
|
|
61
|
+
? "bg-primary-50 border-primary-200 shadow-sm"
|
|
62
|
+
: "bg-white border-transparent hover:border-gray-200 hover:bg-gray-50 hover:shadow-sm"
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
<div className="flex items-start justify-between mb-1.5">
|
|
66
|
+
<div className="font-medium text-gray-900 truncate pr-2 flex-1 text-sm">{displayName}</div>
|
|
67
|
+
<div className="text-[10px] font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 shrink-0 capitalize">
|
|
68
|
+
{channelDisplay}
|
|
49
69
|
</div>
|
|
50
70
|
</div>
|
|
51
71
|
|
|
52
|
-
<div className="
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
<div className="flex flex-wrap gap-2">
|
|
62
|
-
<Button type="button" variant={props.isSelected ? 'default' : 'outline'} size="sm" onClick={props.onToggleHistory}>
|
|
63
|
-
{props.isSelected ? '隐藏历史' : '查看历史'}
|
|
64
|
-
</Button>
|
|
65
|
-
<Button type="button" variant="outline" size="sm" onClick={props.onSave}>
|
|
66
|
-
<Save className="h-4 w-4 mr-1" />
|
|
67
|
-
保存元信息
|
|
68
|
-
</Button>
|
|
69
|
-
<Button type="button" variant="outline" size="sm" onClick={props.onClear}>
|
|
70
|
-
清空历史
|
|
71
|
-
</Button>
|
|
72
|
-
<Button type="button" variant="outline" size="sm" onClick={props.onDelete}>
|
|
73
|
-
<Trash2 className="h-4 w-4 mr-1" />
|
|
74
|
-
删除
|
|
75
|
-
</Button>
|
|
72
|
+
<div className="flex items-center text-xs text-gray-500 justify-between">
|
|
73
|
+
<div className="flex items-center gap-1.5">
|
|
74
|
+
<Clock className="w-3.5 h-3.5" />
|
|
75
|
+
<span className="truncate max-w-[100px]">{formatDate(session.updatedAt).split(' ')[0]}</span>
|
|
76
|
+
</div>
|
|
77
|
+
<div className="flex items-center gap-1">
|
|
78
|
+
<MessageCircle className="w-3.5 h-3.5" />
|
|
79
|
+
<span>{session.messageCount}</span>
|
|
80
|
+
</div>
|
|
76
81
|
</div>
|
|
77
|
-
</
|
|
82
|
+
</button>
|
|
78
83
|
);
|
|
79
84
|
}
|
|
80
85
|
|
|
81
|
-
|
|
86
|
+
// ============================================================================
|
|
87
|
+
// COMPONENT: Right Side Chat Bubble Message Item
|
|
88
|
+
// ============================================================================
|
|
89
|
+
|
|
90
|
+
function SessionMessageBubble({ message }: { message: SessionMessageView }) {
|
|
91
|
+
const isUser = message.role.toLowerCase() === 'user';
|
|
92
|
+
|
|
82
93
|
return (
|
|
83
|
-
<div
|
|
84
|
-
<div className=
|
|
85
|
-
|
|
94
|
+
<div className={cn("flex w-full mb-4", isUser ? "justify-end" : "justify-start")}>
|
|
95
|
+
<div className={cn(
|
|
96
|
+
"max-w-[85%] rounded-2xl p-4 flex gap-3 text-sm",
|
|
97
|
+
isUser
|
|
98
|
+
? "bg-primary text-white rounded-tr-sm shadow-sm"
|
|
99
|
+
: "bg-white border border-gray-100 text-gray-800 rounded-tl-sm shadow-card-sm"
|
|
100
|
+
)}>
|
|
101
|
+
<div className="shrink-0 pt-0.5">
|
|
102
|
+
{isUser ? <User className="w-4 h-4 text-primary-100" /> : <Bot className="w-4 h-4 text-gray-400" />}
|
|
103
|
+
</div>
|
|
104
|
+
<div className="flex-1 space-y-1 overflow-x-hidden">
|
|
105
|
+
<div className="flex items-baseline justify-between gap-4 mb-2">
|
|
106
|
+
<span className={cn("font-semibold text-xs", isUser ? "text-primary-50" : "text-gray-900 capitalize")}>
|
|
107
|
+
{message.role}
|
|
108
|
+
</span>
|
|
109
|
+
<span className={cn("text-[10px]", isUser ? "text-primary-200" : "text-gray-400")}>
|
|
110
|
+
{formatDate(message.timestamp)}
|
|
111
|
+
</span>
|
|
112
|
+
</div>
|
|
113
|
+
<div className="whitespace-pre-wrap break-words leading-relaxed">
|
|
114
|
+
{message.content}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
86
117
|
</div>
|
|
87
|
-
<div className="text-sm whitespace-pre-wrap break-words mt-1">{message.content}</div>
|
|
88
118
|
</div>
|
|
89
119
|
);
|
|
90
120
|
}
|
|
91
121
|
|
|
122
|
+
// ============================================================================
|
|
123
|
+
// MAIN PAGE COMPONENT
|
|
124
|
+
// ============================================================================
|
|
125
|
+
|
|
92
126
|
export function SessionsConfig() {
|
|
93
127
|
const [query, setQuery] = useState('');
|
|
94
|
-
const [limit
|
|
95
|
-
const [activeMinutes
|
|
128
|
+
const [limit] = useState(100);
|
|
129
|
+
const [activeMinutes] = useState(0);
|
|
96
130
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
97
|
-
const [
|
|
98
|
-
|
|
131
|
+
const [selectedChannel, setSelectedChannel] = useState<string>('all');
|
|
132
|
+
|
|
133
|
+
// Local state drafts for editing the currently selected session
|
|
134
|
+
const [draftLabel, setDraftLabel] = useState('');
|
|
135
|
+
const [draftModel, setDraftModel] = useState('');
|
|
99
136
|
|
|
100
137
|
const sessionsParams = useMemo(() => ({ q: query.trim() || undefined, limit, activeMinutes }), [query, limit, activeMinutes]);
|
|
101
138
|
const sessionsQuery = useSessions(sessionsParams);
|
|
102
139
|
const historyQuery = useSessionHistory(selectedKey, 200);
|
|
140
|
+
|
|
103
141
|
const updateSession = useUpdateSession();
|
|
104
142
|
const deleteSession = useDeleteSession();
|
|
105
143
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
144
|
+
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
|
|
145
|
+
const selectedSession = useMemo(() => sessions.find(s => s.key === selectedKey), [sessions, selectedKey]);
|
|
146
|
+
|
|
147
|
+
const channels = useMemo(() => {
|
|
148
|
+
const set = new Set<string>();
|
|
149
|
+
for (const s of sessions) {
|
|
150
|
+
set.add(resolveChannelFromSessionKey(s.key));
|
|
110
151
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
next[session.key] = session.label ?? '';
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
return next;
|
|
152
|
+
return Array.from(set).sort((a, b) => {
|
|
153
|
+
if (a === UNKNOWN_CHANNEL_KEY) return 1;
|
|
154
|
+
if (b === UNKNOWN_CHANNEL_KEY) return -1;
|
|
155
|
+
return a.localeCompare(b);
|
|
119
156
|
});
|
|
120
|
-
|
|
121
|
-
const next = { ...prev };
|
|
122
|
-
for (const session of sessions) {
|
|
123
|
-
if (!(session.key in next)) {
|
|
124
|
-
next[session.key] = session.preferredModel ?? '';
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return next;
|
|
128
|
-
});
|
|
129
|
-
}, [sessionsQuery.data]);
|
|
157
|
+
}, [sessions]);
|
|
130
158
|
|
|
131
|
-
const
|
|
159
|
+
const filteredSessions = useMemo(() => {
|
|
160
|
+
if (selectedChannel === 'all') return sessions;
|
|
161
|
+
return sessions.filter(s => resolveChannelFromSessionKey(s.key) === selectedChannel);
|
|
162
|
+
}, [sessions, selectedChannel]);
|
|
132
163
|
|
|
133
|
-
|
|
164
|
+
// Sync draft states when selecting a new session
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (selectedSession) {
|
|
167
|
+
setDraftLabel(selectedSession.label || '');
|
|
168
|
+
setDraftModel(selectedSession.preferredModel || '');
|
|
169
|
+
} else {
|
|
170
|
+
setDraftLabel('');
|
|
171
|
+
setDraftModel('');
|
|
172
|
+
}
|
|
173
|
+
}, [selectedSession]);
|
|
174
|
+
|
|
175
|
+
const handleSaveMeta = () => {
|
|
176
|
+
if (!selectedKey) return;
|
|
134
177
|
updateSession.mutate({
|
|
135
|
-
key,
|
|
178
|
+
key: selectedKey,
|
|
136
179
|
data: {
|
|
137
|
-
label:
|
|
138
|
-
preferredModel:
|
|
180
|
+
label: draftLabel.trim() || null,
|
|
181
|
+
preferredModel: draftModel.trim() || null
|
|
139
182
|
}
|
|
140
183
|
});
|
|
141
184
|
};
|
|
142
185
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
if (
|
|
146
|
-
|
|
186
|
+
const handleClearHistory = () => {
|
|
187
|
+
if (!selectedKey) return;
|
|
188
|
+
if (window.confirm(t('sessionsClearHistory') + "?")) {
|
|
189
|
+
updateSession.mutate({ key: selectedKey, data: { clearHistory: true } });
|
|
147
190
|
}
|
|
148
191
|
};
|
|
149
192
|
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
if (
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
{
|
|
158
|
-
onSuccess: () => {
|
|
159
|
-
if (selectedKey === key) {
|
|
160
|
-
setSelectedKey(null);
|
|
161
|
-
}
|
|
193
|
+
const handleDeleteSession = () => {
|
|
194
|
+
if (!selectedKey) return;
|
|
195
|
+
if (window.confirm(`${t('sessionsDeleteConfirm')} ?`)) {
|
|
196
|
+
deleteSession.mutate(
|
|
197
|
+
{ key: selectedKey },
|
|
198
|
+
{
|
|
199
|
+
onSuccess: () => setSelectedKey(null)
|
|
162
200
|
}
|
|
163
|
-
|
|
164
|
-
|
|
201
|
+
);
|
|
202
|
+
}
|
|
165
203
|
};
|
|
166
204
|
|
|
167
205
|
return (
|
|
168
|
-
<div className="
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
206
|
+
<div className="h-[calc(100vh-80px)] w-full max-w-[1400px] mx-auto animate-fade-in flex flex-col pt-6 pb-2">
|
|
207
|
+
|
|
208
|
+
{/* Header */}
|
|
209
|
+
<div className="flex items-center justify-between mb-6 shrink-0">
|
|
210
|
+
<div>
|
|
211
|
+
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">{t('sessionsPageTitle')}</h2>
|
|
212
|
+
<p className="text-sm text-gray-500 mt-1">{t('sessionsPageDescription')}</p>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
{/* Global Toolbar */}
|
|
216
|
+
<div className="flex items-center gap-3">
|
|
217
|
+
<Select value={selectedChannel} onValueChange={setSelectedChannel}>
|
|
218
|
+
<SelectTrigger className="w-[180px] h-9 rounded-full bg-gray-50/50 hover:bg-gray-100 border-gray-200 focus:ring-0 shadow-none font-medium text-gray-700">
|
|
219
|
+
<SelectValue placeholder="All Channels" />
|
|
220
|
+
</SelectTrigger>
|
|
221
|
+
<SelectContent className="rounded-xl shadow-lg border-gray-100">
|
|
222
|
+
<SelectItem value="all" className="rounded-lg">All Channels</SelectItem>
|
|
223
|
+
{channels.map(c => (
|
|
224
|
+
<SelectItem key={c} value={c} className="rounded-lg">{displayChannelName(c)}</SelectItem>
|
|
225
|
+
))}
|
|
226
|
+
</SelectContent>
|
|
227
|
+
</Select>
|
|
228
|
+
|
|
229
|
+
<div className="relative w-64">
|
|
230
|
+
<Search className="h-4 w-4 absolute left-3 top-2.5 text-gray-400" />
|
|
231
|
+
<Input
|
|
232
|
+
value={query}
|
|
233
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
234
|
+
placeholder={t('sessionsSearchPlaceholder')}
|
|
235
|
+
className="pl-9 h-9 rounded-full bg-gray-50/50 border-gray-200 focus-visible:bg-white"
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
<Button variant="outline" size="icon" className="h-9 w-9 rounded-full text-gray-500" onClick={() => sessionsQuery.refetch()}>
|
|
239
|
+
<RefreshCw className={cn("h-4 w-4", sessionsQuery.isFetching && "animate-spin")} />
|
|
240
|
+
</Button>
|
|
241
|
+
</div>
|
|
172
242
|
</div>
|
|
173
243
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
<Search className="h-4 w-4 absolute left-3 top-3 text-gray-400" />
|
|
182
|
-
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索 key 或标签" className="pl-9" />
|
|
244
|
+
{/* Main Mailbox Layout */}
|
|
245
|
+
<div className="flex-1 flex gap-6 min-h-0 relative">
|
|
246
|
+
|
|
247
|
+
{/* LEFT COLUMN: List */}
|
|
248
|
+
<div className="w-[320px] flex flex-col shrink-0">
|
|
249
|
+
<div className="flex items-center justify-between px-1 mb-3 text-xs font-medium text-gray-500">
|
|
250
|
+
<span>{sessions.length} {t('sessionsListTitle')}</span>
|
|
183
251
|
</div>
|
|
184
|
-
|
|
185
|
-
<div className="flex
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
<
|
|
189
|
-
|
|
252
|
+
|
|
253
|
+
<div className="flex-1 overflow-y-auto px-1.5 -mx-1.5 pt-1.5 -mt-1.5 space-y-2 pb-10
|
|
254
|
+
[&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:bg-gray-200 [&::-webkit-scrollbar-thumb]:rounded-full hover:[&::-webkit-scrollbar-thumb]:bg-gray-300">
|
|
255
|
+
{sessionsQuery.isLoading ? (
|
|
256
|
+
<div className="text-sm text-gray-400 p-4 text-center">{t('sessionsLoading')}</div>
|
|
257
|
+
) : filteredSessions.length === 0 ? (
|
|
258
|
+
<div className="text-sm text-gray-400 p-4 text-center border-2 border-dashed border-gray-100 rounded-xl mt-4">
|
|
259
|
+
<Inbox className="w-8 h-8 mx-auto mb-2 text-gray-300" />
|
|
260
|
+
{t('sessionsEmpty')}
|
|
261
|
+
</div>
|
|
262
|
+
) : (
|
|
263
|
+
filteredSessions.map(session => (
|
|
264
|
+
<SessionListItem
|
|
265
|
+
key={session.key}
|
|
266
|
+
session={session}
|
|
267
|
+
channel={resolveChannelFromSessionKey(session.key)}
|
|
268
|
+
isSelected={selectedKey === session.key}
|
|
269
|
+
onSelect={() => setSelectedKey(session.key)}
|
|
270
|
+
/>
|
|
271
|
+
))
|
|
272
|
+
)}
|
|
190
273
|
</div>
|
|
191
|
-
</
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
<CardContent className="space-y-3">
|
|
200
|
-
{sessionsQuery.isLoading ? <div className="text-sm text-gray-500">Loading sessions...</div> : null}
|
|
201
|
-
{sessionsQuery.error ? <div className="text-sm text-red-600">{(sessionsQuery.error as Error).message}</div> : null}
|
|
202
|
-
{!sessionsQuery.isLoading && !sessions.length ? <div className="text-sm text-gray-500">暂无会话。</div> : null}
|
|
203
|
-
|
|
204
|
-
{sessions.map((session) => (
|
|
205
|
-
<SessionRow
|
|
206
|
-
key={session.key}
|
|
207
|
-
session={session}
|
|
208
|
-
isSelected={selectedKey === session.key}
|
|
209
|
-
labelValue={labelDraft[session.key] ?? ''}
|
|
210
|
-
modelValue={modelDraft[session.key] ?? ''}
|
|
211
|
-
onToggleHistory={() => setSelectedKey((prev) => (prev === session.key ? null : session.key))}
|
|
212
|
-
onLabelChange={(value) => setLabelDraft((prev) => ({ ...prev, [session.key]: value }))}
|
|
213
|
-
onModelChange={(value) => setModelDraft((prev) => ({ ...prev, [session.key]: value }))}
|
|
214
|
-
onSave={() => saveSessionMeta(session.key)}
|
|
215
|
-
onClear={() => clearSessionHistory(session.key)}
|
|
216
|
-
onDelete={() => deleteSessionByKey(session.key)}
|
|
217
|
-
/>
|
|
218
|
-
))}
|
|
219
|
-
</CardContent>
|
|
220
|
-
</Card>
|
|
221
|
-
|
|
222
|
-
{selectedKey ? (
|
|
223
|
-
<Card>
|
|
224
|
-
<CardHeader>
|
|
225
|
-
<CardTitle>History: {selectedKey}</CardTitle>
|
|
226
|
-
<CardDescription>最近 200 条消息(展示窗口)。</CardDescription>
|
|
227
|
-
</CardHeader>
|
|
228
|
-
<CardContent className="space-y-2">
|
|
229
|
-
{historyQuery.isLoading ? <div className="text-sm text-gray-500">Loading history...</div> : null}
|
|
230
|
-
{historyQuery.error ? <div className="text-sm text-red-600">{(historyQuery.error as Error).message}</div> : null}
|
|
231
|
-
{historyQuery.data ? (
|
|
232
|
-
<div className="text-xs text-gray-500">Total: {historyQuery.data.totalMessages} · metadata: {JSON.stringify(historyQuery.data.metadata ?? {})}</div>
|
|
233
|
-
) : null}
|
|
234
|
-
<div className="max-h-[480px] overflow-auto space-y-2">
|
|
235
|
-
{(historyQuery.data?.messages ?? []).map((message, index) => (
|
|
236
|
-
<SessionMessageItem key={`${message.timestamp}-${index}`} message={message} index={index} />
|
|
237
|
-
))}
|
|
274
|
+
</div>
|
|
275
|
+
|
|
276
|
+
{/* RIGHT COLUMN: Detail View */}
|
|
277
|
+
<div className="flex-1 min-w-0 bg-gray-50/50 rounded-2xl border border-gray-200 flex flex-col overflow-hidden shadow-sm relative">
|
|
278
|
+
|
|
279
|
+
{(updateSession.isPending || deleteSession.isPending) && (
|
|
280
|
+
<div className="absolute top-0 left-0 w-full h-1 bg-primary/20 overflow-hidden z-10">
|
|
281
|
+
<div className="h-full bg-primary animate-pulse w-1/3 rounded-r-full" />
|
|
238
282
|
</div>
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
{selectedKey && selectedSession ? (
|
|
286
|
+
<>
|
|
287
|
+
{/* Detail Header / Metdata Editor */}
|
|
288
|
+
<div className="shrink-0 bg-white border-b border-gray-200 p-5 shadow-sm z-10 space-y-4">
|
|
289
|
+
<div className="flex items-start justify-between">
|
|
290
|
+
<div className="flex items-center gap-3">
|
|
291
|
+
<div className="h-10 w-10 rounded-xl bg-primary-50 flex items-center justify-center text-primary shrink-0">
|
|
292
|
+
<Hash className="h-5 w-5" />
|
|
293
|
+
</div>
|
|
294
|
+
<div>
|
|
295
|
+
<div className="flex items-center gap-2 mb-1">
|
|
296
|
+
<h3 className="text-base font-bold text-gray-900 leading-none">
|
|
297
|
+
{selectedSession.label || selectedSession.key.split(':').pop() || selectedSession.key}
|
|
298
|
+
</h3>
|
|
299
|
+
<span className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 uppercase tracking-wide">
|
|
300
|
+
{displayChannelName(resolveChannelFromSessionKey(selectedSession.key))}
|
|
301
|
+
</span>
|
|
302
|
+
</div>
|
|
303
|
+
<div className="text-xs text-gray-500 font-mono break-all line-clamp-1" title={selectedKey}>
|
|
304
|
+
{selectedKey}
|
|
305
|
+
</div>
|
|
306
|
+
</div>
|
|
307
|
+
</div>
|
|
308
|
+
<div className="flex items-center gap-2 shrink-0">
|
|
309
|
+
<Button variant="outline" size="sm" onClick={handleClearHistory} className="h-8 shadow-none hover:bg-gray-100/50 hover:text-gray-900 border-gray-200">
|
|
310
|
+
{t('sessionsClearHistory')}
|
|
311
|
+
</Button>
|
|
312
|
+
<Button variant="outline" size="sm" onClick={handleDeleteSession} className="h-8 shadow-none hover:bg-red-50 hover:text-red-600 hover:border-red-200 border-gray-200">
|
|
313
|
+
{t('delete')}
|
|
314
|
+
</Button>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
242
317
|
|
|
243
|
-
|
|
318
|
+
<div className="flex items-center gap-3 bg-gray-50/50 p-3 rounded-lg border border-gray-100">
|
|
319
|
+
<Input
|
|
320
|
+
placeholder={t('sessionsLabelPlaceholder')}
|
|
321
|
+
value={draftLabel}
|
|
322
|
+
onChange={e => setDraftLabel(e.target.value)}
|
|
323
|
+
className="h-8 text-sm bg-white"
|
|
324
|
+
/>
|
|
325
|
+
<Input
|
|
326
|
+
placeholder={t('sessionsModelPlaceholder')}
|
|
327
|
+
value={draftModel}
|
|
328
|
+
onChange={e => setDraftModel(e.target.value)}
|
|
329
|
+
className="h-8 text-sm bg-white"
|
|
330
|
+
/>
|
|
331
|
+
<Button size="sm" onClick={handleSaveMeta} className="h-8 px-4 shrink-0 shadow-none" disabled={updateSession.isPending}>
|
|
332
|
+
{t('sessionsSaveMeta')}
|
|
333
|
+
</Button>
|
|
334
|
+
</div>
|
|
335
|
+
</div>
|
|
336
|
+
|
|
337
|
+
{/* Chat History Area */}
|
|
338
|
+
<div className="flex-1 overflow-y-auto p-6 relative
|
|
339
|
+
[&::-webkit-scrollbar]:w-2 [&::-webkit-scrollbar-thumb]:bg-gray-300/80 [&::-webkit-scrollbar-thumb]:rounded-full">
|
|
340
|
+
|
|
341
|
+
{historyQuery.isLoading && (
|
|
342
|
+
<div className="absolute inset-0 flex items-center justify-center bg-gray-50/50 backdrop-blur-sm z-10">
|
|
343
|
+
<div className="flex flex-col items-center gap-3 animate-pulse">
|
|
344
|
+
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
|
345
|
+
<span className="text-sm font-medium text-gray-500">{t('sessionsHistoryLoading')}</span>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
{historyQuery.error && (
|
|
351
|
+
<div className="text-center p-6 bg-red-50 rounded-xl text-red-600 border border-red-100 text-sm">
|
|
352
|
+
{(historyQuery.error as Error).message}
|
|
353
|
+
</div>
|
|
354
|
+
)}
|
|
355
|
+
|
|
356
|
+
{!historyQuery.isLoading && historyQuery.data?.messages.length === 0 && (
|
|
357
|
+
<div className="h-full flex flex-col items-center justify-center text-gray-400">
|
|
358
|
+
<MessageCircle className="w-12 h-12 mb-3 text-gray-300" />
|
|
359
|
+
<p className="text-sm">{t('sessionsEmpty')}</p>
|
|
360
|
+
</div>
|
|
361
|
+
)}
|
|
362
|
+
|
|
363
|
+
<div className="max-w-3xl mx-auto">
|
|
364
|
+
{(historyQuery.data?.messages ?? []).map((message, idx) => (
|
|
365
|
+
<SessionMessageBubble key={`${message.timestamp}-${idx}`} message={message} />
|
|
366
|
+
))}
|
|
367
|
+
</div>
|
|
368
|
+
</div>
|
|
369
|
+
</>
|
|
370
|
+
) : (
|
|
371
|
+
/* Empty State */
|
|
372
|
+
<div className="flex-1 flex flex-col items-center justify-center text-gray-400 p-8 h-full bg-white">
|
|
373
|
+
<div className="w-20 h-20 bg-gray-50 rounded-3xl flex items-center justify-center mb-6 border border-gray-100 shadow-sm rotate-3">
|
|
374
|
+
<Inbox className="h-8 w-8 text-gray-300 -rotate-3" />
|
|
375
|
+
</div>
|
|
376
|
+
<h3 className="text-lg font-bold text-gray-900 mb-2">No Session Selected</h3>
|
|
377
|
+
<p className="text-sm text-center max-w-sm leading-relaxed">
|
|
378
|
+
Select a session from the list on the left to view its chat history and configure its metadata.
|
|
379
|
+
</p>
|
|
380
|
+
</div>
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
244
384
|
</div>
|
|
245
385
|
);
|
|
246
386
|
}
|
|
@@ -1,38 +1,37 @@
|
|
|
1
|
-
import { useUiStore } from '@/stores/ui.store';
|
|
2
1
|
import { cn } from '@/lib/utils';
|
|
2
|
+
import { t } from '@/lib/i18n';
|
|
3
3
|
import { Cpu, GitBranch, History, MessageSquare, Sparkles } from 'lucide-react';
|
|
4
|
+
import { NavLink } from 'react-router-dom';
|
|
4
5
|
|
|
5
6
|
const navItems = [
|
|
6
7
|
{
|
|
7
|
-
|
|
8
|
+
target: '/model',
|
|
8
9
|
label: 'Models',
|
|
9
10
|
icon: Cpu,
|
|
10
11
|
},
|
|
11
12
|
{
|
|
12
|
-
|
|
13
|
+
target: '/providers',
|
|
13
14
|
label: 'Providers',
|
|
14
15
|
icon: Sparkles,
|
|
15
16
|
},
|
|
16
17
|
{
|
|
17
|
-
|
|
18
|
+
target: '/channels',
|
|
18
19
|
label: 'Channels',
|
|
19
20
|
icon: MessageSquare,
|
|
20
21
|
},
|
|
21
22
|
{
|
|
22
|
-
|
|
23
|
+
target: '/runtime',
|
|
23
24
|
label: 'Routing & Runtime',
|
|
24
25
|
icon: GitBranch,
|
|
25
26
|
},
|
|
26
27
|
{
|
|
27
|
-
|
|
28
|
-
label: '
|
|
28
|
+
target: '/sessions',
|
|
29
|
+
label: t('sessions'),
|
|
29
30
|
icon: History,
|
|
30
31
|
}
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
export function Sidebar() {
|
|
34
|
-
const { activeTab, setActiveTab } = useUiStore();
|
|
35
|
-
|
|
36
35
|
return (
|
|
37
36
|
<aside className="w-[240px] bg-transparent flex flex-col h-full py-6 px-4">
|
|
38
37
|
{/* Logo Area */}
|
|
@@ -50,25 +49,28 @@ export function Sidebar() {
|
|
|
50
49
|
<ul className="space-y-1">
|
|
51
50
|
{navItems.map((item) => {
|
|
52
51
|
const Icon = item.icon;
|
|
53
|
-
const isActive = activeTab === item.id;
|
|
54
52
|
|
|
55
53
|
return (
|
|
56
|
-
<li key={item.
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
className={cn(
|
|
54
|
+
<li key={item.target}>
|
|
55
|
+
<NavLink
|
|
56
|
+
to={item.target}
|
|
57
|
+
className={({ isActive }) => cn(
|
|
60
58
|
'group w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-fast',
|
|
61
59
|
isActive
|
|
62
60
|
? 'bg-primary-100 text-primary-700'
|
|
63
61
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
|
64
62
|
)}
|
|
65
63
|
>
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
64
|
+
{({ isActive }) => (
|
|
65
|
+
<>
|
|
66
|
+
<Icon className={cn(
|
|
67
|
+
'h-4 w-4 transition-transform duration-fast group-hover:scale-110',
|
|
68
|
+
isActive ? 'text-primary' : 'text-gray-500'
|
|
69
|
+
)} />
|
|
70
|
+
<span className="flex-1 text-left">{item.label}</span>
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
</NavLink>
|
|
72
74
|
</li>
|
|
73
75
|
);
|
|
74
76
|
})}
|