@nextclaw/ui 0.5.18 → 0.5.20
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 +15 -0
- package/dist/assets/ChannelsList-DqgRRdUH.js +1 -0
- package/dist/assets/ChatPage-BQyomkth.js +1 -0
- package/dist/assets/CronConfig-Bmg449JI.js +1 -0
- package/dist/assets/{DocBrowser-Bgv_z1RY.js → DocBrowser-C35MebbI.js} +1 -1
- package/dist/assets/MarketplacePage-HjEQ8sFt.js +1 -0
- package/dist/assets/{ModelConfig-BTAn_TG4.js → ModelConfig-BpBoi1sz.js} +1 -1
- package/dist/assets/ProvidersList-0tYTV40v.js +1 -0
- package/dist/assets/{RuntimeConfig-kKWoFQjf.js → RuntimeConfig-B_WI-DHf.js} +1 -1
- package/dist/assets/SessionsConfig-BEt-f6WS.js +2 -0
- package/dist/assets/{action-link-Ct1QpDX3.js → action-link-CSScZ_id.js} +1 -1
- package/dist/assets/{card-DnpDTyLa.js → card-Cj58-DCd.js} +1 -1
- package/dist/assets/dialog-Ce8jNftN.js +5 -0
- package/dist/assets/index-CPFSdkyQ.css +1 -0
- package/dist/assets/index-C_z1Na9N.js +2 -0
- package/dist/assets/{label-CBp5G8Jf.js → label-CQdP2NhF.js} +1 -1
- package/dist/assets/{page-layout-BgyUYe3t.js → page-layout-Byyxptub.js} +1 -1
- package/dist/assets/{switch-BSkwgbGc.js → switch-ChJzdp0x.js} +1 -1
- package/dist/assets/{tabs-custom-m7KnhudE.js → tabs-custom-DWlAbbCy.js} +1 -1
- package/dist/assets/useConfig-8lC_4LwH.js +1 -0
- package/dist/assets/{useConfirmDialog-CY7A5iAH.js → useConfirmDialog-B7iWHb5k.js} +1 -1
- package/dist/assets/{vendor-Bhv7yx8z.js → vendor-Dz2q6Qmc.js} +67 -57
- package/dist/index.html +3 -3
- package/package.json +1 -1
- package/src/App.tsx +4 -2
- package/src/api/config.ts +11 -0
- package/src/api/types.ts +22 -0
- package/src/components/chat/ChatPage.tsx +413 -0
- package/src/components/config/CronConfig.tsx +1 -1
- package/src/components/config/SessionsConfig.tsx +1 -1
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/components/marketplace/MarketplacePage.tsx +40 -11
- package/src/hooks/useConfig.ts +11 -0
- package/src/lib/i18n.ts +26 -0
- package/dist/assets/ChannelsList-BQpoGiEX.js +0 -1
- package/dist/assets/CronConfig-K744sEal.js +0 -1
- package/dist/assets/MarketplacePage-C4eeohin.js +0 -1
- package/dist/assets/ProvidersList-DE_n9LRR.js +0 -1
- package/dist/assets/SessionsConfig-Djq5Fzty.js +0 -2
- package/dist/assets/dialog-BaD4ZHIg.js +0 -5
- package/dist/assets/index-DdpR1fdj.css +0 -1
- package/dist/assets/index-qoh9Y37b.js +0 -2
- package/dist/assets/useConfig-BbiIjAg1.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-C_z1Na9N.js"></script>
|
|
10
|
+
<link rel="modulepreload" crossorigin href="/assets/vendor-Dz2q6Qmc.js">
|
|
11
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CPFSdkyQ.css">
|
|
12
12
|
</head>
|
|
13
13
|
|
|
14
14
|
<body>
|
package/package.json
CHANGED
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
|
@@ -92,6 +92,26 @@ export type SessionPatchUpdate = {
|
|
|
92
92
|
clearHistory?: boolean;
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
+
export type ChatTurnRequest = {
|
|
96
|
+
message: string;
|
|
97
|
+
sessionKey?: string;
|
|
98
|
+
agentId?: string;
|
|
99
|
+
channel?: string;
|
|
100
|
+
chatId?: string;
|
|
101
|
+
model?: string;
|
|
102
|
+
metadata?: Record<string, unknown>;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
export type ChatTurnView = {
|
|
106
|
+
reply: string;
|
|
107
|
+
sessionKey: string;
|
|
108
|
+
agentId?: string;
|
|
109
|
+
model?: string;
|
|
110
|
+
requestedAt: string;
|
|
111
|
+
completedAt: string;
|
|
112
|
+
durationMs: number;
|
|
113
|
+
};
|
|
114
|
+
|
|
95
115
|
export type CronScheduleView =
|
|
96
116
|
| { kind: "at"; atMs?: number | null }
|
|
97
117
|
| { kind: "every"; everyMs?: number | null }
|
|
@@ -366,6 +386,8 @@ export type MarketplaceInstallRequest = {
|
|
|
366
386
|
type: MarketplaceItemType;
|
|
367
387
|
spec: string;
|
|
368
388
|
kind?: MarketplaceInstallKind;
|
|
389
|
+
skill?: string;
|
|
390
|
+
installPath?: string;
|
|
369
391
|
version?: string;
|
|
370
392
|
registry?: string;
|
|
371
393
|
force?: boolean;
|
|
@@ -0,0 +1,413 @@
|
|
|
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 { cn } from '@/lib/utils';
|
|
10
|
+
import { formatDateTime, t } from '@/lib/i18n';
|
|
11
|
+
import { Bot, MessageSquareText, Plus, RefreshCw, Search, Send, Trash2, User } from 'lucide-react';
|
|
12
|
+
|
|
13
|
+
const CHAT_SESSION_STORAGE_KEY = 'nextclaw.ui.chat.activeSession';
|
|
14
|
+
|
|
15
|
+
function readStoredSessionKey(): string | null {
|
|
16
|
+
if (typeof window === 'undefined') {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const value = window.localStorage.getItem(CHAT_SESSION_STORAGE_KEY);
|
|
21
|
+
return value && value.trim().length > 0 ? value : null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function writeStoredSessionKey(value: string | null): void {
|
|
28
|
+
if (typeof window === 'undefined') {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
if (!value) {
|
|
33
|
+
window.localStorage.removeItem(CHAT_SESSION_STORAGE_KEY);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
window.localStorage.setItem(CHAT_SESSION_STORAGE_KEY, value);
|
|
37
|
+
} catch {
|
|
38
|
+
// ignore storage errors
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveAgentIdFromSessionKey(sessionKey: string): string | null {
|
|
43
|
+
const match = /^agent:([^:]+):/i.exec(sessionKey.trim());
|
|
44
|
+
if (!match) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
const value = match[1]?.trim();
|
|
48
|
+
return value ? value : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildNewSessionKey(agentId: string): string {
|
|
52
|
+
const slug = Math.random().toString(36).slice(2, 8);
|
|
53
|
+
return `agent:${agentId}:ui:direct:web-${Date.now().toString(36)}${slug}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function sessionDisplayName(session: SessionEntryView): string {
|
|
57
|
+
if (session.label && session.label.trim()) {
|
|
58
|
+
return session.label.trim();
|
|
59
|
+
}
|
|
60
|
+
const chunks = session.key.split(':');
|
|
61
|
+
return chunks[chunks.length - 1] || session.key;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function MessageBubble({ message }: { message: SessionMessageView }) {
|
|
65
|
+
const role = message.role.toLowerCase();
|
|
66
|
+
const isUser = role === 'user';
|
|
67
|
+
return (
|
|
68
|
+
<div className={cn('flex w-full', isUser ? 'justify-end' : 'justify-start')}>
|
|
69
|
+
<div
|
|
70
|
+
className={cn(
|
|
71
|
+
'max-w-[88%] rounded-2xl px-4 py-3 shadow-sm border',
|
|
72
|
+
isUser
|
|
73
|
+
? 'bg-primary text-white border-primary rounded-br-md'
|
|
74
|
+
: 'bg-white text-gray-800 border-gray-200 rounded-bl-md'
|
|
75
|
+
)}
|
|
76
|
+
>
|
|
77
|
+
<div className="mb-1 flex items-center gap-2 text-[11px] opacity-80">
|
|
78
|
+
{isUser ? <User className="h-3.5 w-3.5" /> : <Bot className="h-3.5 w-3.5" />}
|
|
79
|
+
<span className="font-semibold">{message.role}</span>
|
|
80
|
+
<span>{formatDateTime(message.timestamp)}</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div className="whitespace-pre-wrap break-words text-sm leading-relaxed">{message.content}</div>
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function ChatPage() {
|
|
89
|
+
const [query, setQuery] = useState('');
|
|
90
|
+
const [draft, setDraft] = useState('');
|
|
91
|
+
const [selectedSessionKey, setSelectedSessionKey] = useState<string | null>(() => readStoredSessionKey());
|
|
92
|
+
const [selectedAgentId, setSelectedAgentId] = useState('main');
|
|
93
|
+
const [optimisticUserMessage, setOptimisticUserMessage] = useState<SessionMessageView | null>(null);
|
|
94
|
+
|
|
95
|
+
const { confirm, ConfirmDialog } = useConfirmDialog();
|
|
96
|
+
const threadRef = useRef<HTMLDivElement | null>(null);
|
|
97
|
+
|
|
98
|
+
const configQuery = useConfig();
|
|
99
|
+
const sessionsQuery = useSessions({ q: query.trim() || undefined, limit: 120, activeMinutes: 0 });
|
|
100
|
+
const historyQuery = useSessionHistory(selectedSessionKey, 300);
|
|
101
|
+
const deleteSession = useDeleteSession();
|
|
102
|
+
const sendChatTurn = useSendChatTurn();
|
|
103
|
+
|
|
104
|
+
const agentOptions = useMemo(() => {
|
|
105
|
+
const list = configQuery.data?.agents.list ?? [];
|
|
106
|
+
const unique = new Set<string>(['main']);
|
|
107
|
+
for (const item of list) {
|
|
108
|
+
if (typeof item.id === 'string' && item.id.trim().length > 0) {
|
|
109
|
+
unique.add(item.id.trim().toLowerCase());
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return Array.from(unique);
|
|
113
|
+
}, [configQuery.data?.agents.list]);
|
|
114
|
+
|
|
115
|
+
const sessions = useMemo(() => sessionsQuery.data?.sessions ?? [], [sessionsQuery.data?.sessions]);
|
|
116
|
+
const selectedSession = useMemo(
|
|
117
|
+
() => sessions.find((session) => session.key === selectedSessionKey) ?? null,
|
|
118
|
+
[selectedSessionKey, sessions]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
const historyMessages = useMemo(() => historyQuery.data?.messages ?? [], [historyQuery.data?.messages]);
|
|
122
|
+
const mergedMessages = useMemo(() => {
|
|
123
|
+
if (!optimisticUserMessage) {
|
|
124
|
+
return historyMessages;
|
|
125
|
+
}
|
|
126
|
+
return [...historyMessages, optimisticUserMessage];
|
|
127
|
+
}, [historyMessages, optimisticUserMessage]);
|
|
128
|
+
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!selectedSessionKey && sessions.length > 0) {
|
|
131
|
+
setSelectedSessionKey(sessions[0].key);
|
|
132
|
+
}
|
|
133
|
+
}, [selectedSessionKey, sessions]);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
writeStoredSessionKey(selectedSessionKey);
|
|
137
|
+
}, [selectedSessionKey]);
|
|
138
|
+
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const inferred = selectedSessionKey ? resolveAgentIdFromSessionKey(selectedSessionKey) : null;
|
|
141
|
+
if (!inferred) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (selectedAgentId !== inferred) {
|
|
145
|
+
setSelectedAgentId(inferred);
|
|
146
|
+
}
|
|
147
|
+
}, [selectedAgentId, selectedSessionKey]);
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
const element = threadRef.current;
|
|
151
|
+
if (!element) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
element.scrollTop = element.scrollHeight;
|
|
155
|
+
}, [mergedMessages.length, sendChatTurn.isPending, selectedSessionKey]);
|
|
156
|
+
|
|
157
|
+
const createNewSession = () => {
|
|
158
|
+
const next = buildNewSessionKey(selectedAgentId);
|
|
159
|
+
setSelectedSessionKey(next);
|
|
160
|
+
setOptimisticUserMessage(null);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const handleDeleteSession = async () => {
|
|
164
|
+
if (!selectedSessionKey) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const confirmed = await confirm({
|
|
168
|
+
title: t('chatDeleteSessionConfirm'),
|
|
169
|
+
variant: 'destructive',
|
|
170
|
+
confirmLabel: t('delete')
|
|
171
|
+
});
|
|
172
|
+
if (!confirmed) {
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
deleteSession.mutate(
|
|
176
|
+
{ key: selectedSessionKey },
|
|
177
|
+
{
|
|
178
|
+
onSuccess: async () => {
|
|
179
|
+
setSelectedSessionKey(null);
|
|
180
|
+
setOptimisticUserMessage(null);
|
|
181
|
+
await sessionsQuery.refetch();
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
);
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const handleSend = async () => {
|
|
188
|
+
const message = draft.trim();
|
|
189
|
+
if (!message || sendChatTurn.isPending) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const hadActiveSession = Boolean(selectedSessionKey);
|
|
194
|
+
const sessionKey = selectedSessionKey ?? buildNewSessionKey(selectedAgentId);
|
|
195
|
+
if (!selectedSessionKey) {
|
|
196
|
+
setSelectedSessionKey(sessionKey);
|
|
197
|
+
}
|
|
198
|
+
setDraft('');
|
|
199
|
+
setOptimisticUserMessage({
|
|
200
|
+
role: 'user',
|
|
201
|
+
content: message,
|
|
202
|
+
timestamp: new Date().toISOString()
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const result = await sendChatTurn.mutateAsync({
|
|
207
|
+
data: {
|
|
208
|
+
message,
|
|
209
|
+
sessionKey,
|
|
210
|
+
agentId: selectedAgentId,
|
|
211
|
+
channel: 'ui',
|
|
212
|
+
chatId: 'web-ui'
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
setOptimisticUserMessage(null);
|
|
216
|
+
if (result.sessionKey !== sessionKey) {
|
|
217
|
+
setSelectedSessionKey(result.sessionKey);
|
|
218
|
+
}
|
|
219
|
+
await sessionsQuery.refetch();
|
|
220
|
+
if (hadActiveSession) {
|
|
221
|
+
await historyQuery.refetch();
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
setOptimisticUserMessage(null);
|
|
225
|
+
setDraft(message);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<PageLayout fullHeight>
|
|
231
|
+
<PageHeader
|
|
232
|
+
title={t('chatPageTitle')}
|
|
233
|
+
description={t('chatPageDescription')}
|
|
234
|
+
actions={
|
|
235
|
+
<div className="flex items-center gap-2">
|
|
236
|
+
<Button variant="outline" size="sm" onClick={() => historyQuery.refetch()} className="rounded-lg">
|
|
237
|
+
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', historyQuery.isFetching && 'animate-spin')} />
|
|
238
|
+
{t('chatRefresh')}
|
|
239
|
+
</Button>
|
|
240
|
+
<Button variant="primary" size="sm" onClick={createNewSession} className="rounded-lg">
|
|
241
|
+
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
242
|
+
{t('chatNewSession')}
|
|
243
|
+
</Button>
|
|
244
|
+
</div>
|
|
245
|
+
}
|
|
246
|
+
/>
|
|
247
|
+
|
|
248
|
+
<div className="flex-1 min-h-0 flex gap-4 max-lg:flex-col">
|
|
249
|
+
<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">
|
|
250
|
+
<div className="p-4 border-b border-gray-100 space-y-3">
|
|
251
|
+
<div className="relative">
|
|
252
|
+
<Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
|
|
253
|
+
<Input
|
|
254
|
+
value={query}
|
|
255
|
+
onChange={(event) => setQuery(event.target.value)}
|
|
256
|
+
placeholder={t('chatSearchSessionPlaceholder')}
|
|
257
|
+
className="pl-8 h-9 rounded-lg text-xs"
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
<div className="grid grid-cols-2 gap-2">
|
|
261
|
+
<Button variant="outline" size="sm" className="rounded-lg" onClick={() => sessionsQuery.refetch()}>
|
|
262
|
+
<RefreshCw className={cn('h-3.5 w-3.5 mr-1.5', sessionsQuery.isFetching && 'animate-spin')} />
|
|
263
|
+
{t('chatRefresh')}
|
|
264
|
+
</Button>
|
|
265
|
+
<Button variant="subtle" size="sm" className="rounded-lg" onClick={createNewSession}>
|
|
266
|
+
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
267
|
+
{t('chatNewSession')}
|
|
268
|
+
</Button>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar p-2">
|
|
273
|
+
{sessionsQuery.isLoading ? (
|
|
274
|
+
<div className="text-sm text-gray-500 p-4">{t('sessionsLoading')}</div>
|
|
275
|
+
) : sessions.length === 0 ? (
|
|
276
|
+
<div className="p-5 m-2 rounded-xl border border-dashed border-gray-200 text-center text-sm text-gray-500">
|
|
277
|
+
<MessageSquareText className="h-7 w-7 mx-auto mb-2 text-gray-300" />
|
|
278
|
+
{t('sessionsEmpty')}
|
|
279
|
+
</div>
|
|
280
|
+
) : (
|
|
281
|
+
<div className="space-y-1">
|
|
282
|
+
{sessions.map((session) => {
|
|
283
|
+
const active = selectedSessionKey === session.key;
|
|
284
|
+
return (
|
|
285
|
+
<button
|
|
286
|
+
key={session.key}
|
|
287
|
+
onClick={() => setSelectedSessionKey(session.key)}
|
|
288
|
+
className={cn(
|
|
289
|
+
'w-full rounded-xl border px-3 py-2.5 text-left transition-all',
|
|
290
|
+
active
|
|
291
|
+
? 'border-primary/30 bg-primary/5'
|
|
292
|
+
: 'border-transparent hover:border-gray-200 hover:bg-gray-50'
|
|
293
|
+
)}
|
|
294
|
+
>
|
|
295
|
+
<div className="text-sm font-semibold text-gray-900 truncate">{sessionDisplayName(session)}</div>
|
|
296
|
+
<div className="mt-1 text-[11px] text-gray-500 truncate">{session.key}</div>
|
|
297
|
+
<div className="mt-1 text-[11px] text-gray-400">
|
|
298
|
+
{session.messageCount} · {formatDateTime(session.updatedAt)}
|
|
299
|
+
</div>
|
|
300
|
+
</button>
|
|
301
|
+
);
|
|
302
|
+
})}
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
</div>
|
|
306
|
+
</aside>
|
|
307
|
+
|
|
308
|
+
<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">
|
|
309
|
+
<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">
|
|
310
|
+
<div className="min-w-[220px] max-w-[320px]">
|
|
311
|
+
<div className="text-[11px] text-gray-500 mb-1">{t('chatAgentLabel')}</div>
|
|
312
|
+
<Select value={selectedAgentId} onValueChange={setSelectedAgentId}>
|
|
313
|
+
<SelectTrigger className="h-9 rounded-lg">
|
|
314
|
+
<SelectValue placeholder={t('chatSelectAgent')} />
|
|
315
|
+
</SelectTrigger>
|
|
316
|
+
<SelectContent>
|
|
317
|
+
{agentOptions.map((agent) => (
|
|
318
|
+
<SelectItem key={agent} value={agent}>
|
|
319
|
+
{agent}
|
|
320
|
+
</SelectItem>
|
|
321
|
+
))}
|
|
322
|
+
</SelectContent>
|
|
323
|
+
</Select>
|
|
324
|
+
</div>
|
|
325
|
+
|
|
326
|
+
<div className="flex-1 min-w-[260px]">
|
|
327
|
+
<div className="text-[11px] text-gray-500 mb-1">{t('chatSessionLabel')}</div>
|
|
328
|
+
<div className="h-9 rounded-lg border border-gray-200 bg-white px-3 text-xs text-gray-600 flex items-center truncate">
|
|
329
|
+
{selectedSessionKey ?? t('chatNoSession')}
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
|
|
333
|
+
<Button
|
|
334
|
+
variant="outline"
|
|
335
|
+
size="sm"
|
|
336
|
+
className="rounded-lg self-end"
|
|
337
|
+
onClick={handleDeleteSession}
|
|
338
|
+
disabled={!selectedSession || deleteSession.isPending}
|
|
339
|
+
>
|
|
340
|
+
<Trash2 className="h-3.5 w-3.5 mr-1.5" />
|
|
341
|
+
{t('chatDeleteSession')}
|
|
342
|
+
</Button>
|
|
343
|
+
</div>
|
|
344
|
+
|
|
345
|
+
<div ref={threadRef} className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-5 py-5 space-y-3">
|
|
346
|
+
{!selectedSessionKey ? (
|
|
347
|
+
<div className="h-full flex items-center justify-center">
|
|
348
|
+
<div className="text-center text-gray-500">
|
|
349
|
+
<MessageSquareText className="h-8 w-8 mx-auto mb-2 text-gray-300" />
|
|
350
|
+
<div className="text-sm font-medium">{t('chatNoSession')}</div>
|
|
351
|
+
<div className="text-xs mt-1">{t('chatNoSessionHint')}</div>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
) : historyQuery.isLoading ? (
|
|
355
|
+
<div className="text-sm text-gray-500">{t('chatHistoryLoading')}</div>
|
|
356
|
+
) : (
|
|
357
|
+
<>
|
|
358
|
+
{mergedMessages.length === 0 ? (
|
|
359
|
+
<div className="text-sm text-gray-500">{t('chatNoMessages')}</div>
|
|
360
|
+
) : (
|
|
361
|
+
mergedMessages.map((message, index) => (
|
|
362
|
+
<MessageBubble
|
|
363
|
+
key={`${message.timestamp}-${message.role}-${index}`}
|
|
364
|
+
message={message}
|
|
365
|
+
/>
|
|
366
|
+
))
|
|
367
|
+
)}
|
|
368
|
+
{sendChatTurn.isPending && (
|
|
369
|
+
<div className="flex justify-start">
|
|
370
|
+
<div className="rounded-2xl rounded-bl-md border border-gray-200 bg-white px-4 py-3 text-sm text-gray-500">
|
|
371
|
+
{t('chatTyping')}
|
|
372
|
+
</div>
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
</>
|
|
376
|
+
)}
|
|
377
|
+
</div>
|
|
378
|
+
|
|
379
|
+
<div className="border-t border-gray-200 bg-white p-4">
|
|
380
|
+
<div className="rounded-xl border border-gray-200 bg-white p-2">
|
|
381
|
+
<textarea
|
|
382
|
+
value={draft}
|
|
383
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
384
|
+
onKeyDown={(event) => {
|
|
385
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
386
|
+
event.preventDefault();
|
|
387
|
+
void handleSend();
|
|
388
|
+
}
|
|
389
|
+
}}
|
|
390
|
+
placeholder={t('chatInputPlaceholder')}
|
|
391
|
+
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"
|
|
392
|
+
disabled={sendChatTurn.isPending}
|
|
393
|
+
/>
|
|
394
|
+
<div className="flex items-center justify-between px-2 pb-1">
|
|
395
|
+
<div className="text-[11px] text-gray-400">{t('chatInputHint')}</div>
|
|
396
|
+
<Button
|
|
397
|
+
size="sm"
|
|
398
|
+
className="rounded-lg"
|
|
399
|
+
onClick={() => void handleSend()}
|
|
400
|
+
disabled={sendChatTurn.isPending || draft.trim().length === 0}
|
|
401
|
+
>
|
|
402
|
+
<Send className="h-3.5 w-3.5 mr-1.5" />
|
|
403
|
+
{sendChatTurn.isPending ? t('chatSending') : t('chatSend')}
|
|
404
|
+
</Button>
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
</section>
|
|
409
|
+
</div>
|
|
410
|
+
<ConfirmDialog />
|
|
411
|
+
</PageLayout>
|
|
412
|
+
);
|
|
413
|
+
}
|
|
@@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|
|
8
8
|
import { Card, CardContent } from '@/components/ui/card';
|
|
9
9
|
import { cn } from '@/lib/utils';
|
|
10
10
|
import { formatDateTime, t } from '@/lib/i18n';
|
|
11
|
-
import { PageLayout, PageHeader
|
|
11
|
+
import { PageLayout, PageHeader } from '@/components/layout/page-layout';
|
|
12
12
|
import { AlarmClock, RefreshCw, Trash2, Play, Power } from 'lucide-react';
|
|
13
13
|
|
|
14
14
|
type StatusFilter = 'all' | 'enabled' | 'disabled';
|
|
@@ -7,7 +7,7 @@ import { Input } from '@/components/ui/input';
|
|
|
7
7
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
8
8
|
import { cn } from '@/lib/utils';
|
|
9
9
|
import { formatDateShort, formatDateTime, t } from '@/lib/i18n';
|
|
10
|
-
import { PageLayout, PageHeader
|
|
10
|
+
import { PageLayout, PageHeader } from '@/components/layout/page-layout';
|
|
11
11
|
import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle, Settings as SettingsIcon } from 'lucide-react';
|
|
12
12
|
|
|
13
13
|
const UNKNOWN_CHANNEL_KEY = '__unknown_channel__';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cn } from '@/lib/utils';
|
|
2
2
|
import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
|
|
3
3
|
import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
|
|
4
|
-
import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette } from 'lucide-react';
|
|
4
|
+
import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette } from 'lucide-react';
|
|
5
5
|
import { NavLink } from 'react-router-dom';
|
|
6
6
|
import { useDocBrowser } from '@/components/doc-browser';
|
|
7
7
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
@@ -31,6 +31,11 @@ export function Sidebar() {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
const navItems = [
|
|
34
|
+
{
|
|
35
|
+
target: '/chat',
|
|
36
|
+
label: t('chat'),
|
|
37
|
+
icon: MessageCircle,
|
|
38
|
+
},
|
|
34
39
|
{
|
|
35
40
|
target: '/model',
|
|
36
41
|
label: t('model'),
|
|
@@ -28,8 +28,7 @@ const PAGE_SIZE = 12;
|
|
|
28
28
|
type ScopeType = 'all' | 'installed';
|
|
29
29
|
|
|
30
30
|
type InstallState = {
|
|
31
|
-
|
|
32
|
-
installingSpec?: string;
|
|
31
|
+
installingSpecs: ReadonlySet<string>;
|
|
33
32
|
};
|
|
34
33
|
|
|
35
34
|
type ManageState = {
|
|
@@ -234,7 +233,8 @@ function MarketplaceListCard(props: {
|
|
|
234
233
|
const canUninstall = Boolean(canUninstallPlugin || canUninstallSkill);
|
|
235
234
|
|
|
236
235
|
const isDisabled = record ? (record.enabled === false || record.runtimeStatus === 'disabled') : false;
|
|
237
|
-
const
|
|
236
|
+
const installSpec = props.item?.install.spec;
|
|
237
|
+
const isInstalling = typeof installSpec === 'string' && props.installState.installingSpecs.has(installSpec);
|
|
238
238
|
|
|
239
239
|
const displayType = type === 'plugin' ? t('marketplaceTypePlugin') : type === 'skill' ? t('marketplaceTypeSkill') : t('marketplaceTypeExtension');
|
|
240
240
|
|
|
@@ -288,7 +288,7 @@ function MarketplaceListCard(props: {
|
|
|
288
288
|
{props.item && !record && (
|
|
289
289
|
<button
|
|
290
290
|
onClick={() => props.onInstall(props.item as MarketplaceItemSummary)}
|
|
291
|
-
disabled={
|
|
291
|
+
disabled={isInstalling}
|
|
292
292
|
className="inline-flex items-center gap-1.5 h-8 px-4 rounded-xl text-xs font-medium bg-primary text-white hover:bg-primary-600 disabled:opacity-50 transition-colors"
|
|
293
293
|
>
|
|
294
294
|
{isInstalling ? t('marketplaceInstalling') : t('marketplaceInstall')}
|
|
@@ -405,6 +405,7 @@ export function MarketplacePage() {
|
|
|
405
405
|
const [scope, setScope] = useState<ScopeType>('all');
|
|
406
406
|
const [sort, setSort] = useState<MarketplaceSort>('relevance');
|
|
407
407
|
const [page, setPage] = useState(1);
|
|
408
|
+
const [installingSpecs, setInstallingSpecs] = useState<ReadonlySet<string>>(new Set());
|
|
408
409
|
|
|
409
410
|
useEffect(() => {
|
|
410
411
|
const timer = setTimeout(() => {
|
|
@@ -496,10 +497,7 @@ export function MarketplacePage() {
|
|
|
496
497
|
return `${allItems.length} / ${total}`;
|
|
497
498
|
}, [scope, installedQuery.isLoading, installedEntries.length, itemsQuery.data, allItems.length, total, copyKeys.installedCountSuffix]);
|
|
498
499
|
|
|
499
|
-
const installState: InstallState = {
|
|
500
|
-
isPending: installMutation.isPending,
|
|
501
|
-
installingSpec: installMutation.variables?.spec
|
|
502
|
-
};
|
|
500
|
+
const installState: InstallState = { installingSpecs };
|
|
503
501
|
|
|
504
502
|
const manageState: ManageState = {
|
|
505
503
|
isPending: manageMutation.isPending,
|
|
@@ -511,11 +509,42 @@ export function MarketplacePage() {
|
|
|
511
509
|
{ id: 'all', label: t(copyKeys.tabMarketplace) },
|
|
512
510
|
{ id: 'installed', label: t(copyKeys.tabInstalled), count: installedQuery.data?.total ?? 0 }
|
|
513
511
|
];
|
|
514
|
-
const handleInstall = (item: MarketplaceItemSummary) => {
|
|
515
|
-
|
|
512
|
+
const handleInstall = async (item: MarketplaceItemSummary) => {
|
|
513
|
+
const installSpec = item.install.spec;
|
|
514
|
+
if (installingSpecs.has(installSpec)) {
|
|
516
515
|
return;
|
|
517
516
|
}
|
|
518
|
-
|
|
517
|
+
|
|
518
|
+
setInstallingSpecs((prev) => {
|
|
519
|
+
const next = new Set(prev);
|
|
520
|
+
next.add(installSpec);
|
|
521
|
+
return next;
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
await installMutation.mutateAsync({
|
|
526
|
+
type: item.type,
|
|
527
|
+
spec: installSpec,
|
|
528
|
+
kind: item.install.kind,
|
|
529
|
+
...(item.type === 'skill'
|
|
530
|
+
? {
|
|
531
|
+
skill: item.slug,
|
|
532
|
+
installPath: `skills/${item.slug}`
|
|
533
|
+
}
|
|
534
|
+
: {})
|
|
535
|
+
});
|
|
536
|
+
} catch {
|
|
537
|
+
// handled in mutation onError
|
|
538
|
+
} finally {
|
|
539
|
+
setInstallingSpecs((prev) => {
|
|
540
|
+
if (!prev.has(installSpec)) {
|
|
541
|
+
return prev;
|
|
542
|
+
}
|
|
543
|
+
const next = new Set(prev);
|
|
544
|
+
next.delete(installSpec);
|
|
545
|
+
return next;
|
|
546
|
+
});
|
|
547
|
+
}
|
|
519
548
|
};
|
|
520
549
|
|
|
521
550
|
const handleManage = async (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
|