@vibe-forge/client 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/LICENSE +21 -0
  2. package/cli.cjs +6 -0
  3. package/index.html +27 -0
  4. package/package.json +42 -0
  5. package/src/App.tsx +174 -0
  6. package/src/api.ts +241 -0
  7. package/src/components/ArchiveView.scss +168 -0
  8. package/src/components/ArchiveView.tsx +299 -0
  9. package/src/components/AutomationView/AutomationView.scss +26 -0
  10. package/src/components/AutomationView/RuleFormPanel.scss +129 -0
  11. package/src/components/AutomationView/RuleFormPanel.tsx +257 -0
  12. package/src/components/AutomationView/RuleSidebar.scss +219 -0
  13. package/src/components/AutomationView/RuleSidebar.tsx +258 -0
  14. package/src/components/AutomationView/RunHistoryPanel.scss +286 -0
  15. package/src/components/AutomationView/RunHistoryPanel.tsx +320 -0
  16. package/src/components/AutomationView/TaskList.scss +128 -0
  17. package/src/components/AutomationView/TaskList.tsx +79 -0
  18. package/src/components/AutomationView/TriggerList.scss +153 -0
  19. package/src/components/AutomationView/TriggerList.tsx +217 -0
  20. package/src/components/AutomationView/index.tsx +228 -0
  21. package/src/components/AutomationView/types.ts +21 -0
  22. package/src/components/Chat.scss +89 -0
  23. package/src/components/Chat.tsx +92 -0
  24. package/src/components/ConfigView.scss +185 -0
  25. package/src/components/ConfigView.tsx +258 -0
  26. package/src/components/NavRail.scss +71 -0
  27. package/src/components/NavRail.tsx +188 -0
  28. package/src/components/Sidebar.scss +112 -0
  29. package/src/components/Sidebar.tsx +291 -0
  30. package/src/components/chat/ChatHeader.scss +401 -0
  31. package/src/components/chat/ChatHeader.tsx +342 -0
  32. package/src/components/chat/ChatHistoryView.tsx +122 -0
  33. package/src/components/chat/ChatSettingsView.tsx +22 -0
  34. package/src/components/chat/ChatTimelineView.scss +53 -0
  35. package/src/components/chat/ChatTimelineView.tsx +158 -0
  36. package/src/components/chat/CodeBlock.scss +87 -0
  37. package/src/components/chat/CodeBlock.tsx +179 -0
  38. package/src/components/chat/CompletionMenu.scss +70 -0
  39. package/src/components/chat/CompletionMenu.tsx +58 -0
  40. package/src/components/chat/CurrentTodoList.scss +217 -0
  41. package/src/components/chat/CurrentTodoList.tsx +103 -0
  42. package/src/components/chat/MarkdownContent.tsx +43 -0
  43. package/src/components/chat/MessageFooter.tsx +48 -0
  44. package/src/components/chat/MessageItem.scss +251 -0
  45. package/src/components/chat/MessageItem.tsx +78 -0
  46. package/src/components/chat/NewSessionGuide.scss +186 -0
  47. package/src/components/chat/NewSessionGuide.tsx +167 -0
  48. package/src/components/chat/Sender.scss +367 -0
  49. package/src/components/chat/Sender.tsx +541 -0
  50. package/src/components/chat/SessionTimelinePanel/EventList.scss +58 -0
  51. package/src/components/chat/SessionTimelinePanel/EventList.tsx +212 -0
  52. package/src/components/chat/SessionTimelinePanel/gantt.ts +177 -0
  53. package/src/components/chat/SessionTimelinePanel/git-graph.ts +518 -0
  54. package/src/components/chat/SessionTimelinePanel/index.scss +28 -0
  55. package/src/components/chat/SessionTimelinePanel/index.tsx +121 -0
  56. package/src/components/chat/SessionTimelinePanel/mermaid.ts +4 -0
  57. package/src/components/chat/SessionTimelinePanel/types.ts +64 -0
  58. package/src/components/chat/SessionTimelinePanel/utils.ts +20 -0
  59. package/src/components/chat/ThinkingStatus.scss +70 -0
  60. package/src/components/chat/ThinkingStatus.tsx +13 -0
  61. package/src/components/chat/ToolCallBox.scss +137 -0
  62. package/src/components/chat/ToolCallBox.tsx +55 -0
  63. package/src/components/chat/ToolGroup.scss +154 -0
  64. package/src/components/chat/ToolGroup.tsx +102 -0
  65. package/src/components/chat/ToolRenderer.tsx +45 -0
  66. package/src/components/chat/messageUtils.ts +171 -0
  67. package/src/components/chat/safeSerialize.ts +84 -0
  68. package/src/components/chat/tools/DefaultTool.tsx +63 -0
  69. package/src/components/chat/tools/adapter-claude/BashTool.scss +71 -0
  70. package/src/components/chat/tools/adapter-claude/BashTool.tsx +82 -0
  71. package/src/components/chat/tools/adapter-claude/GlobTool.scss +88 -0
  72. package/src/components/chat/tools/adapter-claude/GlobTool.tsx +85 -0
  73. package/src/components/chat/tools/adapter-claude/GrepTool.scss +96 -0
  74. package/src/components/chat/tools/adapter-claude/GrepTool.tsx +114 -0
  75. package/src/components/chat/tools/adapter-claude/LSTool.scss +85 -0
  76. package/src/components/chat/tools/adapter-claude/LSTool.tsx +94 -0
  77. package/src/components/chat/tools/adapter-claude/ReadTool.scss +57 -0
  78. package/src/components/chat/tools/adapter-claude/ReadTool.tsx +87 -0
  79. package/src/components/chat/tools/adapter-claude/TodoTool.scss +78 -0
  80. package/src/components/chat/tools/adapter-claude/TodoTool.tsx +60 -0
  81. package/src/components/chat/tools/adapter-claude/WriteTool.scss +92 -0
  82. package/src/components/chat/tools/adapter-claude/WriteTool.tsx +86 -0
  83. package/src/components/chat/tools/adapter-claude/components/FileList.scss +65 -0
  84. package/src/components/chat/tools/adapter-claude/components/FileList.tsx +185 -0
  85. package/src/components/chat/tools/adapter-claude/index.ts +28 -0
  86. package/src/components/chat/tools/defineToolRender.ts +28 -0
  87. package/src/components/chat/tools/task/GetTaskInfoTool.scss +50 -0
  88. package/src/components/chat/tools/task/GetTaskInfoTool.tsx +88 -0
  89. package/src/components/chat/tools/task/ListTasksTool.scss +56 -0
  90. package/src/components/chat/tools/task/ListTasksTool.tsx +83 -0
  91. package/src/components/chat/tools/task/StartTasksTool.scss +56 -0
  92. package/src/components/chat/tools/task/StartTasksTool.tsx +96 -0
  93. package/src/components/chat/tools/task/components/TaskToolCard.scss +127 -0
  94. package/src/components/chat/tools/task/components/TaskToolCard.tsx +177 -0
  95. package/src/components/chat/tools/task/index.ts +15 -0
  96. package/src/components/chat/useChatModels.tsx +206 -0
  97. package/src/components/chat/useChatSession.ts +370 -0
  98. package/src/components/config/ConfigAboutSection.scss +111 -0
  99. package/src/components/config/ConfigAboutSection.tsx +86 -0
  100. package/src/components/config/ConfigDisplayValue.scss +22 -0
  101. package/src/components/config/ConfigDisplayValue.tsx +62 -0
  102. package/src/components/config/ConfigEditors.scss +65 -0
  103. package/src/components/config/ConfigEditors.tsx +98 -0
  104. package/src/components/config/ConfigFieldRow.scss +97 -0
  105. package/src/components/config/ConfigFieldRow.tsx +36 -0
  106. package/src/components/config/ConfigSectionForm.scss +94 -0
  107. package/src/components/config/ConfigSectionForm.tsx +436 -0
  108. package/src/components/config/ConfigSectionPanel.tsx +67 -0
  109. package/src/components/config/ConfigShortcutInput.scss +11 -0
  110. package/src/components/config/ConfigShortcutInput.tsx +52 -0
  111. package/src/components/config/ConfigSourceSwitch.tsx +57 -0
  112. package/src/components/config/configSchema.ts +319 -0
  113. package/src/components/config/configUtils.ts +83 -0
  114. package/src/components/config/index.tsx +5 -0
  115. package/src/components/config/recordEditors/BooleanRecordEditor.scss +1 -0
  116. package/src/components/config/recordEditors/BooleanRecordEditor.tsx +75 -0
  117. package/src/components/config/recordEditors/KeyValueEditor.scss +1 -0
  118. package/src/components/config/recordEditors/KeyValueEditor.tsx +97 -0
  119. package/src/components/config/recordEditors/McpServersRecordEditor.scss +1 -0
  120. package/src/components/config/recordEditors/McpServersRecordEditor.tsx +258 -0
  121. package/src/components/config/recordEditors/ModelServicesRecordEditor.scss +1 -0
  122. package/src/components/config/recordEditors/ModelServicesRecordEditor.tsx +233 -0
  123. package/src/components/config/recordEditors/RecordEditors.scss +117 -0
  124. package/src/components/config/recordEditors/RecordJsonEditor.scss +1 -0
  125. package/src/components/config/recordEditors/RecordJsonEditor.tsx +113 -0
  126. package/src/components/config/recordEditors/index.tsx +5 -0
  127. package/src/components/knowledge-base/KnowledgeBaseView.scss +19 -0
  128. package/src/components/knowledge-base/KnowledgeBaseView.tsx +186 -0
  129. package/src/components/knowledge-base/components/ActionButton.scss +5 -0
  130. package/src/components/knowledge-base/components/ActionButton.tsx +9 -0
  131. package/src/components/knowledge-base/components/EmptyState.scss +19 -0
  132. package/src/components/knowledge-base/components/EmptyState.tsx +42 -0
  133. package/src/components/knowledge-base/components/EntitiesTab.scss +5 -0
  134. package/src/components/knowledge-base/components/EntitiesTab.tsx +80 -0
  135. package/src/components/knowledge-base/components/EntityItem.scss +82 -0
  136. package/src/components/knowledge-base/components/EntityItem.tsx +79 -0
  137. package/src/components/knowledge-base/components/EntityList.scss +5 -0
  138. package/src/components/knowledge-base/components/EntityList.tsx +70 -0
  139. package/src/components/knowledge-base/components/FilterBar.scss +21 -0
  140. package/src/components/knowledge-base/components/FilterBar.tsx +51 -0
  141. package/src/components/knowledge-base/components/FlowsTab.scss +5 -0
  142. package/src/components/knowledge-base/components/FlowsTab.tsx +80 -0
  143. package/src/components/knowledge-base/components/KnowledgeBaseHeader.scss +27 -0
  144. package/src/components/knowledge-base/components/KnowledgeBaseHeader.tsx +29 -0
  145. package/src/components/knowledge-base/components/KnowledgeList.scss +19 -0
  146. package/src/components/knowledge-base/components/KnowledgeList.tsx +19 -0
  147. package/src/components/knowledge-base/components/LoadingState.scss +5 -0
  148. package/src/components/knowledge-base/components/LoadingState.tsx +11 -0
  149. package/src/components/knowledge-base/components/MetaList.scss +19 -0
  150. package/src/components/knowledge-base/components/MetaList.tsx +18 -0
  151. package/src/components/knowledge-base/components/RulesTab.scss +5 -0
  152. package/src/components/knowledge-base/components/RulesTab.tsx +49 -0
  153. package/src/components/knowledge-base/components/SectionHeader.scss +22 -0
  154. package/src/components/knowledge-base/components/SectionHeader.tsx +21 -0
  155. package/src/components/knowledge-base/components/SkillsTab.scss +5 -0
  156. package/src/components/knowledge-base/components/SkillsTab.tsx +49 -0
  157. package/src/components/knowledge-base/components/SpecItem.scss +138 -0
  158. package/src/components/knowledge-base/components/SpecItem.tsx +131 -0
  159. package/src/components/knowledge-base/components/SpecList.scss +5 -0
  160. package/src/components/knowledge-base/components/SpecList.tsx +70 -0
  161. package/src/components/knowledge-base/components/TabContent.scss +8 -0
  162. package/src/components/knowledge-base/components/TabContent.tsx +17 -0
  163. package/src/components/knowledge-base/components/TabLabel.scss +10 -0
  164. package/src/components/knowledge-base/components/TabLabel.tsx +15 -0
  165. package/src/components/knowledge-base/index.tsx +1 -0
  166. package/src/components/sidebar/SessionItem.scss +256 -0
  167. package/src/components/sidebar/SessionItem.tsx +265 -0
  168. package/src/components/sidebar/SessionList.scss +92 -0
  169. package/src/components/sidebar/SessionList.tsx +166 -0
  170. package/src/components/sidebar/SidebarHeader.scss +79 -0
  171. package/src/components/sidebar/SidebarHeader.tsx +128 -0
  172. package/src/connectionManager.ts +172 -0
  173. package/src/hooks/useGlobalShortcut.ts +26 -0
  174. package/src/hooks/useQueryParams.ts +54 -0
  175. package/src/i18n.ts +22 -0
  176. package/src/main.tsx +41 -0
  177. package/src/resources/locales/en.json +765 -0
  178. package/src/resources/locales/zh.json +766 -0
  179. package/src/store/index.ts +23 -0
  180. package/src/styles/global.scss +100 -0
  181. package/src/utils/shortcutUtils.ts +88 -0
  182. package/src/vite-env.d.ts +12 -0
  183. package/src/ws.ts +33 -0
  184. package/vite.config.ts +26 -0
