@nextclaw/ui 0.10.0 → 0.10.2
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 +24 -1
- package/dist/assets/{ChannelsList-VSRZzxx2.js → ChannelsList-DSMuOmMG.js} +4 -4
- package/dist/assets/ChatPage-do9TwNxj.js +38 -0
- package/dist/assets/{DocBrowser-C65Hbvnb.js → DocBrowser-BjoTblYl.js} +1 -1
- package/dist/assets/{LogoBadge-4qtguXEJ.js → LogoBadge-2yDaYdxw.js} +1 -1
- package/dist/assets/MarketplacePage-DVVk4dlH.js +49 -0
- package/dist/assets/{McpMarketplacePage-CHLkD8yX.js → McpMarketplacePage-B4WUzuLw.js} +1 -1
- package/dist/assets/{ModelConfig-CjsGdmZa.js → ModelConfig-Dr0eI9nN.js} +1 -1
- package/dist/assets/ProvidersList-C7A-mIbe.js +1 -0
- package/dist/assets/{RemoteAccessPage-rOZCnH1x.js → RemoteAccessPage-CI3Am3w1.js} +1 -1
- package/dist/assets/{RuntimeConfig-CmJh6g0R.js → RuntimeConfig-DvSNVSs8.js} +1 -1
- package/dist/assets/{SearchConfig-C_hUuzR4.js → SearchConfig-B6TGIZow.js} +1 -1
- package/dist/assets/{SecretsConfig-Bu_zIRlQ.js → SecretsConfig-CpxaKU1j.js} +1 -1
- package/dist/assets/{SessionsConfig-DA_nqkM_.js → SessionsConfig-B-VHnv4G.js} +1 -1
- package/dist/assets/{chat-message-BOdA4h43.js → chat-message-BMqngrjp.js} +1 -1
- package/dist/assets/index-C6MeoecJ.js +8 -0
- package/dist/assets/index-DdXzLuNG.css +1 -0
- package/dist/assets/{label-BYZ62ajO.js → label-s2ILtQeP.js} +1 -1
- package/dist/assets/{page-layout-UC-h92sU.js → page-layout-BX5Ro4Sj.js} +1 -1
- package/dist/assets/{popover-DASCEr3G.js → popover-qmNpQSIy.js} +1 -1
- package/dist/assets/{security-config-Cvujq4fH.js → security-config--F-f-nDl.js} +1 -1
- package/dist/assets/skeleton-DthPOKSc.js +1 -0
- package/dist/assets/{status-dot-C1AvPwDD.js → status-dot-DWj7aUy8.js} +1 -1
- package/dist/assets/{switch-D3wVuCSh.js → switch-62r7L4Lj.js} +1 -1
- package/dist/assets/tabs-custom-DEmoGMsc.js +1 -0
- package/dist/assets/useConfirmDialog-DzT94nC_.js +1 -0
- package/dist/assets/{vendor-DJt0Azq5.js → vendor-CNhxtHCf.js} +1 -1
- package/dist/index.html +3 -3
- package/package.json +5 -5
- package/src/App.test.tsx +41 -0
- package/src/App.tsx +37 -0
- package/src/api/client.test.ts +12 -0
- package/src/api/client.ts +4 -2
- package/src/api/config.ts +1 -1
- package/src/components/chat/ChatSidebar.tsx +41 -69
- package/src/components/chat/adapters/chat-input-bar.adapter.test.ts +32 -1
- package/src/components/chat/adapters/chat-input-bar.adapter.ts +6 -3
- package/src/components/chat/adapters/chat-message.adapter.test.ts +141 -163
- package/src/components/chat/adapters/chat-message.adapter.ts +35 -0
- package/src/components/chat/chat-composer-state.ts +38 -0
- package/src/components/chat/chat-stream/types.ts +2 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +116 -55
- package/src/components/chat/containers/chat-message-list.container.tsx +2 -0
- package/src/components/chat/managers/chat-session-list.manager.test.ts +16 -1
- package/src/components/chat/managers/chat-session-list.manager.ts +0 -2
- package/src/components/chat/managers/chat-thread.manager.ts +0 -1
- package/src/components/chat/ncp/NcpChatPage.tsx +18 -18
- package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +50 -33
- package/src/components/chat/ncp/ncp-app-client-fetch.ts +5 -123
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +56 -1
- package/src/components/chat/ncp/ncp-chat-page-data.test.ts +8 -0
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +0 -1
- package/src/components/chat/presenter/chat-presenter-context.tsx +6 -0
- package/src/components/chat/stores/chat-input.store.ts +3 -0
- package/src/components/config/ChannelsList.test.tsx +2 -1
- package/src/components/config/weixin-channel-auth-section.test.tsx +2 -1
- package/src/components/layout/Sidebar.tsx +62 -102
- package/src/components/layout/sidebar-items.tsx +172 -0
- package/src/components/layout/sidebar.layout.test.tsx +11 -4
- package/src/hooks/use-auth.ts +1 -2
- package/src/lib/i18n.chat.ts +117 -0
- package/src/lib/i18n.remote.ts +1 -1
- package/src/lib/i18n.ts +2 -112
- package/src/transport/local.transport.ts +28 -7
- package/src/transport/remote.transport.test.ts +135 -0
- package/src/transport/remote.transport.ts +14 -1
- package/src/transport/transport.types.ts +1 -0
- package/dist/assets/ChatPage-CX0ZKE5i.js +0 -41
- package/dist/assets/MarketplacePage-DPCYptfD.js +0 -49
- package/dist/assets/ProvidersList-aXp_mo4J.js +0 -1
- package/dist/assets/index-C63mHRbE.css +0 -1
- package/dist/assets/index-DS7D1-KS.js +0 -8
- package/dist/assets/skeleton-DlYEKkkj.js +0 -1
- package/dist/assets/tabs-custom-CbgS7tu0.js +0 -1
- package/dist/assets/useConfirmDialog-BYbFEIbQ.js +0 -1
|
@@ -14,6 +14,12 @@ export type ChatMessagePartSource =
|
|
|
14
14
|
type: 'text';
|
|
15
15
|
text: string;
|
|
16
16
|
}
|
|
17
|
+
| {
|
|
18
|
+
type: 'file';
|
|
19
|
+
mimeType: string;
|
|
20
|
+
data: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
}
|
|
17
23
|
| {
|
|
18
24
|
type: 'reasoning';
|
|
19
25
|
reasoning: string;
|
|
@@ -58,6 +64,8 @@ export type ChatMessageAdapterTexts = {
|
|
|
58
64
|
toolResultLabel: string;
|
|
59
65
|
toolNoOutputLabel: string;
|
|
60
66
|
toolOutputLabel: string;
|
|
67
|
+
imageAttachmentLabel: string;
|
|
68
|
+
fileAttachmentLabel: string;
|
|
61
69
|
unknownPartLabel: string;
|
|
62
70
|
};
|
|
63
71
|
|
|
@@ -77,6 +85,16 @@ function isReasoningPart(
|
|
|
77
85
|
return part.type === 'reasoning' && typeof part.reasoning === 'string';
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
function isFilePart(
|
|
89
|
+
part: ChatMessagePartSource
|
|
90
|
+
): part is Extract<ChatMessagePartSource, { type: 'file' }> {
|
|
91
|
+
return (
|
|
92
|
+
part.type === 'file' &&
|
|
93
|
+
typeof part.mimeType === 'string' &&
|
|
94
|
+
typeof part.data === 'string'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
80
98
|
function isToolInvocationPart(
|
|
81
99
|
part: ChatMessagePartSource
|
|
82
100
|
): part is Extract<ChatMessagePartSource, { type: 'tool-invocation' }> {
|
|
@@ -182,6 +200,23 @@ export function adaptChatMessages(params: {
|
|
|
182
200
|
label: params.texts.reasoningLabel
|
|
183
201
|
};
|
|
184
202
|
}
|
|
203
|
+
if (isFilePart(part)) {
|
|
204
|
+
const isImage = part.mimeType.startsWith('image/');
|
|
205
|
+
return {
|
|
206
|
+
type: 'file' as const,
|
|
207
|
+
file: {
|
|
208
|
+
label:
|
|
209
|
+
typeof part.name === 'string' && part.name.trim()
|
|
210
|
+
? part.name.trim()
|
|
211
|
+
: isImage
|
|
212
|
+
? params.texts.imageAttachmentLabel
|
|
213
|
+
: params.texts.fileAttachmentLabel,
|
|
214
|
+
mimeType: part.mimeType,
|
|
215
|
+
dataUrl: `data:${part.mimeType};base64,${part.data}`,
|
|
216
|
+
isImage
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
}
|
|
185
220
|
if (isToolInvocationPart(part)) {
|
|
186
221
|
const invocation = part.toolInvocation;
|
|
187
222
|
const detail = summarizeToolArgs(invocation.parsedArgs ?? invocation.args);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
2
3
|
import {
|
|
3
4
|
createChatComposerTokenNode,
|
|
4
5
|
createChatComposerNodesFromText,
|
|
@@ -25,6 +26,10 @@ export function deriveSelectedSkillsFromComposer(nodes: ChatComposerNode[]): str
|
|
|
25
26
|
return extractChatComposerTokenKeys(nodes, 'skill');
|
|
26
27
|
}
|
|
27
28
|
|
|
29
|
+
export function deriveSelectedAttachmentIdsFromComposer(nodes: ChatComposerNode[]): string[] {
|
|
30
|
+
return extractChatComposerTokenKeys(nodes, 'file');
|
|
31
|
+
}
|
|
32
|
+
|
|
28
33
|
export function syncComposerSkills(
|
|
29
34
|
nodes: ChatComposerNode[],
|
|
30
35
|
nextSkills: string[],
|
|
@@ -51,3 +56,36 @@ export function syncComposerSkills(
|
|
|
51
56
|
? prunedNodes
|
|
52
57
|
: normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
|
|
53
58
|
}
|
|
59
|
+
|
|
60
|
+
export function syncComposerAttachments(
|
|
61
|
+
nodes: ChatComposerNode[],
|
|
62
|
+
attachments: readonly NcpDraftAttachment[]
|
|
63
|
+
): ChatComposerNode[] {
|
|
64
|
+
const nextAttachmentIds = new Set(attachments.map((attachment) => attachment.id));
|
|
65
|
+
const prunedNodes = removeChatComposerTokenNodes(
|
|
66
|
+
nodes,
|
|
67
|
+
(node) => node.tokenKind === 'file' && !nextAttachmentIds.has(node.tokenKey)
|
|
68
|
+
);
|
|
69
|
+
const existingAttachmentIds = extractChatComposerTokenKeys(prunedNodes, 'file');
|
|
70
|
+
const appendedNodes = attachments
|
|
71
|
+
.filter((attachment) => !existingAttachmentIds.includes(attachment.id))
|
|
72
|
+
.map((attachment) =>
|
|
73
|
+
createChatComposerTokenNode({
|
|
74
|
+
tokenKind: 'file',
|
|
75
|
+
tokenKey: attachment.id,
|
|
76
|
+
label: attachment.name
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return appendedNodes.length === 0
|
|
81
|
+
? prunedNodes
|
|
82
|
+
: normalizeChatComposerNodes([...prunedNodes, ...appendedNodes]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function pruneComposerAttachments(
|
|
86
|
+
nodes: ChatComposerNode[],
|
|
87
|
+
attachments: readonly NcpDraftAttachment[]
|
|
88
|
+
): NcpDraftAttachment[] {
|
|
89
|
+
const selectedIds = new Set(deriveSelectedAttachmentIdsFromComposer(nodes));
|
|
90
|
+
return attachments.filter((attachment) => selectedIds.has(attachment.id));
|
|
91
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ChatComposerNode } from '@nextclaw/agent-chat-ui';
|
|
2
|
+
import type { NcpDraftAttachment } from '@nextclaw/ncp-react';
|
|
2
3
|
import type { Dispatch, MutableRefObject, SetStateAction } from 'react';
|
|
3
4
|
import type {
|
|
4
5
|
ChatRunView,
|
|
@@ -18,6 +19,7 @@ export type SendMessageParams = {
|
|
|
18
19
|
model?: string;
|
|
19
20
|
thinkingLevel?: ThinkingLevel;
|
|
20
21
|
requestedSkills?: string[];
|
|
22
|
+
attachments?: NcpDraftAttachment[];
|
|
21
23
|
stopSupported?: boolean;
|
|
22
24
|
stopReason?: string;
|
|
23
25
|
restoreDraftOnError?: boolean;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { useMemo, useState } from 'react';
|
|
1
|
+
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { ChatInputBar } from '@nextclaw/agent-chat-ui';
|
|
3
|
+
import {
|
|
4
|
+
DEFAULT_NCP_IMAGE_ATTACHMENT_ACCEPT,
|
|
5
|
+
DEFAULT_NCP_IMAGE_ATTACHMENT_MAX_BYTES,
|
|
6
|
+
readFilesAsNcpDraftAttachments
|
|
7
|
+
} from '@nextclaw/ncp-react';
|
|
3
8
|
import {
|
|
4
9
|
buildChatSlashItems,
|
|
5
10
|
buildModelStateHint,
|
|
@@ -14,6 +19,7 @@ import { usePresenter } from '@/components/chat/presenter/chat-presenter-context
|
|
|
14
19
|
import { useI18n } from '@/components/providers/I18nProvider';
|
|
15
20
|
import { useChatInputStore } from '@/components/chat/stores/chat-input.store';
|
|
16
21
|
import { t } from '@/lib/i18n';
|
|
22
|
+
import { toast } from 'sonner';
|
|
17
23
|
|
|
18
24
|
function buildThinkingLabels(): Record<ChatThinkingLevel, string> {
|
|
19
25
|
return {
|
|
@@ -70,6 +76,7 @@ export function ChatInputBarContainer() {
|
|
|
70
76
|
const { language } = useI18n();
|
|
71
77
|
const snapshot = useChatInputStore((state) => state.snapshot);
|
|
72
78
|
const [slashQuery, setSlashQuery] = useState<string | null>(null);
|
|
79
|
+
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
|
73
80
|
|
|
74
81
|
const officialSkillBadgeLabel = useMemo(() => {
|
|
75
82
|
// Keep memo reactive to locale switches even though `t` is imported as a stable function.
|
|
@@ -102,6 +109,7 @@ export function ChatInputBarContainer() {
|
|
|
102
109
|
const isModelOptionsEmpty = snapshot.isProviderStateResolved && !hasModelOptions;
|
|
103
110
|
const inputDisabled =
|
|
104
111
|
((isModelOptionsLoading || isModelOptionsEmpty) && !snapshot.isSending) || snapshot.sessionTypeUnavailable;
|
|
112
|
+
const attachmentSupported = typeof presenter.chatInputManager.addAttachments === 'function';
|
|
105
113
|
const textareaPlaceholder = isModelOptionsLoading
|
|
106
114
|
? ''
|
|
107
115
|
: hasModelOptions
|
|
@@ -122,6 +130,33 @@ export function ChatInputBarContainer() {
|
|
|
122
130
|
? t('chatStopPreparing')
|
|
123
131
|
: snapshot.stopDisabledReason?.trim() || t('chatStopUnavailable');
|
|
124
132
|
|
|
133
|
+
const showAttachmentError = useCallback((reason: 'unsupported-type' | 'too-large' | 'read-failed') => {
|
|
134
|
+
if (reason === 'unsupported-type') {
|
|
135
|
+
toast.error(t('chatInputImageUnsupported'));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (reason === 'too-large') {
|
|
139
|
+
toast.error(
|
|
140
|
+
t('chatInputImageTooLarge').replace('{maxMb}', String(DEFAULT_NCP_IMAGE_ATTACHMENT_MAX_BYTES / (1024 * 1024)))
|
|
141
|
+
);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
toast.error(t('chatInputImageReadFailed'));
|
|
145
|
+
}, []);
|
|
146
|
+
|
|
147
|
+
const handleFilesAdd = useCallback(async (files: File[]) => {
|
|
148
|
+
if (!attachmentSupported || files.length === 0) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const result = await readFilesAsNcpDraftAttachments(files);
|
|
152
|
+
if (result.attachments.length > 0) {
|
|
153
|
+
presenter.chatInputManager.addAttachments?.(result.attachments);
|
|
154
|
+
}
|
|
155
|
+
if (result.rejected.length > 0) {
|
|
156
|
+
showAttachmentError(result.rejected[0].reason);
|
|
157
|
+
}
|
|
158
|
+
}, [attachmentSupported, presenter.chatInputManager, showAttachmentError]);
|
|
159
|
+
|
|
125
160
|
const toolbarSelects = [
|
|
126
161
|
buildModelToolbarSelect({
|
|
127
162
|
modelOptions: modelRecords,
|
|
@@ -160,60 +195,86 @@ export function ChatInputBarContainer() {
|
|
|
160
195
|
});
|
|
161
196
|
|
|
162
197
|
return (
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
hint={buildModelStateHint({
|
|
183
|
-
isModelOptionsLoading,
|
|
184
|
-
isModelOptionsEmpty,
|
|
185
|
-
onGoToProviders: presenter.chatInputManager.goToProviders,
|
|
186
|
-
texts: {
|
|
187
|
-
noModelOptionsLabel: t('chatModelNoOptions'),
|
|
188
|
-
configureProviderLabel: t('chatGoConfigureProvider')
|
|
189
|
-
}
|
|
190
|
-
})}
|
|
191
|
-
toolbar={{
|
|
192
|
-
selects: toolbarSelects,
|
|
193
|
-
accessories: [
|
|
194
|
-
{
|
|
195
|
-
key: 'attach',
|
|
196
|
-
label: t('chatInputAttach'),
|
|
197
|
-
icon: 'paperclip',
|
|
198
|
-
iconOnly: true,
|
|
199
|
-
disabled: true,
|
|
200
|
-
tooltip: t('chatInputAttachComingSoon')
|
|
198
|
+
<>
|
|
199
|
+
<ChatInputBar
|
|
200
|
+
composer={{
|
|
201
|
+
nodes: snapshot.composerNodes,
|
|
202
|
+
placeholder: textareaPlaceholder,
|
|
203
|
+
disabled: inputDisabled,
|
|
204
|
+
onNodesChange: presenter.chatInputManager.setComposerNodes,
|
|
205
|
+
...(attachmentSupported ? { onFilesAdd: handleFilesAdd } : {}),
|
|
206
|
+
onSlashQueryChange: setSlashQuery
|
|
207
|
+
}}
|
|
208
|
+
slashMenu={{
|
|
209
|
+
isLoading: snapshot.isSkillsLoading,
|
|
210
|
+
items: slashItems,
|
|
211
|
+
texts: {
|
|
212
|
+
slashLoadingLabel: t('chatSlashLoading'),
|
|
213
|
+
slashSectionLabel: t('chatSlashSectionSkills'),
|
|
214
|
+
slashEmptyLabel: t('chatSlashNoResult'),
|
|
215
|
+
slashHintLabel: t('chatSlashHint'),
|
|
216
|
+
slashSkillHintLabel: t('chatSlashSkillHint')
|
|
201
217
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
+
}}
|
|
219
|
+
hint={buildModelStateHint({
|
|
220
|
+
isModelOptionsLoading,
|
|
221
|
+
isModelOptionsEmpty,
|
|
222
|
+
onGoToProviders: presenter.chatInputManager.goToProviders,
|
|
223
|
+
texts: {
|
|
224
|
+
noModelOptionsLabel: t('chatModelNoOptions'),
|
|
225
|
+
configureProviderLabel: t('chatGoConfigureProvider')
|
|
226
|
+
}
|
|
227
|
+
})}
|
|
228
|
+
toolbar={{
|
|
229
|
+
selects: toolbarSelects,
|
|
230
|
+
accessories: [
|
|
231
|
+
{
|
|
232
|
+
key: 'attach',
|
|
233
|
+
label: t('chatInputAttach'),
|
|
234
|
+
icon: 'paperclip',
|
|
235
|
+
iconOnly: true,
|
|
236
|
+
disabled: !attachmentSupported || inputDisabled || snapshot.isSending,
|
|
237
|
+
...(attachmentSupported
|
|
238
|
+
? {
|
|
239
|
+
onClick: () => fileInputRef.current?.click()
|
|
240
|
+
}
|
|
241
|
+
: {
|
|
242
|
+
tooltip: t('chatInputAttachComingSoon')
|
|
243
|
+
})
|
|
244
|
+
}
|
|
245
|
+
],
|
|
246
|
+
skillPicker,
|
|
247
|
+
actions: {
|
|
248
|
+
sendError: snapshot.sendError,
|
|
249
|
+
isSending: snapshot.isSending,
|
|
250
|
+
canStopGeneration: snapshot.canStopGeneration,
|
|
251
|
+
sendDisabled:
|
|
252
|
+
(snapshot.draft.trim().length === 0 && snapshot.attachments.length === 0) ||
|
|
253
|
+
!hasModelOptions ||
|
|
254
|
+
snapshot.sessionTypeUnavailable,
|
|
255
|
+
stopDisabled: !snapshot.canStopGeneration,
|
|
256
|
+
stopHint: resolvedStopHint,
|
|
257
|
+
sendButtonLabel: t('chatSend'),
|
|
258
|
+
stopButtonLabel: t('chatStop'),
|
|
259
|
+
onSend: presenter.chatInputManager.send,
|
|
260
|
+
onStop: presenter.chatInputManager.stop
|
|
261
|
+
}
|
|
262
|
+
}}
|
|
263
|
+
/>
|
|
264
|
+
{attachmentSupported ? (
|
|
265
|
+
<input
|
|
266
|
+
ref={fileInputRef}
|
|
267
|
+
type="file"
|
|
268
|
+
accept={DEFAULT_NCP_IMAGE_ATTACHMENT_ACCEPT}
|
|
269
|
+
multiple
|
|
270
|
+
className="hidden"
|
|
271
|
+
onChange={async (event) => {
|
|
272
|
+
const files = Array.from(event.target.files ?? []);
|
|
273
|
+
event.currentTarget.value = '';
|
|
274
|
+
await handleFilesAdd(files);
|
|
275
|
+
}}
|
|
276
|
+
/>
|
|
277
|
+
) : null}
|
|
278
|
+
</>
|
|
218
279
|
);
|
|
219
280
|
}
|
|
@@ -48,6 +48,8 @@ export function ChatMessageListContainer(props: ChatMessageListContainerProps) {
|
|
|
48
48
|
toolResultLabel: t("chatToolResult"),
|
|
49
49
|
toolNoOutputLabel: t("chatToolNoOutput"),
|
|
50
50
|
toolOutputLabel: t("chatToolOutput"),
|
|
51
|
+
imageAttachmentLabel: t("chatImageAttachment"),
|
|
52
|
+
fileAttachmentLabel: t("chatFileAttachment"),
|
|
51
53
|
unknownPartLabel: t("chatUnknownPart"),
|
|
52
54
|
},
|
|
53
55
|
}),
|
|
@@ -33,7 +33,22 @@ describe('ChatSessionListManager', () => {
|
|
|
33
33
|
|
|
34
34
|
expect(streamActionsManager.resetStreamState).toHaveBeenCalledTimes(1);
|
|
35
35
|
expect(uiManager.goToChatRoot).toHaveBeenCalledTimes(1);
|
|
36
|
-
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).
|
|
36
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
|
|
37
37
|
expect(useChatInputStore.getState().snapshot.pendingSessionType).toBe('codex');
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
it('delegates existing-session selection to routing without eagerly mutating the selected session state', () => {
|
|
41
|
+
const uiManager = {
|
|
42
|
+
goToSession: vi.fn()
|
|
43
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[0];
|
|
44
|
+
const streamActionsManager = {
|
|
45
|
+
resetStreamState: vi.fn()
|
|
46
|
+
} as unknown as ConstructorParameters<typeof ChatSessionListManager>[1];
|
|
47
|
+
|
|
48
|
+
const manager = new ChatSessionListManager(uiManager, streamActionsManager);
|
|
49
|
+
manager.selectSession('session-2');
|
|
50
|
+
|
|
51
|
+
expect(uiManager.goToSession).toHaveBeenCalledWith('session-2');
|
|
52
|
+
expect(useChatSessionListStore.getState().snapshot.selectedSessionKey).toBe('session-1');
|
|
53
|
+
});
|
|
39
54
|
});
|
|
@@ -62,13 +62,11 @@ export class ChatSessionListManager {
|
|
|
62
62
|
? sessionType.trim()
|
|
63
63
|
: defaultSessionType;
|
|
64
64
|
this.streamActionsManager.resetStreamState();
|
|
65
|
-
this.setSelectedSessionKey(null);
|
|
66
65
|
useChatInputStore.getState().setSnapshot({ pendingSessionType: nextSessionType });
|
|
67
66
|
this.uiManager.goToChatRoot();
|
|
68
67
|
};
|
|
69
68
|
|
|
70
69
|
selectSession = (sessionKey: string) => {
|
|
71
|
-
this.setSelectedSessionKey(sessionKey);
|
|
72
70
|
this.uiManager.goToSession(sessionKey);
|
|
73
71
|
};
|
|
74
72
|
|
|
@@ -78,7 +78,6 @@ export class ChatThreadManager {
|
|
|
78
78
|
try {
|
|
79
79
|
await deleteSessionApi(selectedSessionKey);
|
|
80
80
|
this.streamActionsManager.resetStreamState();
|
|
81
|
-
useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: null });
|
|
82
81
|
this.uiManager.goToChatRoot({ replace: true });
|
|
83
82
|
await this.actions.refetchSessions();
|
|
84
83
|
} finally {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { NcpHttpAgentClientEndpoint } from '@nextclaw/ncp-http-agent-client';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
buildNcpRequestEnvelope,
|
|
5
|
+
useHydratedNcpAgent,
|
|
6
|
+
type NcpConversationSeed
|
|
7
|
+
} from '@nextclaw/ncp-react';
|
|
4
8
|
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
|
5
9
|
import { API_BASE } from '@/api/api-base';
|
|
6
10
|
import { fetchNcpSessionMessages } from '@/api/ncp-session';
|
|
@@ -186,29 +190,25 @@ export function NcpChatPage({ view }: ChatPageProps) {
|
|
|
186
190
|
sessionType: payload.sessionType,
|
|
187
191
|
requestedSkills: payload.requestedSkills
|
|
188
192
|
});
|
|
193
|
+
const envelope = buildNcpRequestEnvelope({
|
|
194
|
+
sessionId: payload.sessionKey,
|
|
195
|
+
text: payload.message,
|
|
196
|
+
attachments: payload.attachments,
|
|
197
|
+
metadata
|
|
198
|
+
});
|
|
199
|
+
if (!envelope) {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
189
202
|
try {
|
|
190
203
|
void sessionsQuery.refetch();
|
|
191
|
-
await agent.send(
|
|
192
|
-
sessionId: payload.sessionKey,
|
|
193
|
-
message: {
|
|
194
|
-
id: `user-${Date.now().toString(36)}`,
|
|
195
|
-
sessionId: payload.sessionKey,
|
|
196
|
-
role: 'user',
|
|
197
|
-
status: 'final',
|
|
198
|
-
parts: [{ type: 'text', text: payload.message }],
|
|
199
|
-
timestamp: new Date().toISOString(),
|
|
200
|
-
...(Object.keys(metadata).length > 0 ? { metadata } : {})
|
|
201
|
-
},
|
|
202
|
-
...(Object.keys(metadata).length > 0 ? { metadata } : {})
|
|
203
|
-
});
|
|
204
|
+
await agent.send(envelope);
|
|
204
205
|
await sessionsQuery.refetch();
|
|
205
206
|
} catch (error) {
|
|
206
207
|
if (payload.restoreDraftOnError) {
|
|
207
208
|
if (payload.composerNodes && payload.composerNodes.length > 0) {
|
|
208
|
-
presenter.chatInputManager.
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
: currentNodes
|
|
209
|
+
presenter.chatInputManager.restoreComposerState?.(
|
|
210
|
+
payload.composerNodes,
|
|
211
|
+
payload.attachments ?? []
|
|
212
212
|
);
|
|
213
213
|
} else {
|
|
214
214
|
presenter.chatInputManager.setDraft((currentDraft) =>
|
|
@@ -1,25 +1,25 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
1
2
|
import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
|
|
2
3
|
|
|
3
|
-
const
|
|
4
|
-
request: vi.fn(),
|
|
5
|
-
openStream: vi.fn()
|
|
6
|
-
}));
|
|
7
|
-
|
|
8
|
-
vi.mock('@/transport', () => ({
|
|
9
|
-
appClient: {
|
|
10
|
-
request: mocks.request,
|
|
11
|
-
openStream: mocks.openStream
|
|
12
|
-
}
|
|
13
|
-
}));
|
|
4
|
+
const fetchMock = vi.fn<typeof fetch>();
|
|
14
5
|
|
|
15
6
|
describe('ncp-app-client-fetch', () => {
|
|
16
7
|
beforeEach(() => {
|
|
17
|
-
|
|
18
|
-
|
|
8
|
+
fetchMock.mockReset();
|
|
9
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.unstubAllGlobals();
|
|
19
14
|
});
|
|
20
15
|
|
|
21
|
-
it('
|
|
22
|
-
|
|
16
|
+
it('keeps native fetch semantics and only injects credentials', async () => {
|
|
17
|
+
fetchMock.mockResolvedValue(new Response(JSON.stringify({ ok: true }), {
|
|
18
|
+
status: 200,
|
|
19
|
+
headers: {
|
|
20
|
+
'content-type': 'application/json'
|
|
21
|
+
}
|
|
22
|
+
}));
|
|
23
23
|
const fetchImpl = createNcpAppClientFetch();
|
|
24
24
|
|
|
25
25
|
const response = await fetchImpl('http://127.0.0.1:55667/api/ncp/agent/abort', {
|
|
@@ -31,22 +31,40 @@ describe('ncp-app-client-fetch', () => {
|
|
|
31
31
|
body: JSON.stringify({ sessionId: 's1' })
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
expect(
|
|
34
|
+
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:55667/api/ncp/agent/abort', {
|
|
35
35
|
method: 'POST',
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
headers: {
|
|
37
|
+
accept: 'application/json',
|
|
38
|
+
'content-type': 'application/json'
|
|
39
|
+
},
|
|
40
|
+
body: JSON.stringify({ sessionId: 's1' }),
|
|
41
|
+
credentials: 'include'
|
|
38
42
|
});
|
|
39
43
|
expect(response.ok).toBe(true);
|
|
44
|
+
expect(await response.json()).toEqual({ ok: true });
|
|
40
45
|
});
|
|
41
46
|
|
|
42
|
-
it('
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
it('does not synthesize fake HTTP 500 responses for fetch failures', async () => {
|
|
48
|
+
fetchMock.mockRejectedValue(new Error('Failed to fetch'));
|
|
49
|
+
const fetchImpl = createNcpAppClientFetch();
|
|
50
|
+
|
|
51
|
+
await expect(fetchImpl('http://127.0.0.1:55667/api/ncp/agent/abort', {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
accept: 'application/json',
|
|
55
|
+
'content-type': 'application/json'
|
|
56
|
+
},
|
|
57
|
+
body: JSON.stringify({ sessionId: 's1' })
|
|
58
|
+
})).rejects.toThrow('Failed to fetch');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('preserves native SSE request headers', async () => {
|
|
62
|
+
fetchMock.mockResolvedValue(new Response('event: ncp-event\ndata: {"ok":true}\n\n', {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: {
|
|
65
|
+
'content-type': 'text/event-stream'
|
|
66
|
+
}
|
|
67
|
+
}));
|
|
50
68
|
const fetchImpl = createNcpAppClientFetch();
|
|
51
69
|
|
|
52
70
|
const response = await fetchImpl('http://127.0.0.1:55667/api/ncp/agent/stream?sessionId=s1', {
|
|
@@ -55,15 +73,14 @@ describe('ncp-app-client-fetch', () => {
|
|
|
55
73
|
accept: 'text/event-stream'
|
|
56
74
|
}
|
|
57
75
|
});
|
|
58
|
-
const text = await response.text();
|
|
59
76
|
|
|
60
|
-
expect(
|
|
77
|
+
expect(fetchMock).toHaveBeenCalledWith('http://127.0.0.1:55667/api/ncp/agent/stream?sessionId=s1', {
|
|
61
78
|
method: 'GET',
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
79
|
+
headers: {
|
|
80
|
+
accept: 'text/event-stream'
|
|
81
|
+
},
|
|
82
|
+
credentials: 'include'
|
|
65
83
|
});
|
|
66
|
-
expect(text).toContain('event: ncp-event');
|
|
67
|
-
expect(text).toContain('"text":"hello"');
|
|
84
|
+
expect(await response.text()).toContain('event: ncp-event');
|
|
68
85
|
});
|
|
69
86
|
});
|