@nextclaw/ui 0.6.14 → 0.7.0

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 (94) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +2 -0
  3. package/dist/assets/ChannelsList-DF2U-LY1.js +1 -0
  4. package/dist/assets/ChatPage-BX39y0U5.js +36 -0
  5. package/dist/assets/DocBrowser-B9ws5JL7.js +1 -0
  6. package/dist/assets/{LogoBadge-BxZJ9BJT.js → LogoBadge-DvGAzkZ3.js} +1 -1
  7. package/dist/assets/MarketplacePage-DG5mHWJ8.js +49 -0
  8. package/dist/assets/ModelConfig-BL_HsOsm.js +1 -0
  9. package/dist/assets/ProvidersList-CH5z00YT.js +1 -0
  10. package/dist/assets/RuntimeConfig-BplBgkwo.js +1 -0
  11. package/dist/assets/SearchConfig-BhaI0fUf.js +1 -0
  12. package/dist/assets/{SecretsConfig-9OABNssV.js → SecretsConfig-CFoimOh9.js} +2 -2
  13. package/dist/assets/SessionsConfig-BHTAYn9T.js +2 -0
  14. package/dist/assets/index-BLeJkJ0o.css +1 -0
  15. package/dist/assets/index-DK4TS5ev.js +8 -0
  16. package/dist/assets/index-X5J6Mm--.js +1 -0
  17. package/dist/assets/{index-CkqvHQAt.js → index-uMsNsQX6.js} +1 -1
  18. package/dist/assets/{label-BIjHWZUm.js → label-D8ly4a2P.js} +1 -1
  19. package/dist/assets/page-layout-BSYfvwbp.js +1 -0
  20. package/dist/assets/security-config-DlKEYHNN.js +1 -0
  21. package/dist/assets/{session-run-status-BZEH0QZp.js → session-run-status-TkIuGbVw.js} +1 -1
  22. package/dist/assets/skeleton-CWbsNx2h.js +1 -0
  23. package/dist/assets/{switch-CnGQpdTp.js → switch-Ce_g9lpN.js} +1 -1
  24. package/dist/assets/tabs-custom-Cf5azvT5.js +1 -0
  25. package/dist/assets/useConfirmDialog-A8Ek8Wu7.js +5 -0
  26. package/dist/assets/vendor-B7ozqnFC.js +412 -0
  27. package/dist/index.html +3 -3
  28. package/package.json +9 -10
  29. package/src/App.tsx +49 -27
  30. package/src/api/client.ts +1 -0
  31. package/src/api/config.ts +60 -0
  32. package/src/api/types.ts +29 -1
  33. package/src/api/websocket.ts +2 -0
  34. package/src/components/auth/login-page.tsx +69 -0
  35. package/src/components/chat/ChatConversationPanel.tsx +12 -54
  36. package/src/components/chat/ChatSidebar.tsx +7 -1
  37. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +80 -0
  38. package/src/components/chat/adapters/chat-input-bar.adapter.ts +329 -0
  39. package/src/components/chat/adapters/chat-message.adapter.test.ts +137 -0
  40. package/src/components/chat/adapters/chat-message.adapter.ts +200 -0
  41. package/src/components/chat/chat-input/chat-input-bar.controller.test.tsx +128 -0
  42. package/src/components/chat/chat-input/chat-input-bar.controller.ts +105 -0
  43. package/src/components/chat/containers/chat-input-bar.container.tsx +270 -0
  44. package/src/components/chat/containers/chat-message-list.container.tsx +67 -0
  45. package/src/components/chat/index.ts +1 -0
  46. package/src/components/chat/managers/chat-thread.manager.ts +3 -1
  47. package/src/components/chat/nextclaw/index.ts +23 -0
  48. package/src/components/common/BrandHeader.tsx +4 -1
  49. package/src/components/common/StatusBadge.tsx +32 -20
  50. package/src/components/config/runtime-security-card.tsx +276 -0
  51. package/src/components/config/security-config.tsx +12 -0
  52. package/src/components/layout/Sidebar.tsx +6 -1
  53. package/src/components/marketplace/MarketplacePage.test.tsx +170 -0
  54. package/src/components/marketplace/MarketplacePage.tsx +77 -28
  55. package/src/hooks/use-auth.ts +111 -0
  56. package/src/hooks/useMarketplace.ts +9 -0
  57. package/src/hooks/useWebSocket.ts +53 -1
  58. package/src/lib/i18n.ts +72 -0
  59. package/src/test/setup.ts +16 -0
  60. package/tsconfig.json +3 -2
  61. package/vite.config.ts +2 -1
  62. package/vitest.config.ts +16 -0
  63. package/.eslintrc.cjs +0 -48
  64. package/dist/assets/ChannelsList-DiSnpiW0.js +0 -1
  65. package/dist/assets/ChatPage-DsaIrNHN.js +0 -36
  66. package/dist/assets/DocBrowser-CnfcptGM.js +0 -1
  67. package/dist/assets/MarketplacePage-BI_J_DBQ.js +0 -49
  68. package/dist/assets/ModelConfig-DfL8F4tN.js +0 -1
  69. package/dist/assets/ProvidersList-DpT_oFHZ.js +0 -1
  70. package/dist/assets/RuntimeConfig-BNYR_Iag.js +0 -1
  71. package/dist/assets/SearchConfig-TDBl7Fjh.js +0 -1
  72. package/dist/assets/SessionsConfig-BRwntUDz.js +0 -2
  73. package/dist/assets/card-BYnT3Mxo.js +0 -1
  74. package/dist/assets/index-BCfS4UY1.css +0 -1
  75. package/dist/assets/index-BnUxgevr.js +0 -8
  76. package/dist/assets/input-oaepEtqu.js +0 -1
  77. package/dist/assets/page-layout-B6JXiSQB.js +0 -1
  78. package/dist/assets/popover-LJQgv5l1.js +0 -1
  79. package/dist/assets/tabs-custom-CpSv7pDl.js +0 -1
  80. package/dist/assets/useConfirmDialog-pqAlPdQZ.js +0 -5
  81. package/dist/assets/vendor-BKtTvQYU.js +0 -407
  82. package/src/components/chat/ChatThread.tsx +0 -402
  83. package/src/components/chat/SkillsPicker.tsx +0 -137
  84. package/src/components/chat/chat-input/ChatInputBarView.tsx +0 -82
  85. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +0 -83
  86. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +0 -39
  87. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +0 -31
  88. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +0 -112
  89. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +0 -24
  90. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +0 -58
  91. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +0 -56
  92. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +0 -40
  93. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputThinkingSelector.tsx +0 -74
  94. package/src/components/chat/chat-input/useChatInputBarController.ts +0 -322
