@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.
Files changed (43) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/assets/ChannelsList-DqgRRdUH.js +1 -0
  3. package/dist/assets/ChatPage-BQyomkth.js +1 -0
  4. package/dist/assets/CronConfig-Bmg449JI.js +1 -0
  5. package/dist/assets/{DocBrowser-Bgv_z1RY.js → DocBrowser-C35MebbI.js} +1 -1
  6. package/dist/assets/MarketplacePage-HjEQ8sFt.js +1 -0
  7. package/dist/assets/{ModelConfig-BTAn_TG4.js → ModelConfig-BpBoi1sz.js} +1 -1
  8. package/dist/assets/ProvidersList-0tYTV40v.js +1 -0
  9. package/dist/assets/{RuntimeConfig-kKWoFQjf.js → RuntimeConfig-B_WI-DHf.js} +1 -1
  10. package/dist/assets/SessionsConfig-BEt-f6WS.js +2 -0
  11. package/dist/assets/{action-link-Ct1QpDX3.js → action-link-CSScZ_id.js} +1 -1
  12. package/dist/assets/{card-DnpDTyLa.js → card-Cj58-DCd.js} +1 -1
  13. package/dist/assets/dialog-Ce8jNftN.js +5 -0
  14. package/dist/assets/index-CPFSdkyQ.css +1 -0
  15. package/dist/assets/index-C_z1Na9N.js +2 -0
  16. package/dist/assets/{label-CBp5G8Jf.js → label-CQdP2NhF.js} +1 -1
  17. package/dist/assets/{page-layout-BgyUYe3t.js → page-layout-Byyxptub.js} +1 -1
  18. package/dist/assets/{switch-BSkwgbGc.js → switch-ChJzdp0x.js} +1 -1
  19. package/dist/assets/{tabs-custom-m7KnhudE.js → tabs-custom-DWlAbbCy.js} +1 -1
  20. package/dist/assets/useConfig-8lC_4LwH.js +1 -0
  21. package/dist/assets/{useConfirmDialog-CY7A5iAH.js → useConfirmDialog-B7iWHb5k.js} +1 -1
  22. package/dist/assets/{vendor-Bhv7yx8z.js → vendor-Dz2q6Qmc.js} +67 -57
  23. package/dist/index.html +3 -3
  24. package/package.json +1 -1
  25. package/src/App.tsx +4 -2
  26. package/src/api/config.ts +11 -0
  27. package/src/api/types.ts +22 -0
  28. package/src/components/chat/ChatPage.tsx +413 -0
  29. package/src/components/config/CronConfig.tsx +1 -1
  30. package/src/components/config/SessionsConfig.tsx +1 -1
  31. package/src/components/layout/Sidebar.tsx +6 -1
  32. package/src/components/marketplace/MarketplacePage.tsx +40 -11
  33. package/src/hooks/useConfig.ts +11 -0
  34. package/src/lib/i18n.ts +26 -0
  35. package/dist/assets/ChannelsList-BQpoGiEX.js +0 -1
  36. package/dist/assets/CronConfig-K744sEal.js +0 -1
  37. package/dist/assets/MarketplacePage-C4eeohin.js +0 -1
  38. package/dist/assets/ProvidersList-DE_n9LRR.js +0 -1
  39. package/dist/assets/SessionsConfig-Djq5Fzty.js +0 -2
  40. package/dist/assets/dialog-BaD4ZHIg.js +0 -5
  41. package/dist/assets/index-DdpR1fdj.css +0 -1
  42. package/dist/assets/index-qoh9Y37b.js +0 -2
  43. 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-qoh9Y37b.js"></script>
10
- <link rel="modulepreload" crossorigin href="/assets/vendor-Bhv7yx8z.js">
11
- <link rel="stylesheet" crossorigin href="/assets/index-DdpR1fdj.css">
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextclaw/ui",
3
- "version": "0.5.18",
3
+ "version": "0.5.20",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",
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="/model" replace />} />
51
- <Route path="*" element={<Navigate to="/model" replace />} />
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, PageBody } from '@/components/layout/page-layout';
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, PageBody } from '@/components/layout/page-layout';
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
- isPending: boolean;
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 isInstalling = props.installState.isPending && props.item && props.installState.installingSpec === props.item.install.spec;
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={props.installState.isPending}
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
- if (installMutation.isPending) {
512
+ const handleInstall = async (item: MarketplaceItemSummary) => {
513
+ const installSpec = item.install.spec;
514
+ if (installingSpecs.has(installSpec)) {
516
515
  return;
517
516
  }
518
- installMutation.mutate({ type: item.type, spec: item.install.spec, kind: item.install.kind });
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) => {