@nextclaw/ui 0.6.10 → 0.6.12
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/.eslintrc.cjs +10 -0
- package/CHANGELOG.md +16 -0
- package/dist/assets/ChannelsList-DBDjwf-X.js +1 -0
- package/dist/assets/ChatPage-C18sGGk1.js +36 -0
- package/dist/assets/DocBrowser-ZOplDEMS.js +1 -0
- package/dist/assets/LogoBadge-2LMzEMwe.js +1 -0
- package/dist/assets/MarketplacePage-D4JHYcB5.js +49 -0
- package/dist/assets/ModelConfig-DZVvdLFq.js +1 -0
- package/dist/assets/ProvidersList-Dum31480.js +1 -0
- package/dist/assets/{RuntimeConfig-BO6s-ls-.js → RuntimeConfig-4sb3mpkd.js} +1 -1
- package/dist/assets/SearchConfig-B4u_MxRG.js +1 -0
- package/dist/assets/{SecretsConfig-mayFdxpM.js → SecretsConfig-BQXblZvb.js} +2 -2
- package/dist/assets/SessionsConfig-Jk29xjQU.js +2 -0
- package/dist/assets/{card-BP5YnL-G.js → card-BekAnCgX.js} +1 -1
- package/dist/assets/config-layout-BHnOoweL.js +1 -0
- package/dist/assets/index-BXwjfCEO.css +1 -0
- package/dist/assets/index-Dl6t70wA.js +8 -0
- package/dist/assets/{input-B1D2QX0O.js → input-MMn_Na9q.js} +1 -1
- package/dist/assets/{label-DW0j-fXA.js → label-Dg2ydpN0.js} +1 -1
- package/dist/assets/{page-layout-Ch-H9gD-.js → page-layout-7K0rcz0I.js} +1 -1
- package/dist/assets/session-run-status-CAdjSqeb.js +3 -0
- package/dist/assets/{switch-_cZHlGKB.js → switch-DnDMlDVu.js} +1 -1
- package/dist/assets/{tabs-custom-ARxqYYjG.js → tabs-custom-khLM8lWj.js} +1 -1
- package/dist/assets/{useConfirmDialog-BaU7nIat.js → useConfirmDialog-BYA1XnVU.js} +2 -2
- package/dist/assets/{vendor-C--HHaLf.js → vendor-d7E8OgNx.js} +84 -84
- package/dist/index.html +3 -3
- package/package.json +4 -2
- package/src/App.tsx +3 -2
- package/src/api/config.ts +212 -200
- package/src/api/types.ts +93 -24
- package/src/components/chat/ChatConversationPanel.tsx +102 -121
- package/src/components/chat/ChatPage.tsx +165 -437
- package/src/components/chat/ChatSidebar.tsx +30 -36
- package/src/components/chat/ChatThread.tsx +73 -131
- package/src/components/chat/chat-input/ChatInputBarView.tsx +82 -0
- package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +71 -0
- package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +39 -0
- package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +31 -0
- package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +112 -0
- package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +24 -0
- package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +58 -0
- package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +56 -0
- package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +40 -0
- package/src/components/chat/chat-input/useChatInputBarController.ts +313 -0
- package/src/components/chat/chat-input.types.ts +15 -0
- package/src/components/chat/chat-page-data.ts +121 -0
- package/src/components/chat/chat-page-runtime.ts +221 -0
- package/src/components/chat/chat-session-route.ts +59 -0
- package/src/components/chat/chat-stream/nextbot-parsers.ts +52 -0
- package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +413 -0
- package/src/components/chat/chat-stream/stream-event-adapter.ts +98 -0
- package/src/components/chat/chat-stream/transport.ts +159 -0
- package/src/components/chat/chat-stream/types.ts +76 -0
- package/src/components/chat/managers/chat-input.manager.ts +142 -0
- package/src/components/chat/managers/chat-run-status.manager.ts +32 -0
- package/src/components/chat/managers/chat-session-list.manager.ts +77 -0
- package/src/components/chat/managers/chat-stream-actions.manager.ts +34 -0
- package/src/components/chat/managers/chat-thread.manager.ts +86 -0
- package/src/components/chat/managers/chat-ui.manager.ts +65 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +25 -0
- package/src/components/chat/presenter/chat.presenter.ts +32 -0
- package/src/components/chat/stores/chat-input.store.ts +62 -0
- package/src/components/chat/stores/chat-run-status.store.ts +30 -0
- package/src/components/chat/stores/chat-session-list.store.ts +34 -0
- package/src/components/chat/stores/chat-thread.store.ts +52 -0
- package/src/components/chat/useChatRuntimeController.ts +134 -0
- package/src/components/chat/useChatSessionTypeState.ts +148 -0
- package/src/components/common/MaskedInput.tsx +1 -1
- package/src/components/config/SearchConfig.tsx +297 -0
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/hooks/useConfig.ts +48 -1
- package/src/hooks/useObservable.ts +20 -0
- package/src/lib/chat-message.ts +2 -202
- package/src/lib/chat-runtime-utils.ts +250 -0
- package/src/lib/i18n.ts +31 -0
- package/tsconfig.json +2 -1
- package/vite.config.ts +2 -1
- package/dist/assets/ChannelsList-TyMb5Mgz.js +0 -1
- package/dist/assets/ChatPage-CQerYqvy.js +0 -34
- package/dist/assets/DocBrowser-CNtrA0ps.js +0 -1
- package/dist/assets/LogoBadge-BLqiOM5D.js +0 -1
- package/dist/assets/MarketplacePage-CotZxxNe.js +0 -49
- package/dist/assets/ModelConfig-CCsQ8KFq.js +0 -1
- package/dist/assets/ProvidersList-BYYX5K_g.js +0 -1
- package/dist/assets/SessionsConfig-DAIczdBj.js +0 -2
- package/dist/assets/index-BUiahmWm.css +0 -1
- package/dist/assets/index-D6_5HaDl.js +0 -7
- package/dist/assets/session-run-status-BUYsQeWs.js +0 -5
- package/src/components/chat/ChatInputBar.tsx +0 -590
- package/src/components/chat/useChatStreamController.ts +0 -591
|
@@ -5,27 +5,17 @@ import { BrandHeader } from '@/components/common/BrandHeader';
|
|
|
5
5
|
import { Input } from '@/components/ui/input';
|
|
6
6
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
|
7
7
|
import { SessionRunBadge } from '@/components/common/SessionRunBadge';
|
|
8
|
+
import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
|
|
9
|
+
import { useChatRunStatusStore } from '@/components/chat/stores/chat-run-status.store';
|
|
10
|
+
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
8
11
|
import { cn } from '@/lib/utils';
|
|
9
12
|
import { LANGUAGE_OPTIONS, formatDateTime, t, type I18nLanguage } from '@/lib/i18n';
|
|
10
|
-
import type { SessionRunStatus } from '@/lib/session-run-status';
|
|
11
13
|
import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
|
|
12
14
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
13
15
|
import { useTheme } from '@/components/providers/ThemeProvider';
|
|
14
16
|
import { NavLink } from 'react-router-dom';
|
|
15
17
|
import { AlarmClock, BrainCircuit, Languages, MessageSquareText, Palette, Plus, Search, Settings } from 'lucide-react';
|
|
16
18
|
|
|
17
|
-
type ChatSidebarProps = {
|
|
18
|
-
sessions: SessionEntryView[];
|
|
19
|
-
sessionRunStatusByKey: ReadonlyMap<string, SessionRunStatus>;
|
|
20
|
-
selectedSessionKey: string | null;
|
|
21
|
-
onSelectSession: (key: string) => void;
|
|
22
|
-
onCreateSession: () => void;
|
|
23
|
-
sessionTitle: (session: SessionEntryView) => string;
|
|
24
|
-
isLoading: boolean;
|
|
25
|
-
query: string;
|
|
26
|
-
onQueryChange: (value: string) => void;
|
|
27
|
-
};
|
|
28
|
-
|
|
29
19
|
type DateGroup = {
|
|
30
20
|
label: string;
|
|
31
21
|
sessions: SessionEntryView[];
|
|
@@ -63,18 +53,29 @@ function groupSessionsByDate(sessions: SessionEntryView[]): DateGroup[] {
|
|
|
63
53
|
return groups;
|
|
64
54
|
}
|
|
65
55
|
|
|
56
|
+
function sessionTitle(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
|
+
|
|
66
64
|
const navItems = [
|
|
67
65
|
{ target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
|
|
68
66
|
{ target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
|
|
69
67
|
];
|
|
70
68
|
|
|
71
|
-
export function ChatSidebar(
|
|
69
|
+
export function ChatSidebar() {
|
|
70
|
+
const presenter = usePresenter();
|
|
71
|
+
const listSnapshot = useChatSessionListStore((state) => state.snapshot);
|
|
72
|
+
const runSnapshot = useChatRunStatusStore((state) => state.snapshot);
|
|
72
73
|
const { language, setLanguage } = useI18n();
|
|
73
74
|
const { theme, setTheme } = useTheme();
|
|
74
75
|
const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
|
|
75
76
|
const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
|
|
76
77
|
|
|
77
|
-
const groups = useMemo(() => groupSessionsByDate(
|
|
78
|
+
const groups = useMemo(() => groupSessionsByDate(listSnapshot.sessions), [listSnapshot.sessions]);
|
|
78
79
|
|
|
79
80
|
const handleLanguageSwitch = (nextLang: I18nLanguage) => {
|
|
80
81
|
if (language === nextLang) return;
|
|
@@ -84,33 +85,29 @@ export function ChatSidebar(props: ChatSidebarProps) {
|
|
|
84
85
|
|
|
85
86
|
return (
|
|
86
87
|
<aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
|
|
87
|
-
{/* Logo */}
|
|
88
88
|
<div className="px-5 pt-5 pb-3">
|
|
89
89
|
<BrandHeader />
|
|
90
90
|
</div>
|
|
91
91
|
|
|
92
|
-
{/* New Task button */}
|
|
93
92
|
<div className="px-4 pb-3">
|
|
94
|
-
<Button variant="primary" className="w-full rounded-xl" onClick={
|
|
93
|
+
<Button variant="primary" className="w-full rounded-xl" onClick={presenter.chatSessionListManager.createSession}>
|
|
95
94
|
<Plus className="h-4 w-4 mr-2" />
|
|
96
95
|
{t('chatSidebarNewTask')}
|
|
97
96
|
</Button>
|
|
98
97
|
</div>
|
|
99
98
|
|
|
100
|
-
{/* Search */}
|
|
101
99
|
<div className="px-4 pb-3">
|
|
102
100
|
<div className="relative">
|
|
103
101
|
<Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
|
|
104
102
|
<Input
|
|
105
|
-
value={
|
|
106
|
-
onChange={(
|
|
103
|
+
value={listSnapshot.query}
|
|
104
|
+
onChange={(event) => presenter.chatSessionListManager.setQuery(event.target.value)}
|
|
107
105
|
placeholder={t('chatSidebarSearchPlaceholder')}
|
|
108
106
|
className="pl-8 h-9 rounded-lg text-xs"
|
|
109
107
|
/>
|
|
110
108
|
</div>
|
|
111
109
|
</div>
|
|
112
110
|
|
|
113
|
-
{/* Navigation shortcuts */}
|
|
114
111
|
<div className="px-3 pb-2">
|
|
115
112
|
<ul className="space-y-0.5">
|
|
116
113
|
{navItems.map((item) => {
|
|
@@ -142,12 +139,10 @@ export function ChatSidebar(props: ChatSidebarProps) {
|
|
|
142
139
|
</ul>
|
|
143
140
|
</div>
|
|
144
141
|
|
|
145
|
-
{/* Divider */}
|
|
146
142
|
<div className="mx-4 border-t border-gray-200/60" />
|
|
147
143
|
|
|
148
|
-
{/* Session history */}
|
|
149
144
|
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 py-2">
|
|
150
|
-
{
|
|
145
|
+
{listSnapshot.isLoading ? (
|
|
151
146
|
<div className="text-xs text-gray-500 p-3">{t('sessionsLoading')}</div>
|
|
152
147
|
) : groups.length === 0 ? (
|
|
153
148
|
<div className="p-4 text-center">
|
|
@@ -163,12 +158,12 @@ export function ChatSidebar(props: ChatSidebarProps) {
|
|
|
163
158
|
</div>
|
|
164
159
|
<div className="space-y-0.5">
|
|
165
160
|
{group.sessions.map((session) => {
|
|
166
|
-
const active =
|
|
167
|
-
const runStatus =
|
|
161
|
+
const active = listSnapshot.selectedSessionKey === session.key;
|
|
162
|
+
const runStatus = runSnapshot.sessionRunStatusByKey.get(session.key);
|
|
168
163
|
return (
|
|
169
164
|
<button
|
|
170
165
|
key={session.key}
|
|
171
|
-
onClick={() =>
|
|
166
|
+
onClick={() => presenter.chatSessionListManager.selectSession(session.key)}
|
|
172
167
|
className={cn(
|
|
173
168
|
'w-full rounded-xl px-3 py-2 text-left transition-all text-[13px]',
|
|
174
169
|
active
|
|
@@ -177,7 +172,7 @@ export function ChatSidebar(props: ChatSidebarProps) {
|
|
|
177
172
|
)}
|
|
178
173
|
>
|
|
179
174
|
<div className="grid grid-cols-[minmax(0,1fr)_0.875rem] items-center gap-1.5">
|
|
180
|
-
<span className="truncate font-medium">{
|
|
175
|
+
<span className="truncate font-medium">{sessionTitle(session)}</span>
|
|
181
176
|
<span className="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
|
182
177
|
{runStatus ? <SessionRunBadge status={runStatus} /> : null}
|
|
183
178
|
</span>
|
|
@@ -195,7 +190,6 @@ export function ChatSidebar(props: ChatSidebarProps) {
|
|
|
195
190
|
)}
|
|
196
191
|
</div>
|
|
197
192
|
|
|
198
|
-
{/* Settings footer */}
|
|
199
193
|
<div className="px-3 py-3 border-t border-gray-200/60 space-y-0.5">
|
|
200
194
|
<NavLink
|
|
201
195
|
to="/settings"
|
|
@@ -213,7 +207,7 @@ export function ChatSidebar(props: ChatSidebarProps) {
|
|
|
213
207
|
</>
|
|
214
208
|
)}
|
|
215
209
|
</NavLink>
|
|
216
|
-
<Select value={theme} onValueChange={(
|
|
210
|
+
<Select value={theme} onValueChange={(value) => setTheme(value as UiTheme)}>
|
|
217
211
|
<SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2 text-[13px] font-medium text-gray-600 hover:bg-gray-200/60 focus:ring-0">
|
|
218
212
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
219
213
|
<Palette className="h-4 w-4 text-gray-400" />
|
|
@@ -222,12 +216,12 @@ export function ChatSidebar(props: ChatSidebarProps) {
|
|
|
222
216
|
<span className="ml-auto text-[11px] text-gray-500">{currentThemeLabel}</span>
|
|
223
217
|
</SelectTrigger>
|
|
224
218
|
<SelectContent>
|
|
225
|
-
{THEME_OPTIONS.map((
|
|
226
|
-
<SelectItem key={
|
|
219
|
+
{THEME_OPTIONS.map((option) => (
|
|
220
|
+
<SelectItem key={option.value} value={option.value} className="text-xs">{t(option.labelKey)}</SelectItem>
|
|
227
221
|
))}
|
|
228
222
|
</SelectContent>
|
|
229
223
|
</Select>
|
|
230
|
-
<Select value={language} onValueChange={(
|
|
224
|
+
<Select value={language} onValueChange={(value) => handleLanguageSwitch(value as I18nLanguage)}>
|
|
231
225
|
<SelectTrigger className="w-full h-auto rounded-xl border-0 bg-transparent shadow-none px-3 py-2 text-[13px] font-medium text-gray-600 hover:bg-gray-200/60 focus:ring-0">
|
|
232
226
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
233
227
|
<Languages className="h-4 w-4 text-gray-400" />
|
|
@@ -236,8 +230,8 @@ export function ChatSidebar(props: ChatSidebarProps) {
|
|
|
236
230
|
<span className="ml-auto text-[11px] text-gray-500">{currentLanguageLabel}</span>
|
|
237
231
|
</SelectTrigger>
|
|
238
232
|
<SelectContent>
|
|
239
|
-
{LANGUAGE_OPTIONS.map((
|
|
240
|
-
<SelectItem key={
|
|
233
|
+
{LANGUAGE_OPTIONS.map((option) => (
|
|
234
|
+
<SelectItem key={option.value} value={option.value} className="text-xs">{option.label}</SelectItem>
|
|
241
235
|
))}
|
|
242
236
|
</SelectContent>
|
|
243
237
|
</Select>
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
2
|
-
import type
|
|
2
|
+
import { type UiMessage, type UiMessageRole } from '@nextclaw/agent-chat';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
extractToolCards,
|
|
8
|
-
normalizeChatRole,
|
|
9
|
-
type ChatRole,
|
|
10
|
-
type ChatTimelineAssistantTurnItem,
|
|
5
|
+
stringifyUnknown,
|
|
6
|
+
summarizeToolArgs,
|
|
11
7
|
type ToolCard
|
|
12
8
|
} from '@/lib/chat-message';
|
|
13
9
|
import { formatDateTime, t } from '@/lib/i18n';
|
|
@@ -16,7 +12,7 @@ import remarkGfm from 'remark-gfm';
|
|
|
16
12
|
import { Bot, Check, Clock3, Copy, FileSearch, Globe, Search, SendHorizontal, Terminal, User, Wrench } from 'lucide-react';
|
|
17
13
|
|
|
18
14
|
type ChatThreadProps = {
|
|
19
|
-
|
|
15
|
+
uiMessages: UiMessage[];
|
|
20
16
|
isSending: boolean;
|
|
21
17
|
className?: string;
|
|
22
18
|
};
|
|
@@ -26,9 +22,7 @@ const TOOL_OUTPUT_PREVIEW_MAX = 220;
|
|
|
26
22
|
const CODE_LANGUAGE_REGEX = /language-([a-z0-9-]+)/i;
|
|
27
23
|
const SAFE_LINK_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);
|
|
28
24
|
|
|
29
|
-
type WorkflowToolCard = ToolCard
|
|
30
|
-
_workflowStep?: number;
|
|
31
|
-
};
|
|
25
|
+
type WorkflowToolCard = ToolCard;
|
|
32
26
|
|
|
33
27
|
function trimMarkdown(value: string): string {
|
|
34
28
|
if (value.length <= MARKDOWN_MAX_CHARS) {
|
|
@@ -122,7 +116,7 @@ function MarkdownCodeBlock({ className, children }: { className?: string; childr
|
|
|
122
116
|
);
|
|
123
117
|
}
|
|
124
118
|
|
|
125
|
-
function roleTitle(role:
|
|
119
|
+
function roleTitle(role: UiMessageRole): string {
|
|
126
120
|
if (role === 'user') return t('chatRoleUser');
|
|
127
121
|
if (role === 'assistant') return t('chatRoleAssistant');
|
|
128
122
|
if (role === 'tool') return t('chatRoleTool');
|
|
@@ -153,7 +147,7 @@ function renderToolIcon(name: string) {
|
|
|
153
147
|
return <Wrench className="h-3.5 w-3.5" />;
|
|
154
148
|
}
|
|
155
149
|
|
|
156
|
-
function RoleAvatar({ role }: { role:
|
|
150
|
+
function RoleAvatar({ role }: { role: UiMessageRole }) {
|
|
157
151
|
if (role === 'user') {
|
|
158
152
|
return (
|
|
159
153
|
<div className="h-8 w-8 rounded-full bg-primary text-white flex items-center justify-center shadow-sm">
|
|
@@ -175,7 +169,7 @@ function RoleAvatar({ role }: { role: ChatRole }) {
|
|
|
175
169
|
);
|
|
176
170
|
}
|
|
177
171
|
|
|
178
|
-
function MarkdownBlock({ text, role }: { text: string; role:
|
|
172
|
+
function MarkdownBlock({ text, role }: { text: string; role: UiMessageRole }) {
|
|
179
173
|
const isUser = role === 'user';
|
|
180
174
|
const markdownComponents = useMemo<Components>(() => ({
|
|
181
175
|
a: ({ href, children, ...props }) => {
|
|
@@ -261,9 +255,6 @@ function ToolCardView({ card }: { card: WorkflowToolCard }) {
|
|
|
261
255
|
<div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
|
|
262
256
|
{renderToolIcon(card.name)}
|
|
263
257
|
<span>{title}</span>
|
|
264
|
-
{typeof card._workflowStep === 'number' && (
|
|
265
|
-
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">#{card._workflowStep + 1}</span>
|
|
266
|
-
)}
|
|
267
258
|
<span className="font-mono text-[11px] text-amber-900/80">{card.name}</span>
|
|
268
259
|
</div>
|
|
269
260
|
{card.detail && (
|
|
@@ -291,35 +282,6 @@ function ToolCardView({ card }: { card: WorkflowToolCard }) {
|
|
|
291
282
|
);
|
|
292
283
|
}
|
|
293
284
|
|
|
294
|
-
function ToolWorkflowCard({ cards }: { cards: WorkflowToolCard[] }) {
|
|
295
|
-
const chain = cards
|
|
296
|
-
.map((card) => card.name.trim())
|
|
297
|
-
.filter((name) => name.length > 0)
|
|
298
|
-
.join(' \u2192 ');
|
|
299
|
-
|
|
300
|
-
return (
|
|
301
|
-
<details className="rounded-xl border border-amber-200/80 bg-amber-50/50 p-3">
|
|
302
|
-
<summary className="cursor-pointer list-none">
|
|
303
|
-
<div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
|
|
304
|
-
<Wrench className="h-3.5 w-3.5" />
|
|
305
|
-
<span>{t('chatToolWorkflow')}</span>
|
|
306
|
-
<span className="font-mono text-[11px] text-amber-900/90">{chain || 'tool'}</span>
|
|
307
|
-
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">{cards.length}</span>
|
|
308
|
-
<span className="text-[11px] font-normal text-amber-700/90">{t('chatToolWorkflowDetails')}</span>
|
|
309
|
-
</div>
|
|
310
|
-
</summary>
|
|
311
|
-
<div className="mt-3 space-y-2">
|
|
312
|
-
{cards.map((card, index) => (
|
|
313
|
-
<ToolCardView
|
|
314
|
-
key={`${card.kind}-${card.callId ?? card.name}-${index}`}
|
|
315
|
-
card={{ ...card, _workflowStep: index }}
|
|
316
|
-
/>
|
|
317
|
-
))}
|
|
318
|
-
</div>
|
|
319
|
-
</details>
|
|
320
|
-
);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
285
|
function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: boolean }) {
|
|
324
286
|
return (
|
|
325
287
|
<details className="mt-3">
|
|
@@ -333,18 +295,65 @@ function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: bool
|
|
|
333
295
|
);
|
|
334
296
|
}
|
|
335
297
|
|
|
336
|
-
function
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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;
|
|
341
308
|
const isUser = role === 'user';
|
|
342
|
-
const
|
|
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);
|
|
343
352
|
|
|
344
353
|
return (
|
|
345
354
|
<div
|
|
346
355
|
className={cn(
|
|
347
|
-
'rounded-2xl border px-4 py-3 shadow-sm',
|
|
356
|
+
'inline-block w-fit max-w-full rounded-2xl border px-4 py-3 shadow-sm',
|
|
348
357
|
isUser
|
|
349
358
|
? 'bg-primary text-white border-primary'
|
|
350
359
|
: role === 'assistant'
|
|
@@ -352,94 +361,27 @@ function MessageCard({ message }: { message: SessionMessageView }) {
|
|
|
352
361
|
: 'bg-orange-50/70 text-gray-900 border-orange-200/80'
|
|
353
362
|
)}
|
|
354
363
|
>
|
|
355
|
-
|
|
356
|
-
{primaryReasoning && <ReasoningBlock reasoning={primaryReasoning} isUser={isUser} />}
|
|
357
|
-
{toolCards.length > 0 && (
|
|
358
|
-
<div className={cn('space-y-2', (shouldRenderPrimaryText || primaryReasoning) && 'mt-3')}>
|
|
359
|
-
{toolCards.length > 1 ? (
|
|
360
|
-
<ToolWorkflowCard cards={toolCards} />
|
|
361
|
-
) : (
|
|
362
|
-
toolCards.map((card, index) => (
|
|
363
|
-
<ToolCardView
|
|
364
|
-
key={`${card.kind}-${card.name}-${card.callId ?? index}`}
|
|
365
|
-
card={{ ...card }}
|
|
366
|
-
/>
|
|
367
|
-
))
|
|
368
|
-
)}
|
|
369
|
-
</div>
|
|
370
|
-
)}
|
|
371
|
-
</div>
|
|
372
|
-
);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function AssistantTurnCard({ item }: { item: ChatTimelineAssistantTurnItem }) {
|
|
376
|
-
const renderedSegments: ReactNode[] = [];
|
|
377
|
-
let index = 0;
|
|
378
|
-
while (index < item.segments.length) {
|
|
379
|
-
const segment = item.segments[index];
|
|
380
|
-
if (!segment) {
|
|
381
|
-
index += 1;
|
|
382
|
-
continue;
|
|
383
|
-
}
|
|
384
|
-
if (segment.kind === 'assistant_message') {
|
|
385
|
-
const hasText = Boolean(segment.text);
|
|
386
|
-
const hasReasoning = Boolean(segment.reasoning);
|
|
387
|
-
if (hasText || hasReasoning) {
|
|
388
|
-
renderedSegments.push(
|
|
389
|
-
<div key={`${segment.key}-${index}`}>
|
|
390
|
-
{hasText && <MarkdownBlock text={segment.text} role="assistant" />}
|
|
391
|
-
{hasReasoning && <ReasoningBlock reasoning={segment.reasoning} isUser={false} />}
|
|
392
|
-
</div>
|
|
393
|
-
);
|
|
394
|
-
}
|
|
395
|
-
index += 1;
|
|
396
|
-
continue;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
const groupedCards: WorkflowToolCard[] = [];
|
|
400
|
-
let cursor = index;
|
|
401
|
-
while (cursor < item.segments.length) {
|
|
402
|
-
const current = item.segments[cursor];
|
|
403
|
-
if (!current || current.kind !== 'tool_card') {
|
|
404
|
-
break;
|
|
405
|
-
}
|
|
406
|
-
groupedCards.push(current.card);
|
|
407
|
-
cursor += 1;
|
|
408
|
-
}
|
|
409
|
-
if (groupedCards.length > 1) {
|
|
410
|
-
renderedSegments.push(<ToolWorkflowCard key={`workflow-${segment.key}-${index}`} cards={groupedCards} />);
|
|
411
|
-
} else if (groupedCards.length === 1) {
|
|
412
|
-
renderedSegments.push(<ToolCardView key={`${segment.key}-${index}`} card={groupedCards[0]} />);
|
|
413
|
-
}
|
|
414
|
-
index = cursor;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
return (
|
|
418
|
-
<div className="rounded-2xl border px-4 py-3 shadow-sm bg-white text-gray-900 border-gray-200">
|
|
419
|
-
<div className="space-y-3">{renderedSegments}</div>
|
|
364
|
+
<div className="space-y-2">{renderedParts}</div>
|
|
420
365
|
</div>
|
|
421
366
|
);
|
|
422
367
|
}
|
|
423
368
|
|
|
424
|
-
export function ChatThread({
|
|
425
|
-
const
|
|
369
|
+
export function ChatThread({ uiMessages, isSending, className }: ChatThreadProps) {
|
|
370
|
+
const hasStreamingDraft = uiMessages.some((message) => message.meta?.status === 'streaming');
|
|
426
371
|
|
|
427
372
|
return (
|
|
428
373
|
<div className={cn('space-y-5', className)}>
|
|
429
|
-
{
|
|
430
|
-
const role =
|
|
374
|
+
{uiMessages.map((message) => {
|
|
375
|
+
const {role} = message;
|
|
431
376
|
const isUser = role === 'user';
|
|
377
|
+
const timestamp = resolveUiMessageTimestamp(message);
|
|
432
378
|
return (
|
|
433
|
-
<div key={
|
|
379
|
+
<div key={message.id} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
|
|
434
380
|
{!isUser && <RoleAvatar role={role} />}
|
|
435
|
-
<div className={cn('max-w-[92%]
|
|
436
|
-
{
|
|
437
|
-
<AssistantTurnCard item={item} />
|
|
438
|
-
) : (
|
|
439
|
-
<MessageCard message={item.message} />
|
|
440
|
-
)}
|
|
381
|
+
<div className={cn('max-w-[92%] w-fit space-y-2', isUser && 'flex flex-col items-end')}>
|
|
382
|
+
<MessageCard message={message} />
|
|
441
383
|
<div className={cn('text-[11px] px-1', isUser ? 'text-primary-300' : 'text-gray-400')}>
|
|
442
|
-
{roleTitle(role)} · {formatDateTime(
|
|
384
|
+
{roleTitle(role)} · {formatDateTime(timestamp)}
|
|
443
385
|
</div>
|
|
444
386
|
</div>
|
|
445
387
|
{isUser && <RoleAvatar role={role} />}
|
|
@@ -447,7 +389,7 @@ export function ChatThread({ events, isSending, className }: ChatThreadProps) {
|
|
|
447
389
|
);
|
|
448
390
|
})}
|
|
449
391
|
|
|
450
|
-
{isSending && (
|
|
392
|
+
{isSending && !hasStreamingDraft && (
|
|
451
393
|
<div className="flex gap-3 justify-start">
|
|
452
394
|
<RoleAvatar role="assistant" />
|
|
453
395
|
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-500 shadow-sm">
|
|
@@ -0,0 +1,82 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { SkillsPicker } from '@/components/chat/SkillsPicker';
|
|
2
|
+
import { usePresenter } from '@/components/chat/presenter/chat-presenter-context';
|
|
3
|
+
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
4
|
+
import { ChatInputAttachButton } from '@/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton';
|
|
5
|
+
import { ChatInputModelSelector } from '@/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector';
|
|
6
|
+
import { ChatInputSendControls } from '@/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls';
|
|
7
|
+
import { ChatInputSessionTypeSelector } from '@/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector';
|
|
8
|
+
import { t } from '@/lib/i18n';
|
|
9
|
+
|
|
10
|
+
export function ChatInputBottomToolbar() {
|
|
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 selectedModelOption = snapshot.modelOptions.find((option) => option.value === snapshot.selectedModel);
|
|
17
|
+
const shouldShowSessionTypeSelector =
|
|
18
|
+
snapshot.canEditSessionType &&
|
|
19
|
+
(snapshot.sessionTypeOptions.length > 1 ||
|
|
20
|
+
Boolean(snapshot.selectedSessionType && snapshot.selectedSessionType !== 'native'));
|
|
21
|
+
const selectedSessionTypeOption =
|
|
22
|
+
snapshot.sessionTypeOptions.find((option) => option.value === snapshot.selectedSessionType) ??
|
|
23
|
+
(snapshot.selectedSessionType
|
|
24
|
+
? { value: snapshot.selectedSessionType, label: snapshot.selectedSessionType }
|
|
25
|
+
: null);
|
|
26
|
+
const resolvedStopHint =
|
|
27
|
+
snapshot.stopDisabledReason === '__preparing__'
|
|
28
|
+
? t('chatStopPreparing')
|
|
29
|
+
: snapshot.stopDisabledReason?.trim() || t('chatStopUnavailable');
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="flex items-center justify-between px-3 pb-3">
|
|
33
|
+
<div className="flex items-center gap-1">
|
|
34
|
+
<SkillsPicker
|
|
35
|
+
records={snapshot.skillRecords}
|
|
36
|
+
isLoading={snapshot.isSkillsLoading}
|
|
37
|
+
selectedSkills={snapshot.selectedSkills}
|
|
38
|
+
onSelectedSkillsChange={presenter.chatInputManager.selectSkills}
|
|
39
|
+
/>
|
|
40
|
+
<ChatInputSessionTypeSelector
|
|
41
|
+
shouldShowSessionTypeSelector={shouldShowSessionTypeSelector}
|
|
42
|
+
selectedSessionType={snapshot.selectedSessionType}
|
|
43
|
+
selectedSessionTypeOption={selectedSessionTypeOption}
|
|
44
|
+
sessionTypeOptions={snapshot.sessionTypeOptions}
|
|
45
|
+
onSelectedSessionTypeChange={presenter.chatInputManager.selectSessionType}
|
|
46
|
+
canEditSessionType={snapshot.canEditSessionType}
|
|
47
|
+
/>
|
|
48
|
+
<ChatInputModelSelector
|
|
49
|
+
modelOptions={snapshot.modelOptions}
|
|
50
|
+
selectedModel={snapshot.selectedModel}
|
|
51
|
+
selectedModelOption={selectedModelOption}
|
|
52
|
+
onSelectedModelChange={presenter.chatInputManager.selectModel}
|
|
53
|
+
isModelOptionsLoading={isModelOptionsLoading}
|
|
54
|
+
hasModelOptions={hasModelOptions}
|
|
55
|
+
/>
|
|
56
|
+
<ChatInputAttachButton />
|
|
57
|
+
</div>
|
|
58
|
+
<ChatInputSendControls
|
|
59
|
+
sendError={snapshot.sendError}
|
|
60
|
+
draft={snapshot.draft}
|
|
61
|
+
hasModelOptions={hasModelOptions}
|
|
62
|
+
sessionTypeUnavailable={snapshot.sessionTypeUnavailable}
|
|
63
|
+
isSending={snapshot.isSending}
|
|
64
|
+
canStopGeneration={snapshot.canStopGeneration}
|
|
65
|
+
resolvedStopHint={resolvedStopHint}
|
|
66
|
+
onSend={presenter.chatInputManager.send}
|
|
67
|
+
onStop={presenter.chatInputManager.stop}
|
|
68
|
+
/>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { t } from '@/lib/i18n';
|
|
2
|
+
|
|
3
|
+
type ChatInputModelStateHintProps = {
|
|
4
|
+
isModelOptionsLoading: boolean;
|
|
5
|
+
isModelOptionsEmpty: boolean;
|
|
6
|
+
onGoToProviders: () => void;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function ChatInputModelStateHint(props: ChatInputModelStateHintProps) {
|
|
10
|
+
if (!props.isModelOptionsLoading && !props.isModelOptionsEmpty) {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (props.isModelOptionsLoading) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="px-4 pb-2">
|
|
17
|
+
<div className="inline-flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
|
18
|
+
<span className="h-3 w-28 animate-pulse rounded bg-gray-200" />
|
|
19
|
+
<span className="h-3 w-16 animate-pulse rounded bg-gray-200" />
|
|
20
|
+
</div>
|
|
21
|
+
</div>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className="px-4 pb-2">
|
|
27
|
+
<div className="inline-flex items-center gap-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-800">
|
|
28
|
+
<span>{t('chatModelNoOptions')}</span>
|
|
29
|
+
<button
|
|
30
|
+
type="button"
|
|
31
|
+
onClick={props.onGoToProviders}
|
|
32
|
+
className="font-semibold text-amber-900 underline-offset-2 hover:underline"
|
|
33
|
+
>
|
|
34
|
+
{t('chatGoConfigureProvider')}
|
|
35
|
+
</button>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|