@nextclaw/ui 0.6.10 → 0.6.12
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/.eslintrc.cjs +10 -0
- package/CHANGELOG.md +16 -0
- package/dist/assets/ChannelsList-DBDjwf-X.js +1 -0
- package/dist/assets/ChatPage-C18sGGk1.js +36 -0
- package/dist/assets/DocBrowser-ZOplDEMS.js +1 -0
- package/dist/assets/LogoBadge-2LMzEMwe.js +1 -0
- package/dist/assets/MarketplacePage-D4JHYcB5.js +49 -0
- package/dist/assets/ModelConfig-DZVvdLFq.js +1 -0
- package/dist/assets/ProvidersList-Dum31480.js +1 -0
- package/dist/assets/{RuntimeConfig-BO6s-ls-.js → RuntimeConfig-4sb3mpkd.js} +1 -1
- package/dist/assets/SearchConfig-B4u_MxRG.js +1 -0
- package/dist/assets/{SecretsConfig-mayFdxpM.js → SecretsConfig-BQXblZvb.js} +2 -2
- package/dist/assets/SessionsConfig-Jk29xjQU.js +2 -0
- package/dist/assets/{card-BP5YnL-G.js → card-BekAnCgX.js} +1 -1
- package/dist/assets/config-layout-BHnOoweL.js +1 -0
- package/dist/assets/index-BXwjfCEO.css +1 -0
- package/dist/assets/index-Dl6t70wA.js +8 -0
- package/dist/assets/{input-B1D2QX0O.js → input-MMn_Na9q.js} +1 -1
- package/dist/assets/{label-DW0j-fXA.js → label-Dg2ydpN0.js} +1 -1
- package/dist/assets/{page-layout-Ch-H9gD-.js → page-layout-7K0rcz0I.js} +1 -1
- package/dist/assets/session-run-status-CAdjSqeb.js +3 -0
- package/dist/assets/{switch-_cZHlGKB.js → switch-DnDMlDVu.js} +1 -1
- package/dist/assets/{tabs-custom-ARxqYYjG.js → tabs-custom-khLM8lWj.js} +1 -1
- package/dist/assets/{useConfirmDialog-BaU7nIat.js → useConfirmDialog-BYA1XnVU.js} +2 -2
- package/dist/assets/{vendor-C--HHaLf.js → vendor-d7E8OgNx.js} +84 -84
- package/dist/index.html +3 -3
- package/package.json +4 -2
- package/src/App.tsx +3 -2
- package/src/api/config.ts +212 -200
- package/src/api/types.ts +93 -24
- package/src/components/chat/ChatConversationPanel.tsx +102 -121
- package/src/components/chat/ChatPage.tsx +165 -437
- package/src/components/chat/ChatSidebar.tsx +30 -36
- package/src/components/chat/ChatThread.tsx +73 -131
- package/src/components/chat/chat-input/ChatInputBarView.tsx +82 -0
- package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +71 -0
- package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +39 -0
- package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +31 -0
- package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +112 -0
- package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +24 -0
- package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +58 -0
- package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +56 -0
- package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +40 -0
- package/src/components/chat/chat-input/useChatInputBarController.ts +313 -0
- package/src/components/chat/chat-input.types.ts +15 -0
- package/src/components/chat/chat-page-data.ts +121 -0
- package/src/components/chat/chat-page-runtime.ts +221 -0
- package/src/components/chat/chat-session-route.ts +59 -0
- package/src/components/chat/chat-stream/nextbot-parsers.ts +52 -0
- package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +413 -0
- package/src/components/chat/chat-stream/stream-event-adapter.ts +98 -0
- package/src/components/chat/chat-stream/transport.ts +159 -0
- package/src/components/chat/chat-stream/types.ts +76 -0
- package/src/components/chat/managers/chat-input.manager.ts +142 -0
- package/src/components/chat/managers/chat-run-status.manager.ts +32 -0
- package/src/components/chat/managers/chat-session-list.manager.ts +77 -0
- package/src/components/chat/managers/chat-stream-actions.manager.ts +34 -0
- package/src/components/chat/managers/chat-thread.manager.ts +86 -0
- package/src/components/chat/managers/chat-ui.manager.ts +65 -0
- package/src/components/chat/presenter/chat-presenter-context.tsx +25 -0
- package/src/components/chat/presenter/chat.presenter.ts +32 -0
- package/src/components/chat/stores/chat-input.store.ts +62 -0
- package/src/components/chat/stores/chat-run-status.store.ts +30 -0
- package/src/components/chat/stores/chat-session-list.store.ts +34 -0
- package/src/components/chat/stores/chat-thread.store.ts +52 -0
- package/src/components/chat/useChatRuntimeController.ts +134 -0
- package/src/components/chat/useChatSessionTypeState.ts +148 -0
- package/src/components/common/MaskedInput.tsx +1 -1
- package/src/components/config/SearchConfig.tsx +297 -0
- package/src/components/layout/Sidebar.tsx +6 -1
- package/src/hooks/useConfig.ts +48 -1
- package/src/hooks/useObservable.ts +20 -0
- package/src/lib/chat-message.ts +2 -202
- package/src/lib/chat-runtime-utils.ts +250 -0
- package/src/lib/i18n.ts +31 -0
- package/tsconfig.json +2 -1
- package/vite.config.ts +2 -1
- package/dist/assets/ChannelsList-TyMb5Mgz.js +0 -1
- package/dist/assets/ChatPage-CQerYqvy.js +0 -34
- package/dist/assets/DocBrowser-CNtrA0ps.js +0 -1
- package/dist/assets/LogoBadge-BLqiOM5D.js +0 -1
- package/dist/assets/MarketplacePage-CotZxxNe.js +0 -49
- package/dist/assets/ModelConfig-CCsQ8KFq.js +0 -1
- package/dist/assets/ProvidersList-BYYX5K_g.js +0 -1
- package/dist/assets/SessionsConfig-DAIczdBj.js +0 -2
- package/dist/assets/index-BUiahmWm.css +0 -1
- package/dist/assets/index-D6_5HaDl.js +0 -7
- package/dist/assets/session-run-status-BUYsQeWs.js +0 -5
- package/src/components/chat/ChatInputBar.tsx +0 -590
- package/src/components/chat/useChatStreamController.ts +0 -591
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import type { SessionEntryView } from '@/api/types';
|
|
3
|
+
|
|
4
|
+
export type ChatSessionListSnapshot = {
|
|
5
|
+
sessions: SessionEntryView[];
|
|
6
|
+
selectedSessionKey: string | null;
|
|
7
|
+
selectedAgentId: string;
|
|
8
|
+
query: string;
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type ChatSessionListStore = {
|
|
13
|
+
snapshot: ChatSessionListSnapshot;
|
|
14
|
+
setSnapshot: (patch: Partial<ChatSessionListSnapshot>) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const initialSnapshot: ChatSessionListSnapshot = {
|
|
18
|
+
sessions: [],
|
|
19
|
+
selectedSessionKey: null,
|
|
20
|
+
selectedAgentId: 'main',
|
|
21
|
+
query: '',
|
|
22
|
+
isLoading: false
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const useChatSessionListStore = create<ChatSessionListStore>((set) => ({
|
|
26
|
+
snapshot: initialSnapshot,
|
|
27
|
+
setSnapshot: (patch) =>
|
|
28
|
+
set((state) => ({
|
|
29
|
+
snapshot: {
|
|
30
|
+
...state.snapshot,
|
|
31
|
+
...patch
|
|
32
|
+
}
|
|
33
|
+
}))
|
|
34
|
+
}));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { create } from 'zustand';
|
|
2
|
+
import type { MutableRefObject } from 'react';
|
|
3
|
+
import type { UiMessage } from '@nextclaw/agent-chat';
|
|
4
|
+
import type { ChatModelOption } from '@/components/chat/chat-input.types';
|
|
5
|
+
|
|
6
|
+
export type ChatThreadSnapshot = {
|
|
7
|
+
isProviderStateResolved: boolean;
|
|
8
|
+
modelOptions: ChatModelOption[];
|
|
9
|
+
sessionTypeUnavailable: boolean;
|
|
10
|
+
sessionTypeUnavailableMessage?: string | null;
|
|
11
|
+
selectedSessionKey: string | null;
|
|
12
|
+
sessionDisplayName?: string;
|
|
13
|
+
canDeleteSession: boolean;
|
|
14
|
+
isDeletePending: boolean;
|
|
15
|
+
threadRef: MutableRefObject<HTMLDivElement | null> | null;
|
|
16
|
+
isHistoryLoading: boolean;
|
|
17
|
+
uiMessages: UiMessage[];
|
|
18
|
+
isSending: boolean;
|
|
19
|
+
isAwaitingAssistantOutput: boolean;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ChatThreadStore = {
|
|
23
|
+
snapshot: ChatThreadSnapshot;
|
|
24
|
+
setSnapshot: (patch: Partial<ChatThreadSnapshot>) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const initialSnapshot: ChatThreadSnapshot = {
|
|
28
|
+
isProviderStateResolved: false,
|
|
29
|
+
modelOptions: [],
|
|
30
|
+
sessionTypeUnavailable: false,
|
|
31
|
+
sessionTypeUnavailableMessage: null,
|
|
32
|
+
selectedSessionKey: null,
|
|
33
|
+
sessionDisplayName: undefined,
|
|
34
|
+
canDeleteSession: false,
|
|
35
|
+
isDeletePending: false,
|
|
36
|
+
threadRef: null,
|
|
37
|
+
isHistoryLoading: false,
|
|
38
|
+
uiMessages: [],
|
|
39
|
+
isSending: false,
|
|
40
|
+
isAwaitingAssistantOutput: false
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const useChatThreadStore = create<ChatThreadStore>((set) => ({
|
|
44
|
+
snapshot: initialSnapshot,
|
|
45
|
+
setSnapshot: (patch) =>
|
|
46
|
+
set((state) => ({
|
|
47
|
+
snapshot: {
|
|
48
|
+
...state.snapshot,
|
|
49
|
+
...patch
|
|
50
|
+
}
|
|
51
|
+
}))
|
|
52
|
+
}));
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import { type AgentChatController, getStopDisabledReason } from '@nextclaw/agent-chat';
|
|
3
|
+
import type { ChatRunView, SessionMessageView } from '@/api/types';
|
|
4
|
+
import type { SendMessageParams, UseChatStreamControllerParams } from '@/components/chat/chat-stream/types';
|
|
5
|
+
import { buildResumeMetadata, buildSendMetadata } from '@/components/chat/chat-stream/nextbot-parsers';
|
|
6
|
+
import { buildUiMessagesFromHistoryMessages, normalizeRequestedSkills } from '@/lib/chat-runtime-utils';
|
|
7
|
+
import { useValueFromBehaviorSubject, useValueFromObservable } from '@/hooks/useObservable';
|
|
8
|
+
|
|
9
|
+
export function useChatRuntimeController(
|
|
10
|
+
params: UseChatStreamControllerParams,
|
|
11
|
+
controller: AgentChatController
|
|
12
|
+
) {
|
|
13
|
+
const paramsRef = useRef(params);
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
paramsRef.current = params;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const activeHistorySessionKeyRef = useRef<string | null>(null);
|
|
19
|
+
|
|
20
|
+
// Bind callbacks to controller
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
controller.setCallbacks({
|
|
23
|
+
onRunSettled: async ({ sourceSessionId, resultSessionId }) => {
|
|
24
|
+
const bindings = paramsRef.current;
|
|
25
|
+
await bindings.refetchSessions();
|
|
26
|
+
const activeSessionKey = bindings.selectedSessionKeyRef.current;
|
|
27
|
+
if (!activeSessionKey || activeSessionKey === sourceSessionId || (resultSessionId && activeSessionKey === resultSessionId)) {
|
|
28
|
+
await bindings.refetchHistory();
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
onRunError: ({ sourceMessage, restoreDraft }) => {
|
|
32
|
+
if (restoreDraft) {
|
|
33
|
+
paramsRef.current.setDraft((prev) => (prev.trim().length === 0 && sourceMessage ? sourceMessage : prev));
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
onSessionChanged: (sessionId) => {
|
|
37
|
+
paramsRef.current.setSelectedSessionKey((prev) => (prev === sessionId ? prev : sessionId));
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
}, [controller]);
|
|
41
|
+
|
|
42
|
+
// Reactive state from controller observables
|
|
43
|
+
const uiMessages = useValueFromObservable(controller.messages$, controller.getMessages());
|
|
44
|
+
const isSending = useValueFromBehaviorSubject(controller.isAgentResponding$);
|
|
45
|
+
const isAwaitingAssistantOutput = useValueFromBehaviorSubject(controller.isAwaitingResponse$);
|
|
46
|
+
const activeRun = useValueFromBehaviorSubject(controller.activeRun$);
|
|
47
|
+
const lastSendError = useValueFromBehaviorSubject(controller.lastError$);
|
|
48
|
+
|
|
49
|
+
// Derived state
|
|
50
|
+
const activeBackendRunId = activeRun?.remoteRunId ?? null;
|
|
51
|
+
const stopDisabledReason = getStopDisabledReason(activeRun);
|
|
52
|
+
const canStopCurrentRun = Boolean(
|
|
53
|
+
activeRun && (stopDisabledReason === null || (activeRun.remoteStopCapable && !activeBackendRunId))
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const sendMessage = useCallback(async (payload: SendMessageParams) => {
|
|
57
|
+
const requestedSkills = normalizeRequestedSkills(payload.requestedSkills);
|
|
58
|
+
const metadata = buildSendMetadata(payload, requestedSkills);
|
|
59
|
+
await controller.send({
|
|
60
|
+
message: payload.message,
|
|
61
|
+
sessionId: payload.sessionKey,
|
|
62
|
+
agentId: payload.agentId,
|
|
63
|
+
metadata,
|
|
64
|
+
restoreDraftOnError: payload.restoreDraftOnError,
|
|
65
|
+
stopCapable: payload.stopSupported,
|
|
66
|
+
stopReason: payload.stopReason
|
|
67
|
+
});
|
|
68
|
+
}, [controller]);
|
|
69
|
+
|
|
70
|
+
const resumeRun = useCallback(async (run: ChatRunView) => {
|
|
71
|
+
const backendRunId = run.runId?.trim();
|
|
72
|
+
const sessionKey = run.sessionKey?.trim();
|
|
73
|
+
if (!backendRunId || !sessionKey) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const metadata = buildResumeMetadata(run);
|
|
77
|
+
await controller.resume({
|
|
78
|
+
remoteRunId: backendRunId,
|
|
79
|
+
sessionId: sessionKey,
|
|
80
|
+
agentId: run.agentId,
|
|
81
|
+
metadata,
|
|
82
|
+
stopCapable: run.stopSupported,
|
|
83
|
+
stopReason: run.stopReason
|
|
84
|
+
});
|
|
85
|
+
}, [controller]);
|
|
86
|
+
|
|
87
|
+
const stopCurrentRun = useCallback(async () => {
|
|
88
|
+
await controller.stop();
|
|
89
|
+
}, [controller]);
|
|
90
|
+
|
|
91
|
+
const resetStreamState = useCallback(() => {
|
|
92
|
+
activeHistorySessionKeyRef.current = null;
|
|
93
|
+
controller.reset();
|
|
94
|
+
}, [controller]);
|
|
95
|
+
|
|
96
|
+
const applyHistoryMessages = useCallback((messages: SessionMessageView[], options?: { isLoading?: boolean }) => {
|
|
97
|
+
const isRunActive = Boolean(controller.activeRun$.getValue() || controller.isAgentResponding$.getValue());
|
|
98
|
+
if (isRunActive) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const selectedSessionKey = paramsRef.current.selectedSessionKeyRef.current;
|
|
102
|
+
if (selectedSessionKey !== activeHistorySessionKeyRef.current) {
|
|
103
|
+
activeHistorySessionKeyRef.current = selectedSessionKey;
|
|
104
|
+
if (controller.getMessages().length > 0) {
|
|
105
|
+
controller.setMessages([]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (!selectedSessionKey) {
|
|
109
|
+
if (controller.getMessages().length > 0) {
|
|
110
|
+
controller.setMessages([]);
|
|
111
|
+
}
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (options?.isLoading && messages.length === 0) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
controller.setMessages(buildUiMessagesFromHistoryMessages(messages));
|
|
118
|
+
}, [controller]);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
uiMessages,
|
|
122
|
+
isSending,
|
|
123
|
+
isAwaitingAssistantOutput,
|
|
124
|
+
canStopCurrentRun,
|
|
125
|
+
stopDisabledReason,
|
|
126
|
+
lastSendError,
|
|
127
|
+
activeBackendRunId,
|
|
128
|
+
sendMessage,
|
|
129
|
+
stopCurrentRun,
|
|
130
|
+
resumeRun,
|
|
131
|
+
resetStreamState,
|
|
132
|
+
applyHistoryMessages
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { useEffect, useMemo } from 'react';
|
|
2
|
+
import type { Dispatch, SetStateAction } from 'react';
|
|
3
|
+
import type { SessionEntryView } from '@/api/types';
|
|
4
|
+
import { t } from '@/lib/i18n';
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_SESSION_TYPE = 'native';
|
|
7
|
+
|
|
8
|
+
export type ChatSessionTypeOption = {
|
|
9
|
+
value: string;
|
|
10
|
+
label: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type UseChatSessionTypeStateParams = {
|
|
14
|
+
selectedSession: SessionEntryView | null;
|
|
15
|
+
selectedSessionKey: string | null;
|
|
16
|
+
pendingSessionType: string;
|
|
17
|
+
setPendingSessionType: Dispatch<SetStateAction<string>>;
|
|
18
|
+
sessionTypesData?: {
|
|
19
|
+
defaultType?: string;
|
|
20
|
+
options?: Array<{ value: string; label: string }>;
|
|
21
|
+
} | null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function normalizeSessionType(value: unknown): string {
|
|
25
|
+
if (typeof value !== 'string') {
|
|
26
|
+
return DEFAULT_SESSION_TYPE;
|
|
27
|
+
}
|
|
28
|
+
const normalized = value.trim().toLowerCase();
|
|
29
|
+
return normalized || DEFAULT_SESSION_TYPE;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveSessionTypeLabel(sessionType: string, fallbackLabel?: string): string {
|
|
33
|
+
if (sessionType === 'native') {
|
|
34
|
+
return t('chatSessionTypeNative');
|
|
35
|
+
}
|
|
36
|
+
if (sessionType === 'codex-sdk') {
|
|
37
|
+
return t('chatSessionTypeCodex');
|
|
38
|
+
}
|
|
39
|
+
if (sessionType === 'claude-agent-sdk') {
|
|
40
|
+
return t('chatSessionTypeClaude');
|
|
41
|
+
}
|
|
42
|
+
return fallbackLabel?.trim() || sessionType;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildSessionTypeOptions(
|
|
46
|
+
options: Array<{ value: string; label: string }>
|
|
47
|
+
): ChatSessionTypeOption[] {
|
|
48
|
+
const deduped = new Map<string, ChatSessionTypeOption>();
|
|
49
|
+
for (const option of options) {
|
|
50
|
+
const value = normalizeSessionType(option.value);
|
|
51
|
+
deduped.set(value, {
|
|
52
|
+
value,
|
|
53
|
+
label: option.label?.trim() || resolveSessionTypeLabel(value)
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (!deduped.has(DEFAULT_SESSION_TYPE)) {
|
|
57
|
+
deduped.set(DEFAULT_SESSION_TYPE, {
|
|
58
|
+
value: DEFAULT_SESSION_TYPE,
|
|
59
|
+
label: resolveSessionTypeLabel(DEFAULT_SESSION_TYPE)
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
return Array.from(deduped.values()).sort((left, right) => {
|
|
63
|
+
if (left.value === DEFAULT_SESSION_TYPE) {
|
|
64
|
+
return -1;
|
|
65
|
+
}
|
|
66
|
+
if (right.value === DEFAULT_SESSION_TYPE) {
|
|
67
|
+
return 1;
|
|
68
|
+
}
|
|
69
|
+
return left.value.localeCompare(right.value);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function useChatSessionTypeState(params: UseChatSessionTypeStateParams): {
|
|
74
|
+
sessionTypeOptions: ChatSessionTypeOption[];
|
|
75
|
+
defaultSessionType: string;
|
|
76
|
+
selectedSessionType: string;
|
|
77
|
+
canEditSessionType: boolean;
|
|
78
|
+
sessionTypeUnavailable: boolean;
|
|
79
|
+
sessionTypeUnavailableMessage: string | null;
|
|
80
|
+
} {
|
|
81
|
+
const {
|
|
82
|
+
selectedSession,
|
|
83
|
+
selectedSessionKey,
|
|
84
|
+
pendingSessionType,
|
|
85
|
+
setPendingSessionType,
|
|
86
|
+
sessionTypesData
|
|
87
|
+
} = params;
|
|
88
|
+
|
|
89
|
+
const runtimeSessionTypeOptions = useMemo(
|
|
90
|
+
() => buildSessionTypeOptions(sessionTypesData?.options ?? []),
|
|
91
|
+
[sessionTypesData?.options]
|
|
92
|
+
);
|
|
93
|
+
const sessionTypeOptions = useMemo(() => {
|
|
94
|
+
const options = [...runtimeSessionTypeOptions];
|
|
95
|
+
const currentSessionType = normalizeSessionType(selectedSession?.sessionType);
|
|
96
|
+
if (!options.some((option) => option.value === currentSessionType)) {
|
|
97
|
+
options.push({
|
|
98
|
+
value: currentSessionType,
|
|
99
|
+
label: resolveSessionTypeLabel(currentSessionType)
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return options.sort((left, right) => {
|
|
103
|
+
if (left.value === DEFAULT_SESSION_TYPE) {
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
if (right.value === DEFAULT_SESSION_TYPE) {
|
|
107
|
+
return 1;
|
|
108
|
+
}
|
|
109
|
+
return left.value.localeCompare(right.value);
|
|
110
|
+
});
|
|
111
|
+
}, [runtimeSessionTypeOptions, selectedSession?.sessionType]);
|
|
112
|
+
const defaultSessionType = useMemo(
|
|
113
|
+
() => normalizeSessionType(sessionTypesData?.defaultType ?? DEFAULT_SESSION_TYPE),
|
|
114
|
+
[sessionTypesData?.defaultType]
|
|
115
|
+
);
|
|
116
|
+
const selectedSessionType = useMemo(
|
|
117
|
+
() => normalizeSessionType(selectedSession?.sessionType ?? pendingSessionType ?? defaultSessionType),
|
|
118
|
+
[defaultSessionType, pendingSessionType, selectedSession?.sessionType]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (selectedSessionKey) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
setPendingSessionType(defaultSessionType);
|
|
126
|
+
}, [defaultSessionType, selectedSessionKey, setPendingSessionType]);
|
|
127
|
+
|
|
128
|
+
const canEditSessionType = !selectedSessionKey || Boolean(selectedSession?.sessionTypeMutable);
|
|
129
|
+
const availableSessionTypeSet = useMemo(
|
|
130
|
+
() => new Set(runtimeSessionTypeOptions.map((option) => option.value)),
|
|
131
|
+
[runtimeSessionTypeOptions]
|
|
132
|
+
);
|
|
133
|
+
const sessionTypeUnavailable = Boolean(
|
|
134
|
+
selectedSession && !availableSessionTypeSet.has(normalizeSessionType(selectedSession.sessionType))
|
|
135
|
+
);
|
|
136
|
+
const sessionTypeUnavailableMessage = sessionTypeUnavailable
|
|
137
|
+
? `${resolveSessionTypeLabel(selectedSessionType)} ${t('chatSessionTypeUnavailableSuffix')}`
|
|
138
|
+
: null;
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
sessionTypeOptions,
|
|
142
|
+
defaultSessionType,
|
|
143
|
+
selectedSessionType,
|
|
144
|
+
canEditSessionType,
|
|
145
|
+
sessionTypeUnavailable,
|
|
146
|
+
sessionTypeUnavailableMessage
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -9,7 +9,7 @@ interface MaskedInputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
|
9
9
|
isSet?: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
export function MaskedInput({
|
|
12
|
+
export function MaskedInput({ isSet, className, value, onChange, placeholder, ...props }: MaskedInputProps) {
|
|
13
13
|
const [showKey, setShowKey] = useState(false);
|
|
14
14
|
const [isEditing, setIsEditing] = useState(false);
|
|
15
15
|
const hasUserInput = typeof value === 'string' && value.length > 0;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { ExternalLink, KeyRound, Search as SearchIcon } from 'lucide-react';
|
|
3
|
+
import { PageHeader, PageLayout } from '@/components/layout/page-layout';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { Input } from '@/components/ui/input';
|
|
6
|
+
import { Label } from '@/components/ui/label';
|
|
7
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
8
|
+
import { useConfig, useConfigMeta, useUpdateSearch } from '@/hooks/useConfig';
|
|
9
|
+
import { t } from '@/lib/i18n';
|
|
10
|
+
import { cn } from '@/lib/utils';
|
|
11
|
+
import { CONFIG_DETAIL_CARD_CLASS, CONFIG_SIDEBAR_CARD_CLASS, CONFIG_SPLIT_GRID_CLASS } from './config-layout';
|
|
12
|
+
import type { SearchConfigUpdate, SearchProviderName } from '@/api/types';
|
|
13
|
+
|
|
14
|
+
const FRESHNESS_OPTIONS = [
|
|
15
|
+
{ value: 'noLimit', label: 'searchFreshnessNoLimit' },
|
|
16
|
+
{ value: 'oneDay', label: 'searchFreshnessOneDay' },
|
|
17
|
+
{ value: 'oneWeek', label: 'searchFreshnessOneWeek' },
|
|
18
|
+
{ value: 'oneMonth', label: 'searchFreshnessOneMonth' },
|
|
19
|
+
{ value: 'oneYear', label: 'searchFreshnessOneYear' }
|
|
20
|
+
] as const;
|
|
21
|
+
|
|
22
|
+
export function SearchConfig() {
|
|
23
|
+
const { data: config } = useConfig();
|
|
24
|
+
const { data: meta } = useConfigMeta();
|
|
25
|
+
const updateSearch = useUpdateSearch();
|
|
26
|
+
const providers = meta?.search ?? [];
|
|
27
|
+
const search = config?.search;
|
|
28
|
+
|
|
29
|
+
const [selectedProvider, setSelectedProvider] = useState<SearchProviderName>('bocha');
|
|
30
|
+
const [activeProvider, setActiveProvider] = useState<SearchProviderName>('bocha');
|
|
31
|
+
const [enabledProviders, setEnabledProviders] = useState<SearchProviderName[]>(['bocha']);
|
|
32
|
+
const [maxResults, setMaxResults] = useState('10');
|
|
33
|
+
const [bochaApiKey, setBochaApiKey] = useState('');
|
|
34
|
+
const [bochaBaseUrl, setBochaBaseUrl] = useState('https://api.bocha.cn/v1/web-search');
|
|
35
|
+
const [bochaSummary, setBochaSummary] = useState(true);
|
|
36
|
+
const [bochaFreshness, setBochaFreshness] = useState('noLimit');
|
|
37
|
+
const [braveApiKey, setBraveApiKey] = useState('');
|
|
38
|
+
const [braveBaseUrl, setBraveBaseUrl] = useState('https://api.search.brave.com/res/v1/web/search');
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!search) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
setSelectedProvider(search.provider);
|
|
45
|
+
setActiveProvider(search.provider);
|
|
46
|
+
setEnabledProviders(search.enabledProviders);
|
|
47
|
+
setMaxResults(String(search.defaults.maxResults));
|
|
48
|
+
setBochaBaseUrl(search.providers.bocha.baseUrl);
|
|
49
|
+
setBochaSummary(Boolean(search.providers.bocha.summary));
|
|
50
|
+
setBochaFreshness(search.providers.bocha.freshness ?? 'noLimit');
|
|
51
|
+
setBraveBaseUrl(search.providers.brave.baseUrl);
|
|
52
|
+
}, [search]);
|
|
53
|
+
|
|
54
|
+
const selectedMeta = useMemo(
|
|
55
|
+
() => providers.find((provider) => provider.name === selectedProvider),
|
|
56
|
+
[providers, selectedProvider]
|
|
57
|
+
);
|
|
58
|
+
const selectedView = search?.providers[selectedProvider];
|
|
59
|
+
const selectedEnabled = enabledProviders.includes(selectedProvider);
|
|
60
|
+
const bochaDocsUrl = search?.providers.bocha.docsUrl ?? meta?.search.find((provider) => provider.name === 'bocha')?.docsUrl ?? 'https://open.bocha.cn';
|
|
61
|
+
const activationButtonLabel = selectedEnabled
|
|
62
|
+
? t('searchProviderDeactivate')
|
|
63
|
+
: t('searchProviderActivate');
|
|
64
|
+
|
|
65
|
+
const buildSearchPayload = (
|
|
66
|
+
nextEnabledProviders: SearchProviderName[] = enabledProviders,
|
|
67
|
+
nextActiveProvider: SearchProviderName = activeProvider
|
|
68
|
+
): SearchConfigUpdate => ({
|
|
69
|
+
provider: nextActiveProvider,
|
|
70
|
+
enabledProviders: nextEnabledProviders,
|
|
71
|
+
defaults: {
|
|
72
|
+
maxResults: Number(maxResults) || 10
|
|
73
|
+
},
|
|
74
|
+
providers: {
|
|
75
|
+
bocha: {
|
|
76
|
+
apiKey: bochaApiKey || undefined,
|
|
77
|
+
baseUrl: bochaBaseUrl,
|
|
78
|
+
summary: bochaSummary,
|
|
79
|
+
freshness: bochaFreshness
|
|
80
|
+
},
|
|
81
|
+
brave: {
|
|
82
|
+
apiKey: braveApiKey || undefined,
|
|
83
|
+
baseUrl: braveBaseUrl
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const handleToggleEnabled = () => {
|
|
89
|
+
const nextEnabledProviders = selectedEnabled
|
|
90
|
+
? enabledProviders.filter((provider) => provider !== selectedProvider)
|
|
91
|
+
: [...enabledProviders, selectedProvider];
|
|
92
|
+
setEnabledProviders(nextEnabledProviders);
|
|
93
|
+
updateSearch.mutate({
|
|
94
|
+
data: buildSearchPayload(nextEnabledProviders)
|
|
95
|
+
});
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const handleActiveProviderChange = (value: string) => {
|
|
99
|
+
setActiveProvider(value as SearchProviderName);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const handleSubmit = (event: React.FormEvent) => {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
updateSearch.mutate({
|
|
105
|
+
data: buildSearchPayload()
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
if (!search || providers.length === 0) {
|
|
110
|
+
return <div className="p-8">{t('loading')}</div>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<PageLayout>
|
|
115
|
+
<PageHeader title={t('searchPageTitle')} description={t('searchPageDescription')} />
|
|
116
|
+
|
|
117
|
+
<div className={CONFIG_SPLIT_GRID_CLASS}>
|
|
118
|
+
<section className={CONFIG_SIDEBAR_CARD_CLASS}>
|
|
119
|
+
<div className="border-b border-gray-100 px-4 py-4">
|
|
120
|
+
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-gray-500">{t('searchChannels')}</p>
|
|
121
|
+
</div>
|
|
122
|
+
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-3">
|
|
123
|
+
{providers.map((provider) => {
|
|
124
|
+
const providerView = search.providers[provider.name];
|
|
125
|
+
const isEnabled = enabledProviders.includes(provider.name);
|
|
126
|
+
const isSelected = selectedProvider === provider.name;
|
|
127
|
+
return (
|
|
128
|
+
<button
|
|
129
|
+
key={provider.name}
|
|
130
|
+
type="button"
|
|
131
|
+
onClick={() => setSelectedProvider(provider.name)}
|
|
132
|
+
className={cn(
|
|
133
|
+
'w-full rounded-xl border p-3 text-left transition-all',
|
|
134
|
+
isSelected
|
|
135
|
+
? 'border-primary/30 bg-primary-50/40 shadow-sm'
|
|
136
|
+
: 'border-gray-200/70 bg-white hover:border-gray-300 hover:bg-gray-50/70'
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
<div className="flex items-start justify-between gap-3">
|
|
140
|
+
<div className="min-w-0">
|
|
141
|
+
<p className="truncate text-sm font-semibold text-gray-900">{provider.displayName}</p>
|
|
142
|
+
<p className="line-clamp-2 text-[11px] text-gray-500">
|
|
143
|
+
{provider.name === 'bocha' ? t('searchProviderBochaDescription') : t('searchProviderBraveDescription')}
|
|
144
|
+
</p>
|
|
145
|
+
</div>
|
|
146
|
+
<div className="flex flex-col items-end gap-1">
|
|
147
|
+
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600">
|
|
148
|
+
{providerView.apiKeySet ? t('searchStatusConfigured') : t('searchStatusNeedsSetup')}
|
|
149
|
+
</span>
|
|
150
|
+
{isEnabled ? (
|
|
151
|
+
<span className="rounded-full bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700">
|
|
152
|
+
{t('searchProviderActivated')}
|
|
153
|
+
</span>
|
|
154
|
+
) : null}
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</button>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
</div>
|
|
161
|
+
</section>
|
|
162
|
+
|
|
163
|
+
<form onSubmit={handleSubmit} className={cn(CONFIG_DETAIL_CARD_CLASS, 'p-6')}>
|
|
164
|
+
{!selectedMeta || !selectedView ? (
|
|
165
|
+
<div className="flex h-full items-center justify-center text-sm text-gray-500">{t('searchNoProviderSelected')}</div>
|
|
166
|
+
) : (
|
|
167
|
+
<div className="space-y-6 overflow-y-auto">
|
|
168
|
+
<div className="flex items-start justify-between gap-4">
|
|
169
|
+
<div className="flex items-center gap-3">
|
|
170
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary text-white">
|
|
171
|
+
<SearchIcon className="h-5 w-5" />
|
|
172
|
+
</div>
|
|
173
|
+
<div>
|
|
174
|
+
<h3 className="text-lg font-semibold text-gray-900">{selectedMeta.displayName}</h3>
|
|
175
|
+
<p className="text-sm text-gray-500">{selectedMeta.description}</p>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
<Button
|
|
179
|
+
type="button"
|
|
180
|
+
variant={selectedEnabled ? 'secondary' : 'outline'}
|
|
181
|
+
className="rounded-xl"
|
|
182
|
+
onClick={handleToggleEnabled}
|
|
183
|
+
>
|
|
184
|
+
{activationButtonLabel}
|
|
185
|
+
</Button>
|
|
186
|
+
</div>
|
|
187
|
+
|
|
188
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
189
|
+
<div className="space-y-2">
|
|
190
|
+
<Label>{t('searchActiveProvider')}</Label>
|
|
191
|
+
<Select value={activeProvider} onValueChange={handleActiveProviderChange}>
|
|
192
|
+
<SelectTrigger className="rounded-xl">
|
|
193
|
+
<SelectValue />
|
|
194
|
+
</SelectTrigger>
|
|
195
|
+
<SelectContent>
|
|
196
|
+
{providers.map((provider) => (
|
|
197
|
+
<SelectItem key={provider.name} value={provider.name}>{provider.displayName}</SelectItem>
|
|
198
|
+
))}
|
|
199
|
+
</SelectContent>
|
|
200
|
+
</Select>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
<div className="space-y-2">
|
|
204
|
+
<Label>{t('searchDefaultMaxResults')}</Label>
|
|
205
|
+
<Input
|
|
206
|
+
value={maxResults}
|
|
207
|
+
onChange={(event) => setMaxResults(event.target.value)}
|
|
208
|
+
inputMode="numeric"
|
|
209
|
+
className="rounded-xl"
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
{selectedProvider === 'bocha' ? (
|
|
215
|
+
<div className="space-y-4">
|
|
216
|
+
<div className="space-y-2">
|
|
217
|
+
<Label>{t('apiKey')}</Label>
|
|
218
|
+
<Input
|
|
219
|
+
type="password"
|
|
220
|
+
value={bochaApiKey}
|
|
221
|
+
onChange={(event) => setBochaApiKey(event.target.value)}
|
|
222
|
+
placeholder={search.providers.bocha.apiKeyMasked || t('enterApiKey')}
|
|
223
|
+
className="rounded-xl"
|
|
224
|
+
/>
|
|
225
|
+
</div>
|
|
226
|
+
<div className="space-y-2">
|
|
227
|
+
<Label>{t('searchProviderBaseUrl')}</Label>
|
|
228
|
+
<Input value={bochaBaseUrl} onChange={(event) => setBochaBaseUrl(event.target.value)} className="rounded-xl" />
|
|
229
|
+
</div>
|
|
230
|
+
<div className="grid gap-4 md:grid-cols-2">
|
|
231
|
+
<div className="space-y-2">
|
|
232
|
+
<Label>{t('searchProviderSummary')}</Label>
|
|
233
|
+
<Select value={bochaSummary ? 'true' : 'false'} onValueChange={(value) => setBochaSummary(value === 'true')}>
|
|
234
|
+
<SelectTrigger className="rounded-xl">
|
|
235
|
+
<SelectValue />
|
|
236
|
+
</SelectTrigger>
|
|
237
|
+
<SelectContent>
|
|
238
|
+
<SelectItem value="true">{t('enabled')}</SelectItem>
|
|
239
|
+
<SelectItem value="false">{t('disabled')}</SelectItem>
|
|
240
|
+
</SelectContent>
|
|
241
|
+
</Select>
|
|
242
|
+
</div>
|
|
243
|
+
<div className="space-y-2">
|
|
244
|
+
<Label>{t('searchProviderFreshness')}</Label>
|
|
245
|
+
<Select value={bochaFreshness} onValueChange={setBochaFreshness}>
|
|
246
|
+
<SelectTrigger className="rounded-xl">
|
|
247
|
+
<SelectValue />
|
|
248
|
+
</SelectTrigger>
|
|
249
|
+
<SelectContent>
|
|
250
|
+
{FRESHNESS_OPTIONS.map((option) => (
|
|
251
|
+
<SelectItem key={option.value} value={option.value}>{t(option.label)}</SelectItem>
|
|
252
|
+
))}
|
|
253
|
+
</SelectContent>
|
|
254
|
+
</Select>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
<div className="space-y-2">
|
|
258
|
+
<a href={bochaDocsUrl} target="_blank" rel="noreferrer">
|
|
259
|
+
<Button type="button" variant="outline" className="rounded-xl">
|
|
260
|
+
<ExternalLink className="mr-2 h-4 w-4" />
|
|
261
|
+
{t('searchProviderOpenDocs')}
|
|
262
|
+
</Button>
|
|
263
|
+
</a>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
) : (
|
|
267
|
+
<div className="space-y-4">
|
|
268
|
+
<div className="space-y-2">
|
|
269
|
+
<Label>{t('apiKey')}</Label>
|
|
270
|
+
<Input
|
|
271
|
+
type="password"
|
|
272
|
+
value={braveApiKey}
|
|
273
|
+
onChange={(event) => setBraveApiKey(event.target.value)}
|
|
274
|
+
placeholder={search.providers.brave.apiKeyMasked || t('enterApiKey')}
|
|
275
|
+
className="rounded-xl"
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
<div className="space-y-2">
|
|
279
|
+
<Label>{t('searchProviderBaseUrl')}</Label>
|
|
280
|
+
<Input value={braveBaseUrl} onChange={(event) => setBraveBaseUrl(event.target.value)} className="rounded-xl" />
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
)}
|
|
284
|
+
|
|
285
|
+
<div className="flex justify-end">
|
|
286
|
+
<Button type="submit" disabled={updateSearch.isPending}>
|
|
287
|
+
<KeyRound className="mr-2 h-4 w-4" />
|
|
288
|
+
{updateSearch.isPending ? t('saving') : t('saveChanges')}
|
|
289
|
+
</Button>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</form>
|
|
294
|
+
</div>
|
|
295
|
+
</PageLayout>
|
|
296
|
+
);
|
|
297
|
+
}
|