@nextclaw/ui 0.6.15 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -0
  3. package/dist/assets/ChannelsList-DF2U-LY1.js +1 -0
  4. package/dist/assets/ChatPage-BX39y0U5.js +36 -0
  5. package/dist/assets/DocBrowser-B9ws5JL7.js +1 -0
  6. package/dist/assets/{LogoBadge-Cer0jX6t.js → LogoBadge-DvGAzkZ3.js} +1 -1
  7. package/dist/assets/MarketplacePage-DG5mHWJ8.js +49 -0
  8. package/dist/assets/ModelConfig-BL_HsOsm.js +1 -0
  9. package/dist/assets/ProvidersList-CH5z00YT.js +1 -0
  10. package/dist/assets/RuntimeConfig-BplBgkwo.js +1 -0
  11. package/dist/assets/SearchConfig-BhaI0fUf.js +1 -0
  12. package/dist/assets/{SecretsConfig-BnGVZiv4.js → SecretsConfig-CFoimOh9.js} +2 -2
  13. package/dist/assets/SessionsConfig-BHTAYn9T.js +2 -0
  14. package/dist/assets/index-BLeJkJ0o.css +1 -0
  15. package/dist/assets/index-DK4TS5ev.js +8 -0
  16. package/dist/assets/index-X5J6Mm--.js +1 -0
  17. package/dist/assets/{index-CkqvHQAt.js → index-uMsNsQX6.js} +1 -1
  18. package/dist/assets/{label-DkL14Jvl.js → label-D8ly4a2P.js} +1 -1
  19. package/dist/assets/page-layout-BSYfvwbp.js +1 -0
  20. package/dist/assets/security-config-DlKEYHNN.js +1 -0
  21. package/dist/assets/{session-run-status-tZ4ISNj-.js → session-run-status-TkIuGbVw.js} +1 -1
  22. package/dist/assets/skeleton-CWbsNx2h.js +1 -0
  23. package/dist/assets/{switch-CgbPbIX3.js → switch-Ce_g9lpN.js} +1 -1
  24. package/dist/assets/tabs-custom-Cf5azvT5.js +1 -0
  25. package/dist/assets/useConfirmDialog-A8Ek8Wu7.js +5 -0
  26. package/dist/assets/vendor-B7ozqnFC.js +412 -0
  27. package/dist/index.html +3 -3
  28. package/package.json +9 -5
  29. package/src/App.tsx +49 -27
  30. package/src/api/client.ts +1 -0
  31. package/src/api/config.ts +60 -0
  32. package/src/api/types.ts +26 -0
  33. package/src/components/auth/login-page.tsx +69 -0
  34. package/src/components/chat/ChatConversationPanel.tsx +12 -54
  35. package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +80 -0
  36. package/src/components/chat/adapters/chat-input-bar.adapter.ts +329 -0
  37. package/src/components/chat/adapters/chat-message.adapter.test.ts +137 -0
  38. package/src/components/chat/adapters/chat-message.adapter.ts +200 -0
  39. package/src/components/chat/chat-input/chat-input-bar.controller.test.tsx +128 -0
  40. package/src/components/chat/chat-input/chat-input-bar.controller.ts +105 -0
  41. package/src/components/chat/containers/chat-input-bar.container.tsx +270 -0
  42. package/src/components/chat/containers/chat-message-list.container.tsx +67 -0
  43. package/src/components/chat/index.ts +1 -0
  44. package/src/components/chat/managers/chat-thread.manager.ts +3 -1
  45. package/src/components/chat/nextclaw/index.ts +23 -0
  46. package/src/components/config/runtime-security-card.tsx +276 -0
  47. package/src/components/config/security-config.tsx +12 -0
  48. package/src/components/layout/Sidebar.tsx +6 -1
  49. package/src/components/marketplace/MarketplacePage.test.tsx +170 -0
  50. package/src/components/marketplace/MarketplacePage.tsx +77 -28
  51. package/src/hooks/use-auth.ts +111 -0
  52. package/src/hooks/useMarketplace.ts +9 -0
  53. package/src/lib/i18n.ts +72 -0
  54. package/src/test/setup.ts +16 -0
  55. package/tsconfig.json +3 -2
  56. package/vite.config.ts +2 -1
  57. package/vitest.config.ts +16 -0
  58. package/dist/assets/ChannelsList-DzeVn-JC.js +0 -1
  59. package/dist/assets/ChatPage-BiFhIm1-.js +0 -36
  60. package/dist/assets/DocBrowser-By3lF9yN.js +0 -1
  61. package/dist/assets/MarketplacePage-EZxALdIz.js +0 -49
  62. package/dist/assets/ModelConfig-AchYxLft.js +0 -1
  63. package/dist/assets/ProvidersList-BsD-4kKX.js +0 -1
  64. package/dist/assets/RuntimeConfig-sKOERbFD.js +0 -1
  65. package/dist/assets/SearchConfig-DAfvDwX6.js +0 -1
  66. package/dist/assets/SessionsConfig-CzvrKDRs.js +0 -2
  67. package/dist/assets/card-BAM7vbMg.js +0 -1
  68. package/dist/assets/index-D9rRqOi8.css +0 -1
  69. package/dist/assets/index-DJZ5y7t1.js +0 -8
  70. package/dist/assets/input-BoelTiYL.js +0 -1
  71. package/dist/assets/page-layout-CERNdqzB.js +0 -1
  72. package/dist/assets/popover-uwYz3Chm.js +0 -1
  73. package/dist/assets/tabs-custom-pDyl95el.js +0 -1
  74. package/dist/assets/useConfirmDialog-DyP6Ac75.js +0 -5
  75. package/dist/assets/vendor-BKtTvQYU.js +0 -407
  76. package/src/components/chat/ChatThread.tsx +0 -402
  77. package/src/components/chat/SkillsPicker.tsx +0 -137
  78. package/src/components/chat/chat-input/ChatInputBarView.tsx +0 -82
  79. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +0 -83
  80. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +0 -39
  81. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +0 -31
  82. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +0 -112
  83. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +0 -24
  84. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +0 -58
  85. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +0 -56
  86. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +0 -40
  87. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputThinkingSelector.tsx +0 -74
  88. package/src/components/chat/chat-input/useChatInputBarController.ts +0 -322
