@nextclaw/ui 0.5.48 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/assets/ChannelsList-CkCpHSto.js +1 -0
- package/dist/assets/ChatPage-DM4XNsrW.js +32 -0
- package/dist/assets/DocBrowser-B5Aqiz6W.js +1 -0
- package/dist/assets/MarketplacePage-BIi0bBdW.js +49 -0
- package/dist/assets/ModelConfig-BTFiEAxQ.js +1 -0
- package/dist/assets/{ProvidersList-BXHpjVtO.js → ProvidersList-cdk1d-G_.js} +1 -1
- package/dist/assets/RuntimeConfig-CFqFsXmR.js +1 -0
- package/dist/assets/{SecretsConfig-KkgMzdt1.js → SecretsConfig-CIKasCek.js} +2 -2
- package/dist/assets/SessionsConfig-mnCLFtbo.js +2 -0
- package/dist/assets/{card-D7NY0Szf.js → card-C1BUfR85.js} +1 -1
- package/dist/assets/index-Dxas8MJ9.js +2 -0
- package/dist/assets/index-P4YzN9iS.css +1 -0
- package/dist/assets/{label-Ojs7Al6B.js → label-CwWfYbuj.js} +1 -1
- package/dist/assets/{logos-B1qBsCSi.js → logos-DDyjHSEU.js} +1 -1
- package/dist/assets/{page-layout-CUMMO0nN.js → page-layout-DKTRKcHL.js} +1 -1
- package/dist/assets/provider-models-y4mUDcGF.js +1 -0
- package/dist/assets/{switch-BdhS_16-.js → switch-Bi3yeYiC.js} +1 -1
- package/dist/assets/{tabs-custom-D261E5EA.js → tabs-custom-HZFNZrc0.js} +1 -1
- package/dist/assets/useConfig-CgzVQTZl.js +6 -0
- package/dist/assets/{useConfirmDialog-BUKGHDL6.js → useConfirmDialog-DwD21HlD.js} +2 -2
- package/dist/assets/{vendor-Dh04PGww.js → vendor-Ylg6Wdt_.js} +84 -69
- package/dist/index.html +3 -3
- package/package.json +2 -1
- package/src/App.tsx +10 -6
- package/src/api/config.ts +42 -1
- package/src/api/types.ts +29 -0
- package/src/components/chat/ChatConversationPanel.tsx +109 -85
- package/src/components/chat/ChatInputBar.tsx +245 -0
- package/src/components/chat/ChatPage.tsx +365 -187
- package/src/components/chat/ChatSidebar.tsx +242 -0
- package/src/components/chat/ChatThread.tsx +92 -25
- package/src/components/chat/ChatWelcome.tsx +61 -0
- package/src/components/chat/SkillsPicker.tsx +137 -0
- package/src/components/chat/useChatStreamController.ts +287 -56
- package/src/components/config/ChannelForm.tsx +1 -1
- package/src/components/config/ChannelsList.tsx +3 -3
- package/src/components/config/ModelConfig.tsx +11 -89
- package/src/components/config/RuntimeConfig.tsx +29 -1
- package/src/components/layout/AppLayout.tsx +42 -6
- package/src/components/layout/Sidebar.tsx +68 -62
- package/src/components/marketplace/MarketplacePage.tsx +13 -3
- package/src/components/ui/popover.tsx +31 -0
- package/src/hooks/useConfig.ts +18 -0
- package/src/lib/i18n.ts +53 -0
- package/src/lib/provider-models.ts +129 -0
- package/dist/assets/ChannelsList-C8cguFLc.js +0 -1
- package/dist/assets/ChatPage-BkHWNUNR.js +0 -32
- package/dist/assets/CronConfig-D-ESQlvk.js +0 -1
- package/dist/assets/DocBrowser-B9ZD6pAk.js +0 -1
- package/dist/assets/MarketplacePage-Ds_l9KTF.js +0 -49
- package/dist/assets/ModelConfig-N1tbLv9b.js +0 -1
- package/dist/assets/RuntimeConfig-KsKfkjgv.js +0 -1
- package/dist/assets/SessionsConfig-CWBp8IPf.js +0 -2
- package/dist/assets/index-BRBYYgR_.js +0 -2
- package/dist/assets/index-C5cdRzpO.css +0 -1
- package/dist/assets/useConfig-txxbxXnT.js +0 -6
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { SessionEntryView } from '@/api/types';
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Input } from '@/components/ui/input';
|
|
5
|
+
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
|
6
|
+
import { cn } from '@/lib/utils';
|
|
7
|
+
import { LANGUAGE_OPTIONS, formatDateTime, t, type I18nLanguage } from '@/lib/i18n';
|
|
8
|
+
import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
|
|
9
|
+
import { useI18n } from '@/components/providers/I18nProvider';
|
|
10
|
+
import { useTheme } from '@/components/providers/ThemeProvider';
|
|
11
|
+
import { NavLink } from 'react-router-dom';
|
|
12
|
+
import { AlarmClock, BrainCircuit, Languages, MessageSquareText, Palette, Plus, Search, Settings } from 'lucide-react';
|
|
13
|
+
|
|
14
|
+
type ChatSidebarProps = {
|
|
15
|
+
sessions: SessionEntryView[];
|
|
16
|
+
selectedSessionKey: string | null;
|
|
17
|
+
onSelectSession: (key: string) => void;
|
|
18
|
+
onCreateSession: () => void;
|
|
19
|
+
sessionTitle: (session: SessionEntryView) => string;
|
|
20
|
+
isLoading: boolean;
|
|
21
|
+
query: string;
|
|
22
|
+
onQueryChange: (value: string) => void;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type DateGroup = {
|
|
26
|
+
label: string;
|
|
27
|
+
sessions: SessionEntryView[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
function groupSessionsByDate(sessions: SessionEntryView[]): DateGroup[] {
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
33
|
+
const yesterdayStart = todayStart - 86_400_000;
|
|
34
|
+
const sevenDaysStart = todayStart - 7 * 86_400_000;
|
|
35
|
+
|
|
36
|
+
const today: SessionEntryView[] = [];
|
|
37
|
+
const yesterday: SessionEntryView[] = [];
|
|
38
|
+
const previous7: SessionEntryView[] = [];
|
|
39
|
+
const older: SessionEntryView[] = [];
|
|
40
|
+
|
|
41
|
+
for (const session of sessions) {
|
|
42
|
+
const ts = new Date(session.updatedAt).getTime();
|
|
43
|
+
if (ts >= todayStart) {
|
|
44
|
+
today.push(session);
|
|
45
|
+
} else if (ts >= yesterdayStart) {
|
|
46
|
+
yesterday.push(session);
|
|
47
|
+
} else if (ts >= sevenDaysStart) {
|
|
48
|
+
previous7.push(session);
|
|
49
|
+
} else {
|
|
50
|
+
older.push(session);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const groups: DateGroup[] = [];
|
|
55
|
+
if (today.length > 0) groups.push({ label: t('chatSidebarToday'), sessions: today });
|
|
56
|
+
if (yesterday.length > 0) groups.push({ label: t('chatSidebarYesterday'), sessions: yesterday });
|
|
57
|
+
if (previous7.length > 0) groups.push({ label: t('chatSidebarPrevious7Days'), sessions: previous7 });
|
|
58
|
+
if (older.length > 0) groups.push({ label: t('chatSidebarOlder'), sessions: older });
|
|
59
|
+
return groups;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const navItems = [
|
|
63
|
+
{ target: '/cron', label: () => t('chatSidebarScheduledTasks'), icon: AlarmClock },
|
|
64
|
+
{ target: '/skills', label: () => t('chatSidebarSkills'), icon: BrainCircuit },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export function ChatSidebar(props: ChatSidebarProps) {
|
|
68
|
+
const { language, setLanguage } = useI18n();
|
|
69
|
+
const { theme, setTheme } = useTheme();
|
|
70
|
+
const currentThemeLabel = t(THEME_OPTIONS.find((o) => o.value === theme)?.labelKey ?? 'themeWarm');
|
|
71
|
+
const currentLanguageLabel = LANGUAGE_OPTIONS.find((o) => o.value === language)?.label ?? language;
|
|
72
|
+
|
|
73
|
+
const groups = useMemo(() => groupSessionsByDate(props.sessions), [props.sessions]);
|
|
74
|
+
|
|
75
|
+
const handleLanguageSwitch = (nextLang: I18nLanguage) => {
|
|
76
|
+
if (language === nextLang) return;
|
|
77
|
+
setLanguage(nextLang);
|
|
78
|
+
window.location.reload();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<aside className="w-[280px] shrink-0 flex flex-col h-full bg-secondary border-r border-gray-200/60">
|
|
83
|
+
{/* Logo */}
|
|
84
|
+
<div className="px-5 pt-5 pb-3">
|
|
85
|
+
<div className="flex items-center gap-2.5">
|
|
86
|
+
<div className="h-7 w-7 rounded-lg overflow-hidden flex items-center justify-center">
|
|
87
|
+
<img src="/logo.svg" alt="NextClaw" className="h-full w-full object-contain" />
|
|
88
|
+
</div>
|
|
89
|
+
<span className="text-[15px] font-semibold text-gray-800 tracking-[-0.01em]">NextClaw</span>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
{/* New Task button */}
|
|
94
|
+
<div className="px-4 pb-3">
|
|
95
|
+
<Button variant="primary" className="w-full rounded-xl" onClick={props.onCreateSession}>
|
|
96
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
97
|
+
{t('chatSidebarNewTask')}
|
|
98
|
+
</Button>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* Search */}
|
|
102
|
+
<div className="px-4 pb-3">
|
|
103
|
+
<div className="relative">
|
|
104
|
+
<Search className="h-3.5 w-3.5 absolute left-3 top-2.5 text-gray-400" />
|
|
105
|
+
<Input
|
|
106
|
+
value={props.query}
|
|
107
|
+
onChange={(e) => props.onQueryChange(e.target.value)}
|
|
108
|
+
placeholder={t('chatSidebarSearchPlaceholder')}
|
|
109
|
+
className="pl-8 h-9 rounded-lg text-xs"
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Navigation shortcuts */}
|
|
115
|
+
<div className="px-3 pb-2">
|
|
116
|
+
<ul className="space-y-0.5">
|
|
117
|
+
{navItems.map((item) => {
|
|
118
|
+
const Icon = item.icon;
|
|
119
|
+
return (
|
|
120
|
+
<li key={item.target}>
|
|
121
|
+
<NavLink
|
|
122
|
+
to={item.target}
|
|
123
|
+
className={({ isActive }) => cn(
|
|
124
|
+
'group w-full flex items-center gap-3 px-3 py-2 rounded-xl text-[13px] font-medium transition-all duration-150',
|
|
125
|
+
isActive
|
|
126
|
+
? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
|
|
127
|
+
: 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
{({ isActive }) => (
|
|
131
|
+
<>
|
|
132
|
+
<Icon className={cn(
|
|
133
|
+
'h-4 w-4 transition-colors',
|
|
134
|
+
isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800'
|
|
135
|
+
)} />
|
|
136
|
+
<span>{item.label()}</span>
|
|
137
|
+
</>
|
|
138
|
+
)}
|
|
139
|
+
</NavLink>
|
|
140
|
+
</li>
|
|
141
|
+
);
|
|
142
|
+
})}
|
|
143
|
+
</ul>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{/* Divider */}
|
|
147
|
+
<div className="mx-4 border-t border-gray-200/60" />
|
|
148
|
+
|
|
149
|
+
{/* Session history */}
|
|
150
|
+
<div className="flex-1 min-h-0 overflow-y-auto custom-scrollbar px-3 py-2">
|
|
151
|
+
{props.isLoading ? (
|
|
152
|
+
<div className="text-xs text-gray-500 p-3">{t('sessionsLoading')}</div>
|
|
153
|
+
) : groups.length === 0 ? (
|
|
154
|
+
<div className="p-4 text-center">
|
|
155
|
+
<MessageSquareText className="h-6 w-6 mx-auto mb-2 text-gray-300" />
|
|
156
|
+
<div className="text-xs text-gray-500">{t('sessionsEmpty')}</div>
|
|
157
|
+
</div>
|
|
158
|
+
) : (
|
|
159
|
+
<div className="space-y-3">
|
|
160
|
+
{groups.map((group) => (
|
|
161
|
+
<div key={group.label}>
|
|
162
|
+
<div className="px-2 py-1 text-[11px] font-medium text-gray-400 uppercase tracking-wider">
|
|
163
|
+
{group.label}
|
|
164
|
+
</div>
|
|
165
|
+
<div className="space-y-0.5">
|
|
166
|
+
{group.sessions.map((session) => {
|
|
167
|
+
const active = props.selectedSessionKey === session.key;
|
|
168
|
+
return (
|
|
169
|
+
<button
|
|
170
|
+
key={session.key}
|
|
171
|
+
onClick={() => props.onSelectSession(session.key)}
|
|
172
|
+
className={cn(
|
|
173
|
+
'w-full rounded-xl px-3 py-2 text-left transition-all text-[13px]',
|
|
174
|
+
active
|
|
175
|
+
? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
|
|
176
|
+
: 'text-gray-700 hover:bg-gray-200/60 hover:text-gray-900'
|
|
177
|
+
)}
|
|
178
|
+
>
|
|
179
|
+
<div className="truncate font-medium">{props.sessionTitle(session)}</div>
|
|
180
|
+
<div className="mt-0.5 text-[11px] text-gray-400 truncate">
|
|
181
|
+
{session.messageCount} · {formatDateTime(session.updatedAt)}
|
|
182
|
+
</div>
|
|
183
|
+
</button>
|
|
184
|
+
);
|
|
185
|
+
})}
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
))}
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
{/* Settings footer */}
|
|
194
|
+
<div className="px-3 py-3 border-t border-gray-200/60 space-y-0.5">
|
|
195
|
+
<NavLink
|
|
196
|
+
to="/settings"
|
|
197
|
+
className={({ isActive }) => cn(
|
|
198
|
+
'group w-full flex items-center gap-2.5 px-3 py-2 rounded-xl text-[13px] font-medium transition-all duration-150',
|
|
199
|
+
isActive
|
|
200
|
+
? 'bg-gray-200 text-gray-900 font-semibold shadow-sm'
|
|
201
|
+
: 'text-gray-600 hover:bg-gray-200/60 hover:text-gray-900'
|
|
202
|
+
)}
|
|
203
|
+
>
|
|
204
|
+
{({ isActive }) => (
|
|
205
|
+
<>
|
|
206
|
+
<Settings className={cn('h-4 w-4 transition-colors', isActive ? 'text-gray-900' : 'text-gray-500 group-hover:text-gray-800')} />
|
|
207
|
+
<span>{t('settings')}</span>
|
|
208
|
+
</>
|
|
209
|
+
)}
|
|
210
|
+
</NavLink>
|
|
211
|
+
<Select value={theme} onValueChange={(v) => setTheme(v as UiTheme)}>
|
|
212
|
+
<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">
|
|
213
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
214
|
+
<Palette className="h-4 w-4 text-gray-400" />
|
|
215
|
+
<span>{t('theme')}</span>
|
|
216
|
+
</div>
|
|
217
|
+
<span className="ml-auto text-[11px] text-gray-500">{currentThemeLabel}</span>
|
|
218
|
+
</SelectTrigger>
|
|
219
|
+
<SelectContent>
|
|
220
|
+
{THEME_OPTIONS.map((o) => (
|
|
221
|
+
<SelectItem key={o.value} value={o.value} className="text-xs">{t(o.labelKey)}</SelectItem>
|
|
222
|
+
))}
|
|
223
|
+
</SelectContent>
|
|
224
|
+
</Select>
|
|
225
|
+
<Select value={language} onValueChange={(v) => handleLanguageSwitch(v as I18nLanguage)}>
|
|
226
|
+
<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">
|
|
227
|
+
<div className="flex items-center gap-2.5 min-w-0">
|
|
228
|
+
<Languages className="h-4 w-4 text-gray-400" />
|
|
229
|
+
<span>{t('language')}</span>
|
|
230
|
+
</div>
|
|
231
|
+
<span className="ml-auto text-[11px] text-gray-500">{currentLanguageLabel}</span>
|
|
232
|
+
</SelectTrigger>
|
|
233
|
+
<SelectContent>
|
|
234
|
+
{LANGUAGE_OPTIONS.map((o) => (
|
|
235
|
+
<SelectItem key={o.value} value={o.value} className="text-xs">{o.label}</SelectItem>
|
|
236
|
+
))}
|
|
237
|
+
</SelectContent>
|
|
238
|
+
</Select>
|
|
239
|
+
</div>
|
|
240
|
+
</aside>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useMemo } from 'react';
|
|
1
|
+
import { useMemo, type ReactNode } from 'react';
|
|
2
2
|
import type { SessionEventView, SessionMessageView } from '@/api/types';
|
|
3
3
|
import { cn } from '@/lib/utils';
|
|
4
4
|
import {
|
|
@@ -25,6 +25,10 @@ type ChatThreadProps = {
|
|
|
25
25
|
const MARKDOWN_MAX_CHARS = 140_000;
|
|
26
26
|
const TOOL_OUTPUT_PREVIEW_MAX = 220;
|
|
27
27
|
|
|
28
|
+
type WorkflowToolCard = ToolCard & {
|
|
29
|
+
_workflowStep?: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
28
32
|
function trimMarkdown(value: string): string {
|
|
29
33
|
if (value.length <= MARKDOWN_MAX_CHARS) {
|
|
30
34
|
return value;
|
|
@@ -104,7 +108,7 @@ function MarkdownBlock({ text, role }: { text: string; role: ChatRole }) {
|
|
|
104
108
|
);
|
|
105
109
|
}
|
|
106
110
|
|
|
107
|
-
function ToolCardView({ card }: { card:
|
|
111
|
+
function ToolCardView({ card }: { card: WorkflowToolCard }) {
|
|
108
112
|
const title = card.kind === 'call' ? t('chatToolCall') : t('chatToolResult');
|
|
109
113
|
const output = card.text?.trim() ?? '';
|
|
110
114
|
const showDetails = output.length > TOOL_OUTPUT_PREVIEW_MAX || output.includes('\n');
|
|
@@ -113,9 +117,12 @@ function ToolCardView({ card }: { card: ToolCard }) {
|
|
|
113
117
|
|
|
114
118
|
return (
|
|
115
119
|
<div className="rounded-xl border border-amber-200/80 bg-amber-50/60 px-3 py-2.5">
|
|
116
|
-
<div className="flex items-center gap-2 text-xs text-amber-800 font-semibold">
|
|
120
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
|
|
117
121
|
{renderToolIcon(card.name)}
|
|
118
122
|
<span>{title}</span>
|
|
123
|
+
{typeof card._workflowStep === 'number' && (
|
|
124
|
+
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">#{card._workflowStep + 1}</span>
|
|
125
|
+
)}
|
|
119
126
|
<span className="font-mono text-[11px] text-amber-900/80">{card.name}</span>
|
|
120
127
|
</div>
|
|
121
128
|
{card.detail && (
|
|
@@ -143,6 +150,35 @@ function ToolCardView({ card }: { card: ToolCard }) {
|
|
|
143
150
|
);
|
|
144
151
|
}
|
|
145
152
|
|
|
153
|
+
function ToolWorkflowCard({ cards }: { cards: WorkflowToolCard[] }) {
|
|
154
|
+
const chain = cards
|
|
155
|
+
.map((card) => card.name.trim())
|
|
156
|
+
.filter((name) => name.length > 0)
|
|
157
|
+
.join(' \u2192 ');
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<details className="rounded-xl border border-amber-200/80 bg-amber-50/50 p-3">
|
|
161
|
+
<summary className="cursor-pointer list-none">
|
|
162
|
+
<div className="flex flex-wrap items-center gap-2 text-xs text-amber-800 font-semibold">
|
|
163
|
+
<Wrench className="h-3.5 w-3.5" />
|
|
164
|
+
<span>{t('chatToolWorkflow')}</span>
|
|
165
|
+
<span className="font-mono text-[11px] text-amber-900/90">{chain || 'tool'}</span>
|
|
166
|
+
<span className="rounded-md bg-amber-100 px-1.5 py-0.5 text-[10px] text-amber-700">{cards.length}</span>
|
|
167
|
+
<span className="text-[11px] font-normal text-amber-700/90">{t('chatToolWorkflowDetails')}</span>
|
|
168
|
+
</div>
|
|
169
|
+
</summary>
|
|
170
|
+
<div className="mt-3 space-y-2">
|
|
171
|
+
{cards.map((card, index) => (
|
|
172
|
+
<ToolCardView
|
|
173
|
+
key={`${card.kind}-${card.callId ?? card.name}-${index}`}
|
|
174
|
+
card={{ ...card, _workflowStep: index }}
|
|
175
|
+
/>
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
</details>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
146
182
|
function ReasoningBlock({ reasoning, isUser }: { reasoning: string; isUser: boolean }) {
|
|
147
183
|
return (
|
|
148
184
|
<details className="mt-3">
|
|
@@ -179,9 +215,16 @@ function MessageCard({ message }: { message: SessionMessageView }) {
|
|
|
179
215
|
{primaryReasoning && <ReasoningBlock reasoning={primaryReasoning} isUser={isUser} />}
|
|
180
216
|
{toolCards.length > 0 && (
|
|
181
217
|
<div className={cn('space-y-2', (shouldRenderPrimaryText || primaryReasoning) && 'mt-3')}>
|
|
182
|
-
{toolCards.
|
|
183
|
-
<
|
|
184
|
-
)
|
|
218
|
+
{toolCards.length > 1 ? (
|
|
219
|
+
<ToolWorkflowCard cards={toolCards} />
|
|
220
|
+
) : (
|
|
221
|
+
toolCards.map((card, index) => (
|
|
222
|
+
<ToolCardView
|
|
223
|
+
key={`${card.kind}-${card.name}-${card.callId ?? index}`}
|
|
224
|
+
card={{ ...card }}
|
|
225
|
+
/>
|
|
226
|
+
))
|
|
227
|
+
)}
|
|
185
228
|
</div>
|
|
186
229
|
)}
|
|
187
230
|
</div>
|
|
@@ -189,26 +232,50 @@ function MessageCard({ message }: { message: SessionMessageView }) {
|
|
|
189
232
|
}
|
|
190
233
|
|
|
191
234
|
function AssistantTurnCard({ item }: { item: ChatTimelineAssistantTurnItem }) {
|
|
235
|
+
const renderedSegments: ReactNode[] = [];
|
|
236
|
+
let index = 0;
|
|
237
|
+
while (index < item.segments.length) {
|
|
238
|
+
const segment = item.segments[index];
|
|
239
|
+
if (!segment) {
|
|
240
|
+
index += 1;
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (segment.kind === 'assistant_message') {
|
|
244
|
+
const hasText = Boolean(segment.text);
|
|
245
|
+
const hasReasoning = Boolean(segment.reasoning);
|
|
246
|
+
if (hasText || hasReasoning) {
|
|
247
|
+
renderedSegments.push(
|
|
248
|
+
<div key={`${segment.key}-${index}`}>
|
|
249
|
+
{hasText && <MarkdownBlock text={segment.text} role="assistant" />}
|
|
250
|
+
{hasReasoning && <ReasoningBlock reasoning={segment.reasoning} isUser={false} />}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
index += 1;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const groupedCards: WorkflowToolCard[] = [];
|
|
259
|
+
let cursor = index;
|
|
260
|
+
while (cursor < item.segments.length) {
|
|
261
|
+
const current = item.segments[cursor];
|
|
262
|
+
if (!current || current.kind !== 'tool_card') {
|
|
263
|
+
break;
|
|
264
|
+
}
|
|
265
|
+
groupedCards.push(current.card);
|
|
266
|
+
cursor += 1;
|
|
267
|
+
}
|
|
268
|
+
if (groupedCards.length > 1) {
|
|
269
|
+
renderedSegments.push(<ToolWorkflowCard key={`workflow-${segment.key}-${index}`} cards={groupedCards} />);
|
|
270
|
+
} else if (groupedCards.length === 1) {
|
|
271
|
+
renderedSegments.push(<ToolCardView key={`${segment.key}-${index}`} card={groupedCards[0]} />);
|
|
272
|
+
}
|
|
273
|
+
index = cursor;
|
|
274
|
+
}
|
|
275
|
+
|
|
192
276
|
return (
|
|
193
277
|
<div className="rounded-2xl border px-4 py-3 shadow-sm bg-white text-gray-900 border-gray-200">
|
|
194
|
-
<div className="space-y-3">
|
|
195
|
-
{item.segments.map((segment, index) => {
|
|
196
|
-
if (segment.kind === 'assistant_message') {
|
|
197
|
-
const hasText = Boolean(segment.text);
|
|
198
|
-
const hasReasoning = Boolean(segment.reasoning);
|
|
199
|
-
if (!hasText && !hasReasoning) {
|
|
200
|
-
return null;
|
|
201
|
-
}
|
|
202
|
-
return (
|
|
203
|
-
<div key={`${segment.key}-${index}`}>
|
|
204
|
-
{hasText && <MarkdownBlock text={segment.text} role="assistant" />}
|
|
205
|
-
{hasReasoning && <ReasoningBlock reasoning={segment.reasoning} isUser={false} />}
|
|
206
|
-
</div>
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
return <ToolCardView key={`${segment.key}-${index}`} card={segment.card} />;
|
|
210
|
-
})}
|
|
211
|
-
</div>
|
|
278
|
+
<div className="space-y-3">{renderedSegments}</div>
|
|
212
279
|
</div>
|
|
213
280
|
);
|
|
214
281
|
}
|
|
@@ -224,7 +291,7 @@ export function ChatThread({ events, isSending, className }: ChatThreadProps) {
|
|
|
224
291
|
return (
|
|
225
292
|
<div key={item.key} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
|
|
226
293
|
{!isUser && <RoleAvatar role={role} />}
|
|
227
|
-
<div className={cn('max-w-[
|
|
294
|
+
<div className={cn('max-w-[92%] min-w-[280px] space-y-2', isUser && 'flex flex-col items-end')}>
|
|
228
295
|
{item.kind === 'assistant_turn' ? (
|
|
229
296
|
<AssistantTurnCard item={item} />
|
|
230
297
|
) : (
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { t } from '@/lib/i18n';
|
|
2
|
+
import { Bot, BrainCircuit, AlarmClock, MessageCircle } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
type ChatWelcomeProps = {
|
|
5
|
+
onCreateSession: () => void;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const capabilities = [
|
|
9
|
+
{
|
|
10
|
+
icon: MessageCircle,
|
|
11
|
+
titleKey: 'chatWelcomeCapability1Title' as const,
|
|
12
|
+
descKey: 'chatWelcomeCapability1Desc' as const,
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
icon: BrainCircuit,
|
|
16
|
+
titleKey: 'chatWelcomeCapability2Title' as const,
|
|
17
|
+
descKey: 'chatWelcomeCapability2Desc' as const,
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
icon: AlarmClock,
|
|
21
|
+
titleKey: 'chatWelcomeCapability3Title' as const,
|
|
22
|
+
descKey: 'chatWelcomeCapability3Desc' as const,
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
export function ChatWelcome({ onCreateSession }: ChatWelcomeProps) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="h-full flex items-center justify-center p-8">
|
|
29
|
+
<div className="max-w-lg w-full text-center">
|
|
30
|
+
{/* Bot avatar */}
|
|
31
|
+
<div className="mx-auto mb-6 h-16 w-16 rounded-2xl bg-primary/10 flex items-center justify-center">
|
|
32
|
+
<Bot className="h-8 w-8 text-primary" />
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
{/* Greeting */}
|
|
36
|
+
<h2 className="text-xl font-semibold text-gray-900 mb-2">{t('chatWelcomeTitle')}</h2>
|
|
37
|
+
<p className="text-sm text-gray-500 mb-8">{t('chatWelcomeSubtitle')}</p>
|
|
38
|
+
|
|
39
|
+
{/* Capability cards */}
|
|
40
|
+
<div className="grid grid-cols-3 gap-3">
|
|
41
|
+
{capabilities.map((cap) => {
|
|
42
|
+
const Icon = cap.icon;
|
|
43
|
+
return (
|
|
44
|
+
<button
|
|
45
|
+
key={cap.titleKey}
|
|
46
|
+
onClick={onCreateSession}
|
|
47
|
+
className="rounded-2xl border border-gray-200 bg-white p-4 text-left shadow-card hover:shadow-card-hover transition-shadow cursor-pointer"
|
|
48
|
+
>
|
|
49
|
+
<div className="h-9 w-9 rounded-xl bg-primary/8 flex items-center justify-center mb-3">
|
|
50
|
+
<Icon className="h-4.5 w-4.5 text-primary" />
|
|
51
|
+
</div>
|
|
52
|
+
<div className="text-sm font-semibold text-gray-900 mb-1">{t(cap.titleKey)}</div>
|
|
53
|
+
<div className="text-[11px] text-gray-500 leading-relaxed">{t(cap.descKey)}</div>
|
|
54
|
+
</button>
|
|
55
|
+
);
|
|
56
|
+
})}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
}
|