@@ -0,0 +1,185 @@
1
+ .config-view {
2
+ height: 100%;
3
+ display: flex;
4
+ flex-direction: column;
5
+ color: var(--text-color);
6
+ background: var(--bg-color);
7
+
8
+ &__state {
9
+ display: grid;
10
+ place-items: center;
11
+ flex: 1;
12
+ }
13
+
14
+ &__tabs-wrap {
15
+ display: flex;
16
+ min-height: 0;
17
+ flex: 1;
18
+ .ant-tabs-content-holder > .ant-tabs-content {
19
+ height: 100%;
20
+ & > .ant-tabs-tabpane {
21
+ padding: 16px 128px;
22
+ padding-bottom: 0;
23
+ height: 100%;
24
+ }
25
+ }
26
+ }
27
+
28
+ &__tabs {
29
+ width: 100%;
30
+ height: 100%;
31
+ }
32
+ }
33
+
34
+ .config-view__content {
35
+ display: flex;
36
+ flex-direction: column;
37
+ flex: 1;
38
+ gap: 12px;
39
+ height: 100%;
40
+ }
41
+
42
+ .config-view__editor-wrap {
43
+ display: flex;
44
+ flex-direction: column;
45
+ gap: 10px;
46
+ min-height: 0;
47
+ }
48
+
49
+ .config-view__section-header {
50
+ display: flex;
51
+ align-items: center;
52
+ justify-content: space-between;
53
+ gap: 12px;
54
+ .ant-radio-group {
55
+ display: flex;
56
+ align-items: center;
57
+ }
58
+ }
59
+
60
+ .config-view__card {
61
+ flex-grow: 1;
62
+ margin: 0 -20px;
63
+ padding: 0 20px;
64
+ padding-bottom: 20px;
65
+ overflow: scroll;
66
+ }
67
+
68
+ .config-view__card + .config-view__card {
69
+ margin-top: 14px;
70
+ }
71
+
72
+ .config-view__section-title {
73
+ display: inline-flex;
74
+ align-items: center;
75
+ gap: 8px;
76
+ font-size: 18px;
77
+ font-weight: 600;
78
+ color: var(--text-color);
79
+ }
80
+
81
+ .config-view__section-icon {
82
+ font-size: 18px;
83
+ color: var(--sub-text-color);
84
+ }
85
+
86
+ .config-view__section-hint {
87
+ font-size: 12px;
88
+ color: var(--sub-text-color);
89
+ }
90
+
91
+ .config-view__source-option {
92
+ display: inline-flex;
93
+ align-items: center;
94
+ gap: 6px;
95
+ }
96
+
97
+ .config-view .ant-empty-image {
98
+ display: none;
99
+ }
100
+
101
+ .config-view__tab-label {
102
+ display: inline-flex;
103
+ align-items: center;
104
+ gap: 10px;
105
+ width: 100%;
106
+ line-height: 1;
107
+ }
108
+
109
+ .config-view__tab-icon {
110
+ font-size: 18px;
111
+ color: var(--sub-text-color);
112
+ width: 18px;
113
+ height: 18px;
114
+ line-height: 18px;
115
+ display: inline-flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ }
119
+
120
+ .config-view__tab-text {
121
+ font-size: 13px;
122
+ color: var(--text-color);
123
+ line-height: 18px;
124
+ }
125
+
126
+ .config-view__tabs > .ant-tabs-nav {
127
+ min-width: 320px;
128
+ padding: 12px 16px;
129
+ background-color: var(--sub-bg-color);
130
+ border-right: 1px solid var(--sub-border-color);
131
+
132
+ .ant-tabs-tab {
133
+ display: flex;
134
+ align-items: center;
135
+ min-height: 28px;
136
+
137
+ border-radius: 8px;
138
+ transition: .3s;
139
+ &:not(.ant-tabs-tab-disabled):hover {
140
+ background-color: var(--sub-sub-bg-color);
141
+ }
142
+ }
143
+
144
+ .ant-tabs-tab-btn {
145
+ display: flex;
146
+ align-items: center;
147
+ width: 100%;
148
+ color: var(--text-color);
149
+ }
150
+
151
+ .ant-tabs-tab-disabled {
152
+ cursor: default;
153
+ }
154
+
155
+ .ant-tabs-tab-disabled .ant-tabs-tab-btn {
156
+ cursor: default;
157
+ color: var(--sub-text-color);
158
+ font-size: 11px;
159
+ text-transform: uppercase;
160
+ letter-spacing: .06em;
161
+ }
162
+
163
+ .ant-tabs-tab-active {
164
+ font-weight: 600;
165
+ color: var(--sub-sub-text-color);
166
+ background: var(--sub-sub-bg-color);
167
+ .material-symbols-rounded {
168
+ font-variation-settings: 'FILL' 1;
169
+ }
170
+ }
171
+
172
+ .ant-tabs-content-holder {
173
+ padding: 16px;
174
+ }
175
+ }
176
+
177
+ .config-view__tabs > .ant-tabs-content-holder {
178
+ height: 100%;
179
+ }
180
+
181
+ .config-view__group-label {
182
+ display: flex;
183
+ width: 100%;
184
+ margin-left: -24px;
185
+ }
@@ -0,0 +1,258 @@
1
+ import './ConfigView.scss'
2
+
3
+ import { App, Empty, Space, Spin, Tabs } from 'antd'
4
+ import { useEffect, useMemo, useRef, useState } from 'react'
5
+ import { useTranslation } from 'react-i18next'
6
+ import useSWR from 'swr'
7
+
8
+ import type { AboutInfo, ConfigResponse, ConfigSource } from '@vibe-forge/core'
9
+
10
+ import { getConfig, updateConfig } from '../api'
11
+ import { AboutSection, ConfigSectionPanel, ConfigSourceSwitch, DisplayValue } from './config'
12
+ import { cloneValue, getValueByPath, isEmptyValue } from './config/configUtils'
13
+
14
+ export function ConfigView() {
15
+ const { t } = useTranslation()
16
+ const { message } = App.useApp()
17
+ const { data, isLoading, error, mutate } = useSWR<ConfigResponse>('/api/config', getConfig)
18
+ const [sourceKey, setSourceKey] = useState<ConfigSource>('project')
19
+ const [activeTabKey, setActiveTabKey] = useState('general')
20
+ const [drafts, setDrafts] = useState<Record<string, unknown>>({})
21
+ const configPresent = data?.meta?.configPresent
22
+ const currentSource = data?.sources?.[sourceKey]
23
+ const draftsRef = useRef<Record<string, unknown>>(drafts)
24
+ const saveTimersRef = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
25
+ const savingRef = useRef<Record<string, boolean>>({})
26
+ const lastSavedRef = useRef<Record<string, string>>({})
27
+ const mergedModelServices = useMemo(() => data?.sources?.merged?.modelServices ?? {}, [
28
+ data?.sources?.merged?.modelServices
29
+ ])
30
+ const mergedAdapters = useMemo(() => data?.sources?.merged?.adapters ?? {}, [
31
+ data?.sources?.merged?.adapters
32
+ ])
33
+
34
+ useEffect(() => {
35
+ if (configPresent?.project) {
36
+ setSourceKey('project')
37
+ } else if (configPresent?.user) {
38
+ setSourceKey('user')
39
+ }
40
+ }, [configPresent?.project, configPresent?.user])
41
+
42
+ const configTabKeys = useMemo(() =>
43
+ new Set([
44
+ 'general',
45
+ 'conversation',
46
+ 'modelServices',
47
+ 'adapters',
48
+ 'plugins',
49
+ 'mcp',
50
+ 'shortcuts'
51
+ ]), [])
52
+
53
+ const tabs = useMemo(() => [
54
+ { key: 'group-config', type: 'group', label: t('config.groups.config') },
55
+ { key: 'general', icon: 'tune', label: t('config.sections.general'), value: currentSource?.general },
56
+ {
57
+ key: 'conversation',
58
+ icon: 'forum',
59
+ label: t('config.sections.conversation'),
60
+ value: currentSource?.conversation
61
+ },
62
+ {
63
+ key: 'modelServices',
64
+ icon: 'model_training',
65
+ label: t('config.sections.modelServices'),
66
+ value: currentSource?.modelServices
67
+ },
68
+ {
69
+ key: 'adapters',
70
+ icon: 'settings_input_component',
71
+ label: t('config.sections.adapters'),
72
+ value: currentSource?.adapters
73
+ },
74
+ { key: 'plugins', icon: 'extension', label: t('config.sections.plugins'), value: currentSource?.plugins },
75
+ { key: 'mcp', icon: 'account_tree', label: t('config.sections.mcp'), value: currentSource?.mcp },
76
+ { key: 'shortcuts', icon: 'keyboard', label: t('config.sections.shortcuts'), value: currentSource?.shortcuts },
77
+ { key: 'group-app', type: 'group', label: t('config.groups.app') },
78
+ { key: 'experiments', icon: 'science', label: t('config.sections.experiments'), value: data?.meta?.experiments },
79
+ { key: 'about', icon: 'info', label: t('config.sections.about'), value: data?.meta?.about }
80
+ ], [currentSource, data?.meta?.about, data?.meta?.experiments, t])
81
+ const tabKeys = useMemo(() => new Set(tabs.filter(tab => tab.type !== 'group').map(tab => tab.key)), [tabs])
82
+
83
+ const activeTab = useMemo(() => tabs.find(tab => tab.key === activeTabKey), [tabs, activeTabKey])
84
+
85
+ const initializedTabRef = useRef(false)
86
+ useEffect(() => {
87
+ if (initializedTabRef.current) return
88
+ const params = new URLSearchParams(window.location.search)
89
+ const tabKey = params.get('tab')
90
+ if (tabKey != null && tabKeys.has(tabKey)) {
91
+ setActiveTabKey(tabKey)
92
+ }
93
+ initializedTabRef.current = true
94
+ }, [tabKeys])
95
+
96
+ useEffect(() => {
97
+ if (!tabKeys.has(activeTabKey)) return
98
+ const params = new URLSearchParams(window.location.search)
99
+ params.set('tab', activeTabKey)
100
+ const nextUrl = `${window.location.pathname}?${params.toString()}${window.location.hash}`
101
+ window.history.replaceState(null, '', nextUrl)
102
+ }, [activeTabKey, tabKeys])
103
+
104
+ useEffect(() => {
105
+ if (activeTab == null) return
106
+ if (!configTabKeys.has(activeTab.key)) return
107
+ const draftKey = `${sourceKey}:${activeTab.key}`
108
+ setDrafts((prev) => {
109
+ const currentDraft = prev[draftKey]
110
+ const sourceValue = activeTab.value ?? {}
111
+ if (currentDraft !== undefined) {
112
+ if (isEmptyValue(currentDraft) && !isEmptyValue(sourceValue)) {
113
+ return { ...prev, [draftKey]: cloneValue(sourceValue) }
114
+ }
115
+ return prev
116
+ }
117
+ return { ...prev, [draftKey]: cloneValue(sourceValue) }
118
+ })
119
+ }, [activeTab, configTabKeys, sourceKey])
120
+
121
+ useEffect(() => {
122
+ draftsRef.current = drafts
123
+ }, [drafts])
124
+
125
+ useEffect(() => {
126
+ return () => {
127
+ Object.values(saveTimersRef.current).forEach((timer) => {
128
+ clearTimeout(timer)
129
+ })
130
+ }
131
+ }, [])
132
+
133
+ const getDraftKey = (sectionKey: string, source = sourceKey) => `${source}:${sectionKey}`
134
+ const generalDraftValue = useMemo(() => {
135
+ const draftKey = getDraftKey('general')
136
+ return (drafts[draftKey] ?? cloneValue(currentSource?.general ?? {}) ?? {}) as Record<string, unknown>
137
+ }, [drafts, currentSource?.general, sourceKey])
138
+ const selectedModelService = (() => {
139
+ const value = getValueByPath(generalDraftValue, ['defaultModelService'])
140
+ return typeof value === 'string' ? value : undefined
141
+ })()
142
+
143
+ const scheduleSave = (sectionKey: string, source: ConfigSource, nextValue: unknown) => {
144
+ const draftKey = getDraftKey(sectionKey, source)
145
+ const serialized = JSON.stringify(nextValue ?? {})
146
+ if (lastSavedRef.current[draftKey] === serialized) {
147
+ return
148
+ }
149
+ if (saveTimersRef.current[draftKey]) {
150
+ clearTimeout(saveTimersRef.current[draftKey])
151
+ }
152
+ saveTimersRef.current[draftKey] = setTimeout(async () => {
153
+ if (savingRef.current[draftKey]) return
154
+ const currentValue = draftsRef.current[draftKey] ?? nextValue
155
+ const currentSerialized = JSON.stringify(currentValue ?? {})
156
+ if (lastSavedRef.current[draftKey] === currentSerialized) return
157
+ savingRef.current[draftKey] = true
158
+ try {
159
+ await updateConfig(source, sectionKey, currentValue)
160
+ lastSavedRef.current[draftKey] = currentSerialized
161
+ await mutate()
162
+ } catch {
163
+ void message.error(t('config.saveFailed'))
164
+ } finally {
165
+ savingRef.current[draftKey] = false
166
+ }
167
+ }, 800)
168
+ }
169
+
170
+ const handleDraftChange = (sectionKey: string, nextValue: unknown) => {
171
+ const draftKey = getDraftKey(sectionKey)
172
+ setDrafts(prev => ({ ...prev, [draftKey]: nextValue }))
173
+ scheduleSave(sectionKey, sourceKey, nextValue)
174
+ }
175
+
176
+ return (
177
+ <div className='config-view'>
178
+ {isLoading && (
179
+ <div className='config-view__state'>
180
+ <Spin />
181
+ </div>
182
+ )}
183
+ {!isLoading && (error != null) && (
184
+ <div className='config-view__state'>
185
+ <Empty description={t('config.loadFailed')} />
186
+ </div>
187
+ )}
188
+ {!isLoading && error == null && (
189
+ <div className='config-view__tabs-wrap'>
190
+ <Tabs
191
+ tabPosition='left'
192
+ tabBarGutter={4}
193
+ indicator={{ size: 0 }}
194
+ className='config-view__tabs'
195
+ activeKey={activeTabKey}
196
+ onChange={(key) => {
197
+ if (key !== 'group-config' && key !== 'group-app') {
198
+ setActiveTabKey(key)
199
+ }
200
+ }}
201
+ items={tabs.map((tab) => {
202
+ if (tab.type === 'group') {
203
+ return {
204
+ key: tab.key,
205
+ label: <span className='config-view__group-label'>{tab.label}</span>,
206
+ disabled: true,
207
+ children: <div />
208
+ }
209
+ }
210
+ return {
211
+ key: tab.key,
212
+ label: (
213
+ <span className='config-view__tab-label'>
214
+ <span className='material-symbols-rounded config-view__tab-icon'>{tab.icon}</span>
215
+ <span className='config-view__tab-text'>{tab.label}</span>
216
+ </span>
217
+ ),
218
+ children: (
219
+ <div className='config-view__content'>
220
+ {tab.key === 'about' && (
221
+ <AboutSection value={tab.value as AboutInfo | undefined} />
222
+ )}
223
+ {tab.key !== 'about' && !configTabKeys.has(tab.key) && (
224
+ <DisplayValue value={tab.value} sectionKey={tab.key} t={t} />
225
+ )}
226
+ {configTabKeys.has(tab.key) && (
227
+ <ConfigSectionPanel
228
+ sectionKey={tab.key}
229
+ title={tab.label}
230
+ icon={tab.icon}
231
+ value={drafts[getDraftKey(tab.key)] ?? cloneValue(tab.value ?? {}) ?? {}}
232
+ onChange={(next) => handleDraftChange(tab.key, next)}
233
+ mergedModelServices={mergedModelServices as Record<string, unknown>}
234
+ mergedAdapters={mergedAdapters as Record<string, unknown>}
235
+ selectedModelService={selectedModelService}
236
+ t={t}
237
+ headerExtra={(
238
+ <Space size={12}>
239
+ <ConfigSourceSwitch
240
+ value={sourceKey}
241
+ onChange={setSourceKey}
242
+ configPresent={configPresent}
243
+ t={t}
244
+ />
245
+ </Space>
246
+ )}
247
+ />
248
+ )}
249
+ </div>
250
+ )
251
+ }
252
+ })}
253
+ />
254
+ </div>
255
+ )}
256
+ </div>
257
+ )
258
+ }
@@ -0,0 +1,71 @@
1
+ .nav-rail {
2
+ width: 56px;
3
+ height: 100%;
4
+ background-color: var(--sub-sub-bg-color);
5
+ border-right: 1px solid var(--sub-sub-border-color);
6
+ display: flex;
7
+ flex-direction: column;
8
+ justify-content: space-between;
9
+ padding: 12px 0;
10
+ flex-shrink: 0;
11
+ z-index: 20;
12
+
13
+ .nav-rail-top,
14
+ .nav-rail-bottom {
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ gap: 12px;
19
+ }
20
+
21
+ .nav-item {
22
+ width: 40px;
23
+ height: 40px;
24
+ display: flex;
25
+ align-items: center;
26
+ justify-content: center;
27
+ border-radius: 8px;
28
+ color: var(--sub-sub-text-color, #6b7280);
29
+ transition: all .2s;
30
+
31
+ &:hover {
32
+ background-color: var(--nav-hover-bg, #f3f4f6);
33
+ color: var(--nav-hover-text, #111827);
34
+ }
35
+
36
+ &.active {
37
+ background-color: var(--nav-active-bg, #eff6ff);
38
+ color: var(--nav-active-text, #2563eb);
39
+
40
+ .material-symbols-rounded {
41
+ font-variation-settings: 'FILL' 1;
42
+ }
43
+ }
44
+
45
+ .material-symbols-rounded {
46
+ font-size: 24px;
47
+ }
48
+ }
49
+ }
50
+
51
+ .nav-menu-icon {
52
+ font-size: 18px;
53
+ display: inline-flex;
54
+ align-items: center;
55
+ line-height: 1;
56
+
57
+ &.active {
58
+ color: var(--primary-color, #2563eb);
59
+ }
60
+ }
61
+
62
+ .nav-menu-icon-placeholder {
63
+ width: 18px;
64
+ }
65
+
66
+ html.dark .nav-rail {
67
+ --nav-hover-bg: #1a1a1a;
68
+ --nav-hover-text: #ffffff;
69
+ --nav-active-bg: #111b26;
70
+ --nav-active-text: #3b82f6;
71
+ }
@@ -0,0 +1,188 @@
1
+ import './NavRail.scss'
2
+
3
+ import { Button, Dropdown, Tooltip } from 'antd'
4
+ import type { MenuProps } from 'antd'
5
+ import { useAtom } from 'jotai'
6
+ import React from 'react'
7
+ import { useTranslation } from 'react-i18next'
8
+ import { useLocation, useNavigate } from 'react-router-dom'
9
+
10
+ import { themeAtom } from '../store'
11
+
12
+ export function NavRail({
13
+ collapsed,
14
+ onToggleCollapse
15
+ }: {
16
+ collapsed?: boolean
17
+ onToggleCollapse?: () => void
18
+ }) {
19
+ const { t, i18n } = useTranslation()
20
+ const [themeMode, setThemeMode] = useAtom(themeAtom)
21
+ const navigate = useNavigate()
22
+ const location = useLocation()
23
+
24
+ const currentPath = location.pathname
25
+
26
+ const langItems: MenuProps['items'] = React.useMemo(() => [
27
+ {
28
+ key: 'zh',
29
+ label: '简体中文',
30
+ icon: i18n.language.startsWith('zh')
31
+ ? (
32
+ <span className='material-symbols-rounded nav-menu-icon active'>check</span>
33
+ )
34
+ : <div className='nav-menu-icon-placeholder' />,
35
+ onClick: () => {
36
+ void i18n.changeLanguage('zh')
37
+ }
38
+ },
39
+ {
40
+ key: 'en',
41
+ label: 'English',
42
+ icon: i18n.language.startsWith('en')
43
+ ? (
44
+ <span className='material-symbols-rounded nav-menu-icon active'>check</span>
45
+ )
46
+ : <div className='nav-menu-icon-placeholder' />,
47
+ onClick: () => {
48
+ void i18n.changeLanguage('en')
49
+ }
50
+ }
51
+ ], [i18n.language])
52
+
53
+ const themeItems: MenuProps['items'] = React.useMemo(() => [
54
+ {
55
+ key: 'light',
56
+ label: t('common.themeLight'),
57
+ icon: themeMode === 'light'
58
+ ? <span className='material-symbols-rounded nav-menu-icon active'>check</span>
59
+ : <span className='material-symbols-rounded nav-menu-icon'>light_mode</span>,
60
+ onClick: () => {
61
+ setThemeMode('light')
62
+ localStorage.setItem('theme', 'light')
63
+ }
64
+ },
65
+ {
66
+ key: 'dark',
67
+ label: t('common.themeDark'),
68
+ icon: themeMode === 'dark'
69
+ ? <span className='material-symbols-rounded nav-menu-icon active'>check</span>
70
+ : <span className='material-symbols-rounded nav-menu-icon'>dark_mode</span>,
71
+ onClick: () => {
72
+ setThemeMode('dark')
73
+ localStorage.setItem('theme', 'dark')
74
+ }
75
+ },
76
+ {
77
+ key: 'system',
78
+ label: t('common.themeSystem'),
79
+ icon: themeMode === 'system'
80
+ ? <span className='material-symbols-rounded nav-menu-icon active'>check</span>
81
+ : <span className='material-symbols-rounded nav-menu-icon'>desktop_windows</span>,
82
+ onClick: () => {
83
+ setThemeMode('system')
84
+ localStorage.setItem('theme', 'system')
85
+ }
86
+ }
87
+ ], [themeMode, t, setThemeMode])
88
+
89
+ const navItems = React.useMemo(() => [
90
+ {
91
+ key: 'sessions',
92
+ icon: 'chat_bubble',
93
+ label: t('common.sessions'),
94
+ path: '/',
95
+ active: currentPath === '/' || currentPath.startsWith('/session/')
96
+ },
97
+ {
98
+ key: 'knowledge',
99
+ icon: 'library_books',
100
+ label: t('common.knowledgeBase'),
101
+ path: '/knowledge',
102
+ active: currentPath === '/knowledge'
103
+ },
104
+ {
105
+ key: 'automation',
106
+ icon: 'schedule',
107
+ label: t('common.automation'),
108
+ path: '/automation',
109
+ active: currentPath === '/automation'
110
+ },
111
+ {
112
+ key: 'archive',
113
+ icon: 'archive',
114
+ label: t('common.archivedSessions'),
115
+ path: '/archive',
116
+ active: currentPath === '/archive'
117
+ }
118
+ ], [currentPath, t])
119
+
120
+ return (
121
+ <div className='nav-rail'>
122
+ <div className='nav-rail-top'>
123
+ {navItems.map(item => (
124
+ <Tooltip key={item.key} title={item.label} placement='right'>
125
+ <span>
126
+ <Button
127
+ type='text'
128
+ className={`nav-item ${item.active ? 'active' : ''}`}
129
+ onClick={() => void navigate(item.path)}
130
+ icon={<span className='material-symbols-rounded'>{item.icon}</span>}
131
+ />
132
+ </span>
133
+ </Tooltip>
134
+ ))}
135
+ </div>
136
+ <div className='nav-rail-bottom'>
137
+ <Tooltip title={t('common.theme')} placement='right'>
138
+ <span>
139
+ <Dropdown
140
+ menu={{
141
+ items: themeItems
142
+ }}
143
+ placement='topRight'
144
+ trigger={['click']}
145
+ >
146
+ <Button
147
+ type='text'
148
+ className='nav-item'
149
+ icon={
150
+ <span className='material-symbols-rounded'>
151
+ {themeMode === 'light' ? 'light_mode' : themeMode === 'dark' ? 'dark_mode' : 'desktop_windows'}
152
+ </span>
153
+ }
154
+ />
155
+ </Dropdown>
156
+ </span>
157
+ </Tooltip>
158
+ <Tooltip title={t('common.language')} placement='right'>
159
+ <span>
160
+ <Dropdown
161
+ menu={{
162
+ items: langItems
163
+ }}
164
+ placement='topRight'
165
+ trigger={['click']}
166
+ >
167
+ <Button
168
+ type='text'
169
+ className='nav-item'
170
+ icon={<span className='material-symbols-rounded'>language</span>}
171
+ />
172
+ </Dropdown>
173
+ </span>
174
+ </Tooltip>
175
+ <Tooltip title={t('common.settings')} placement='right'>
176
+ <span>
177
+ <Button
178
+ type='text'
179
+ className={`nav-item ${currentPath === '/config' ? 'active' : ''}`}
180
+ icon={<span className='material-symbols-rounded'>settings</span>}
181
+ onClick={() => void navigate('/config')}
182
+ />
183
+ </span>
184
+ </Tooltip>
185
+ </div>
186
+ </div>
187
+ )
188
+ }