@@ -0,0 +1,329 @@
1
+ import type {
2
+ ChatInlineHint,
3
+ ChatSelectedItem,
4
+ ChatSkillPickerOption,
5
+ ChatSkillPickerProps,
6
+ ChatSlashItem,
7
+ ChatToolbarSelect
8
+ } from '@nextclaw/agent-chat-ui';
9
+
10
+ export type ChatThinkingLevel = 'off' | 'minimal' | 'low' | 'medium' | 'high' | 'adaptive' | 'xhigh';
11
+
12
+ export type ChatSkillRecord = {
13
+ key: string;
14
+ label: string;
15
+ description?: string;
16
+ descriptionZh?: string;
17
+ badgeLabel?: string;
18
+ };
19
+
20
+ export type ChatModelRecord = {
21
+ value: string;
22
+ modelLabel: string;
23
+ providerLabel: string;
24
+ thinkingCapability?: {
25
+ supported: ChatThinkingLevel[];
26
+ default?: ChatThinkingLevel | null;
27
+ } | null;
28
+ };
29
+
30
+ const SLASH_ITEM_MATCH_SCORE = {
31
+ exactSpec: 1200,
32
+ exactLabel: 1150,
33
+ prefixSpec: 1000,
34
+ prefixLabel: 950,
35
+ prefixToken: 900,
36
+ containsSpec: 800,
37
+ containsLabel: 760,
38
+ containsDescription: 500,
39
+ subsequence: 300,
40
+ fallback: 1
41
+ } as const;
42
+
43
+ export type ChatInputBarAdapterTexts = {
44
+ slashSkillSubtitle: string;
45
+ slashSkillSpecLabel: string;
46
+ noSkillDescription: string;
47
+ modelSelectPlaceholder: string;
48
+ modelNoOptionsLabel: string;
49
+ sessionTypePlaceholder: string;
50
+ thinkingLabels: Record<ChatThinkingLevel, string>;
51
+ noModelOptionsLabel: string;
52
+ configureProviderLabel: string;
53
+ };
54
+
55
+ export function resolveSlashQuery(draft: string): string | null {
56
+ const match = /^\/([^\s]*)$/.exec(draft);
57
+ if (!match) {
58
+ return null;
59
+ }
60
+ return (match[1] ?? '').trim().toLowerCase();
61
+ }
62
+
63
+ function normalizeSearchText(value: string | null | undefined): string {
64
+ return (value ?? '').trim().toLowerCase();
65
+ }
66
+
67
+ function isSubsequenceMatch(query: string, target: string): boolean {
68
+ if (!query || !target) {
69
+ return false;
70
+ }
71
+ let pointer = 0;
72
+ for (const char of target) {
73
+ if (char === query[pointer]) {
74
+ pointer += 1;
75
+ if (pointer >= query.length) {
76
+ return true;
77
+ }
78
+ }
79
+ }
80
+ return false;
81
+ }
82
+
83
+ function scoreSkillRecord(record: ChatSkillRecord, query: string): number {
84
+ const normalizedQuery = normalizeSearchText(query);
85
+ if (!normalizedQuery) {
86
+ return SLASH_ITEM_MATCH_SCORE.fallback;
87
+ }
88
+
89
+ const spec = normalizeSearchText(record.key);
90
+ const label = normalizeSearchText(record.label || record.key);
91
+ const description = normalizeSearchText(`${record.descriptionZh ?? ''} ${record.description ?? ''}`);
92
+ const labelTokens = label.split(/[\s/_-]+/).filter(Boolean);
93
+
94
+ if (spec === normalizedQuery) {
95
+ return SLASH_ITEM_MATCH_SCORE.exactSpec;
96
+ }
97
+ if (label === normalizedQuery) {
98
+ return SLASH_ITEM_MATCH_SCORE.exactLabel;
99
+ }
100
+ if (spec.startsWith(normalizedQuery)) {
101
+ return SLASH_ITEM_MATCH_SCORE.prefixSpec;
102
+ }
103
+ if (label.startsWith(normalizedQuery)) {
104
+ return SLASH_ITEM_MATCH_SCORE.prefixLabel;
105
+ }
106
+ if (labelTokens.some((token) => token.startsWith(normalizedQuery))) {
107
+ return SLASH_ITEM_MATCH_SCORE.prefixToken;
108
+ }
109
+ if (spec.includes(normalizedQuery)) {
110
+ return SLASH_ITEM_MATCH_SCORE.containsSpec;
111
+ }
112
+ if (label.includes(normalizedQuery)) {
113
+ return SLASH_ITEM_MATCH_SCORE.containsLabel;
114
+ }
115
+ if (description.includes(normalizedQuery)) {
116
+ return SLASH_ITEM_MATCH_SCORE.containsDescription;
117
+ }
118
+ if (isSubsequenceMatch(normalizedQuery, label) || isSubsequenceMatch(normalizedQuery, spec)) {
119
+ return SLASH_ITEM_MATCH_SCORE.subsequence;
120
+ }
121
+ return 0;
122
+ }
123
+
124
+ export function buildChatSlashItems(
125
+ skillRecords: ChatSkillRecord[],
126
+ normalizedSlashQuery: string,
127
+ texts: Pick<ChatInputBarAdapterTexts, 'slashSkillSubtitle' | 'slashSkillSpecLabel' | 'noSkillDescription'>
128
+ ): ChatSlashItem[] {
129
+ const skillSortCollator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
130
+
131
+ return skillRecords
132
+ .map((record, order) => ({
133
+ record,
134
+ order,
135
+ score: scoreSkillRecord(record, normalizedSlashQuery)
136
+ }))
137
+ .filter((entry) => entry.score > 0)
138
+ .sort((left, right) => {
139
+ if (right.score !== left.score) {
140
+ return right.score - left.score;
141
+ }
142
+ const leftLabel = (left.record.label || left.record.key).trim();
143
+ const rightLabel = (right.record.label || right.record.key).trim();
144
+ const labelCompare = skillSortCollator.compare(leftLabel, rightLabel);
145
+ if (labelCompare !== 0) {
146
+ return labelCompare;
147
+ }
148
+ return left.order - right.order;
149
+ })
150
+ .map(({ record }) => ({
151
+ key: `skill:${record.key}`,
152
+ title: record.label || record.key,
153
+ subtitle: texts.slashSkillSubtitle,
154
+ description: (record.descriptionZh ?? record.description ?? '').trim() || texts.noSkillDescription,
155
+ detailLines: [`${texts.slashSkillSpecLabel}: ${record.key}`],
156
+ value: record.key
157
+ }));
158
+ }
159
+
160
+ export function buildSelectedSkillItems(
161
+ selectedSkills: string[],
162
+ skillRecords: ChatSkillRecord[]
163
+ ): ChatSelectedItem[] {
164
+ return selectedSkills.map((spec) => {
165
+ const matched = skillRecords.find((record) => record.key === spec);
166
+ return {
167
+ key: spec,
168
+ label: matched?.label || spec
169
+ };
170
+ });
171
+ }
172
+
173
+ export function buildSkillPickerOptions(skillRecords: ChatSkillRecord[]): ChatSkillPickerOption[] {
174
+ return skillRecords.map((record) => ({
175
+ key: record.key,
176
+ label: record.label,
177
+ description: record.descriptionZh || record.description || '',
178
+ badgeLabel: record.badgeLabel
179
+ }));
180
+ }
181
+
182
+ export function buildSkillPickerModel(params: {
183
+ skillRecords: ChatSkillRecord[];
184
+ selectedSkills: string[];
185
+ isLoading: boolean;
186
+ onSelectedKeysChange: (next: string[]) => void;
187
+ texts: {
188
+ title: string;
189
+ searchPlaceholder: string;
190
+ emptyLabel: string;
191
+ loadingLabel: string;
192
+ manageLabel: string;
193
+ };
194
+ }): ChatSkillPickerProps {
195
+ return {
196
+ title: params.texts.title,
197
+ searchPlaceholder: params.texts.searchPlaceholder,
198
+ emptyLabel: params.texts.emptyLabel,
199
+ loadingLabel: params.texts.loadingLabel,
200
+ isLoading: params.isLoading,
201
+ manageLabel: params.texts.manageLabel,
202
+ manageHref: '/marketplace/skills',
203
+ options: buildSkillPickerOptions(params.skillRecords),
204
+ selectedKeys: params.selectedSkills,
205
+ onSelectedKeysChange: params.onSelectedKeysChange
206
+ };
207
+ }
208
+
209
+ export function buildModelStateHint(params: {
210
+ isModelOptionsLoading: boolean;
211
+ isModelOptionsEmpty: boolean;
212
+ onGoToProviders: () => void;
213
+ texts: Pick<ChatInputBarAdapterTexts, 'noModelOptionsLabel' | 'configureProviderLabel'>;
214
+ }): ChatInlineHint | null {
215
+ if (!params.isModelOptionsLoading && !params.isModelOptionsEmpty) {
216
+ return null;
217
+ }
218
+ if (params.isModelOptionsLoading) {
219
+ return {
220
+ tone: 'neutral',
221
+ loading: true
222
+ };
223
+ }
224
+ return {
225
+ tone: 'warning',
226
+ text: params.texts.noModelOptionsLabel,
227
+ actionLabel: params.texts.configureProviderLabel,
228
+ onAction: params.onGoToProviders
229
+ };
230
+ }
231
+
232
+ export function buildModelToolbarSelect(params: {
233
+ modelOptions: ChatModelRecord[];
234
+ selectedModel: string;
235
+ isModelOptionsLoading: boolean;
236
+ hasModelOptions: boolean;
237
+ onValueChange: (value: string) => void;
238
+ texts: Pick<ChatInputBarAdapterTexts, 'modelSelectPlaceholder' | 'modelNoOptionsLabel'>;
239
+ }): ChatToolbarSelect {
240
+ const selectedModelOption = params.modelOptions.find((option) => option.value === params.selectedModel);
241
+
242
+ return {
243
+ key: 'model',
244
+ value: params.hasModelOptions ? params.selectedModel : undefined,
245
+ placeholder: params.texts.modelSelectPlaceholder,
246
+ selectedLabel: selectedModelOption
247
+ ? `${selectedModelOption.providerLabel}/${selectedModelOption.modelLabel}`
248
+ : undefined,
249
+ icon: 'sparkles',
250
+ options: params.modelOptions.map((option) => ({
251
+ value: option.value,
252
+ label: option.modelLabel,
253
+ description: option.providerLabel
254
+ })),
255
+ disabled: !params.hasModelOptions,
256
+ loading: params.isModelOptionsLoading,
257
+ emptyLabel: params.texts.modelNoOptionsLabel,
258
+ onValueChange: params.onValueChange
259
+ };
260
+ }
261
+
262
+ export function buildSessionTypeToolbarSelect(params: {
263
+ selectedSessionType?: string;
264
+ selectedSessionTypeOption: { value: string; label: string } | null;
265
+ sessionTypeOptions: Array<{ value: string; label: string }>;
266
+ onValueChange: (value: string) => void;
267
+ canEditSessionType: boolean;
268
+ shouldShow: boolean;
269
+ texts: Pick<ChatInputBarAdapterTexts, 'sessionTypePlaceholder'>;
270
+ }): ChatToolbarSelect | null {
271
+ if (!params.shouldShow) {
272
+ return null;
273
+ }
274
+
275
+ return {
276
+ key: 'session-type',
277
+ value: params.selectedSessionType,
278
+ placeholder: params.texts.sessionTypePlaceholder,
279
+ selectedLabel: params.selectedSessionTypeOption?.label,
280
+ options: params.sessionTypeOptions.map((option) => ({
281
+ value: option.value,
282
+ label: option.label
283
+ })),
284
+ disabled: !params.canEditSessionType,
285
+ onValueChange: params.onValueChange
286
+ };
287
+ }
288
+
289
+ function normalizeThinkingLevels(levels: ChatThinkingLevel[]): ChatThinkingLevel[] {
290
+ const deduped: ChatThinkingLevel[] = [];
291
+ for (const level of ['off', ...levels] as ChatThinkingLevel[]) {
292
+ if (!deduped.includes(level)) {
293
+ deduped.push(level);
294
+ }
295
+ }
296
+ return deduped;
297
+ }
298
+
299
+ export function buildThinkingToolbarSelect(params: {
300
+ supportedLevels: ChatThinkingLevel[];
301
+ selectedThinkingLevel: ChatThinkingLevel | null;
302
+ defaultThinkingLevel?: ChatThinkingLevel | null;
303
+ onValueChange: (value: ChatThinkingLevel) => void;
304
+ texts: Pick<ChatInputBarAdapterTexts, 'thinkingLabels'>;
305
+ }): ChatToolbarSelect | null {
306
+ if (params.supportedLevels.length === 0) {
307
+ return null;
308
+ }
309
+
310
+ const options = normalizeThinkingLevels(params.supportedLevels);
311
+ const fallback = options.includes('off') ? 'off' : options[0];
312
+ const resolvedValue =
313
+ (params.selectedThinkingLevel && options.includes(params.selectedThinkingLevel) && params.selectedThinkingLevel) ||
314
+ (params.defaultThinkingLevel && options.includes(params.defaultThinkingLevel) && params.defaultThinkingLevel) ||
315
+ fallback;
316
+
317
+ return {
318
+ key: 'thinking',
319
+ value: resolvedValue,
320
+ placeholder: params.texts.thinkingLabels[resolvedValue],
321
+ selectedLabel: params.texts.thinkingLabels[resolvedValue],
322
+ icon: 'brain',
323
+ options: options.map((level) => ({
324
+ value: level,
325
+ label: params.texts.thinkingLabels[level]
326
+ })),
327
+ onValueChange: (value) => params.onValueChange(value as ChatThinkingLevel)
328
+ };
329
+ }
@@ -0,0 +1,137 @@
1
+ import { ToolInvocationStatus, type UiMessage } from '@nextclaw/agent-chat';
2
+ import { adaptChatMessages } from '@/components/chat/adapters/chat-message.adapter';
3
+ import type { ChatMessageSource } from '@/components/chat/adapters/chat-message.adapter';
4
+
5
+ function toSource(uiMessages: UiMessage[]): ChatMessageSource[] {
6
+ return uiMessages as unknown as ChatMessageSource[];
7
+ }
8
+
9
+ describe('adaptChatMessages', () => {
10
+ it('maps markdown, reasoning, and tool parts into UI view models', () => {
11
+ const messages: UiMessage[] = [
12
+ {
13
+ id: 'assistant-1',
14
+ role: 'assistant',
15
+ meta: {
16
+ status: 'final',
17
+ timestamp: '2026-03-17T10:00:00.000Z'
18
+ },
19
+ parts: [
20
+ { type: 'text', text: 'hello world' },
21
+ {
22
+ type: 'reasoning',
23
+ reasoning: 'internal reasoning',
24
+ details: []
25
+ },
26
+ {
27
+ type: 'tool-invocation',
28
+ toolInvocation: {
29
+ status: ToolInvocationStatus.RESULT,
30
+ toolCallId: 'call-1',
31
+ toolName: 'web_search',
32
+ args: '{"q":"hello"}',
33
+ result: { ok: true }
34
+ }
35
+ }
36
+ ]
37
+ }
38
+ ];
39
+
40
+ const adapted = adaptChatMessages({
41
+ uiMessages: toSource(messages),
42
+ formatTimestamp: (value) => `formatted:${value}`,
43
+ texts: {
44
+ roleLabels: {
45
+ user: 'You',
46
+ assistant: 'Assistant',
47
+ tool: 'Tool',
48
+ system: 'System',
49
+ fallback: 'Message'
50
+ },
51
+ reasoningLabel: 'Reasoning',
52
+ toolCallLabel: 'Tool Call',
53
+ toolResultLabel: 'Tool Result',
54
+ toolNoOutputLabel: 'No output',
55
+ toolOutputLabel: 'View Output',
56
+ unknownPartLabel: 'Unknown Part'
57
+ }
58
+ });
59
+
60
+ expect(adapted).toHaveLength(1);
61
+ expect(adapted[0]?.roleLabel).toBe('Assistant');
62
+ expect(adapted[0]?.timestampLabel).toBe('formatted:2026-03-17T10:00:00.000Z');
63
+ expect(adapted[0]?.parts.map((part) => part.type)).toEqual(['markdown', 'reasoning', 'tool-card']);
64
+ expect(adapted[0]?.parts[2]).toMatchObject({
65
+ type: 'tool-card',
66
+ card: {
67
+ titleLabel: 'Tool Result',
68
+ outputLabel: 'View Output'
69
+ }
70
+ });
71
+ });
72
+
73
+ it('maps non-standard roles back to the generic message role', () => {
74
+ const adapted = adaptChatMessages({
75
+ uiMessages: [
76
+ {
77
+ id: 'data-1',
78
+ role: 'data',
79
+ parts: [{ type: 'text', text: 'payload' }]
80
+ }
81
+ ] as unknown as ChatMessageSource[],
82
+ formatTimestamp: () => 'formatted',
83
+ texts: {
84
+ roleLabels: {
85
+ user: 'You',
86
+ assistant: 'Assistant',
87
+ tool: 'Tool',
88
+ system: 'System',
89
+ fallback: 'Message'
90
+ },
91
+ reasoningLabel: 'Reasoning',
92
+ toolCallLabel: 'Tool Call',
93
+ toolResultLabel: 'Tool Result',
94
+ toolNoOutputLabel: 'No output',
95
+ toolOutputLabel: 'View Output',
96
+ unknownPartLabel: 'Unknown Part'
97
+ }
98
+ });
99
+
100
+ expect(adapted[0]?.role).toBe('message');
101
+ expect(adapted[0]?.roleLabel).toBe('Message');
102
+ });
103
+
104
+ it('maps unknown parts into a visible fallback part', () => {
105
+ const adapted = adaptChatMessages({
106
+ uiMessages: [
107
+ {
108
+ id: 'x-1',
109
+ role: 'assistant',
110
+ parts: [{ type: 'step-start', value: 'x' }]
111
+ }
112
+ ] as unknown as ChatMessageSource[],
113
+ formatTimestamp: () => 'formatted',
114
+ texts: {
115
+ roleLabels: {
116
+ user: 'You',
117
+ assistant: 'Assistant',
118
+ tool: 'Tool',
119
+ system: 'System',
120
+ fallback: 'Message'
121
+ },
122
+ reasoningLabel: 'Reasoning',
123
+ toolCallLabel: 'Tool Call',
124
+ toolResultLabel: 'Tool Result',
125
+ toolNoOutputLabel: 'No output',
126
+ toolOutputLabel: 'View Output',
127
+ unknownPartLabel: 'Unknown Part'
128
+ }
129
+ });
130
+
131
+ expect(adapted[0]?.parts[0]).toMatchObject({
132
+ type: 'unknown',
133
+ rawType: 'step-start',
134
+ label: 'Unknown Part'
135
+ });
136
+ });
137
+ });
@@ -0,0 +1,200 @@
1
+ import {
2
+ stringifyUnknown,
3
+ summarizeToolArgs,
4
+ type ToolCard
5
+ } from '@/lib/chat-message';
6
+ import type {
7
+ ChatMessageRole,
8
+ ChatMessageViewModel,
9
+ ChatToolPartViewModel
10
+ } from '@nextclaw/agent-chat-ui';
11
+
12
+ export type ChatMessagePartSource =
13
+ | {
14
+ type: 'text';
15
+ text: string;
16
+ }
17
+ | {
18
+ type: 'reasoning';
19
+ reasoning: string;
20
+ }
21
+ | {
22
+ type: 'tool-invocation';
23
+ toolInvocation: {
24
+ status?: string;
25
+ toolName: string;
26
+ args?: unknown;
27
+ parsedArgs?: unknown;
28
+ result?: unknown;
29
+ error?: string;
30
+ toolCallId?: string;
31
+ };
32
+ }
33
+ | {
34
+ type: string;
35
+ [key: string]: unknown;
36
+ };
37
+
38
+ export type ChatMessageSource = {
39
+ id: string;
40
+ role: string;
41
+ meta?: {
42
+ timestamp?: string;
43
+ status?: string;
44
+ };
45
+ parts: ChatMessagePartSource[];
46
+ };
47
+
48
+ export type ChatMessageAdapterTexts = {
49
+ roleLabels: {
50
+ user: string;
51
+ assistant: string;
52
+ tool: string;
53
+ system: string;
54
+ fallback: string;
55
+ };
56
+ reasoningLabel: string;
57
+ toolCallLabel: string;
58
+ toolResultLabel: string;
59
+ toolNoOutputLabel: string;
60
+ toolOutputLabel: string;
61
+ unknownPartLabel: string;
62
+ };
63
+
64
+ function isRecord(value: unknown): value is Record<string, unknown> {
65
+ return typeof value === 'object' && value !== null;
66
+ }
67
+
68
+ function isTextPart(part: ChatMessagePartSource): part is Extract<ChatMessagePartSource, { type: 'text' }> {
69
+ return part.type === 'text' && typeof part.text === 'string';
70
+ }
71
+
72
+ function isReasoningPart(
73
+ part: ChatMessagePartSource
74
+ ): part is Extract<ChatMessagePartSource, { type: 'reasoning' }> {
75
+ return part.type === 'reasoning' && typeof part.reasoning === 'string';
76
+ }
77
+
78
+ function isToolInvocationPart(
79
+ part: ChatMessagePartSource
80
+ ): part is Extract<ChatMessagePartSource, { type: 'tool-invocation' }> {
81
+ if (part.type !== 'tool-invocation') {
82
+ return false;
83
+ }
84
+ if (!isRecord(part.toolInvocation)) {
85
+ return false;
86
+ }
87
+ return typeof part.toolInvocation.toolName === 'string';
88
+ }
89
+
90
+ function resolveMessageTimestamp(message: ChatMessageSource): string {
91
+ const candidate = message.meta?.timestamp;
92
+ if (candidate && Number.isFinite(Date.parse(candidate))) {
93
+ return candidate;
94
+ }
95
+ return new Date().toISOString();
96
+ }
97
+
98
+ function resolveRoleLabel(role: string, texts: ChatMessageAdapterTexts['roleLabels']): string {
99
+ if (role === 'user') {
100
+ return texts.user;
101
+ }
102
+ if (role === 'assistant') {
103
+ return texts.assistant;
104
+ }
105
+ if (role === 'tool') {
106
+ return texts.tool;
107
+ }
108
+ if (role === 'system') {
109
+ return texts.system;
110
+ }
111
+ return texts.fallback;
112
+ }
113
+
114
+ function resolveUiRole(role: string): ChatMessageRole {
115
+ if (role === 'user' || role === 'assistant' || role === 'tool' || role === 'system') {
116
+ return role;
117
+ }
118
+ return 'message';
119
+ }
120
+
121
+ function buildToolCard(toolCard: ToolCard, texts: ChatMessageAdapterTexts): ChatToolPartViewModel {
122
+ return {
123
+ kind: toolCard.kind,
124
+ toolName: toolCard.name,
125
+ summary: toolCard.detail,
126
+ output: toolCard.text,
127
+ hasResult: Boolean(toolCard.hasResult),
128
+ titleLabel: toolCard.kind === 'call' ? texts.toolCallLabel : texts.toolResultLabel,
129
+ outputLabel: texts.toolOutputLabel,
130
+ emptyLabel: texts.toolNoOutputLabel
131
+ };
132
+ }
133
+
134
+ export function adaptChatMessages(params: {
135
+ uiMessages: ChatMessageSource[];
136
+ texts: ChatMessageAdapterTexts;
137
+ formatTimestamp: (value: string) => string;
138
+ }): ChatMessageViewModel[] {
139
+ return params.uiMessages.map((message) => ({
140
+ id: message.id,
141
+ role: resolveUiRole(message.role),
142
+ roleLabel: resolveRoleLabel(message.role, params.texts.roleLabels),
143
+ timestampLabel: params.formatTimestamp(resolveMessageTimestamp(message)),
144
+ status: message.meta?.status,
145
+ parts: message.parts
146
+ .map((part) => {
147
+ if (isTextPart(part)) {
148
+ const text = part.text.trim();
149
+ if (!text) {
150
+ return null;
151
+ }
152
+ return {
153
+ type: 'markdown' as const,
154
+ text
155
+ };
156
+ }
157
+ if (isReasoningPart(part)) {
158
+ const text = part.reasoning.trim();
159
+ if (!text) {
160
+ return null;
161
+ }
162
+ return {
163
+ type: 'reasoning' as const,
164
+ text,
165
+ label: params.texts.reasoningLabel
166
+ };
167
+ }
168
+ if (isToolInvocationPart(part)) {
169
+ const invocation = part.toolInvocation;
170
+ const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
171
+ const rawResult = typeof invocation.error === 'string' && invocation.error.trim()
172
+ ? invocation.error.trim()
173
+ : invocation.result != null
174
+ ? stringifyUnknown(invocation.result).trim()
175
+ : '';
176
+ const hasResult =
177
+ invocation.status === 'result' || invocation.status === 'error' || invocation.status === 'cancelled';
178
+ const card: ToolCard = {
179
+ kind: hasResult ? 'result' : 'call',
180
+ name: invocation.toolName,
181
+ detail,
182
+ text: rawResult || undefined,
183
+ callId: invocation.toolCallId || undefined,
184
+ hasResult
185
+ };
186
+ return {
187
+ type: 'tool-card' as const,
188
+ card: buildToolCard(card, params.texts)
189
+ };
190
+ }
191
+ return {
192
+ type: 'unknown' as const,
193
+ label: params.texts.unknownPartLabel,
194
+ rawType: typeof part.type === 'string' ? part.type : 'unknown',
195
+ text: stringifyUnknown(part)
196
+ };
197
+ })
198
+ .filter((part) => part !== null)
199
+ }));
200
+ }