@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.
Files changed (90) hide show
  1. package/.eslintrc.cjs +10 -0
  2. package/CHANGELOG.md +16 -0
  3. package/dist/assets/ChannelsList-DBDjwf-X.js +1 -0
  4. package/dist/assets/ChatPage-C18sGGk1.js +36 -0
  5. package/dist/assets/DocBrowser-ZOplDEMS.js +1 -0
  6. package/dist/assets/LogoBadge-2LMzEMwe.js +1 -0
  7. package/dist/assets/MarketplacePage-D4JHYcB5.js +49 -0
  8. package/dist/assets/ModelConfig-DZVvdLFq.js +1 -0
  9. package/dist/assets/ProvidersList-Dum31480.js +1 -0
  10. package/dist/assets/{RuntimeConfig-BO6s-ls-.js → RuntimeConfig-4sb3mpkd.js} +1 -1
  11. package/dist/assets/SearchConfig-B4u_MxRG.js +1 -0
  12. package/dist/assets/{SecretsConfig-mayFdxpM.js → SecretsConfig-BQXblZvb.js} +2 -2
  13. package/dist/assets/SessionsConfig-Jk29xjQU.js +2 -0
  14. package/dist/assets/{card-BP5YnL-G.js → card-BekAnCgX.js} +1 -1
  15. package/dist/assets/config-layout-BHnOoweL.js +1 -0
  16. package/dist/assets/index-BXwjfCEO.css +1 -0
  17. package/dist/assets/index-Dl6t70wA.js +8 -0
  18. package/dist/assets/{input-B1D2QX0O.js → input-MMn_Na9q.js} +1 -1
  19. package/dist/assets/{label-DW0j-fXA.js → label-Dg2ydpN0.js} +1 -1
  20. package/dist/assets/{page-layout-Ch-H9gD-.js → page-layout-7K0rcz0I.js} +1 -1
  21. package/dist/assets/session-run-status-CAdjSqeb.js +3 -0
  22. package/dist/assets/{switch-_cZHlGKB.js → switch-DnDMlDVu.js} +1 -1
  23. package/dist/assets/{tabs-custom-ARxqYYjG.js → tabs-custom-khLM8lWj.js} +1 -1
  24. package/dist/assets/{useConfirmDialog-BaU7nIat.js → useConfirmDialog-BYA1XnVU.js} +2 -2
  25. package/dist/assets/{vendor-C--HHaLf.js → vendor-d7E8OgNx.js} +84 -84
  26. package/dist/index.html +3 -3
  27. package/package.json +4 -2
  28. package/src/App.tsx +3 -2
  29. package/src/api/config.ts +212 -200
  30. package/src/api/types.ts +93 -24
  31. package/src/components/chat/ChatConversationPanel.tsx +102 -121
  32. package/src/components/chat/ChatPage.tsx +165 -437
  33. package/src/components/chat/ChatSidebar.tsx +30 -36
  34. package/src/components/chat/ChatThread.tsx +73 -131
  35. package/src/components/chat/chat-input/ChatInputBarView.tsx +82 -0
  36. package/src/components/chat/chat-input/ChatInputBottomToolbar.tsx +71 -0
  37. package/src/components/chat/chat-input/components/ChatInputModelStateHint.tsx +39 -0
  38. package/src/components/chat/chat-input/components/ChatInputSelectedSkillsSection.tsx +31 -0
  39. package/src/components/chat/chat-input/components/ChatInputSlashPanelSection.tsx +112 -0
  40. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputAttachButton.tsx +24 -0
  41. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputModelSelector.tsx +58 -0
  42. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSendControls.tsx +56 -0
  43. package/src/components/chat/chat-input/components/bottom-toolbar/ChatInputSessionTypeSelector.tsx +40 -0
  44. package/src/components/chat/chat-input/useChatInputBarController.ts +313 -0
  45. package/src/components/chat/chat-input.types.ts +15 -0
  46. package/src/components/chat/chat-page-data.ts +121 -0
  47. package/src/components/chat/chat-page-runtime.ts +221 -0
  48. package/src/components/chat/chat-session-route.ts +59 -0
  49. package/src/components/chat/chat-stream/nextbot-parsers.ts +52 -0
  50. package/src/components/chat/chat-stream/nextbot-runtime-agent.ts +413 -0
  51. package/src/components/chat/chat-stream/stream-event-adapter.ts +98 -0
  52. package/src/components/chat/chat-stream/transport.ts +159 -0
  53. package/src/components/chat/chat-stream/types.ts +76 -0
  54. package/src/components/chat/managers/chat-input.manager.ts +142 -0
  55. package/src/components/chat/managers/chat-run-status.manager.ts +32 -0
  56. package/src/components/chat/managers/chat-session-list.manager.ts +77 -0
  57. package/src/components/chat/managers/chat-stream-actions.manager.ts +34 -0
  58. package/src/components/chat/managers/chat-thread.manager.ts +86 -0
  59. package/src/components/chat/managers/chat-ui.manager.ts +65 -0
  60. package/src/components/chat/presenter/chat-presenter-context.tsx +25 -0
  61. package/src/components/chat/presenter/chat.presenter.ts +32 -0
  62. package/src/components/chat/stores/chat-input.store.ts +62 -0
  63. package/src/components/chat/stores/chat-run-status.store.ts +30 -0
  64. package/src/components/chat/stores/chat-session-list.store.ts +34 -0
  65. package/src/components/chat/stores/chat-thread.store.ts +52 -0
  66. package/src/components/chat/useChatRuntimeController.ts +134 -0
  67. package/src/components/chat/useChatSessionTypeState.ts +148 -0
  68. package/src/components/common/MaskedInput.tsx +1 -1
  69. package/src/components/config/SearchConfig.tsx +297 -0
  70. package/src/components/layout/Sidebar.tsx +6 -1
  71. package/src/hooks/useConfig.ts +48 -1
  72. package/src/hooks/useObservable.ts +20 -0
  73. package/src/lib/chat-message.ts +2 -202
  74. package/src/lib/chat-runtime-utils.ts +250 -0
  75. package/src/lib/i18n.ts +31 -0
  76. package/tsconfig.json +2 -1
  77. package/vite.config.ts +2 -1
  78. package/dist/assets/ChannelsList-TyMb5Mgz.js +0 -1
  79. package/dist/assets/ChatPage-CQerYqvy.js +0 -34
  80. package/dist/assets/DocBrowser-CNtrA0ps.js +0 -1
  81. package/dist/assets/LogoBadge-BLqiOM5D.js +0 -1
  82. package/dist/assets/MarketplacePage-CotZxxNe.js +0 -49
  83. package/dist/assets/ModelConfig-CCsQ8KFq.js +0 -1
  84. package/dist/assets/ProvidersList-BYYX5K_g.js +0 -1
  85. package/dist/assets/SessionsConfig-DAIczdBj.js +0 -2
  86. package/dist/assets/index-BUiahmWm.css +0 -1
  87. package/dist/assets/index-D6_5HaDl.js +0 -7
  88. package/dist/assets/session-run-status-BUYsQeWs.js +0 -5
  89. package/src/components/chat/ChatInputBar.tsx +0 -590
  90. 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({ maskedValue, isSet, className, value, onChange, placeholder, ...props }: MaskedInputProps) {
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
+ }