@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-TFFw4Cem.js +1 -0
  3. package/dist/assets/ChatPage-BUm3UPap.js +32 -0
  4. package/dist/assets/CronConfig-9dYfTRJl.js +1 -0
  5. package/dist/assets/{DocBrowser-BDQFCtYk.js → DocBrowser-BIV0vpA0.js} +1 -1
  6. package/dist/assets/MarketplacePage-2Zi0JSVi.js +1 -0
  7. package/dist/assets/{ModelConfig-Ditrj5-j.js → ModelConfig-h21P5rV0.js} +1 -1
  8. package/dist/assets/ProvidersList-DEaK1a3y.js +1 -0
  9. package/dist/assets/RuntimeConfig-DXMzf-gF.js +1 -0
  10. package/dist/assets/SessionsConfig-SdXvn_9E.js +2 -0
  11. package/dist/assets/{action-link-C13-2zV4.js → action-link-C9xMkxl2.js} +1 -1
  12. package/dist/assets/{card-wQP6HJ6W.js → card-Cnqfntk5.js} +1 -1
  13. package/dist/assets/chat-message-B7oqvJ2d.js +3 -0
  14. package/dist/assets/dialog-DJs630RE.js +5 -0
  15. package/dist/assets/index-CrUDzcei.js +2 -0
  16. package/dist/assets/index-Zy7fAOe1.css +1 -0
  17. package/dist/assets/{label-BNwROQB2.js → label-CXGuE6Oa.js} +1 -1
  18. package/dist/assets/{page-layout-BXJRMNor.js → page-layout-BVZlyPFt.js} +1 -1
  19. package/dist/assets/{switch-CsMPT5De.js → switch-BLF45eI3.js} +1 -1
  20. package/dist/assets/{tabs-custom-DczZ7pO4.js → tabs-custom-DQ0GpEV5.js} +1 -1
  21. package/dist/assets/useConfig-vFQvF4kn.js +1 -0
  22. package/dist/assets/{useConfirmDialog-CKMEeckR.js → useConfirmDialog-CK7KAyDf.js} +1 -1
  23. package/dist/assets/{vendor-Bhv7yx8z.js → vendor-RXIbhDBC.js} +95 -60
  24. package/dist/index.html +3 -3
  25. package/package.json +4 -1
  26. package/src/App.tsx +4 -2
  27. package/src/api/config.ts +11 -0
  28. package/src/api/types.ts +23 -1
  29. package/src/components/chat/ChatPage.tsx +378 -0
  30. package/src/components/chat/ChatThread.tsx +210 -0
  31. package/src/components/config/CronConfig.tsx +1 -1
  32. package/src/components/config/SessionsConfig.tsx +4 -2
  33. package/src/components/layout/Sidebar.tsx +6 -1
  34. package/src/components/marketplace/MarketplacePage.tsx +39 -20
  35. package/src/hooks/useConfig.ts +11 -0
  36. package/src/index.css +69 -0
  37. package/src/lib/chat-message.ts +215 -0
  38. package/src/lib/i18n.ts +36 -0
  39. package/dist/assets/ChannelsList-GbfILabx.js +0 -1
  40. package/dist/assets/CronConfig-BV-xuLqt.js +0 -1
  41. package/dist/assets/MarketplacePage-B2BPK3EZ.js +0 -1
  42. package/dist/assets/ProvidersList-Dl-9bD7O.js +0 -1
  43. package/dist/assets/RuntimeConfig-DTzfw7el.js +0 -1
  44. package/dist/assets/SessionsConfig-CBD8eCim.js +0 -2
  45. package/dist/assets/dialog-CCWBaWyg.js +0 -5
  46. package/dist/assets/index-CDWTAUj_.js +0 -2
  47. package/dist/assets/index-DdpR1fdj.css +0 -1
  48. 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, PageBody } from '@/components/layout/page-layout';
11
+ import { PageLayout, PageHeader } from '@/components/layout/page-layout';
12
12
  import { AlarmClock, RefreshCw, Trash2, Play, Power } from 'lucide-react';
13
13
 
14
14
  type StatusFilter = 'all' | 'enabled' | 'disabled';
@@ -7,7 +7,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 { PageLayout, PageHeader, PageBody } from '@/components/layout/page-layout';
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
- {message.content}
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
- isPending: boolean;
32
- installingSpec?: string;
31
+ installingSpecs: ReadonlySet<string>;
33
32
  };
34
33
 
35
34
  type ManageState = {
@@ -234,7 +233,8 @@ function MarketplaceListCard(props: {
234
233
  const canUninstall = Boolean(canUninstallPlugin || canUninstallSkill);
235
234
 
236
235
  const isDisabled = record ? (record.enabled === false || record.runtimeStatus === 'disabled') : false;
237
- const isInstalling = props.installState.isPending && props.item && props.installState.installingSpec === props.item.install.spec;
236
+ const installSpec = props.item?.install.spec;
237
+ const isInstalling = typeof installSpec === 'string' && props.installState.installingSpecs.has(installSpec);
238
238
 
239
239
  const displayType = type === 'plugin' ? t('marketplaceTypePlugin') : type === 'skill' ? t('marketplaceTypeSkill') : t('marketplaceTypeExtension');
240
240
 
@@ -288,7 +288,7 @@ function MarketplaceListCard(props: {
288
288
  {props.item && !record && (
289
289
  <button
290
290
  onClick={() => props.onInstall(props.item as MarketplaceItemSummary)}
291
- disabled={props.installState.isPending}
291
+ disabled={isInstalling}
292
292
  className="inline-flex items-center gap-1.5 h-8 px-4 rounded-xl text-xs font-medium bg-primary text-white hover:bg-primary-600 disabled:opacity-50 transition-colors"
293
293
  >
294
294
  {isInstalling ? t('marketplaceInstalling') : t('marketplaceInstall')}
@@ -405,6 +405,7 @@ export function MarketplacePage() {
405
405
  const [scope, setScope] = useState<ScopeType>('all');
406
406
  const [sort, setSort] = useState<MarketplaceSort>('relevance');
407
407
  const [page, setPage] = useState(1);
408
+ const [installingSpecs, setInstallingSpecs] = useState<ReadonlySet<string>>(new Set());
408
409
 
409
410
  useEffect(() => {
410
411
  const timer = setTimeout(() => {
@@ -496,10 +497,7 @@ export function MarketplacePage() {
496
497
  return `${allItems.length} / ${total}`;
497
498
  }, [scope, installedQuery.isLoading, installedEntries.length, itemsQuery.data, allItems.length, total, copyKeys.installedCountSuffix]);
498
499
 
499
- const installState: InstallState = {
500
- isPending: installMutation.isPending,
501
- installingSpec: installMutation.variables?.spec
502
- };
500
+ const installState: InstallState = { installingSpecs };
503
501
 
504
502
  const manageState: ManageState = {
505
503
  isPending: manageMutation.isPending,
@@ -511,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
- if (installMutation.isPending) {
512
+ const handleInstall = async (item: MarketplaceItemSummary) => {
513
+ const installSpec = item.install.spec;
514
+ if (installingSpecs.has(installSpec)) {
516
515
  return;
517
516
  }
518
- installMutation.mutate({
519
- type: item.type,
520
- spec: item.install.spec,
521
- kind: item.install.kind,
522
- ...(item.type === 'skill'
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) => {
@@ -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
+ }