@nextclaw/ui 0.5.19 → 0.5.21
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/ChannelsList-TFFw4Cem.js +1 -0
- package/dist/assets/ChatPage-BUm3UPap.js +32 -0
- package/dist/assets/CronConfig-9dYfTRJl.js +1 -0
- package/dist/assets/{DocBrowser-BDQFCtYk.js → DocBrowser-BIV0vpA0.js} +1 -1
- package/dist/assets/MarketplacePage-2Zi0JSVi.js +1 -0
- package/dist/assets/{ModelConfig-Ditrj5-j.js → ModelConfig-h21P5rV0.js} +1 -1
- package/dist/assets/ProvidersList-DEaK1a3y.js +1 -0
- package/dist/assets/RuntimeConfig-DXMzf-gF.js +1 -0
- package/dist/assets/SessionsConfig-SdXvn_9E.js +2 -0
- package/dist/assets/{action-link-C13-2zV4.js → action-link-C9xMkxl2.js} +1 -1
- package/dist/assets/{card-wQP6HJ6W.js → card-Cnqfntk5.js} +1 -1
- package/dist/assets/chat-message-B7oqvJ2d.js +3 -0
- package/dist/assets/dialog-DJs630RE.js +5 -0
- package/dist/assets/index-CrUDzcei.js +2 -0
- package/dist/assets/index-Zy7fAOe1.css +1 -0
- package/dist/assets/{label-BNwROQB2.js → label-CXGuE6Oa.js} +1 -1
- package/dist/assets/{page-layout-BXJRMNor.js → page-layout-BVZlyPFt.js} +1 -1
- package/dist/assets/{switch-CsMPT5De.js → switch-BLF45eI3.js} +1 -1
- package/dist/assets/{tabs-custom-DczZ7pO4.js → tabs-custom-DQ0GpEV5.js} +1 -1
- package/dist/assets/useConfig-vFQvF4kn.js +1 -0
- package/dist/assets/{useConfirmDialog-CKMEeckR.js → useConfirmDialog-CK7KAyDf.js} +1 -1
- package/dist/assets/{vendor-Bhv7yx8z.js → vendor-RXIbhDBC.js} +95 -60
- package/dist/index.html +3 -3
- package/package.json +4 -1
- package/src/App.tsx +4 -2
- package/src/api/config.ts +11 -0
- package/src/api/types.ts +23 -1
- package/src/components/chat/ChatPage.tsx +378 -0
- package/src/components/chat/ChatThread.tsx +210 -0
- package/src/components/config/CronConfig.tsx +1 -1
- package/src/components/config/SessionsConfig.tsx +4 -2
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/components/marketplace/MarketplacePage.tsx +39 -20
- package/src/hooks/useConfig.ts +11 -0
- package/src/index.css +69 -0
- package/src/lib/chat-message.ts +215 -0
- package/src/lib/i18n.ts +36 -0
- package/dist/assets/ChannelsList-GbfILabx.js +0 -1
- package/dist/assets/CronConfig-BV-xuLqt.js +0 -1
- package/dist/assets/MarketplacePage-B2BPK3EZ.js +0 -1
- package/dist/assets/ProvidersList-Dl-9bD7O.js +0 -1
- package/dist/assets/RuntimeConfig-DTzfw7el.js +0 -1
- package/dist/assets/SessionsConfig-CBD8eCim.js +0 -2
- package/dist/assets/dialog-CCWBaWyg.js +0 -5
- package/dist/assets/index-CDWTAUj_.js +0 -2
- package/dist/assets/index-DdpR1fdj.css +0 -1
- package/dist/assets/useConfig-JBr27I5l.js +0 -1
package/dist/index.html
CHANGED
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
8
8
|
<title>NextClaw - 系统配置</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="modulepreload" crossorigin href="/assets/vendor-
|
|
11
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-CrUDzcei.js"></script>
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-RXIbhDBC.js">
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-Zy7fAOe1.css">
|
|
12
12
|
</head>
|
|
13
13
|
|
|
14
14
|
<body>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nextclaw/ui",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.21",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -19,7 +19,10 @@
|
|
|
19
19
|
"react": "^18.3.1",
|
|
20
20
|
"react-dom": "^18.3.1",
|
|
21
21
|
"react-hook-form": "^7.53.2",
|
|
22
|
+
"react-markdown": "^10.1.0",
|
|
22
23
|
"react-router-dom": "^7.13.0",
|
|
24
|
+
"rehype-sanitize": "^6.0.0",
|
|
25
|
+
"remark-gfm": "^4.0.1",
|
|
23
26
|
"sonner": "^1.7.1",
|
|
24
27
|
"tailwind-merge": "^2.5.4",
|
|
25
28
|
"zod": "^3.23.8",
|
package/src/App.tsx
CHANGED
|
@@ -15,6 +15,7 @@ const queryClient = new QueryClient({
|
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
const ModelConfigPage = lazy(async () => ({ default: (await import('@/components/config/ModelConfig')).ModelConfig }));
|
|
18
|
+
const ChatPage = lazy(async () => ({ default: (await import('@/components/chat/ChatPage')).ChatPage }));
|
|
18
19
|
const ProvidersListPage = lazy(async () => ({ default: (await import('@/components/config/ProvidersList')).ProvidersList }));
|
|
19
20
|
const ChannelsListPage = lazy(async () => ({ default: (await import('@/components/config/ChannelsList')).ChannelsList }));
|
|
20
21
|
const RuntimeConfigPage = lazy(async () => ({ default: (await import('@/components/config/RuntimeConfig')).RuntimeConfig }));
|
|
@@ -39,6 +40,7 @@ function AppContent() {
|
|
|
39
40
|
<AppLayout>
|
|
40
41
|
<div key={location.pathname} className="animate-fade-in w-full h-full">
|
|
41
42
|
<Routes>
|
|
43
|
+
<Route path="/chat" element={<LazyRoute><ChatPage /></LazyRoute>} />
|
|
42
44
|
<Route path="/model" element={<LazyRoute><ModelConfigPage /></LazyRoute>} />
|
|
43
45
|
<Route path="/providers" element={<LazyRoute><ProvidersListPage /></LazyRoute>} />
|
|
44
46
|
<Route path="/channels" element={<LazyRoute><ChannelsListPage /></LazyRoute>} />
|
|
@@ -47,8 +49,8 @@ function AppContent() {
|
|
|
47
49
|
<Route path="/cron" element={<LazyRoute><CronConfigPage /></LazyRoute>} />
|
|
48
50
|
<Route path="/marketplace" element={<Navigate to="/marketplace/plugins" replace />} />
|
|
49
51
|
<Route path="/marketplace/:type" element={<LazyRoute><MarketplacePage /></LazyRoute>} />
|
|
50
|
-
<Route path="/" element={<Navigate to="/
|
|
51
|
-
<Route path="*" element={<Navigate to="/
|
|
52
|
+
<Route path="/" element={<Navigate to="/chat" replace />} />
|
|
53
|
+
<Route path="*" element={<Navigate to="/chat" replace />} />
|
|
52
54
|
</Routes>
|
|
53
55
|
</div>
|
|
54
56
|
</AppLayout>
|
package/src/api/config.ts
CHANGED
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
SessionsListView,
|
|
13
13
|
SessionHistoryView,
|
|
14
14
|
SessionPatchUpdate,
|
|
15
|
+
ChatTurnRequest,
|
|
16
|
+
ChatTurnView,
|
|
15
17
|
CronListView,
|
|
16
18
|
CronEnableRequest,
|
|
17
19
|
CronRunRequest,
|
|
@@ -167,6 +169,15 @@ export async function deleteSession(key: string): Promise<{ deleted: boolean }>
|
|
|
167
169
|
return response.data;
|
|
168
170
|
}
|
|
169
171
|
|
|
172
|
+
// POST /api/chat/turn
|
|
173
|
+
export async function sendChatTurn(data: ChatTurnRequest): Promise<ChatTurnView> {
|
|
174
|
+
const response = await api.post<ChatTurnView>('/api/chat/turn', data);
|
|
175
|
+
if (!response.ok) {
|
|
176
|
+
throw new Error(response.error.message);
|
|
177
|
+
}
|
|
178
|
+
return response.data;
|
|
179
|
+
}
|
|
180
|
+
|
|
170
181
|
// GET /api/cron
|
|
171
182
|
export async function fetchCronJobs(params?: { all?: boolean }): Promise<CronListView> {
|
|
172
183
|
const query = new URLSearchParams();
|
package/src/api/types.ts
CHANGED
|
@@ -73,10 +73,12 @@ export type SessionsListView = {
|
|
|
73
73
|
|
|
74
74
|
export type SessionMessageView = {
|
|
75
75
|
role: string;
|
|
76
|
-
content:
|
|
76
|
+
content: unknown;
|
|
77
77
|
timestamp: string;
|
|
78
78
|
name?: string;
|
|
79
79
|
tool_call_id?: string;
|
|
80
|
+
tool_calls?: Array<Record<string, unknown>>;
|
|
81
|
+
reasoning_content?: string;
|
|
80
82
|
};
|
|
81
83
|
|
|
82
84
|
export type SessionHistoryView = {
|
|
@@ -92,6 +94,26 @@ export type SessionPatchUpdate = {
|
|
|
92
94
|
clearHistory?: boolean;
|
|
93
95
|
};
|
|
94
96
|
|
|
97
|
+
export type ChatTurnRequest = {
|
|
98
|
+
message: string;
|
|
99
|
+
sessionKey?: string;
|
|
100
|
+
agentId?: string;
|
|
101
|
+
channel?: string;
|
|
102
|
+
chatId?: string;
|
|
103
|
+
model?: string;
|
|
104
|
+
metadata?: Record<string, unknown>;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export type ChatTurnView = {
|
|
108
|
+
reply: string;
|
|
109
|
+
sessionKey: string;
|
|
110
|
+
agentId?: string;
|
|
111
|
+
model?: string;
|
|
112
|
+
requestedAt: string;
|
|
113
|
+
completedAt: string;
|
|
114
|
+
durationMs: number;
|
|
115
|
+
};
|
|
116
|
+
|
|
95
117
|
export type CronScheduleView =
|
|
96
118
|
| { kind: "at"; atMs?: number | null }
|
|
97
119
|
| { kind: "every"; everyMs?: number | null }
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { SessionEntryView, SessionMessageView } from '@/api/types';
|
|
3
|
+
import { useConfig, useDeleteSession, useSendChatTurn, useSessionHistory, useSessions } from '@/hooks/useConfig';
|
|
4
|
+
import { useConfirmDialog } from '@/hooks/useConfirmDialog';
|
|
5
|
+
import { Button } from '@/components/ui/button';
|
|
6
|
+
import { Input } from '@/components/ui/input';
|
|
7
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
8
|
+
import { PageHeader, PageLayout } from '@/components/layout/page-layout';
|
|
9
|
+
import { ChatThread } from '@/components/chat/ChatThread';
|
|
10
|
+
import { cn } from '@/lib/utils';
|
|
11
|
+
import { formatDateTime, t } from '@/lib/i18n';
|
|
12
|
+
import { MessageSquareText, Plus, RefreshCw, Search, Send, Trash2 } from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
const CHAT_SESSION_STORAGE_KEY = 'nextclaw.ui.chat.activeSession';
|
|
15
|
+
|
|
16
|
+
function readStoredSessionKey(): string | null {
|
|
17
|
+
if (typeof window === 'undefined') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
const value = window.localStorage.getItem(CHAT_SESSION_STORAGE_KEY);
|
|
22
|
+
return value && value.trim().length > 0 ? value : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeStoredSessionKey(value: string | null): void {
|
|
29
|
+
if (typeof window === 'undefined') {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
if (!value) {
|
|
34
|
+
window.localStorage.removeItem(CHAT_SESSION_STORAGE_KEY);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
window.localStorage.setItem(CHAT_SESSION_STORAGE_KEY, value);
|
|
38
|
+
} catch {
|
|
39
|
+
// ignore storage errors
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveAgentIdFromSessionKey(sessionKey: string): string | null {
|
|
44
|
+
const match = /^agent:([^:]+):/i.exec(sessionKey.trim());
|
|
45
|
+
if (!match) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const value = match[1]?.trim();
|
|
49
|
+
return value ? value : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildNewSessionKey(agentId: string): string {
|
|
53
|
+
const slug = Math.random().toString(36).slice(2, 8);
|
|
54
|
+
return `agent:${agentId}:ui:direct:web-${Date.now().toString(36)}${slug}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sessionDisplayName(session: SessionEntryView): string {
|
|
58
|
+
if (session.label && session.label.trim()) {
|
|
59
|
+
return session.label.trim();
|
|
60
|
+
}
|
|
61
|
+
const chunks = session.key.split(':');
|
|
62
|
+
return chunks[chunks.length - 1] || session.key;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function ChatPage() {
|
|
66
|
+
const [query, setQuery] = useState('');
|
|
67
|
+
const [draft, setDraft] = useState('');
|
|
68
|
+
const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
|
|
69
|
+
const [selectedAgentId, setSelectedAgentId] = useState('main');
|
|
70
|
+
const [optimisticUserMessage, setOptimisticUserMessage] = useState<SessionMessageView | null>(null);
|
|
71
|
+
|
|
72
|
+
const { confirm, ConfirmDialog } = useConfirmDialog();
|
|
73
|
+
const threadRef = useRef<HTMLDivElement | null>(null);
|
|
74
|
+
|
|
75
|
+
const configQuery = useConfig();
|
|
76
|
+
const sessionsQuery = useSessions({ q: query.trim() || undefined, limit: 120, activeMinutes: 0 });
|
|
77
|
+
const historyQuery = useSessionHistory(selectedSessionKey, 300);
|
|
78
|
+
const deleteSession = useDeleteSession();
|
|
79
|
+
const sendChatTurn = useSendChatTurn();
|
|
80
|
+
|
|
81
|
+
const agentOptions = useMemo(() => {
|
|
82
|
+
const list = configQuery.data?.agents.list ?? [];
|
|
83
|
+
const unique = new Set<string>(['main']);
|
|
84
|
+
for (const item of list) {
|
|
85
|
+
if (typeof item.id === 'string' && item.id.trim().length > 0) {
|
|
86
|
+
unique.add(item.id.trim().toLowerCase());
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return Array.from(unique);
|
|
90
|
+
}, [configQuery.data?.agents.list]);
|
|
91
|
+
|
|
92
|
+
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
|
|
93
|
+
const selectedSession = useMemo(
|
|
94
|
+
() => sessions.find((session) => session.key === selectedSessionKey) ?? null,
|
|
95
|
+
[selectedSessionKey, sessions]
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const historyMessages = useMemo(() => historyQuery.data?.messages ?? [], [historyQuery.data?.messages]);
|
|
99
|
+
const mergedMessages = useMemo(() => {
|
|
100
|
+
if (!optimisticUserMessage) {
|
|
101
|
+
return historyMessages;
|
|
102
|
+
}
|
|
103
|
+
return [...historyMessages, optimisticUserMessage];
|
|
104
|
+
}, [historyMessages, optimisticUserMessage]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
if (!selectedSessionKey && sessions.length > 0) {
|
|
108
|
+
setSelectedSessionKey(sessions[0].key);
|
|
109
|
+
}
|
|
110
|
+
}, [selectedSessionKey, sessions]);
|
|
111
|
+
|
|
112
|
+
useEffect(() => {
|
|
113
|
+
writeStoredSessionKey(selectedSessionKey);
|
|
114
|
+
}, [selectedSessionKey]);
|
|
115
|
+
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
const inferred = selectedSessionKey ? resolveAgentIdFromSessionKey(selectedSessionKey) : null;
|
|
118
|
+
if (!inferred) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (selectedAgentId !== inferred) {
|
|
122
|
+
setSelectedAgentId(inferred);
|
|
123
|
+
}
|
|
124
|
+
}, [selectedAgentId, selectedSessionKey]);
|
|
125
|
+
|
|
126
|
+
useEffect(() => {
|
|
127
|
+
const element = threadRef.current;
|
|
128
|
+
if (!element) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
element.scrollTop = element.scrollHeight;
|
|
132
|
+
}, [mergedMessages.length, sendChatTurn.isPending, selectedSessionKey]);
|
|
133
|
+
|
|
134
|
+
const createNewSession = () => {
|
|
135
|
+
const next = buildNewSessionKey(selectedAgentId);
|
|
136
|
+
setSelectedSessionKey(next);
|
|
137
|
+
setOptimisticUserMessage(null);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const handleDeleteSession = async () => {
|
|
141
|
+
if (!selectedSessionKey) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const confirmed = await confirm({
|
|
145
|
+
title: t('chatDeleteSessionConfirm'),
|
|
146
|
+
variant: 'destructive',
|
|
147
|
+
confirmLabel: t('delete')
|
|
148
|
+
});
|
|
149
|
+
if (!confirmed) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
deleteSession.mutate(
|
|
153
|
+
{ key: selectedSessionKey },
|
|
154
|
+
{
|
|
155
|
+
onSuccess: async () => {
|
|
156
|
+
setSelectedSessionKey(null);
|
|
157
|
+
setOptimisticUserMessage(null);
|
|
158
|
+
await sessionsQuery.refetch();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const handleSend = async () => {
|
|
165
|
+
const message = draft.trim();
|
|
166
|
+
if (!message || sendChatTurn.isPending) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const hadActiveSession = Boolean(selectedSessionKey);
|
|
171
|
+
const sessionKey = selectedSessionKey ?? buildNewSessionKey(selectedAgentId);
|
|
172
|
+
if (!selectedSessionKey) {
|
|
173
|
+
setSelectedSessionKey(sessionKey);
|
|
174
|
+
}
|
|
175
|
+
setDraft('');
|
|
176
|
+
setOptimisticUserMessage({
|
|
177
|
+
role: 'user',
|
|
178
|
+
content: message,
|
|
179
|
+
timestamp: new Date().toISOString()
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const result = await sendChatTurn.mutateAsync({
|
|
184
|
+
data: {
|
|
185
|
+
message,
|
|
186
|
+
sessionKey,
|
|
187
|
+
agentId: selectedAgentId,
|
|
188
|
+
channel: 'ui',
|
|
189
|
+
chatId: 'web-ui'
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
setOptimisticUserMessage(null);
|
|
193
|
+
if (result.sessionKey !== sessionKey) {
|
|
194
|
+
setSelectedSessionKey(result.sessionKey);
|
|
195
|
+
}
|
|
196
|
+
await sessionsQuery.refetch();
|
|
197
|
+
if (hadActiveSession) {
|
|
198
|
+
await historyQuery.refetch();
|
|
199
|
+
}
|
|
200
|
+
} catch {
|
|
201
|
+
setOptimisticUserMessage(null);
|
|
202
|
+
setDraft(message);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<PageLayout fullHeight>
|
|
208
|
+
<PageHeader
|
|
209
|
+
title={t('chatPageTitle')}
|
|
210
|
+
description={t('chatPageDescription')}
|
|
211
|
+
actions={
|
|
212
|
+
<div className="flex items-center gap-2">
|
|
213
|
+
<Button variant="outline" size="sm" onClick={() => historyQuery.refetch()} className="rounded-lg">
|
|
214
|
+
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', historyQuery.isFetching && 'animate-spin')} />
|
|
215
|
+
{t('chatRefresh')}
|
|
216
|
+
</Button>
|
|
217
|
+
<Button variant="primary" size="sm" onClick={createNewSession} className="rounded-lg">
|
|
218
|
+
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
219
|
+
{t('chatNewSession')}
|
|
220
|
+
</Button>
|
|
221
|
+
</div>
|
|
222
|
+
}
|
|
223
|
+
/>
|
|
224
|
+
|
|
225
|
+
<div className="flex-1 min-h-0 flex gap-4 max-lg:flex-col">
|
|
226
|
+
<aside className="w-[320px] max-lg:w-full shrink-0 rounded-2xl border border-gray-200 bg-white shadow-card flex flex-col min-h-0">
|
|
227
|
+
<div className="p-4 border-b border-gray-100 space-y-3">
|
|
228
|
+
<div className="relative">
|
|
229
|
+
<Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
|
|
230
|
+
<Input
|
|
231
|
+
value={query}
|
|
232
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
233
|
+
placeholder={t('chatSearchSessionPlaceholder')}
|
|
234
|
+
className="pl-8 h-9 rounded-lg text-xs"
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="grid grid-cols-2 gap-2">
|
|
238
|
+
<Button variant="outline" size="sm" className="rounded-lg" onClick={() => sessionsQuery.refetch()}>
|
|
239
|
+
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', sessionsQuery.isFetching && 'animate-spin')} />
|
|
240
|
+
{t('chatRefresh')}
|
|
241
|
+
</Button>
|
|
242
|
+
<Button variant="subtle" size="sm" className="rounded-lg" onClick={createNewSession}>
|
|
243
|
+
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
244
|
+
{t('chatNewSession')}
|
|
245
|
+
</Button>
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
|
|
249
|
+
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
|
|
250
|
+
{sessionsQuery.isLoading ? (
|
|
251
|
+
<div className="text-sm text-gray-500 p-4">{t('sessionsLoading')}</div>
|
|
252
|
+
) : sessions.length === 0 ? (
|
|
253
|
+
<div className="p-5 m-2 rounded-xl border border-dashed border-gray-200 text-center text-sm text-gray-500">
|
|
254
|
+
<MessageSquareText className="h-7 w-7 mx-auto mb-2 text-gray-300" />
|
|
255
|
+
{t('sessionsEmpty')}
|
|
256
|
+
</div>
|
|
257
|
+
) : (
|
|
258
|
+
<div className="space-y-1">
|
|
259
|
+
{sessions.map((session) => {
|
|
260
|
+
const active = selectedSessionKey === session.key;
|
|
261
|
+
return (
|
|
262
|
+
<button
|
|
263
|
+
key={session.key}
|
|
264
|
+
onClick={() => setSelectedSessionKey(session.key)}
|
|
265
|
+
className={cn(
|
|
266
|
+
'w-full rounded-xl border px-3 py-2.5 text-left transition-all',
|
|
267
|
+
active
|
|
268
|
+
? 'border-primary/30 bg-primary/5'
|
|
269
|
+
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
<div className="text-sm font-semibold text-gray-900 truncate">{sessionDisplayName(session)}</div>
|
|
273
|
+
<div className="mt-1 text-[11px] text-gray-500 truncate">{session.key}</div>
|
|
274
|
+
<div className="mt-1 text-[11px] text-gray-400">
|
|
275
|
+
{session.messageCount} · {formatDateTime(session.updatedAt)}
|
|
276
|
+
</div>
|
|
277
|
+
</button>
|
|
278
|
+
);
|
|
279
|
+
})}
|
|
280
|
+
</div>
|
|
281
|
+
)}
|
|
282
|
+
</div>
|
|
283
|
+
</aside>
|
|
284
|
+
|
|
285
|
+
<section className="flex-1 min-h-0 rounded-2xl border border-gray-200 bg-gradient-to-b from-gray-50/60 to-white shadow-card flex flex-col overflow-hidden">
|
|
286
|
+
<div className="px-5 py-4 border-b border-gray-200/80 bg-white/80 backdrop-blur-sm flex flex-wrap items-center gap-3">
|
|
287
|
+
<div className="min-w-[220px] max-w-[320px]">
|
|
288
|
+
<div className="text-[11px] text-gray-500 mb-1">{t('chatAgentLabel')}</div>
|
|
289
|
+
<Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
|
|
290
|
+
<SelectTrigger className="h-9 rounded-lg">
|
|
291
|
+
<SelectValue placeholder={t('chatSelectAgent')} />
|
|
292
|
+
</SelectTrigger>
|
|
293
|
+
<SelectContent>
|
|
294
|
+
{agentOptions.map((agent) => (
|
|
295
|
+
<SelectItem key={agent} value={agent}>
|
|
296
|
+
{agent}
|
|
297
|
+
</SelectItem>
|
|
298
|
+
))}
|
|
299
|
+
</SelectContent>
|
|
300
|
+
</Select>
|
|
301
|
+
</div>
|
|
302
|
+
|
|
303
|
+
<div className="flex-1 min-w-[260px]">
|
|
304
|
+
<div className="text-[11px] text-gray-500 mb-1">{t('chatSessionLabel')}</div>
|
|
305
|
+
<div className="h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs text-gray-600 flex items-center truncate">
|
|
306
|
+
{selectedSessionKey ?? t('chatNoSession')}
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
|
|
310
|
+
<Button
|
|
311
|
+
variant="outline"
|
|
312
|
+
size="sm"
|
|
313
|
+
className="rounded-lg self-end"
|
|
314
|
+
onClick={handleDeleteSession}
|
|
315
|
+
disabled={!selectedSession || deleteSession.isPending}
|
|
316
|
+
>
|
|
317
|
+
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
318
|
+
{t('chatDeleteSession')}
|
|
319
|
+
</Button>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
<div ref={threadRef} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-5 py-5">
|
|
323
|
+
{!selectedSessionKey ? (
|
|
324
|
+
<div className="h-full flex items-center justify-center">
|
|
325
|
+
<div className="text-center text-gray-500">
|
|
326
|
+
<MessageSquareText className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
|
327
|
+
<div className="text-sm font-medium">{t('chatNoSession')}</div>
|
|
328
|
+
<div className="text-xs mt-1">{t('chatNoSessionHint')}</div>
|
|
329
|
+
</div>
|
|
330
|
+
</div>
|
|
331
|
+
) : historyQuery.isLoading ? (
|
|
332
|
+
<div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
|
|
333
|
+
) : (
|
|
334
|
+
<>
|
|
335
|
+
{mergedMessages.length === 0 ? (
|
|
336
|
+
<div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
|
|
337
|
+
) : (
|
|
338
|
+
<ChatThread messages={mergedMessages} isSending={sendChatTurn.isPending} />
|
|
339
|
+
)}
|
|
340
|
+
</>
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
|
|
344
|
+
<div className="border-t border-gray-200 bg-white p-4">
|
|
345
|
+
<div className="rounded-xl border border-gray-200 bg-white p-2">
|
|
346
|
+
<textarea
|
|
347
|
+
value={draft}
|
|
348
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
349
|
+
onKeyDown={(event) => {
|
|
350
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
351
|
+
event.preventDefault();
|
|
352
|
+
void handleSend();
|
|
353
|
+
}
|
|
354
|
+
}}
|
|
355
|
+
placeholder={t('chatInputPlaceholder')}
|
|
356
|
+
className="w-full min-h-[68px] max-h-[220px] resize-y bg-transparent outline-none text-sm px-2 py-1.5 text-gray-800 placeholder:text-gray-400"
|
|
357
|
+
disabled={sendChatTurn.isPending}
|
|
358
|
+
/>
|
|
359
|
+
<div className="flex items-center justify-between px-2 pb-1">
|
|
360
|
+
<div className="text-[11px] text-gray-400">{t('chatInputHint')}</div>
|
|
361
|
+
<Button
|
|
362
|
+
size="sm"
|
|
363
|
+
className="rounded-lg"
|
|
364
|
+
onClick={() => void handleSend()}
|
|
365
|
+
disabled={sendChatTurn.isPending || draft.trim().length === 0}
|
|
366
|
+
>
|
|
367
|
+
<Send className="h-3.5 w-3.5 mr-1.5" />
|
|
368
|
+
{sendChatTurn.isPending ? t('chatSending') : t('chatSend')}
|
|
369
|
+
</Button>
|
|
370
|
+
</div>
|
|
371
|
+
</div>
|
|
372
|
+
</div>
|
|
373
|
+
</section>
|
|
374
|
+
</div>
|
|
375
|
+
<ConfirmDialog />
|
|
376
|
+
</PageLayout>
|
|
377
|
+
);
|
|
378
|
+
}
|