@@ -1,402 +0,0 @@
1
- import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
2
- import { type UiMessage, type UiMessageRole } from '@nextclaw/agent-chat';
3
- import { cn } from '@/lib/utils';
4
- import {
5
- stringifyUnknown,
6
- summarizeToolArgs,
7
- type ToolCard
8
- } from '@/lib/chat-message';
9
- import { formatDateTime, t } from '@/lib/i18n';
10
- import ReactMarkdown, { type Components } from 'react-markdown';
11
- import remarkGfm from 'remark-gfm';
12
- import { Bot, Check, Clock3, Copy, FileSearch, Globe, Search, SendHorizontal, Terminal, User, Wrench } from 'lucide-react';
13
-
14
- type ChatThreadProps = {
15
- uiMessages: UiMessage[];
16
- isSending: boolean;
17
- className?: string;
18
- };
19
-
20
- const MARKDOWN_MAX_CHARS = 140_000;
21
- const TOOL_OUTPUT_PREVIEW_MAX = 220;
22
- const CODE_LANGUAGE_REGEX = /language-([a-z0-9-]+)/i;
23
- const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
24
-
25
- type WorkflowToolCard = ToolCard;
26
-
27
- function trimMarkdown(value: string): string {
28
- if (value.length <= MARKDOWN_MAX_CHARS) {
29
- return value;
30
- }
31
- return `${value.slice(0, MARKDOWN_MAX_CHARS)}\n\n…`;
32
- }
33
-
34
- function flattenNodeText(value: ReactNode): string {
35
- if (typeof value === 'string' || typeof value === 'number') {
36
- return String(value);
37
- }
38
- if (Array.isArray(value)) {
39
- return value.map(flattenNodeText).join('');
40
- }
41
- return '';
42
- }
43
-
44
- function normalizeCodeText(value: ReactNode): string {
45
- const content = flattenNodeText(value);
46
- return content.endsWith('\n') ? content.slice(0, -1) : content;
47
- }
48
-
49
- function resolveCodeLanguage(className?: string): string {
50
- const match = className ? CODE_LANGUAGE_REGEX.exec(className) : null;
51
- return match?.[1]?.toLowerCase() || 'text';
52
- }
53
-
54
- function resolveSafeHref(href?: string): string | null {
55
- if (!href) {
56
- return null;
57
- }
58
- if (href.startsWith('#') || href.startsWith('/') || href.startsWith('./') || href.startsWith('../')) {
59
- return href;
60
- }
61
- try {
62
- const url = new URL(href);
63
- return SAFE_LINK_PROTOCOLS.has(url.protocol) ? href : null;
64
- } catch {
65
- return null;
66
- }
67
- }
68
-
69
- function isExternalHref(href: string): boolean {
70
- return /^https?:\/\//i.test(href);
71
- }
72
-
73
- function MarkdownCodeBlock({ className, children }: { className?: string; children: ReactNode }) {
74
- const [copied, setCopied] = useState(false);
75
- const language = useMemo(() => resolveCodeLanguage(className), [className]);
76
- const codeText = useMemo(() => normalizeCodeText(children), [children]);
77
-
78
- const handleCopy = useCallback(async () => {
79
- if (!codeText || typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
80
- return;
81
- }
82
- try {
83
- await navigator.clipboard.writeText(codeText);
84
- setCopied(true);
85
- } catch {
86
- setCopied(false);
87
- }
88
- }, [codeText]);
89
-
90
- useEffect(() => {
91
- if (!copied || typeof window === 'undefined') {
92
- return;
93
- }
94
- const timer = window.setTimeout(() => setCopied(false), 1300);
95
- return () => window.clearTimeout(timer);
96
- }, [copied]);
97
-
98
- return (
99
- <div className="chat-codeblock">
100
- <div className="chat-codeblock-toolbar">
101
- <span className="chat-codeblock-language">{language}</span>
102
- <button
103
- type="button"
104
- className="chat-codeblock-copy"
105
- onClick={handleCopy}
106
- aria-label={copied ? t('chatCodeCopied') : t('chatCodeCopy')}
107
- >
108
- {copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
109
- <span>{copied ? t('chatCodeCopied') : t('chatCodeCopy')}</span>
110
- </button>
111
- </div>
112
- <pre>
113
- <code className={className}>{codeText}</code>
114
- </pre>
115
- </div>
116
- );
117
- }
118
-
119
- function roleTitle(role: UiMessageRole): string {
120
- if (role === 'user') return t('chatRoleUser');
121
- if (role === 'assistant') return t('chatRoleAssistant');
122
- if (role === 'tool') return t('chatRoleTool');
123
- if (role === 'system') return t('chatRoleSystem');
124
- return t('chatRoleMessage');
125
- }
126
-
127
- function renderToolIcon(name: string) {
128
- const lowered = name.toLowerCase();
129
- if (lowered.includes('exec') || lowered.includes('shell') || lowered.includes('command')) {
130
- return <Terminal className="h-3.5 w-3.5" />;
131
- }
132
- if (lowered.includes('search')) {
133
- return <Search className="h-3.5 w-3.5" />;
134
- }
135
- if (lowered.includes('fetch') || lowered.includes('http') || lowered.includes('web')) {
136
- return <Globe className="h-3.5 w-3.5" />;
137
- }
138
- if (lowered.includes('read') || lowered.includes('file')) {
139
- return <FileSearch className="h-3.5 w-3.5" />;
140
- }
141
- if (lowered.includes('message') || lowered.includes('send')) {
142
- return <SendHorizontal className="h-3.5 w-3.5" />;
143
- }
144
- if (lowered.includes('cron') || lowered.includes('schedule')) {
145
- return <Clock3 className="h-3.5 w-3.5" />;
146
- }
147
- return <Wrench className="h-3.5 w-3.5" />;
148
- }
149
-
150
- function RoleAvatar({ role }: { role: UiMessageRole }) {
151
- if (role === 'user') {
152
- return (
153
- <div className="h-8 w-8 rounded-full bg-primary text-white flex items-center justify-center shadow-sm">
154
- <User className="h-4 w-4" />
155
- </div>
156
- );
157
- }
158
- if (role === 'assistant') {
159
- return (
160
- <div className="h-8 w-8 rounded-full bg-slate-900 text-white flex items-center justify-center shadow-sm">
161
- <Bot className="h-4 w-4" />
162
- </div>
163
- );
164
- }
165
- return (
166
- <div className="h-8 w-8 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center shadow-sm">
167
- <Wrench className="h-4 w-4" />
168
- </div>
169
- );
170
- }
171
-
172
- function MarkdownBlock({ text, role }: { text: string; role: UiMessageRole }) {
173
- const isUser = role === 'user';
174
- const markdownComponents = useMemo<Components>(() => ({
175
- a: ({ href, children, ...props }) => {
176
- const safeHref = resolveSafeHref(href);
177
- if (!safeHref) {
178
- return <span className="chat-link-invalid">{children}</span>;
179
- }
180
- const external = isExternalHref(safeHref);
181
- return (
182
- <a
183
- {...props}
184
- href={safeHref}
185
- target={external ? '_blank' : undefined}
186
- rel={external ? 'noreferrer noopener' : undefined}
187
- >
188
- {children}
189
- </a>
190
- );
191
- },
192
- table: ({ children, ...props }) => (
193
- <div className="chat-table-wrap">
194
- <table {...props}>{children}</table>
195
- </div>
196
- ),
197
- input: ({ type, checked, ...props }) => {
198
- if (type !== 'checkbox') {
199
- return <input {...props} type={type} />;
200
- }
201
- return (
202
- <input
203
- {...props}
204
- type="checkbox"
205
- checked={checked}
206
- readOnly
207
- disabled
208
- className="chat-task-checkbox"
209
- />
210
- );
211
- },
212
- img: ({ src, alt, ...props }) => {
213
- const safeSrc = resolveSafeHref(src);
214
- if (!safeSrc) {
215
- return null;
216
- }
217
- return <img {...props} src={safeSrc} alt={alt || ''} loading="lazy" decoding="async" />;
218
- },
219
- code: ({ className, children, ...props }) => {
220
- const plainText = String(children ?? '');
221
- const isInlineCode = !className && !plainText.includes('\n');
222
- if (isInlineCode) {
223
- return (
224
- <code {...props} className={cn('chat-inline-code', className)}>
225
- {children}
226
- </code>
227
- );
228
- }
229
- return <MarkdownCodeBlock className={className}>{children}</MarkdownCodeBlock>;
230
- }
231
- }), []);
232
-
233
- return (
234
- <div className={cn('chat-markdown', isUser ? 'chat-markdown-user' : 'chat-markdown-assistant')}>
235
- <ReactMarkdown
236
- skipHtml
237
- remarkPlugins={[remarkGfm]}
238
- components={markdownComponents}
239
- >
240
- {trimMarkdown(text)}
241
- </ReactMarkdown>
242
- </div>
243
- );
244
- }
245
-
246
- function ToolCardView({ card }: { card: WorkflowToolCard }) {
247
- const title = card.kind === 'call' ? t('chatToolCall') : t('chatToolResult');
248
- const output = card.text?.trim() ?? '';
249
- const showDetails = output.length > TOOL_OUTPUT_PREVIEW_MAX || output.includes('\n');
250
- const preview = showDetails ? `${output.slice(0, TOOL_OUTPUT_PREVIEW_MAX)}…` : output;
251
- const showOutputSection = card.kind === 'result' || card.hasResult;
252
-
253
- return (
254
- <div className="rounded-xl border border-amber-200/80 bg-amber-50/60 px-3 py-2.5">
255
- <div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
256
- {renderToolIcon(card.name)}
257
- <span>{title}</span>
258
- <span className="font-mono text-[11px] text-amber-900/80">{card.name}</span>
259
- </div>
260
- {card.detail && (
261
- <div className="mt-1 text-[11px] text-amber-800/90 font-mono break-words">{card.detail}</div>
262
- )}
263
- {showOutputSection && (
264
- <div className="mt-2">
265
- {!output ? (
266
- <div className="text-[11px] text-amber-700/80">{t('chatToolNoOutput')}</div>
267
- ) : showDetails ? (
268
- <details className="group">
269
- <summary className="cursor-pointer text-[11px] text-amber-700">{t('chatToolOutput')}</summary>
270
- <pre className="mt-2 rounded-lg border border-amber-200 bg-amber-100/40 p-2 text-[11px] whitespace-pre-wrap break-words text-amber-900">
271
- {output}
272
- </pre>
273
- </details>
274
- ) : (
275
- <pre className="rounded-lg border border-amber-200 bg-amber-100/40 p-2 text-[11px] whitespace-pre-wrap break-words text-amber-900">
276
- {preview}
277
- </pre>
278
- )}
279
- </div>
280
- )}
281
- </div>
282
- );
283
- }
284
-
285
- function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: boolean }) {
286
- return (
287
- <details className="mt-3">
288
- <summary className={cn('cursor-pointer text-xs', isUser ? 'text-primary-100' : 'text-gray-500')}>
289
- {t('chatReasoning')}
290
- </summary>
291
- <pre className={cn('mt-2 text-[11px] whitespace-pre-wrap break-words rounded-lg p-2', isUser ? 'bg-primary-700/60' : 'bg-gray-100')}>
292
- {reasoning}
293
- </pre>
294
- </details>
295
- );
296
- }
297
-
298
- function resolveUiMessageTimestamp(message: UiMessage): string {
299
- const candidate = message.meta?.timestamp;
300
- if (candidate && Number.isFinite(Date.parse(candidate))) {
301
- return candidate;
302
- }
303
- return new Date().toISOString();
304
- }
305
-
306
- function MessageCard({ message }: { message: UiMessage }) {
307
- const role = message.role;
308
- const isUser = role === 'user';
309
- const renderedParts = message.parts
310
- .map((part, index) => {
311
- if (part.type === 'text') {
312
- const text = part.text.trim();
313
- if (!text) {
314
- return null;
315
- }
316
- return <MarkdownBlock key={`text-${index}`} text={text} role={role} />;
317
- }
318
- if (part.type === 'reasoning') {
319
- const reasoning = part.reasoning.trim();
320
- if (!reasoning) {
321
- return null;
322
- }
323
- return <ReasoningBlock key={`reasoning-${index}`} reasoning={reasoning} isUser={isUser} />;
324
- }
325
- if (part.type === 'tool-invocation') {
326
- const invocation = part.toolInvocation;
327
- const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
328
- const rawResult = typeof invocation.error === 'string' && invocation.error.trim()
329
- ? invocation.error.trim()
330
- : invocation.result != null
331
- ? stringifyUnknown(invocation.result).trim()
332
- : '';
333
- const hasResult =
334
- invocation.status === 'result' || invocation.status === 'error' || invocation.status === 'cancelled';
335
- const card: ToolCard = {
336
- kind: invocation.status === 'result' && !invocation.args ? 'result' : 'call',
337
- name: invocation.toolName,
338
- detail,
339
- text: rawResult || undefined,
340
- callId: invocation.toolCallId || undefined,
341
- hasResult
342
- };
343
- return (
344
- <div key={`tool-${invocation.toolCallId || index}`} className="mt-0.5">
345
- <ToolCardView card={card} />
346
- </div>
347
- );
348
- }
349
- return null;
350
- })
351
- .filter((node) => node !== null);
352
-
353
- return (
354
- <div
355
- className={cn(
356
- 'inline-block w-fit max-w-full rounded-2xl border px-4 py-3 shadow-sm',
357
- isUser
358
- ? 'bg-primary text-white border-primary'
359
- : role === 'assistant'
360
- ? 'bg-white text-gray-900 border-gray-200'
361
- : 'bg-orange-50/70 text-gray-900 border-orange-200/80'
362
- )}
363
- >
364
- <div className="space-y-2">{renderedParts}</div>
365
- </div>
366
- );
367
- }
368
-
369
- export function ChatThread({ uiMessages, isSending, className }: ChatThreadProps) {
370
- const hasStreamingDraft = uiMessages.some((message) => message.meta?.status === 'streaming');
371
-
372
- return (
373
- <div className={cn('space-y-5', className)}>
374
- {uiMessages.map((message) => {
375
- const {role} = message;
376
- const isUser = role === 'user';
377
- const timestamp = resolveUiMessageTimestamp(message);
378
- return (
379
- <div key={message.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
380
- {!isUser && <RoleAvatar role={role} />}
381
- <div className={cn('max-w-[92%] w-fit space-y-2', isUser && 'flex flex-col items-end')}>
382
- <MessageCard message={message} />
383
- <div className={cn('text-[11px] px-1', isUser ? 'text-primary-300' : 'text-gray-400')}>
384
- {roleTitle(role)} · {formatDateTime(timestamp)}
385
- </div>
386
- </div>
387
- {isUser && <RoleAvatar role={role} />}
388
- </div>
389
- );
390
- })}
391
-
392
- {isSending && !hasStreamingDraft && (
393
- <div className="flex gap-3 justify-start">
394
- <RoleAvatar role="assistant" />
395
- <div className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-500 shadow-sm">
396
- {t('chatTyping')}
397
- </div>
398
- </div>
399
- )}
400
- </div>
401
- );
402
- }
@@ -1,137 +0,0 @@
1
- import { useMemo, useState } from 'react';
2
- import { NavLink } from 'react-router-dom';
3
- import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover';
4
- import { Input } from '@/components/ui/input';
5
- import { useI18n } from '@/components/providers/I18nProvider';
6
- import type { MarketplaceInstalledRecord } from '@/api/types';
7
- import { t } from '@/lib/i18n';
8
- import { BrainCircuit, Check, ExternalLink, Search, Puzzle } from 'lucide-react';
9
-
10
- type SkillsPickerProps = {
11
- records: MarketplaceInstalledRecord[];
12
- isLoading?: boolean;
13
- selectedSkills: string[];
14
- onSelectedSkillsChange: (next: string[]) => void;
15
- };
16
-
17
- export function SkillsPicker({ records, isLoading = false, selectedSkills, onSelectedSkillsChange }: SkillsPickerProps) {
18
- const { language } = useI18n();
19
- const [query, setQuery] = useState('');
20
- const selectedSkillSet = useMemo(() => new Set(selectedSkills), [selectedSkills]);
21
- const selectedCount = selectedSkills.length;
22
- const filteredRecords = useMemo(() => {
23
- const keyword = query.trim().toLowerCase();
24
- if (!keyword) {
25
- return records;
26
- }
27
- return records.filter((record) => {
28
- const haystack = [record.label, record.spec, record.description, record.descriptionZh]
29
- .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
30
- .join(' ')
31
- .toLowerCase();
32
- return haystack.includes(keyword);
33
- });
34
- }, [query, records]);
35
-
36
- const handleToggleSelection = (record: MarketplaceInstalledRecord) => {
37
- const skillName = record.spec?.trim();
38
- if (!skillName) {
39
- return;
40
- }
41
- if (selectedSkillSet.has(skillName)) {
42
- onSelectedSkillsChange(selectedSkills.filter((item) => item !== skillName));
43
- return;
44
- }
45
- onSelectedSkillsChange([...selectedSkills, skillName]);
46
- };
47
-
48
- return (
49
- <Popover>
50
- <PopoverTrigger asChild>
51
- <button
52
- type="button"
53
- className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors"
54
- >
55
- <BrainCircuit className="h-4 w-4" />
56
- <span>{t('chatSkillsPickerTitle')}</span>
57
- {selectedCount > 0 && (
58
- <span className="ml-0.5 min-w-[18px] h-[18px] flex items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-semibold">
59
- {selectedCount}
60
- </span>
61
- )}
62
- </button>
63
- </PopoverTrigger>
64
- <PopoverContent side="top" align="start" className="w-[360px] p-0">
65
- <div className="space-y-2 border-b border-gray-100 px-4 py-3">
66
- <div className="text-sm font-semibold text-gray-900">{t('chatSkillsPickerTitle')}</div>
67
- <div className="relative">
68
- <Search className="pointer-events-none absolute left-3 top-2.5 h-3.5 w-3.5 text-gray-400" />
69
- <Input
70
- value={query}
71
- onChange={(event) => setQuery(event.target.value)}
72
- placeholder={t('chatSkillsPickerSearchPlaceholder')}
73
- className="h-8 rounded-lg pl-8 text-xs"
74
- />
75
- </div>
76
- </div>
77
- <div className="max-h-[320px] overflow-y-auto custom-scrollbar">
78
- {isLoading ? (
79
- <div className="p-4 text-xs text-gray-500">{t('sessionsLoading')}</div>
80
- ) : filteredRecords.length === 0 ? (
81
- <div className="p-4 text-xs text-gray-500 text-center">{t('chatSkillsPickerEmpty')}</div>
82
- ) : (
83
- <div className="py-1">
84
- {filteredRecords.map((record) => (
85
- <button
86
- key={record.spec}
87
- type="button"
88
- onClick={() => handleToggleSelection(record)}
89
- className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-gray-50"
90
- >
91
- <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gray-100 text-gray-500">
92
- <Puzzle className="h-4 w-4" />
93
- </div>
94
- <div className="min-w-0 flex-1">
95
- <div className="flex items-center gap-1.5">
96
- <span className="truncate text-sm text-gray-900">{record.label || record.spec}</span>
97
- {record.origin === 'builtin' && (
98
- <span className="shrink-0 text-[10px] bg-primary/10 text-primary px-1.5 py-0.5 rounded-full font-medium">
99
- {t('chatSkillsPickerOfficial')}
100
- </span>
101
- )}
102
- </div>
103
- <div className="mt-0.5 truncate text-xs text-gray-500">
104
- {(language === 'zh' ? record.descriptionZh : record.description)?.trim() ||
105
- record.description?.trim() ||
106
- t('chatSkillsPickerNoDescription')}
107
- </div>
108
- </div>
109
- <div className="ml-3 shrink-0">
110
- <span
111
- className={
112
- selectedSkillSet.has(record.spec)
113
- ? 'inline-flex h-5 w-5 items-center justify-center rounded-full bg-primary text-white'
114
- : 'inline-flex h-5 w-5 items-center justify-center rounded-full border border-gray-300 bg-white'
115
- }
116
- >
117
- {selectedSkillSet.has(record.spec) && <Check className="h-3 w-3" />}
118
- </span>
119
- </div>
120
- </button>
121
- ))}
122
- </div>
123
- )}
124
- </div>
125
- <div className="px-4 py-2.5 border-t border-gray-100">
126
- <NavLink
127
- to="/marketplace/skills"
128
- className="inline-flex items-center gap-1.5 text-xs font-medium text-primary hover:text-primary/80 transition-colors"
129
- >
130
- {t('chatSkillsPickerManage')}
131
- <ExternalLink className="h-3 w-3" />
132
- </NavLink>
133
- </div>
134
- </PopoverContent>
135
- </Popover>
136
- );
137
- }
@@ -1,82 +0,0 @@
1
- import { useChatInputBarController } from '@/components/chat/chat-input/useChatInputBarController';
2
- import { ChatInputBottomToolbar } from '@/components/chat/chat-input/ChatInputBottomToolbar';
3
- import { ChatInputModelStateHint } from '@/components/chat/chat-input/components/ChatInputModelStateHint';
4
- import { ChatInputSelectedSkillsSection } from '@/components/chat/chat-input/components/ChatInputSelectedSkillsSection';
5
- import { ChatInputSlashPanelSection } from '@/components/chat/chat-input/components/ChatInputSlashPanelSection';
6
- import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
7
- import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
8
- import { t } from '@/lib/i18n';
9
-
10
- export function ChatInputBarView() {
11
- const presenter = usePresenter();
12
- const snapshot = useChatInputStore((state) => state.snapshot);
13
-
14
- const hasModelOptions = snapshot.modelOptions.length > 0;
15
- const isModelOptionsLoading = !snapshot.isProviderStateResolved && !hasModelOptions;
16
- const isModelOptionsEmpty = snapshot.isProviderStateResolved && !hasModelOptions;
17
- const inputDisabled = ((isModelOptionsLoading || isModelOptionsEmpty) && !snapshot.isSending) || snapshot.sessionTypeUnavailable;
18
- const textareaPlaceholder = isModelOptionsLoading
19
- ? ''
20
- : hasModelOptions
21
- ? t('chatInputPlaceholder')
22
- : t('chatModelNoOptions');
23
-
24
- const controller = useChatInputBarController({
25
- draft: snapshot.draft,
26
- onDraftChange: presenter.chatInputManager.setDraft,
27
- onSend: presenter.chatInputManager.send,
28
- onStop: presenter.chatInputManager.stop,
29
- canStopGeneration: snapshot.canStopGeneration,
30
- isSending: snapshot.isSending,
31
- skillRecords: snapshot.skillRecords,
32
- isSkillsLoading: snapshot.isSkillsLoading,
33
- selectedSkills: snapshot.selectedSkills,
34
- onSelectedSkillsChange: presenter.chatInputManager.selectSkills
35
- });
36
-
37
- return (
38
- <div className="border-t border-gray-200/80 bg-white p-4">
39
- <div className="mx-auto w-full max-w-[min(1120px,100%)]">
40
- <div className="rounded-2xl border border-gray-200 bg-white shadow-card overflow-hidden">
41
- <div className="relative">
42
- <textarea
43
- value={snapshot.draft}
44
- onChange={(event) => presenter.chatInputManager.setDraft(event.target.value)}
45
- disabled={inputDisabled}
46
- onKeyDown={controller.onTextareaKeyDown}
47
- placeholder={textareaPlaceholder}
48
- className="w-full min-h-[68px] max-h-[220px] resize-y bg-transparent outline-none text-sm px-4 py-3 text-gray-800 placeholder:text-gray-400"
49
- />
50
- <ChatInputSlashPanelSection
51
- slashAnchorRef={controller.slashAnchorRef}
52
- slashListRef={controller.slashListRef}
53
- isSlashPanelOpen={controller.isSlashPanelOpen}
54
- isSlashPanelLoading={controller.isSlashPanelLoading}
55
- resolvedSlashPanelWidth={controller.resolvedSlashPanelWidth}
56
- skillSlashItems={controller.skillSlashItems}
57
- activeSlashIndex={controller.activeSlashIndex}
58
- activeSlashItem={controller.activeSlashItem}
59
- onSelectSlashItem={controller.onSelectSlashItem}
60
- onSlashPanelOpenChange={controller.onSlashPanelOpenChange}
61
- onSetActiveSlashIndex={controller.onSetActiveSlashIndex}
62
- />
63
- </div>
64
-
65
- <ChatInputModelStateHint
66
- isModelOptionsLoading={isModelOptionsLoading}
67
- isModelOptionsEmpty={isModelOptionsEmpty}
68
- onGoToProviders={presenter.chatInputManager.goToProviders}
69
- />
70
-
71
- <ChatInputSelectedSkillsSection
72
- records={controller.selectedSkillRecords}
73
- selectedSkills={snapshot.selectedSkills}
74
- onSelectedSkillsChange={presenter.chatInputManager.selectSkills}
75
- />
76
-
77
- <ChatInputBottomToolbar />
78
- </div>
79
- </div>
80
- </div>
81
- );
82
- }