@nextclaw/ui 0.7.0 → 0.9.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.
- package/CHANGELOG.md +30 -0
- package/dist/assets/ChannelsList-C7F_As4r.js +1 -0
- package/dist/assets/ChatPage-Oo7-OUsx.js +37 -0
- package/dist/assets/{DocBrowser-B9ws5JL7.js → DocBrowser-Dsd8Dlq8.js} +1 -1
- package/dist/assets/{LogoBadge-DvGAzkZ3.js → LogoBadge-2ChEc_oz.js} +1 -1
- package/dist/assets/MarketplacePage-BXck6-X3.js +49 -0
- package/dist/assets/{ModelConfig-BL_HsOsm.js → ModelConfig-CgHRSD0b.js} +1 -1
- package/dist/assets/ProvidersList-PPfZucvS.js +1 -0
- package/dist/assets/RuntimeConfig-ClLEKNTN.js +1 -0
- package/dist/assets/{SearchConfig-BhaI0fUf.js → SearchConfig-CuXVCbrf.js} +1 -1
- package/dist/assets/{SecretsConfig-CFoimOh9.js → SecretsConfig-udJz6Ake.js} +2 -2
- package/dist/assets/SessionsConfig-C1XnFfiC.js +2 -0
- package/dist/assets/{session-run-status-TkIuGbVw.js → chat-message-BETwXLD4.js} +3 -3
- package/dist/assets/{index-uMsNsQX6.js → index-COJdlL0e.js} +1 -1
- package/dist/assets/index-CsvP4CER.js +8 -0
- package/dist/assets/index-D-bXl7qL.css +1 -0
- package/dist/assets/{label-D8ly4a2P.js → label-BGL-ztxh.js} +1 -1
- package/dist/assets/{page-layout-BSYfvwbp.js → page-layout-aw88k7tG.js} +1 -1
- package/dist/assets/popover-DyEvzhmV.js +1 -0
- package/dist/assets/security-config-BuPAQn82.js +1 -0
- package/dist/assets/skeleton-drzO_tdU.js +1 -0
- package/dist/assets/{switch-Ce_g9lpN.js → switch-BK8jIzto.js} +1 -1
- package/dist/assets/{tabs-custom-Cf5azvT5.js → tabs-custom-Da3cEOji.js} +1 -1
- package/dist/assets/{useConfirmDialog-A8Ek8Wu7.js → useConfirmDialog-z0CE92iS.js} +2 -2
- package/dist/assets/{vendor-B7ozqnFC.js → vendor-CkJHmX1g.js} +65 -70
- package/dist/index.html +3 -3
- package/package.json +5 -2
- package/src/api/config.ts +9 -0
- package/src/api/ncp-session.ts +50 -0
- package/src/api/types.ts +20 -0
- package/src/components/chat/ChatConversationPanel.test.tsx +65 -0
- package/src/components/chat/ChatConversationPanel.tsx +21 -12
- package/src/components/chat/ChatPage.tsx +10 -324
- package/src/components/chat/ChatSidebar.test.tsx +203 -0
- package/src/components/chat/ChatSidebar.tsx +97 -7
- package/src/components/chat/adapters/chat-message.adapter.test.ts +132 -81
- package/src/components/chat/adapters/chat-message.adapter.ts +27 -9
- package/src/components/chat/chat-chain.test.ts +22 -0
- package/src/components/chat/chat-chain.ts +23 -0
- package/src/components/chat/chat-page-data.ts +30 -1
- package/src/components/chat/chat-page-runtime.test.ts +181 -0
- package/src/components/chat/chat-page-runtime.ts +101 -15
- package/src/components/chat/chat-page-shell.tsx +103 -0
- package/src/components/chat/chat-session-preference-sync.test.ts +62 -0
- package/src/components/chat/chat-session-preference-sync.ts +75 -0
- package/src/components/chat/containers/chat-input-bar.container.tsx +0 -22
- package/src/components/chat/containers/chat-message-list.container.tsx +34 -26
- package/src/components/chat/legacy/LegacyChatPage.tsx +252 -0
- package/src/components/chat/managers/chat-input.manager.ts +5 -0
- package/src/components/chat/managers/chat-session-list.manager.test.ts +39 -0
- package/src/components/chat/managers/chat-session-list.manager.ts +9 -3
- package/src/components/chat/ncp/NcpChatPage.tsx +381 -0
- package/src/components/chat/ncp/ncp-chat-input.manager.ts +179 -0
- package/src/components/chat/ncp/ncp-chat-page-data.ts +166 -0
- package/src/components/chat/ncp/ncp-chat-thread.manager.ts +89 -0
- package/src/components/chat/ncp/ncp-chat.presenter.ts +33 -0
- package/src/components/chat/ncp/ncp-session-adapter.test.ts +75 -0
- package/src/components/chat/ncp/ncp-session-adapter.ts +214 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +43 -4
- package/src/components/chat/stores/chat-thread.store.ts +2 -0
- package/src/components/chat/useChatSessionTypeState.test.tsx +58 -0
- package/src/components/chat/useChatSessionTypeState.ts +25 -8
- package/src/hooks/use-ncp-chat-session-types.ts +11 -0
- package/src/hooks/useConfig.ts +41 -1
- package/src/hooks/useMarketplace.ts +7 -4
- package/src/hooks/useWebSocket.ts +23 -2
- package/src/lib/i18n.ts +1 -1
- package/tailwind.config.js +8 -3
- package/tsconfig.json +4 -1
- package/dist/assets/ChannelsList-DF2U-LY1.js +0 -1
- package/dist/assets/ChatPage-BX39y0U5.js +0 -36
- package/dist/assets/MarketplacePage-DG5mHWJ8.js +0 -49
- package/dist/assets/ProvidersList-CH5z00YT.js +0 -1
- package/dist/assets/RuntimeConfig-BplBgkwo.js +0 -1
- package/dist/assets/SessionsConfig-BHTAYn9T.js +0 -2
- package/dist/assets/index-BLeJkJ0o.css +0 -1
- package/dist/assets/index-DK4TS5ev.js +0 -8
- package/dist/assets/index-X5J6Mm--.js +0 -1
- package/dist/assets/security-config-DlKEYHNN.js +0 -1
- package/dist/assets/skeleton-CWbsNx2h.js +0 -1
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
3
|
+
import type { SessionEntryView } from '@/api/types';
|
|
4
|
+
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
5
|
+
import {
|
|
6
|
+
adaptNcpSessionSummaries,
|
|
7
|
+
readNcpSessionPreferredThinking
|
|
8
|
+
} from '@/components/chat/ncp/ncp-session-adapter';
|
|
9
|
+
import { useChatSessionTypeState } from '@/components/chat/useChatSessionTypeState';
|
|
10
|
+
import {
|
|
11
|
+
resolveSelectedModelValue,
|
|
12
|
+
resolveRecentSessionPreferredModel,
|
|
13
|
+
useSyncSelectedModel
|
|
14
|
+
} from '@/components/chat/chat-page-runtime';
|
|
15
|
+
import {
|
|
16
|
+
useConfig,
|
|
17
|
+
useConfigMeta,
|
|
18
|
+
useNcpSessions
|
|
19
|
+
} from '@/hooks/useConfig';
|
|
20
|
+
import { useNcpChatSessionTypes } from '@/hooks/use-ncp-chat-session-types';
|
|
21
|
+
import { useMarketplaceInstalled } from '@/hooks/useMarketplace';
|
|
22
|
+
import { buildProviderModelCatalog, composeProviderModel, resolveModelThinkingCapability } from '@/lib/provider-models';
|
|
23
|
+
|
|
24
|
+
type UseNcpChatPageDataParams = {
|
|
25
|
+
query: string;
|
|
26
|
+
selectedSessionKey: string | null;
|
|
27
|
+
pendingSessionType: string;
|
|
28
|
+
setPendingSessionType: Dispatch<SetStateAction<string>>;
|
|
29
|
+
setSelectedModel: Dispatch<SetStateAction<string>>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function filterSessionsByQuery(sessions: SessionEntryView[], query: string): SessionEntryView[] {
|
|
33
|
+
const normalizedQuery = query.trim().toLowerCase();
|
|
34
|
+
if (!normalizedQuery) {
|
|
35
|
+
return sessions;
|
|
36
|
+
}
|
|
37
|
+
return sessions.filter((session) => session.key.toLowerCase().includes(normalizedQuery));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function useNcpChatPageData(params: UseNcpChatPageDataParams) {
|
|
41
|
+
const configQuery = useConfig();
|
|
42
|
+
const configMetaQuery = useConfigMeta();
|
|
43
|
+
const sessionsQuery = useNcpSessions({ limit: 200 });
|
|
44
|
+
const sessionTypesQuery = useNcpChatSessionTypes();
|
|
45
|
+
const installedSkillsQuery = useMarketplaceInstalled('skill');
|
|
46
|
+
const isProviderStateResolved =
|
|
47
|
+
(configQuery.isFetched || configQuery.isSuccess) &&
|
|
48
|
+
(configMetaQuery.isFetched || configMetaQuery.isSuccess);
|
|
49
|
+
|
|
50
|
+
const modelOptions = useMemo<ChatModelOption[]>(() => {
|
|
51
|
+
const providers = buildProviderModelCatalog({
|
|
52
|
+
meta: configMetaQuery.data,
|
|
53
|
+
config: configQuery.data,
|
|
54
|
+
onlyConfigured: true
|
|
55
|
+
});
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const options: ChatModelOption[] = [];
|
|
58
|
+
for (const provider of providers) {
|
|
59
|
+
for (const localModel of provider.models) {
|
|
60
|
+
const value = composeProviderModel(provider.prefix, localModel);
|
|
61
|
+
if (!value || seen.has(value)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
seen.add(value);
|
|
65
|
+
options.push({
|
|
66
|
+
value,
|
|
67
|
+
modelLabel: localModel,
|
|
68
|
+
providerLabel: provider.displayName,
|
|
69
|
+
thinkingCapability: resolveModelThinkingCapability(provider.modelThinking, localModel, provider.aliases)
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return options.sort((left, right) => {
|
|
74
|
+
const providerCompare = left.providerLabel.localeCompare(right.providerLabel);
|
|
75
|
+
if (providerCompare !== 0) {
|
|
76
|
+
return providerCompare;
|
|
77
|
+
}
|
|
78
|
+
return left.modelLabel.localeCompare(right.modelLabel);
|
|
79
|
+
});
|
|
80
|
+
}, [configMetaQuery.data, configQuery.data]);
|
|
81
|
+
|
|
82
|
+
const sessionSummaries = useMemo(
|
|
83
|
+
() => sessionsQuery.data?.sessions ?? [],
|
|
84
|
+
[sessionsQuery.data?.sessions]
|
|
85
|
+
);
|
|
86
|
+
const allSessions = useMemo(
|
|
87
|
+
() => adaptNcpSessionSummaries(sessionSummaries),
|
|
88
|
+
[sessionSummaries]
|
|
89
|
+
);
|
|
90
|
+
const sessions = useMemo(
|
|
91
|
+
() => filterSessionsByQuery(allSessions, params.query),
|
|
92
|
+
[allSessions, params.query]
|
|
93
|
+
);
|
|
94
|
+
const selectedSession = useMemo(
|
|
95
|
+
() => allSessions.find((session) => session.key === params.selectedSessionKey) ?? null,
|
|
96
|
+
[allSessions, params.selectedSessionKey]
|
|
97
|
+
);
|
|
98
|
+
const selectedSessionSummary = useMemo(
|
|
99
|
+
() => sessionSummaries.find((session) => session.sessionId === params.selectedSessionKey) ?? null,
|
|
100
|
+
[params.selectedSessionKey, sessionSummaries]
|
|
101
|
+
);
|
|
102
|
+
const skillRecords = useMemo(
|
|
103
|
+
() => installedSkillsQuery.data?.records ?? [],
|
|
104
|
+
[installedSkillsQuery.data?.records]
|
|
105
|
+
);
|
|
106
|
+
const selectedSessionThinkingLevel = useMemo(
|
|
107
|
+
() => (selectedSessionSummary ? readNcpSessionPreferredThinking(selectedSessionSummary) : null),
|
|
108
|
+
[selectedSessionSummary]
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
const sessionTypeState = useChatSessionTypeState({
|
|
112
|
+
selectedSession,
|
|
113
|
+
selectedSessionKey: params.selectedSessionKey,
|
|
114
|
+
pendingSessionType: params.pendingSessionType,
|
|
115
|
+
setPendingSessionType: params.setPendingSessionType,
|
|
116
|
+
sessionTypesData: sessionTypesQuery.data
|
|
117
|
+
});
|
|
118
|
+
const recentSessionPreferredModel = useMemo(
|
|
119
|
+
() =>
|
|
120
|
+
resolveRecentSessionPreferredModel({
|
|
121
|
+
sessions: allSessions,
|
|
122
|
+
selectedSessionKey: params.selectedSessionKey,
|
|
123
|
+
sessionType: sessionTypeState.selectedSessionType
|
|
124
|
+
}),
|
|
125
|
+
[allSessions, params.selectedSessionKey, sessionTypeState.selectedSessionType]
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
useSyncSelectedModel({
|
|
129
|
+
modelOptions,
|
|
130
|
+
selectedSessionKey: params.selectedSessionKey,
|
|
131
|
+
selectedSessionPreferredModel: selectedSession?.preferredModel,
|
|
132
|
+
fallbackPreferredModel: recentSessionPreferredModel,
|
|
133
|
+
defaultModel: configQuery.data?.agents.defaults.model,
|
|
134
|
+
setSelectedModel: params.setSelectedModel
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const hydratedSessionModel = useMemo(
|
|
138
|
+
() =>
|
|
139
|
+
resolveSelectedModelValue({
|
|
140
|
+
currentSelectedModel: '',
|
|
141
|
+
modelOptions,
|
|
142
|
+
selectedSessionPreferredModel: selectedSession?.preferredModel,
|
|
143
|
+
fallbackPreferredModel: recentSessionPreferredModel,
|
|
144
|
+
defaultModel: configQuery.data?.agents.defaults.model,
|
|
145
|
+
preferSessionPreferredModel: true
|
|
146
|
+
}),
|
|
147
|
+
[configQuery.data?.agents.defaults.model, modelOptions, recentSessionPreferredModel, selectedSession?.preferredModel]
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
configQuery,
|
|
152
|
+
configMetaQuery,
|
|
153
|
+
sessionsQuery,
|
|
154
|
+
sessionTypesQuery,
|
|
155
|
+
installedSkillsQuery,
|
|
156
|
+
isProviderStateResolved,
|
|
157
|
+
modelOptions,
|
|
158
|
+
sessionSummaries,
|
|
159
|
+
sessions,
|
|
160
|
+
skillRecords,
|
|
161
|
+
selectedSession,
|
|
162
|
+
hydratedSessionModel,
|
|
163
|
+
selectedSessionThinkingLevel,
|
|
164
|
+
...sessionTypeState
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { deleteNcpSession as deleteNcpSessionApi } from '@/api/ncp-session';
|
|
2
|
+
import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
|
|
3
|
+
import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
4
|
+
import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
5
|
+
import { useChatSessionListStore } from '@/components/chat/stores/chat-session-list.store';
|
|
6
|
+
import type { ChatThreadSnapshot } from '@/components/chat/stores/chat-thread.store';
|
|
7
|
+
import { useChatThreadStore } from '@/components/chat/stores/chat-thread.store';
|
|
8
|
+
import { t } from '@/lib/i18n';
|
|
9
|
+
|
|
10
|
+
export type NcpChatThreadManagerActions = {
|
|
11
|
+
refetchSessions: () => Promise<unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const noopAsync = async () => {};
|
|
15
|
+
|
|
16
|
+
export class NcpChatThreadManager {
|
|
17
|
+
private actions: NcpChatThreadManagerActions = {
|
|
18
|
+
refetchSessions: noopAsync
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private uiManager: ChatUiManager,
|
|
23
|
+
private sessionListManager: ChatSessionListManager,
|
|
24
|
+
private streamActionsManager: ChatStreamActionsManager
|
|
25
|
+
) {}
|
|
26
|
+
|
|
27
|
+
bindActions = (patch: Partial<NcpChatThreadManagerActions>) => {
|
|
28
|
+
this.actions = {
|
|
29
|
+
...this.actions,
|
|
30
|
+
...patch
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
private hasSnapshotChanges = (patch: Partial<ChatThreadSnapshot>): boolean => {
|
|
35
|
+
const current = useChatThreadStore.getState().snapshot;
|
|
36
|
+
for (const [key, value] of Object.entries(patch) as Array<[keyof ChatThreadSnapshot, ChatThreadSnapshot[keyof ChatThreadSnapshot]]>) {
|
|
37
|
+
if (!Object.is(current[key], value)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
syncSnapshot = (patch: Partial<ChatThreadSnapshot>) => {
|
|
45
|
+
if (!this.hasSnapshotChanges(patch)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
useChatThreadStore.getState().setSnapshot(patch);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
deleteSession = () => {
|
|
52
|
+
void this.deleteCurrentSession();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
createSession = () => {
|
|
56
|
+
this.sessionListManager.createSession();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
goToProviders = () => {
|
|
60
|
+
this.uiManager.goToProviders();
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
private deleteCurrentSession = async () => {
|
|
64
|
+
const {
|
|
65
|
+
snapshot: { selectedSessionKey }
|
|
66
|
+
} = useChatSessionListStore.getState();
|
|
67
|
+
if (!selectedSessionKey) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const confirmed = await this.uiManager.confirm({
|
|
71
|
+
title: t('chatDeleteSessionConfirm'),
|
|
72
|
+
variant: 'destructive',
|
|
73
|
+
confirmLabel: t('delete')
|
|
74
|
+
});
|
|
75
|
+
if (!confirmed) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
useChatThreadStore.getState().setSnapshot({ isDeletePending: true });
|
|
79
|
+
try {
|
|
80
|
+
await deleteNcpSessionApi(selectedSessionKey);
|
|
81
|
+
this.streamActionsManager.resetStreamState();
|
|
82
|
+
useChatSessionListStore.getState().setSnapshot({ selectedSessionKey: null });
|
|
83
|
+
this.uiManager.goToChatRoot({ replace: true });
|
|
84
|
+
await this.actions.refetchSessions();
|
|
85
|
+
} finally {
|
|
86
|
+
useChatThreadStore.getState().setSnapshot({ isDeletePending: false });
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ChatRunStatusManager } from '@/components/chat/managers/chat-run-status.manager';
|
|
2
|
+
import { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
|
|
3
|
+
import { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
4
|
+
import { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
5
|
+
import { NcpChatInputManager } from '@/components/chat/ncp/ncp-chat-input.manager';
|
|
6
|
+
import { NcpChatThreadManager } from '@/components/chat/ncp/ncp-chat-thread.manager';
|
|
7
|
+
|
|
8
|
+
export class NcpChatPresenter {
|
|
9
|
+
chatUiManager = new ChatUiManager();
|
|
10
|
+
chatStreamActionsManager = new ChatStreamActionsManager();
|
|
11
|
+
chatSessionListManager = new ChatSessionListManager(this.chatUiManager, this.chatStreamActionsManager);
|
|
12
|
+
chatInputManager = new NcpChatInputManager(
|
|
13
|
+
this.chatUiManager,
|
|
14
|
+
this.chatStreamActionsManager,
|
|
15
|
+
() => this.getDraftSessionId()
|
|
16
|
+
);
|
|
17
|
+
chatRunStatusManager = new ChatRunStatusManager();
|
|
18
|
+
chatThreadManager = new NcpChatThreadManager(
|
|
19
|
+
this.chatUiManager,
|
|
20
|
+
this.chatSessionListManager,
|
|
21
|
+
this.chatStreamActionsManager
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
private draftSessionId = '';
|
|
25
|
+
|
|
26
|
+
setDraftSessionId = (sessionId: string) => {
|
|
27
|
+
this.draftSessionId = sessionId;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
private getDraftSessionId(): string {
|
|
31
|
+
return this.draftSessionId;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
adaptNcpSessionSummary,
|
|
3
|
+
buildNcpSessionRunStatusByKey,
|
|
4
|
+
readNcpSessionPreferredThinking
|
|
5
|
+
} from '@/components/chat/ncp/ncp-session-adapter';
|
|
6
|
+
import type { NcpSessionSummaryView } from '@/api/types';
|
|
7
|
+
|
|
8
|
+
function createSummary(partial: Partial<NcpSessionSummaryView> = {}): NcpSessionSummaryView {
|
|
9
|
+
return {
|
|
10
|
+
sessionId: 'ncp-session-1',
|
|
11
|
+
messageCount: 3,
|
|
12
|
+
updatedAt: '2026-03-18T00:00:00.000Z',
|
|
13
|
+
status: 'idle',
|
|
14
|
+
...partial
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('adaptNcpSessionSummary', () => {
|
|
19
|
+
it('maps session metadata into shared session entry fields', () => {
|
|
20
|
+
const adapted = adaptNcpSessionSummary(
|
|
21
|
+
createSummary({
|
|
22
|
+
metadata: {
|
|
23
|
+
label: 'NCP Planning Thread',
|
|
24
|
+
model: 'openai/gpt-5',
|
|
25
|
+
session_type: 'native'
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(adapted).toMatchObject({
|
|
31
|
+
key: 'ncp-session-1',
|
|
32
|
+
label: 'NCP Planning Thread',
|
|
33
|
+
preferredModel: 'openai/gpt-5',
|
|
34
|
+
sessionType: 'native',
|
|
35
|
+
sessionTypeMutable: false,
|
|
36
|
+
messageCount: 3
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('readNcpSessionPreferredThinking', () => {
|
|
42
|
+
it('normalizes persisted thinking metadata for UI hydration', () => {
|
|
43
|
+
const thinking = readNcpSessionPreferredThinking(
|
|
44
|
+
createSummary({
|
|
45
|
+
metadata: {
|
|
46
|
+
preferred_thinking: 'HIGH'
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
expect(thinking).toBe('high');
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('buildNcpSessionRunStatusByKey', () => {
|
|
56
|
+
it('marks the active local session as running before the server summary catches up', () => {
|
|
57
|
+
const statuses = buildNcpSessionRunStatusByKey({
|
|
58
|
+
summaries: [createSummary({ sessionId: 'ncp-session-1', status: 'idle' })],
|
|
59
|
+
activeSessionId: 'ncp-session-1',
|
|
60
|
+
isLocallyRunning: true
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(statuses.get('ncp-session-1')).toBe('running');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('keeps persisted running sessions marked as running', () => {
|
|
67
|
+
const statuses = buildNcpSessionRunStatusByKey({
|
|
68
|
+
summaries: [createSummary({ sessionId: 'ncp-session-2', status: 'running' })],
|
|
69
|
+
activeSessionId: null,
|
|
70
|
+
isLocallyRunning: false
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(statuses.get('ncp-session-2')).toBe('running');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { ToolInvocationStatus, type UIMessage } from '@nextclaw/agent-chat';
|
|
2
|
+
import type { NcpMessagePart } from '@nextclaw/ncp';
|
|
3
|
+
import type { NcpMessageView, NcpSessionSummaryView, SessionEntryView, ThinkingLevel } from '@/api/types';
|
|
4
|
+
import type { SessionRunStatus } from '@/lib/session-run-status';
|
|
5
|
+
|
|
6
|
+
const THINKING_LEVEL_SET = new Set<string>(['off', 'minimal', 'low', 'medium', 'high', 'adaptive', 'xhigh']);
|
|
7
|
+
|
|
8
|
+
function stringifyUnknown(value: unknown): string {
|
|
9
|
+
if (typeof value === 'string') {
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
try {
|
|
13
|
+
return JSON.stringify(value ?? {});
|
|
14
|
+
} catch {
|
|
15
|
+
return String(value ?? '');
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readOptionalString(value: unknown): string | null {
|
|
20
|
+
if (typeof value !== 'string') {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const trimmed = value.trim();
|
|
24
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readMetadata(summary: NcpSessionSummaryView): Record<string, unknown> | null {
|
|
28
|
+
const { metadata } = summary;
|
|
29
|
+
if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return metadata as Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readNcpSessionPreferredModel(summary: NcpSessionSummaryView): string | null {
|
|
36
|
+
const metadata = readMetadata(summary);
|
|
37
|
+
if (!metadata) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return (
|
|
41
|
+
readOptionalString(metadata.preferred_model) ??
|
|
42
|
+
readOptionalString(metadata.preferredModel) ??
|
|
43
|
+
readOptionalString(metadata.model)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readNcpSessionPreferredThinking(summary: NcpSessionSummaryView): ThinkingLevel | null {
|
|
48
|
+
const metadata = readMetadata(summary);
|
|
49
|
+
if (!metadata) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const rawValue =
|
|
53
|
+
readOptionalString(metadata.preferred_thinking) ??
|
|
54
|
+
readOptionalString(metadata.thinking) ??
|
|
55
|
+
readOptionalString(metadata.thinking_level) ??
|
|
56
|
+
readOptionalString(metadata.thinkingLevel);
|
|
57
|
+
if (!rawValue) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
const normalized = rawValue.toLowerCase();
|
|
61
|
+
return THINKING_LEVEL_SET.has(normalized) ? (normalized as ThinkingLevel) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function readNcpSessionLabel(summary: NcpSessionSummaryView): string | null {
|
|
65
|
+
const metadata = readMetadata(summary);
|
|
66
|
+
if (!metadata) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return readOptionalString(metadata.label);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function readNcpSessionType(summary: NcpSessionSummaryView): string {
|
|
73
|
+
const metadata = readMetadata(summary);
|
|
74
|
+
if (!metadata) {
|
|
75
|
+
return 'native';
|
|
76
|
+
}
|
|
77
|
+
return readOptionalString(metadata.session_type) ?? readOptionalString(metadata.sessionType) ?? 'native';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mapToolStatus(part: Extract<NcpMessagePart, { type: 'tool-invocation' }>): ToolInvocationStatus {
|
|
81
|
+
if (part.state === 'result') {
|
|
82
|
+
return ToolInvocationStatus.RESULT;
|
|
83
|
+
}
|
|
84
|
+
if (part.state === 'partial-call') {
|
|
85
|
+
return ToolInvocationStatus.PARTIAL_CALL;
|
|
86
|
+
}
|
|
87
|
+
return ToolInvocationStatus.CALL;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function toUiParts(parts: NcpMessagePart[]): UIMessage['parts'] {
|
|
91
|
+
const uiParts: UIMessage['parts'] = [];
|
|
92
|
+
for (const part of parts) {
|
|
93
|
+
if (part.type === 'text') {
|
|
94
|
+
uiParts.push({ type: 'text', text: part.text });
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (part.type === 'rich-text') {
|
|
98
|
+
uiParts.push({ type: 'text', text: part.text });
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (part.type === 'reasoning') {
|
|
102
|
+
uiParts.push({
|
|
103
|
+
type: 'reasoning',
|
|
104
|
+
reasoning: part.text,
|
|
105
|
+
details: []
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (part.type === 'source') {
|
|
110
|
+
uiParts.push({
|
|
111
|
+
type: 'source',
|
|
112
|
+
source: {
|
|
113
|
+
sourceType: 'url',
|
|
114
|
+
id: part.url ?? part.title ?? Math.random().toString(36).slice(2, 8),
|
|
115
|
+
url: part.url ?? '',
|
|
116
|
+
...(part.title ? { title: part.title } : {})
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (part.type === 'file' && part.contentBase64) {
|
|
122
|
+
uiParts.push({
|
|
123
|
+
type: 'file',
|
|
124
|
+
mimeType: part.mimeType ?? 'application/octet-stream',
|
|
125
|
+
data: part.contentBase64
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (part.type === 'step-start') {
|
|
130
|
+
uiParts.push({ type: 'step-start' });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (part.type === 'tool-invocation') {
|
|
134
|
+
uiParts.push({
|
|
135
|
+
type: 'tool-invocation',
|
|
136
|
+
toolInvocation: {
|
|
137
|
+
status: mapToolStatus(part),
|
|
138
|
+
toolCallId: part.toolCallId ?? `${part.toolName}-${Math.random().toString(36).slice(2, 8)}`,
|
|
139
|
+
toolName: part.toolName,
|
|
140
|
+
args: stringifyUnknown(part.args),
|
|
141
|
+
result: part.result
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return uiParts;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function normalizeRole(role: NcpMessageView['role']): UIMessage['role'] {
|
|
150
|
+
if (role === 'service') {
|
|
151
|
+
return 'system';
|
|
152
|
+
}
|
|
153
|
+
return role === 'tool' ? 'assistant' : role;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function adaptNcpMessageToUiMessage(message: NcpMessageView): UIMessage {
|
|
157
|
+
return {
|
|
158
|
+
id: message.id,
|
|
159
|
+
role: normalizeRole(message.role),
|
|
160
|
+
parts: toUiParts(message.parts),
|
|
161
|
+
meta: {
|
|
162
|
+
source: 'stream',
|
|
163
|
+
status: message.status,
|
|
164
|
+
sessionKey: message.sessionId,
|
|
165
|
+
timestamp: message.timestamp
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function adaptNcpMessagesToUiMessages(messages: readonly NcpMessageView[]): UIMessage[] {
|
|
171
|
+
console.log('[adaptNcpMessagesToUiMessages]', { messages });
|
|
172
|
+
return messages.map(adaptNcpMessageToUiMessage);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function adaptNcpSessionSummary(summary: NcpSessionSummaryView): SessionEntryView {
|
|
176
|
+
const label = readNcpSessionLabel(summary);
|
|
177
|
+
const preferredModel = readNcpSessionPreferredModel(summary);
|
|
178
|
+
return {
|
|
179
|
+
key: summary.sessionId,
|
|
180
|
+
createdAt: summary.updatedAt,
|
|
181
|
+
updatedAt: summary.updatedAt,
|
|
182
|
+
...(label ? { label } : {}),
|
|
183
|
+
...(preferredModel ? { preferredModel } : {}),
|
|
184
|
+
sessionType: readNcpSessionType(summary),
|
|
185
|
+
sessionTypeMutable: false,
|
|
186
|
+
messageCount: summary.messageCount
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function adaptNcpSessionSummaries(summaries: NcpSessionSummaryView[]): SessionEntryView[] {
|
|
191
|
+
return summaries.map(adaptNcpSessionSummary);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function buildNcpSessionRunStatusByKey(params: {
|
|
195
|
+
summaries: readonly NcpSessionSummaryView[];
|
|
196
|
+
activeSessionId?: string | null;
|
|
197
|
+
isLocallyRunning?: boolean;
|
|
198
|
+
}): Map<string, SessionRunStatus> {
|
|
199
|
+
const map = new Map<string, SessionRunStatus>();
|
|
200
|
+
for (const summary of params.summaries) {
|
|
201
|
+
if (summary.status === 'running') {
|
|
202
|
+
map.set(summary.sessionId, 'running');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const activeSessionId = readOptionalString(params.activeSessionId);
|
|
206
|
+
if (params.isLocallyRunning && activeSessionId) {
|
|
207
|
+
map.set(activeSessionId, 'running');
|
|
208
|
+
}
|
|
209
|
+
return map;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export function createNcpSessionId(): string {
|
|
213
|
+
return `ncp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
214
|
+
}
|
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
import { createContext, useContext } from 'react';
|
|
2
2
|
import type { ReactNode } from 'react';
|
|
3
|
-
import type {
|
|
3
|
+
import type { SetStateAction } from 'react';
|
|
4
|
+
import type { ChatRunStatusManager } from '@/components/chat/managers/chat-run-status.manager';
|
|
5
|
+
import type { ChatSessionListManager } from '@/components/chat/managers/chat-session-list.manager';
|
|
6
|
+
import type { ChatStreamActionsManager } from '@/components/chat/managers/chat-stream-actions.manager';
|
|
7
|
+
import type { ChatUiManager } from '@/components/chat/managers/chat-ui.manager';
|
|
8
|
+
import type { ChatThreadSnapshot } from '@/components/chat/stores/chat-thread.store';
|
|
9
|
+
import type { ThinkingLevel } from '@/api/types';
|
|
4
10
|
|
|
5
|
-
|
|
11
|
+
export type ChatInputManagerLike = {
|
|
12
|
+
syncSnapshot: (patch: Record<string, unknown>) => void;
|
|
13
|
+
setDraft: (next: SetStateAction<string>) => void;
|
|
14
|
+
setPendingSessionType: (next: SetStateAction<string>) => void;
|
|
15
|
+
send: () => Promise<void>;
|
|
16
|
+
stop: () => Promise<void>;
|
|
17
|
+
goToProviders: () => void;
|
|
18
|
+
setSelectedModel: (next: SetStateAction<string>) => void;
|
|
19
|
+
setSelectedThinkingLevel: (next: SetStateAction<ThinkingLevel | null>) => void;
|
|
20
|
+
setSelectedSkills: (next: SetStateAction<string[]>) => void;
|
|
21
|
+
selectSessionType: (value: string) => void;
|
|
22
|
+
selectModel: (value: string) => void;
|
|
23
|
+
selectThinkingLevel: (value: ThinkingLevel) => void;
|
|
24
|
+
selectSkills: (next: string[]) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ChatThreadManagerLike = {
|
|
28
|
+
bindActions: (patch: { refetchSessions?: () => Promise<unknown> }) => void;
|
|
29
|
+
syncSnapshot: (patch: Partial<ChatThreadSnapshot>) => void;
|
|
30
|
+
deleteSession: () => void;
|
|
31
|
+
createSession: () => void;
|
|
32
|
+
goToProviders: () => void;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type ChatPresenterLike = {
|
|
36
|
+
chatUiManager: ChatUiManager;
|
|
37
|
+
chatStreamActionsManager: ChatStreamActionsManager;
|
|
38
|
+
chatInputManager: ChatInputManagerLike;
|
|
39
|
+
chatSessionListManager: ChatSessionListManager;
|
|
40
|
+
chatRunStatusManager: ChatRunStatusManager;
|
|
41
|
+
chatThreadManager: ChatThreadManagerLike;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const ChatPresenterContext = createContext<ChatPresenterLike | null>(null);
|
|
6
45
|
|
|
7
46
|
type ChatPresenterProviderProps = {
|
|
8
|
-
presenter:
|
|
47
|
+
presenter: ChatPresenterLike;
|
|
9
48
|
children: ReactNode;
|
|
10
49
|
};
|
|
11
50
|
|
|
@@ -13,7 +52,7 @@ export function ChatPresenterProvider({ presenter, children }: ChatPresenterProv
|
|
|
13
52
|
return <ChatPresenterContext.Provider value={presenter}>{children}</ChatPresenterContext.Provider>;
|
|
14
53
|
}
|
|
15
54
|
|
|
16
|
-
export function usePresenter():
|
|
55
|
+
export function usePresenter(): ChatPresenterLike {
|
|
17
56
|
const presenter = useContext(ChatPresenterContext);
|
|
18
57
|
if (!presenter) {
|
|
19
58
|
throw new Error('usePresenter must be used inside ChatPresenterProvider');
|
|
@@ -8,6 +8,7 @@ export type ChatThreadSnapshot = {
|
|
|
8
8
|
modelOptions: ChatModelOption[];
|
|
9
9
|
sessionTypeUnavailable: boolean;
|
|
10
10
|
sessionTypeUnavailableMessage?: string | null;
|
|
11
|
+
sessionTypeLabel?: string | null;
|
|
11
12
|
selectedSessionKey: string | null;
|
|
12
13
|
sessionDisplayName?: string;
|
|
13
14
|
canDeleteSession: boolean;
|
|
@@ -29,6 +30,7 @@ const initialSnapshot: ChatThreadSnapshot = {
|
|
|
29
30
|
modelOptions: [],
|
|
30
31
|
sessionTypeUnavailable: false,
|
|
31
32
|
sessionTypeUnavailableMessage: null,
|
|
33
|
+
sessionTypeLabel: null,
|
|
32
34
|
selectedSessionKey: null,
|
|
33
35
|
sessionDisplayName: undefined,
|
|
34
36
|
canDeleteSession: false,
|