@nextclaw/ui 0.5.19 → 0.5.21
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 +12 -0
- package/dist/assets/ChannelsList-TFFw4Cem.js +1 -0
- package/dist/assets/ChatPage-BUm3UPap.js +32 -0
- package/dist/assets/CronConfig-9dYfTRJl.js +1 -0
- package/dist/assets/{DocBrowser-BDQFCtYk.js → DocBrowser-BIV0vpA0.js} +1 -1
- package/dist/assets/MarketplacePage-2Zi0JSVi.js +1 -0
- package/dist/assets/{ModelConfig-Ditrj5-j.js → ModelConfig-h21P5rV0.js} +1 -1
- package/dist/assets/ProvidersList-DEaK1a3y.js +1 -0
- package/dist/assets/RuntimeConfig-DXMzf-gF.js +1 -0
- package/dist/assets/SessionsConfig-SdXvn_9E.js +2 -0
- package/dist/assets/{action-link-C13-2zV4.js → action-link-C9xMkxl2.js} +1 -1
- package/dist/assets/{card-wQP6HJ6W.js → card-Cnqfntk5.js} +1 -1
- package/dist/assets/chat-message-B7oqvJ2d.js +3 -0
- package/dist/assets/dialog-DJs630RE.js +5 -0
- package/dist/assets/index-CrUDzcei.js +2 -0
- package/dist/assets/index-Zy7fAOe1.css +1 -0
- package/dist/assets/{label-BNwROQB2.js → label-CXGuE6Oa.js} +1 -1
- package/dist/assets/{page-layout-BXJRMNor.js → page-layout-BVZlyPFt.js} +1 -1
- package/dist/assets/{switch-CsMPT5De.js → switch-BLF45eI3.js} +1 -1
- package/dist/assets/{tabs-custom-DczZ7pO4.js → tabs-custom-DQ0GpEV5.js} +1 -1
- package/dist/assets/useConfig-vFQvF4kn.js +1 -0
- package/dist/assets/{useConfirmDialog-CKMEeckR.js → useConfirmDialog-CK7KAyDf.js} +1 -1
- package/dist/assets/{vendor-Bhv7yx8z.js → vendor-RXIbhDBC.js} +95 -60
- package/dist/index.html +3 -3
- package/package.json +4 -1
- package/src/App.tsx +4 -2
- package/src/api/config.ts +11 -0
- package/src/api/types.ts +23 -1
- package/src/components/chat/ChatPage.tsx +378 -0
- package/src/components/chat/ChatThread.tsx +210 -0
- package/src/components/config/CronConfig.tsx +1 -1
- package/src/components/config/SessionsConfig.tsx +4 -2
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/components/marketplace/MarketplacePage.tsx +39 -20
- package/src/hooks/useConfig.ts +11 -0
- package/src/index.css +69 -0
- package/src/lib/chat-message.ts +215 -0
- package/src/lib/i18n.ts +36 -0
- package/dist/assets/ChannelsList-GbfILabx.js +0 -1
- package/dist/assets/CronConfig-BV-xuLqt.js +0 -1
- package/dist/assets/MarketplacePage-B2BPK3EZ.js +0 -1
- package/dist/assets/ProvidersList-Dl-9bD7O.js +0 -1
- package/dist/assets/RuntimeConfig-DTzfw7el.js +0 -1
- package/dist/assets/SessionsConfig-CBD8eCim.js +0 -2
- package/dist/assets/dialog-CCWBaWyg.js +0 -5
- package/dist/assets/index-CDWTAUj_.js +0 -2
- package/dist/assets/index-DdpR1fdj.css +0 -1
- package/dist/assets/useConfig-JBr27I5l.js +0 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { SessionMessageView } from '@/api/types';
|
|
3
|
+
import { cn } from '@/lib/utils';
|
|
4
|
+
import { extractMessageText, extractToolCards, groupChatMessages, type ChatRole, type ToolCard } from '@/lib/chat-message';
|
|
5
|
+
import { formatDateTime, t } from '@/lib/i18n';
|
|
6
|
+
import ReactMarkdown from 'react-markdown';
|
|
7
|
+
import rehypeSanitize from 'rehype-sanitize';
|
|
8
|
+
import remarkGfm from 'remark-gfm';
|
|
9
|
+
import { Bot, Clock3, FileSearch, Globe, Search, SendHorizontal, Terminal, User, Wrench } from 'lucide-react';
|
|
10
|
+
|
|
11
|
+
type ChatThreadProps = {
|
|
12
|
+
messages: SessionMessageView[];
|
|
13
|
+
isSending: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MARKDOWN_MAX_CHARS = 140_000;
|
|
18
|
+
const TOOL_OUTPUT_PREVIEW_MAX = 220;
|
|
19
|
+
|
|
20
|
+
function trimMarkdown(value: string): string {
|
|
21
|
+
if (value.length <= MARKDOWN_MAX_CHARS) {
|
|
22
|
+
return value;
|
|
23
|
+
}
|
|
24
|
+
return `${value.slice(0, MARKDOWN_MAX_CHARS)}\n\n…`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function roleTitle(role: ChatRole): string {
|
|
28
|
+
if (role === 'user') return t('chatRoleUser');
|
|
29
|
+
if (role === 'assistant') return t('chatRoleAssistant');
|
|
30
|
+
if (role === 'tool') return t('chatRoleTool');
|
|
31
|
+
if (role === 'system') return t('chatRoleSystem');
|
|
32
|
+
return t('chatRoleMessage');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function renderToolIcon(name: string) {
|
|
36
|
+
const lowered = name.toLowerCase();
|
|
37
|
+
if (lowered.includes('exec') || lowered.includes('shell') || lowered.includes('command')) {
|
|
38
|
+
return <Terminal className="h-3.5 w-3.5" />;
|
|
39
|
+
}
|
|
40
|
+
if (lowered.includes('search')) {
|
|
41
|
+
return <Search className="h-3.5 w-3.5" />;
|
|
42
|
+
}
|
|
43
|
+
if (lowered.includes('fetch') || lowered.includes('http') || lowered.includes('web')) {
|
|
44
|
+
return <Globe className="h-3.5 w-3.5" />;
|
|
45
|
+
}
|
|
46
|
+
if (lowered.includes('read') || lowered.includes('file')) {
|
|
47
|
+
return <FileSearch className="h-3.5 w-3.5" />;
|
|
48
|
+
}
|
|
49
|
+
if (lowered.includes('message') || lowered.includes('send')) {
|
|
50
|
+
return <SendHorizontal className="h-3.5 w-3.5" />;
|
|
51
|
+
}
|
|
52
|
+
if (lowered.includes('cron') || lowered.includes('schedule')) {
|
|
53
|
+
return <Clock3 className="h-3.5 w-3.5" />;
|
|
54
|
+
}
|
|
55
|
+
return <Wrench className="h-3.5 w-3.5" />;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function RoleAvatar({ role }: { role: ChatRole }) {
|
|
59
|
+
if (role === 'user') {
|
|
60
|
+
return (
|
|
61
|
+
<div className="h-8 w-8 rounded-full bg-primary text-white flex items-center justify-center shadow-sm">
|
|
62
|
+
<User className="h-4 w-4" />
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (role === 'assistant') {
|
|
67
|
+
return (
|
|
68
|
+
<div className="h-8 w-8 rounded-full bg-slate-900 text-white flex items-center justify-center shadow-sm">
|
|
69
|
+
<Bot className="h-4 w-4" />
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return (
|
|
74
|
+
<div className="h-8 w-8 rounded-full bg-amber-100 text-amber-700 flex items-center justify-center shadow-sm">
|
|
75
|
+
<Wrench className="h-4 w-4" />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function MarkdownBlock({ text, role }: { text: string; role: ChatRole }) {
|
|
81
|
+
const isUser = role === 'user';
|
|
82
|
+
return (
|
|
83
|
+
<div className={cn('chat-markdown', isUser ? 'chat-markdown-user' : 'chat-markdown-assistant')}>
|
|
84
|
+
<ReactMarkdown
|
|
85
|
+
remarkPlugins={[remarkGfm]}
|
|
86
|
+
rehypePlugins={[rehypeSanitize]}
|
|
87
|
+
components={{
|
|
88
|
+
a: ({ ...props }) => (
|
|
89
|
+
<a {...props} target="_blank" rel="noreferrer noopener" />
|
|
90
|
+
)
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
{trimMarkdown(text)}
|
|
94
|
+
</ReactMarkdown>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ToolCardView({ card }: { card: ToolCard }) {
|
|
100
|
+
const title = card.kind === 'call' ? t('chatToolCall') : t('chatToolResult');
|
|
101
|
+
const output = card.text?.trim() ?? '';
|
|
102
|
+
const showDetails = output.length > TOOL_OUTPUT_PREVIEW_MAX || output.includes('\n');
|
|
103
|
+
const preview = showDetails ? `${output.slice(0, TOOL_OUTPUT_PREVIEW_MAX)}…` : output;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="rounded-xl border border-amber-200/80 bg-amber-50/60 px-3 py-2.5">
|
|
107
|
+
<div className="flex items-center gap-2 text-xs text-amber-800 font-semibold">
|
|
108
|
+
{renderToolIcon(card.name)}
|
|
109
|
+
<span>{title}</span>
|
|
110
|
+
<span className="font-mono text-[11px] text-amber-900/80">{card.name}</span>
|
|
111
|
+
</div>
|
|
112
|
+
{card.detail && (
|
|
113
|
+
<div className="mt-1 text-[11px] text-amber-800/90 font-mono break-words">{card.detail}</div>
|
|
114
|
+
)}
|
|
115
|
+
{card.kind === 'result' && (
|
|
116
|
+
<div className="mt-2">
|
|
117
|
+
{!output ? (
|
|
118
|
+
<div className="text-[11px] text-amber-700/80">{t('chatToolNoOutput')}</div>
|
|
119
|
+
) : showDetails ? (
|
|
120
|
+
<details className="group">
|
|
121
|
+
<summary className="cursor-pointer text-[11px] text-amber-700">{t('chatToolOutput')}</summary>
|
|
122
|
+
<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">
|
|
123
|
+
{output}
|
|
124
|
+
</pre>
|
|
125
|
+
</details>
|
|
126
|
+
) : (
|
|
127
|
+
<pre className="rounded-lg border border-amber-200 bg-amber-100/40 p-2 text-[11px] whitespace-pre-wrap break-words text-amber-900">
|
|
128
|
+
{preview}
|
|
129
|
+
</pre>
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function MessageCard({ message, role }: { message: SessionMessageView; role: ChatRole }) {
|
|
138
|
+
const text = extractMessageText(message.content).trim();
|
|
139
|
+
const toolCards = extractToolCards(message);
|
|
140
|
+
const reasoning = typeof message.reasoning_content === 'string' ? message.reasoning_content.trim() : '';
|
|
141
|
+
const shouldRenderText = Boolean(text) && !(role === 'tool' && toolCards.length > 0);
|
|
142
|
+
const isUser = role === 'user';
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<div
|
|
146
|
+
className={cn(
|
|
147
|
+
'rounded-2xl border px-4 py-3 shadow-sm',
|
|
148
|
+
isUser
|
|
149
|
+
? 'bg-primary text-white border-primary'
|
|
150
|
+
: role === 'assistant'
|
|
151
|
+
? 'bg-white text-gray-900 border-gray-200'
|
|
152
|
+
: 'bg-orange-50/70 text-gray-900 border-orange-200/80'
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
{shouldRenderText && <MarkdownBlock text={text} role={role} />}
|
|
156
|
+
{reasoning && (
|
|
157
|
+
<details className="mt-3">
|
|
158
|
+
<summary className={cn('cursor-pointer text-xs', isUser ? 'text-primary-100' : 'text-gray-500')}>
|
|
159
|
+
{t('chatReasoning')}
|
|
160
|
+
</summary>
|
|
161
|
+
<pre className={cn('mt-2 text-[11px] whitespace-pre-wrap break-words rounded-lg p-2', isUser ? 'bg-primary-700/60' : 'bg-gray-100')}>
|
|
162
|
+
{reasoning}
|
|
163
|
+
</pre>
|
|
164
|
+
</details>
|
|
165
|
+
)}
|
|
166
|
+
{toolCards.length > 0 && (
|
|
167
|
+
<div className="mt-3 space-y-2">
|
|
168
|
+
{toolCards.map((card, index) => (
|
|
169
|
+
<ToolCardView key={`${card.kind}-${card.name}-${card.callId ?? index}`} card={card} />
|
|
170
|
+
))}
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
</div>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function ChatThread({ messages, isSending, className }: ChatThreadProps) {
|
|
178
|
+
const groups = useMemo(() => groupChatMessages(messages), [messages]);
|
|
179
|
+
|
|
180
|
+
return (
|
|
181
|
+
<div className={cn('space-y-5', className)}>
|
|
182
|
+
{groups.map((group) => {
|
|
183
|
+
const isUser = group.role === 'user';
|
|
184
|
+
return (
|
|
185
|
+
<div key={group.key} className={cn('flex gap-3', isUser ? 'justify-end' : 'justify-start')}>
|
|
186
|
+
{!isUser && <RoleAvatar role={group.role} />}
|
|
187
|
+
<div className={cn('max-w-[88%] min-w-[280px] space-y-2', isUser && 'flex flex-col items-end')}>
|
|
188
|
+
{group.messages.map((message, index) => (
|
|
189
|
+
<MessageCard key={`${group.key}-${index}`} message={message} role={group.role} />
|
|
190
|
+
))}
|
|
191
|
+
<div className={cn('text-[11px] px-1', isUser ? 'text-primary-300' : 'text-gray-400')}>
|
|
192
|
+
{roleTitle(group.role)} · {formatDateTime(group.timestamp)}
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
{isUser && <RoleAvatar role={group.role} />}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
})}
|
|
199
|
+
|
|
200
|
+
{isSending && (
|
|
201
|
+
<div className="flex gap-3 justify-start">
|
|
202
|
+
<RoleAvatar role="assistant" />
|
|
203
|
+
<div className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-500 shadow-sm">
|
|
204
|
+
{t('chatTyping')}
|
|
205
|
+
</div>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
);
|
|
210
|
+
}
|
|
@@ -8,7 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|
|
8
8
|
import { Card, CardContent } from '@/components/ui/card';
|
|
9
9
|
import { cn } from '@/lib/utils';
|
|
10
10
|
import { formatDateTime, t } from '@/lib/i18n';
|
|
11
|
-
import { PageLayout, PageHeader
|
|
11
|
+
import { PageLayout, PageHeader } from '@/components/layout/page-layout';
|
|
12
12
|
import { AlarmClock, RefreshCw, Trash2, Play, Power } from 'lucide-react';
|
|
13
13
|
|
|
14
14
|
type StatusFilter = 'all' | 'enabled' | 'disabled';
|
|
@@ -7,7 +7,8 @@ import { Input } from '@/components/ui/input';
|
|
|
7
7
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
8
8
|
import { cn } from '@/lib/utils';
|
|
9
9
|
import { formatDateShort, formatDateTime, t } from '@/lib/i18n';
|
|
10
|
-
import {
|
|
10
|
+
import { extractMessageText } from '@/lib/chat-message';
|
|
11
|
+
import { PageLayout, PageHeader } from '@/components/layout/page-layout';
|
|
11
12
|
import { RefreshCw, Search, Clock, Inbox, Hash, Bot, User, MessageCircle, Settings as SettingsIcon } from 'lucide-react';
|
|
12
13
|
|
|
13
14
|
const UNKNOWN_CHANNEL_KEY = '__unknown_channel__';
|
|
@@ -86,6 +87,7 @@ function SessionListItem({ session, channel, isSelected, onSelect }: SessionList
|
|
|
86
87
|
|
|
87
88
|
function SessionMessageBubble({ message }: { message: SessionMessageView }) {
|
|
88
89
|
const isUser = message.role.toLowerCase() === 'user';
|
|
90
|
+
const content = extractMessageText(message.content).trim();
|
|
89
91
|
|
|
90
92
|
return (
|
|
91
93
|
<div className={cn("flex w-full mb-6", isUser ? "justify-end" : "justify-start")}>
|
|
@@ -108,7 +110,7 @@ function SessionMessageBubble({ message }: { message: SessionMessageView }) {
|
|
|
108
110
|
</span>
|
|
109
111
|
</div>
|
|
110
112
|
<div className="whitespace-pre-wrap break-words leading-relaxed text-[15px]">
|
|
111
|
-
{
|
|
113
|
+
{content || '-'}
|
|
112
114
|
</div>
|
|
113
115
|
</div>
|
|
114
116
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { cn } from '@/lib/utils';
|
|
2
2
|
import { LANGUAGE_OPTIONS, t, type I18nLanguage } from '@/lib/i18n';
|
|
3
3
|
import { THEME_OPTIONS, type UiTheme } from '@/lib/theme';
|
|
4
|
-
import { Cpu, GitBranch, History, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette } from 'lucide-react';
|
|
4
|
+
import { Cpu, GitBranch, History, MessageCircle, MessageSquare, Sparkles, BookOpen, Plug, BrainCircuit, AlarmClock, Languages, Palette } from 'lucide-react';
|
|
5
5
|
import { NavLink } from 'react-router-dom';
|
|
6
6
|
import { useDocBrowser } from '@/components/doc-browser';
|
|
7
7
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
@@ -31,6 +31,11 @@ export function Sidebar() {
|
|
|
31
31
|
};
|
|
32
32
|
|
|
33
33
|
const navItems = [
|
|
34
|
+
{
|
|
35
|
+
target: '/chat',
|
|
36
|
+
label: t('chat'),
|
|
37
|
+
icon: MessageCircle,
|
|
38
|
+
},
|
|
34
39
|
{
|
|
35
40
|
target: '/model',
|
|
36
41
|
label: t('model'),
|
|
@@ -28,8 +28,7 @@ const PAGE_SIZE = 12;
|
|
|
28
28
|
type ScopeType = 'all' | 'installed';
|
|
29
29
|
|
|
30
30
|
type InstallState = {
|
|
31
|
-
|
|
32
|
-
installingSpec?: string;
|
|
31
|
+
installingSpecs: ReadonlySet<string>;
|
|
33
32
|
};
|
|
34
33
|
|
|
35
34
|
type ManageState = {
|
|
@@ -234,7 +233,8 @@ function MarketplaceListCard(props: {
|
|
|
234
233
|
const canUninstall = Boolean(canUninstallPlugin || canUninstallSkill);
|
|
235
234
|
|
|
236
235
|
const isDisabled = record ? (record.enabled === false || record.runtimeStatus === 'disabled') : false;
|
|
237
|
-
const
|
|
236
|
+
const installSpec = props.item?.install.spec;
|
|
237
|
+
const isInstalling = typeof installSpec === 'string' && props.installState.installingSpecs.has(installSpec);
|
|
238
238
|
|
|
239
239
|
const displayType = type === 'plugin' ? t('marketplaceTypePlugin') : type === 'skill' ? t('marketplaceTypeSkill') : t('marketplaceTypeExtension');
|
|
240
240
|
|
|
@@ -288,7 +288,7 @@ function MarketplaceListCard(props: {
|
|
|
288
288
|
{props.item && !record && (
|
|
289
289
|
<button
|
|
290
290
|
onClick={() => props.onInstall(props.item as MarketplaceItemSummary)}
|
|
291
|
-
disabled={
|
|
291
|
+
disabled={isInstalling}
|
|
292
292
|
className="inline-flex items-center gap-1.5 h-8 px-4 rounded-xl text-xs font-medium bg-primary text-white hover:bg-primary-600 disabled:opacity-50 transition-colors"
|
|
293
293
|
>
|
|
294
294
|
{isInstalling ? t('marketplaceInstalling') : t('marketplaceInstall')}
|
|
@@ -405,6 +405,7 @@ export function MarketplacePage() {
|
|
|
405
405
|
const [scope, setScope] = useState<ScopeType>('all');
|
|
406
406
|
const [sort, setSort] = useState<MarketplaceSort>('relevance');
|
|
407
407
|
const [page, setPage] = useState(1);
|
|
408
|
+
const [installingSpecs, setInstallingSpecs] = useState<ReadonlySet<string>>(new Set());
|
|
408
409
|
|
|
409
410
|
useEffect(() => {
|
|
410
411
|
const timer = setTimeout(() => {
|
|
@@ -496,10 +497,7 @@ export function MarketplacePage() {
|
|
|
496
497
|
return `${allItems.length} / ${total}`;
|
|
497
498
|
}, [scope, installedQuery.isLoading, installedEntries.length, itemsQuery.data, allItems.length, total, copyKeys.installedCountSuffix]);
|
|
498
499
|
|
|
499
|
-
const installState: InstallState = {
|
|
500
|
-
isPending: installMutation.isPending,
|
|
501
|
-
installingSpec: installMutation.variables?.spec
|
|
502
|
-
};
|
|
500
|
+
const installState: InstallState = { installingSpecs };
|
|
503
501
|
|
|
504
502
|
const manageState: ManageState = {
|
|
505
503
|
isPending: manageMutation.isPending,
|
|
@@ -511,21 +509,42 @@ export function MarketplacePage() {
|
|
|
511
509
|
{ id: 'all', label: t(copyKeys.tabMarketplace) },
|
|
512
510
|
{ id: 'installed', label: t(copyKeys.tabInstalled), count: installedQuery.data?.total ?? 0 }
|
|
513
511
|
];
|
|
514
|
-
const handleInstall = (item: MarketplaceItemSummary) => {
|
|
515
|
-
|
|
512
|
+
const handleInstall = async (item: MarketplaceItemSummary) => {
|
|
513
|
+
const installSpec = item.install.spec;
|
|
514
|
+
if (installingSpecs.has(installSpec)) {
|
|
516
515
|
return;
|
|
517
516
|
}
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
? {
|
|
524
|
-
skill: item.slug,
|
|
525
|
-
installPath: `skills/${item.slug}`
|
|
526
|
-
}
|
|
527
|
-
: {})
|
|
517
|
+
|
|
518
|
+
setInstallingSpecs((prev) => {
|
|
519
|
+
const next = new Set(prev);
|
|
520
|
+
next.add(installSpec);
|
|
521
|
+
return next;
|
|
528
522
|
});
|
|
523
|
+
|
|
524
|
+
try {
|
|
525
|
+
await installMutation.mutateAsync({
|
|
526
|
+
type: item.type,
|
|
527
|
+
spec: installSpec,
|
|
528
|
+
kind: item.install.kind,
|
|
529
|
+
...(item.type === 'skill'
|
|
530
|
+
? {
|
|
531
|
+
skill: item.slug,
|
|
532
|
+
installPath: `skills/${item.slug}`
|
|
533
|
+
}
|
|
534
|
+
: {})
|
|
535
|
+
});
|
|
536
|
+
} catch {
|
|
537
|
+
// handled in mutation onError
|
|
538
|
+
} finally {
|
|
539
|
+
setInstallingSpecs((prev) => {
|
|
540
|
+
if (!prev.has(installSpec)) {
|
|
541
|
+
return prev;
|
|
542
|
+
}
|
|
543
|
+
const next = new Set(prev);
|
|
544
|
+
next.delete(installSpec);
|
|
545
|
+
return next;
|
|
546
|
+
});
|
|
547
|
+
}
|
|
529
548
|
};
|
|
530
549
|
|
|
531
550
|
const handleManage = async (action: MarketplaceManageAction, record: MarketplaceInstalledRecord) => {
|
package/src/hooks/useConfig.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
fetchSessionHistory,
|
|
13
13
|
updateSession,
|
|
14
14
|
deleteSession,
|
|
15
|
+
sendChatTurn,
|
|
15
16
|
fetchCronJobs,
|
|
16
17
|
deleteCronJob,
|
|
17
18
|
setCronJobEnabled,
|
|
@@ -169,6 +170,16 @@ export function useDeleteSession() {
|
|
|
169
170
|
});
|
|
170
171
|
}
|
|
171
172
|
|
|
173
|
+
export function useSendChatTurn() {
|
|
174
|
+
return useMutation({
|
|
175
|
+
mutationFn: ({ data }: { data: Parameters<typeof sendChatTurn>[0] }) =>
|
|
176
|
+
sendChatTurn(data),
|
|
177
|
+
onError: (error: Error) => {
|
|
178
|
+
toast.error(t('chatSendFailed') + ': ' + error.message);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
export function useCronJobs(params: { all?: boolean } = { all: true }) {
|
|
173
184
|
return useQuery({
|
|
174
185
|
queryKey: ['cron', params],
|
package/src/index.css
CHANGED
|
@@ -182,3 +182,72 @@
|
|
|
182
182
|
.animate-pulse-soft {
|
|
183
183
|
animation: pulse-soft 3s ease-in-out infinite;
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
.chat-markdown {
|
|
187
|
+
font-size: 0.9rem;
|
|
188
|
+
line-height: 1.6;
|
|
189
|
+
word-break: break-word;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.chat-markdown > * + * {
|
|
193
|
+
margin-top: 0.5rem;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
.chat-markdown h1,
|
|
197
|
+
.chat-markdown h2,
|
|
198
|
+
.chat-markdown h3 {
|
|
199
|
+
font-size: 1rem;
|
|
200
|
+
font-weight: 700;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
.chat-markdown ul,
|
|
204
|
+
.chat-markdown ol {
|
|
205
|
+
padding-left: 1.1rem;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
.chat-markdown code {
|
|
209
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
210
|
+
font-size: 0.8rem;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.chat-markdown :not(pre) > code {
|
|
214
|
+
padding: 0.1rem 0.3rem;
|
|
215
|
+
border-radius: 0.35rem;
|
|
216
|
+
background: rgba(148, 163, 184, 0.18);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
.chat-markdown pre {
|
|
220
|
+
overflow-x: auto;
|
|
221
|
+
border-radius: 0.65rem;
|
|
222
|
+
padding: 0.65rem 0.75rem;
|
|
223
|
+
background: rgba(15, 23, 42, 0.9);
|
|
224
|
+
color: #e2e8f0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.chat-markdown table {
|
|
228
|
+
width: 100%;
|
|
229
|
+
border-collapse: collapse;
|
|
230
|
+
font-size: 0.8rem;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.chat-markdown th,
|
|
234
|
+
.chat-markdown td {
|
|
235
|
+
border: 1px solid rgba(148, 163, 184, 0.3);
|
|
236
|
+
padding: 0.35rem 0.45rem;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
.chat-markdown blockquote {
|
|
240
|
+
border-left: 3px solid rgba(148, 163, 184, 0.55);
|
|
241
|
+
padding-left: 0.65rem;
|
|
242
|
+
color: rgba(30, 41, 59, 0.85);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.chat-markdown-user a {
|
|
246
|
+
color: #dbeafe;
|
|
247
|
+
text-decoration: underline;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.chat-markdown-assistant a {
|
|
251
|
+
color: hsl(var(--primary));
|
|
252
|
+
text-decoration: underline;
|
|
253
|
+
}
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import type { SessionMessageView } from '@/api/types';
|
|
2
|
+
|
|
3
|
+
export type ChatRole = 'user' | 'assistant' | 'tool' | 'system' | 'other';
|
|
4
|
+
|
|
5
|
+
export type ToolCard = {
|
|
6
|
+
kind: 'call' | 'result';
|
|
7
|
+
name: string;
|
|
8
|
+
detail?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
callId?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type GroupedChatMessage = {
|
|
14
|
+
key: string;
|
|
15
|
+
role: ChatRole;
|
|
16
|
+
messages: SessionMessageView[];
|
|
17
|
+
timestamp: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const MERGE_WINDOW_MS = 2 * 60 * 1000;
|
|
21
|
+
const TOOL_DETAIL_FIELDS = ['cmd', 'command', 'query', 'q', 'path', 'url', 'to', 'channel', 'agentId', 'sessionKey'];
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function truncateText(value: string, maxChars = 2400): string {
|
|
28
|
+
if (value.length <= maxChars) {
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
return `${value.slice(0, maxChars)}\n…`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function stringifyUnknown(value: unknown): string {
|
|
35
|
+
if (typeof value === 'string') {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
if (typeof value === 'number' || typeof value === 'boolean') {
|
|
39
|
+
return String(value);
|
|
40
|
+
}
|
|
41
|
+
if (value == null) {
|
|
42
|
+
return '';
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
return truncateText(JSON.stringify(value, null, 2));
|
|
46
|
+
} catch {
|
|
47
|
+
return String(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function parseArgsObject(value: unknown): Record<string, unknown> | null {
|
|
52
|
+
if (isRecord(value)) {
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
if (typeof value !== 'string') {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const trimmed = value.trim();
|
|
59
|
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = JSON.parse(trimmed) as unknown;
|
|
64
|
+
return isRecord(parsed) ? parsed : null;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function summarizeToolArgs(args: unknown): string | undefined {
|
|
71
|
+
const parsed = parseArgsObject(args);
|
|
72
|
+
if (!parsed) {
|
|
73
|
+
const text = stringifyUnknown(args).trim();
|
|
74
|
+
return text ? truncateText(text, 120) : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const items: string[] = [];
|
|
78
|
+
for (const field of TOOL_DETAIL_FIELDS) {
|
|
79
|
+
const value = parsed[field];
|
|
80
|
+
if (typeof value === 'string' && value.trim()) {
|
|
81
|
+
items.push(`${field}: ${value.trim()}`);
|
|
82
|
+
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
|
83
|
+
items.push(`${field}: ${String(value)}`);
|
|
84
|
+
}
|
|
85
|
+
if (items.length >= 2) {
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (items.length > 0) {
|
|
90
|
+
return items.join(' · ');
|
|
91
|
+
}
|
|
92
|
+
return truncateText(stringifyUnknown(parsed), 140);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function toToolName(value: unknown): string {
|
|
96
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
97
|
+
return 'tool';
|
|
98
|
+
}
|
|
99
|
+
return value.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function normalizeChatRole(message: Pick<SessionMessageView, 'role' | 'name' | 'tool_call_id' | 'tool_calls'>): ChatRole {
|
|
103
|
+
const role = message.role.toLowerCase().trim();
|
|
104
|
+
if (role === 'user') {
|
|
105
|
+
return 'user';
|
|
106
|
+
}
|
|
107
|
+
if (role === 'assistant') {
|
|
108
|
+
return 'assistant';
|
|
109
|
+
}
|
|
110
|
+
if (role === 'system') {
|
|
111
|
+
return 'system';
|
|
112
|
+
}
|
|
113
|
+
if (role === 'tool' || role === 'tool_result' || role === 'toolresult' || role === 'function') {
|
|
114
|
+
return 'tool';
|
|
115
|
+
}
|
|
116
|
+
if (typeof message.tool_call_id === 'string' || Array.isArray(message.tool_calls) || typeof message.name === 'string') {
|
|
117
|
+
return 'tool';
|
|
118
|
+
}
|
|
119
|
+
return 'other';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function extractMessageText(content: unknown): string {
|
|
123
|
+
if (typeof content === 'string') {
|
|
124
|
+
return content;
|
|
125
|
+
}
|
|
126
|
+
if (Array.isArray(content)) {
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
for (const item of content) {
|
|
129
|
+
if (typeof item === 'string') {
|
|
130
|
+
parts.push(item);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (!isRecord(item)) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (typeof item.text === 'string') {
|
|
137
|
+
parts.push(item.text);
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (typeof item.content === 'string') {
|
|
141
|
+
parts.push(item.content);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (parts.length > 0) {
|
|
145
|
+
return parts.join('\n');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return stringifyUnknown(content);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function extractToolCards(message: SessionMessageView): ToolCard[] {
|
|
152
|
+
const cards: ToolCard[] = [];
|
|
153
|
+
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
|
154
|
+
for (const call of toolCalls) {
|
|
155
|
+
if (!isRecord(call)) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
const fn = isRecord(call.function) ? call.function : null;
|
|
159
|
+
const name = toToolName(fn?.name ?? call.name);
|
|
160
|
+
const args = fn?.arguments ?? call.arguments;
|
|
161
|
+
cards.push({
|
|
162
|
+
kind: 'call',
|
|
163
|
+
name,
|
|
164
|
+
detail: summarizeToolArgs(args),
|
|
165
|
+
callId: typeof call.id === 'string' ? call.id : undefined
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const role = normalizeChatRole(message);
|
|
170
|
+
if (role === 'tool' || typeof message.tool_call_id === 'string') {
|
|
171
|
+
const text = extractMessageText(message.content).trim();
|
|
172
|
+
cards.push({
|
|
173
|
+
kind: 'result',
|
|
174
|
+
name: toToolName(message.name ?? cards[0]?.name),
|
|
175
|
+
text,
|
|
176
|
+
callId: typeof message.tool_call_id === 'string' ? message.tool_call_id : undefined
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return cards;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function groupChatMessages(messages: SessionMessageView[]): GroupedChatMessage[] {
|
|
184
|
+
const groups: GroupedChatMessage[] = [];
|
|
185
|
+
let lastTs = 0;
|
|
186
|
+
|
|
187
|
+
for (let index = 0; index < messages.length; index += 1) {
|
|
188
|
+
const message = messages[index];
|
|
189
|
+
const role = normalizeChatRole(message);
|
|
190
|
+
const parsedTs = Date.parse(message.timestamp);
|
|
191
|
+
const ts = Number.isFinite(parsedTs) ? parsedTs : Date.now();
|
|
192
|
+
const previous = groups[groups.length - 1];
|
|
193
|
+
const canMerge =
|
|
194
|
+
previous &&
|
|
195
|
+
previous.role === role &&
|
|
196
|
+
Math.abs(ts - lastTs) <= MERGE_WINDOW_MS;
|
|
197
|
+
|
|
198
|
+
if (canMerge) {
|
|
199
|
+
previous.messages.push(message);
|
|
200
|
+
previous.timestamp = message.timestamp;
|
|
201
|
+
lastTs = ts;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
groups.push({
|
|
206
|
+
key: `${message.timestamp}-${index}-${role}`,
|
|
207
|
+
role,
|
|
208
|
+
messages: [message],
|
|
209
|
+
timestamp: message.timestamp
|
|
210
|
+
});
|
|
211
|
+
lastTs = ts;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return groups;
|
|
215
|
+
}
|