@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,206 @@
1
+ import React, { useEffect, useMemo, useState } from 'react'
2
+ import { useTranslation } from 'react-i18next'
3
+ import useSWR from 'swr'
4
+
5
+ import type { ConfigResponse, ModelServiceConfig, RecommendedModelConfig } from '@vibe-forge/core'
6
+ import { getConfig } from '../../api'
7
+
8
+ interface ModelSelectOption {
9
+ value: string
10
+ label: React.ReactNode
11
+ searchText: string
12
+ }
13
+
14
+ interface ModelSelectGroup {
15
+ label: React.ReactNode
16
+ options: ModelSelectOption[]
17
+ }
18
+
19
+ export function useChatModels() {
20
+ const { t } = useTranslation()
21
+ const [selectedModel, setSelectedModel] = useState<string | undefined>(undefined)
22
+ const { data: configRes } = useSWR<ConfigResponse>('/api/config', getConfig)
23
+
24
+ const mergedModelServices = useMemo(() => {
25
+ const services = configRes?.sources?.merged?.modelServices
26
+ return (services ?? {}) as Record<string, ModelServiceConfig>
27
+ }, [configRes?.sources?.merged?.modelServices])
28
+
29
+ const recommendedModels = useMemo(() => {
30
+ const raw = configRes?.sources?.merged?.general?.recommendedModels
31
+ if (!Array.isArray(raw)) return []
32
+ return raw.filter((item): item is RecommendedModelConfig => (
33
+ item != null && typeof item === 'object' && typeof item.model === 'string' && item.model.trim() !== ''
34
+ ))
35
+ }, [configRes?.sources?.merged?.general?.recommendedModels])
36
+
37
+ const modelServiceEntries = useMemo(() => Object.entries(mergedModelServices), [mergedModelServices])
38
+
39
+ const availableModels = useMemo(() => {
40
+ const list: Array<{ model: string; serviceKey: string; serviceTitle: string }> = []
41
+ for (const [serviceKey, serviceValue] of modelServiceEntries) {
42
+ const service = (serviceValue != null && typeof serviceValue === 'object') ? serviceValue as ModelServiceConfig : undefined
43
+ const serviceTitle = service?.title?.trim() !== '' ? service?.title ?? '' : serviceKey
44
+ const models = Array.isArray(service?.models) ? service?.models.filter(item => typeof item === 'string') : []
45
+ for (const model of models) {
46
+ list.push({ model, serviceKey, serviceTitle })
47
+ }
48
+ }
49
+ return list
50
+ }, [modelServiceEntries])
51
+
52
+ const availableModelValues = useMemo(() => availableModels.map(item => item.model), [availableModels])
53
+ const availableModelKey = useMemo(() => availableModelValues.join('|'), [availableModelValues])
54
+ const availableModelSet = useMemo(() => new Set(availableModelValues), [availableModelKey])
55
+ const hasAvailableModels = availableModelValues.length > 0
56
+ const defaultModelService = configRes?.sources?.merged?.general?.defaultModelService
57
+ const defaultModel = configRes?.sources?.merged?.general?.defaultModel
58
+ const resolvedDefaultModel = useMemo(() => {
59
+ if (!hasAvailableModels) return undefined
60
+ if (defaultModel && availableModelSet.has(defaultModel)) return defaultModel
61
+ if (defaultModelService && mergedModelServices[defaultModelService]) {
62
+ const service = mergedModelServices[defaultModelService]
63
+ const models = Array.isArray(service?.models) ? service?.models.filter(item => typeof item === 'string') : []
64
+ if (models.length > 0) return models[0]
65
+ }
66
+ return availableModelValues[0]
67
+ }, [availableModelSet, availableModelValues, defaultModel, defaultModelService, hasAvailableModels, mergedModelServices])
68
+
69
+ useEffect(() => {
70
+ if (!hasAvailableModels) {
71
+ setSelectedModel(undefined)
72
+ return
73
+ }
74
+ setSelectedModel((prev) => {
75
+ if (prev != null && availableModelSet.has(prev)) return prev
76
+ return resolvedDefaultModel
77
+ })
78
+ }, [availableModelSet, hasAvailableModels, resolvedDefaultModel])
79
+
80
+ const modelOptions = useMemo<ModelSelectGroup[]>(() => {
81
+ const buildOption = (params: {
82
+ value: string
83
+ title: string
84
+ description?: string
85
+ serviceKey?: string
86
+ serviceTitle?: string
87
+ }) => {
88
+ const description = params.description?.trim()
89
+ const label = (
90
+ <div className='model-option'>
91
+ <div className='model-option-title'>{params.title}</div>
92
+ {description && <div className='model-option-desc'>{description}</div>}
93
+ </div>
94
+ )
95
+ const searchText = [
96
+ params.title,
97
+ params.value,
98
+ params.serviceTitle,
99
+ params.serviceKey,
100
+ description
101
+ ]
102
+ .filter(Boolean)
103
+ .join(' ')
104
+ return {
105
+ value: params.value,
106
+ label,
107
+ searchText
108
+ }
109
+ }
110
+
111
+ const modelToService = new Map<string, { key: string; title: string }>()
112
+ for (const entry of availableModels) {
113
+ if (!modelToService.has(entry.model)) {
114
+ modelToService.set(entry.model, { key: entry.serviceKey, title: entry.serviceTitle })
115
+ }
116
+ }
117
+
118
+ const resolveFirstAlias = (modelsAlias: Record<string, string[]> | undefined, model: string) => {
119
+ if (!modelsAlias) return undefined
120
+ for (const [alias, aliasModels] of Object.entries(modelsAlias)) {
121
+ if (!Array.isArray(aliasModels)) continue
122
+ if (aliasModels.includes(model)) return alias
123
+ }
124
+ return undefined
125
+ }
126
+
127
+ const serviceGroups = modelServiceEntries
128
+ .map(([serviceKey, serviceValue]) => {
129
+ const service = (serviceValue != null && typeof serviceValue === 'object') ? serviceValue as ModelServiceConfig : undefined
130
+ const serviceTitle = service?.title?.trim() !== '' ? service?.title ?? '' : serviceKey
131
+ const groupTitle = serviceTitle?.trim() !== '' ? serviceTitle : serviceKey
132
+ const serviceDescription = service?.description
133
+ const models = Array.isArray(service?.models) ? service?.models.filter(item => typeof item === 'string') : []
134
+ if (models.length === 0) return null
135
+ const options = models.map((model) => {
136
+ const alias = resolveFirstAlias(service?.modelsAlias as Record<string, string[]> | undefined, model)
137
+ const title = alias ?? model
138
+ const description = alias ? model : serviceTitle
139
+ return buildOption({
140
+ value: model,
141
+ title,
142
+ description,
143
+ serviceKey,
144
+ serviceTitle
145
+ })
146
+ })
147
+ return {
148
+ label: (
149
+ <div className='model-group-label'>
150
+ <div className='model-group-title'>{groupTitle}</div>
151
+ {serviceDescription && <div className='model-group-desc'>{serviceDescription}</div>}
152
+ </div>
153
+ ),
154
+ options
155
+ }
156
+ })
157
+ .filter((item): item is NonNullable<typeof item> => item != null)
158
+
159
+ const recommendedOptions = recommendedModels
160
+ .filter((item) => {
161
+ if (item.placement && item.placement !== 'modelSelector') return false
162
+ return availableModelSet.has(item.model)
163
+ })
164
+ .map((item) => {
165
+ const serviceInfo = item.service ? mergedModelServices[item.service] : undefined
166
+ const serviceTitle = item.service
167
+ ? (serviceInfo?.title?.trim() !== '' ? serviceInfo?.title ?? '' : item.service)
168
+ : modelToService.get(item.model)?.title
169
+ const alias = item.service
170
+ ? resolveFirstAlias(serviceInfo?.modelsAlias as Record<string, string[]> | undefined, item.model)
171
+ : undefined
172
+ const title = item.title?.trim() !== '' ? item.title ?? '' : (alias ?? item.model)
173
+ const description = item.description?.trim() !== ''
174
+ ? item.description
175
+ : serviceTitle
176
+ return buildOption({
177
+ value: item.model,
178
+ title,
179
+ description,
180
+ serviceKey: item.service ?? modelToService.get(item.model)?.key,
181
+ serviceTitle
182
+ })
183
+ })
184
+
185
+ const groups = []
186
+ if (recommendedOptions.length > 0) {
187
+ const recommendedTitle = t('chat.modelGroupRecommended', { defaultValue: '推荐模型' })
188
+ groups.push({
189
+ label: (
190
+ <div className='model-group-label'>
191
+ <div className='model-group-title'>{recommendedTitle}</div>
192
+ </div>
193
+ ),
194
+ options: recommendedOptions
195
+ })
196
+ }
197
+ return [...groups, ...serviceGroups]
198
+ }, [availableModelSet, availableModels, mergedModelServices, modelServiceEntries, recommendedModels, t])
199
+
200
+ return {
201
+ selectedModel,
202
+ setSelectedModel,
203
+ modelOptions,
204
+ hasAvailableModels
205
+ }
206
+ }
@@ -0,0 +1,370 @@
1
+ import { App } from 'antd'
2
+ import { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { useTranslation } from 'react-i18next'
4
+ import { useNavigate } from 'react-router-dom'
5
+ import { useSWRConfig } from 'swr'
6
+
7
+ import type { AskUserQuestionParams, ChatMessage, Session, SessionInfo, WSEvent } from '@vibe-forge/core'
8
+ import { useQueryParams } from '#~/hooks/useQueryParams.js'
9
+ import { createSession, getSessionMessages } from '../../api'
10
+ import { connectionManager } from '../../connectionManager'
11
+ import type { ChatHeaderView } from './ChatHeader'
12
+
13
+ const normalizeView = (value: string): ChatHeaderView => {
14
+ if (value === 'timeline' || value === 'settings' || value === 'history') {
15
+ return value
16
+ }
17
+ return 'history'
18
+ }
19
+
20
+ export function useChatSession({
21
+ session,
22
+ selectedModel,
23
+ hasAvailableModels
24
+ }: {
25
+ session?: Session
26
+ selectedModel?: string
27
+ hasAvailableModels: boolean
28
+ }) {
29
+ const { message } = App.useApp()
30
+ const { t } = useTranslation()
31
+ const navigate = useNavigate()
32
+ const [messages, setMessages] = useState<ChatMessage[]>([])
33
+ const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null)
34
+ const [interactionRequest, setInteractionRequest] = useState<{ id: string; payload: AskUserQuestionParams } | null>(
35
+ null
36
+ )
37
+ const [isCreating, setIsCreating] = useState(false)
38
+ const [isReady, setIsReady] = useState(false)
39
+ const { values: queryValues, update: updateQuery } = useQueryParams<{ view: string }>({
40
+ keys: ['view'],
41
+ defaults: {
42
+ view: 'history'
43
+ },
44
+ omit: {
45
+ view: (value) => value === 'history'
46
+ }
47
+ })
48
+ const activeView = normalizeView(queryValues.view)
49
+ const setActiveView = useCallback((view: ChatHeaderView) => {
50
+ updateQuery({ view })
51
+ }, [updateQuery])
52
+ const messagesEndRef = useRef<HTMLDivElement>(null)
53
+ const messagesContainerRef = useRef<HTMLDivElement>(null)
54
+ const wasAtBottom = useRef<boolean>(true)
55
+ const isInitialLoadRef = useRef<boolean>(true)
56
+ const lastConnectedModelRef = useRef<string | undefined>(undefined)
57
+ const [showScrollBottom, setShowScrollBottom] = useState(false)
58
+ const { mutate } = useSWRConfig()
59
+
60
+ const isThinking = isCreating || session?.status === 'running'
61
+
62
+ const scrollToBottom = (behavior: ScrollBehavior = 'smooth') => {
63
+ setTimeout(() => {
64
+ if (messagesContainerRef.current) {
65
+ messagesContainerRef.current.scrollTo({
66
+ top: messagesContainerRef.current.scrollHeight,
67
+ behavior
68
+ })
69
+ }
70
+ }, 50)
71
+ }
72
+
73
+ useEffect(() => {
74
+ const container = messagesContainerRef.current
75
+ if (!container) return
76
+
77
+ const handleScroll = () => {
78
+ const { scrollTop, scrollHeight, clientHeight } = container
79
+ const atBottom = scrollHeight - scrollTop <= clientHeight + 100
80
+ wasAtBottom.current = atBottom
81
+ setShowScrollBottom(!atBottom && scrollHeight > clientHeight)
82
+ }
83
+
84
+ container.addEventListener('scroll', handleScroll)
85
+ return () => container.removeEventListener('scroll', handleScroll)
86
+ }, [])
87
+
88
+ useEffect(() => {
89
+ if (isInitialLoadRef.current && messages.length > 0) {
90
+ scrollToBottom('auto')
91
+ const timer = setTimeout(() => {
92
+ setIsReady(true)
93
+ isInitialLoadRef.current = false
94
+ }, 50)
95
+ return () => clearTimeout(timer)
96
+ } else if (wasAtBottom.current) {
97
+ scrollToBottom('smooth')
98
+ }
99
+ }, [messages])
100
+
101
+ useEffect(() => {
102
+ if (session?.id == null || session.id === '') {
103
+ setMessages([])
104
+ setSessionInfo(null)
105
+ setIsReady(true)
106
+ lastConnectedModelRef.current = undefined
107
+ return
108
+ }
109
+
110
+ setMessages([])
111
+ setSessionInfo(null)
112
+ setIsReady(false)
113
+ isInitialLoadRef.current = true
114
+
115
+ let isDisposed = false
116
+
117
+ const fetchHistory = async () => {
118
+ try {
119
+ const res = await getSessionMessages(session.id)
120
+ if (isDisposed) return
121
+ const events = res.messages as WSEvent[]
122
+
123
+ if (res.session) {
124
+ const updatedSession = res.session
125
+ void mutate('/api/sessions', (prev: { sessions: Session[] } | undefined) => {
126
+ if (prev?.sessions == null) return prev
127
+ const newSessions = prev.sessions.map((s: Session) =>
128
+ s.id === updatedSession.id ? { ...s, ...updatedSession } : s
129
+ )
130
+ return { ...prev, sessions: newSessions }
131
+ }, false)
132
+ }
133
+
134
+ if (res.interaction) {
135
+ setInteractionRequest(res.interaction)
136
+ } else {
137
+ setInteractionRequest(null)
138
+ }
139
+
140
+ let currentMessages: ChatMessage[] = []
141
+ let currentSessionInfo: SessionInfo | null = null
142
+
143
+ for (const data of events) {
144
+ if (data.type === 'message') {
145
+ const exists = currentMessages.find((msg) => msg.id === data.message.id)
146
+ if (exists != null) {
147
+ currentMessages = currentMessages.map((msg) => (msg.id === data.message.id ? data.message : msg))
148
+ } else {
149
+ currentMessages.push(data.message)
150
+ }
151
+ } else if (data.type === 'session_info') {
152
+ if (data.info != null && data.info.type !== 'summary') {
153
+ currentSessionInfo = data.info
154
+ }
155
+ } else if (data.type === 'tool_result') {
156
+ currentMessages = currentMessages.map((msg) => {
157
+ if (msg.toolCall != null && msg.toolCall.id === data.toolCallId) {
158
+ return {
159
+ ...msg,
160
+ toolCall: {
161
+ ...msg.toolCall,
162
+ status: data.isError === true ? 'error' : 'success',
163
+ output: data.output
164
+ }
165
+ }
166
+ }
167
+ return msg
168
+ })
169
+ }
170
+ }
171
+
172
+ setMessages(currentMessages)
173
+ setSessionInfo(currentSessionInfo)
174
+
175
+ setTimeout(() => {
176
+ if (isDisposed) return
177
+ setIsReady(true)
178
+ isInitialLoadRef.current = false
179
+ }, 100)
180
+ } catch (err) {
181
+ console.error('Failed to fetch history messages:', err)
182
+ }
183
+ }
184
+
185
+ void fetchHistory()
186
+
187
+ let cleanup: (() => void) | undefined
188
+ const normalizedModel = selectedModel ?? ''
189
+ const modelChanged = selectedModel != null
190
+ && lastConnectedModelRef.current != null
191
+ && normalizedModel !== lastConnectedModelRef.current
192
+ && session?.status !== 'running'
193
+ if (modelChanged) {
194
+ connectionManager.send(session.id, { type: 'terminate_session' })
195
+ connectionManager.close(session.id)
196
+ }
197
+ lastConnectedModelRef.current = normalizedModel
198
+
199
+ const timer = setTimeout(() => {
200
+ if (isDisposed) return
201
+
202
+ cleanup = connectionManager.connect(session.id, {
203
+ onOpen() {
204
+ },
205
+ onMessage(data: WSEvent) {
206
+ if (isDisposed) return
207
+ if (data.type === 'error') {
208
+ void message.error(data.message)
209
+ } else if (data.type === 'session_updated') {
210
+ void mutate('/api/sessions', (prev: { sessions: Session[] } | undefined) => {
211
+ if (prev?.sessions == null) return prev
212
+ const updatedSession = data.session as Session | { id: string; isDeleted: boolean }
213
+
214
+ if ('isDeleted' in updatedSession && updatedSession.isDeleted) {
215
+ return {
216
+ ...prev,
217
+ sessions: prev.sessions.filter((s: Session) => s.id !== updatedSession.id)
218
+ }
219
+ }
220
+
221
+ const typedUpdatedSession = updatedSession as Session
222
+ const newSessions = prev.sessions.map((s: Session) =>
223
+ s.id === typedUpdatedSession.id ? { ...s, ...typedUpdatedSession } : s
224
+ )
225
+
226
+ if (
227
+ !newSessions.some((s: Session) => s.id === typedUpdatedSession.id) && !('isDeleted' in updatedSession)
228
+ ) {
229
+ newSessions.unshift(typedUpdatedSession)
230
+ }
231
+
232
+ return { ...prev, sessions: newSessions }
233
+ }, false)
234
+ } else if (data.type === 'message') {
235
+ setMessages((m) => {
236
+ const exists = m.find((msg) => msg.id === data.message.id)
237
+ if (exists != null) {
238
+ return m.map((msg) => (msg.id === data.message.id ? data.message : msg))
239
+ }
240
+ return [...m, data.message]
241
+ })
242
+ } else if (data.type === 'session_info') {
243
+ if (data.info != null && data.info.type === 'summary') {
244
+ void mutate('/api/sessions')
245
+ } else {
246
+ setSessionInfo(data.info ?? null)
247
+ if (isInitialLoadRef.current) {
248
+ setTimeout(() => {
249
+ if (isDisposed) return
250
+ if (isInitialLoadRef.current) {
251
+ setIsReady(true)
252
+ isInitialLoadRef.current = false
253
+ }
254
+ }, 100)
255
+ }
256
+ }
257
+ } else if (data.type === 'tool_result') {
258
+ setMessages((m) => {
259
+ return m.map((msg) => {
260
+ if (msg.toolCall != null && msg.toolCall.id === data.toolCallId) {
261
+ return {
262
+ ...msg,
263
+ toolCall: {
264
+ ...msg.toolCall,
265
+ status: data.isError === true ? 'error' : 'success',
266
+ output: data.output
267
+ }
268
+ }
269
+ }
270
+ return msg
271
+ })
272
+ })
273
+ } else if (data.type === 'interaction_request') {
274
+ setInteractionRequest({ id: data.id, payload: data.payload })
275
+ }
276
+ },
277
+ onClose() {
278
+ }
279
+ }, selectedModel ? { model: selectedModel } : undefined)
280
+ }, modelChanged ? 200 : 100)
281
+
282
+ return () => {
283
+ isDisposed = true
284
+ clearTimeout(timer)
285
+ cleanup?.()
286
+ }
287
+ }, [selectedModel, session?.id, session?.status, mutate])
288
+
289
+ useEffect(() => {
290
+ if (activeView !== queryValues.view) {
291
+ updateQuery({ view: activeView })
292
+ }
293
+ }, [activeView, queryValues.view, updateQuery])
294
+
295
+ const send = async (text: string) => {
296
+ if (text.trim() === '' || isThinking) return
297
+ if (!hasAvailableModels) {
298
+ void message.warning(t('chat.modelConfigRequired'))
299
+ return
300
+ }
301
+
302
+ if (!session?.id) {
303
+ setIsCreating(true)
304
+ try {
305
+ const { session: newSession } = await createSession(undefined, text.trim())
306
+
307
+ await mutate('/api/sessions', (prev: { sessions: Session[] } | undefined) => {
308
+ if (!prev?.sessions) return { sessions: [newSession] }
309
+ return {
310
+ ...prev,
311
+ sessions: [newSession, ...prev.sessions]
312
+ }
313
+ }, false)
314
+
315
+ void navigate(`/session/${newSession.id}`)
316
+ } catch (err) {
317
+ console.error(err)
318
+ setIsCreating(false)
319
+ void message.error('Failed to create session')
320
+ }
321
+ return
322
+ }
323
+
324
+ connectionManager.send(session.id, {
325
+ type: 'user_message',
326
+ text: text.trim()
327
+ })
328
+ }
329
+
330
+ const interrupt = () => {
331
+ if (!session?.id || isThinking === false) return
332
+ connectionManager.send(session.id, {
333
+ type: 'interrupt'
334
+ })
335
+ }
336
+
337
+ const clearMessages = () => {
338
+ setMessages([])
339
+ void message.success('Messages cleared')
340
+ }
341
+
342
+ const handleInteractionResponse = (id: string, data: string | string[]) => {
343
+ if (!session?.id) return
344
+ connectionManager.send(session.id, {
345
+ type: 'interaction_response',
346
+ id,
347
+ data
348
+ })
349
+ setInteractionRequest(null)
350
+ }
351
+
352
+ return {
353
+ messages,
354
+ sessionInfo,
355
+ interactionRequest,
356
+ isCreating,
357
+ isReady,
358
+ isThinking,
359
+ activeView,
360
+ setActiveView,
361
+ messagesEndRef,
362
+ messagesContainerRef,
363
+ showScrollBottom,
364
+ scrollToBottom,
365
+ send,
366
+ interrupt,
367
+ clearMessages,
368
+ handleInteractionResponse
369
+ }
370
+ }
@@ -0,0 +1,111 @@
1
+ .config-about {
2
+ display: flex;
3
+ flex-direction: column;
4
+ gap: 12px;
5
+ }
6
+
7
+ .config-about__card {
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: space-between;
11
+ gap: 12px;
12
+ padding: 16px;
13
+ border-radius: 12px;
14
+ border: 1px solid var(--border-color);
15
+ background: var(--code-bg);
16
+ }
17
+
18
+ .config-about__app {
19
+ display: flex;
20
+ align-items: center;
21
+ gap: 12px;
22
+ }
23
+
24
+ .config-about__app-icon {
25
+ width: 44px;
26
+ height: 44px;
27
+ border-radius: 10px;
28
+ display: flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ background: var(--tag-bg);
32
+ border: 1px solid var(--border-color);
33
+ color: var(--primary-color);
34
+ }
35
+
36
+ .config-about__app-info {
37
+ display: flex;
38
+ flex-direction: column;
39
+ gap: 4px;
40
+ }
41
+
42
+ .config-about__app-title {
43
+ font-size: 14px;
44
+ font-weight: 600;
45
+ color: var(--text-color);
46
+ }
47
+
48
+ .config-about__app-meta {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 2px;
52
+ font-size: 12px;
53
+ color: var(--sub-text-color);
54
+ }
55
+
56
+ .config-about__app-version {
57
+ color: var(--text-color);
58
+ }
59
+
60
+ .config-about__primary {
61
+ display: inline-flex;
62
+ align-items: center;
63
+ justify-content: center;
64
+ padding: 8px 14px;
65
+ border-radius: 8px;
66
+ background: var(--tag-bg);
67
+ color: var(--text-color);
68
+ text-decoration: none;
69
+ font-size: 12px;
70
+ border: 1px solid var(--border-color);
71
+ white-space: nowrap;
72
+ }
73
+
74
+ .config-about__list {
75
+ display: flex;
76
+ flex-direction: column;
77
+ border-radius: 12px;
78
+ border: 1px solid var(--border-color);
79
+ overflow: hidden;
80
+ background: var(--bg-color);
81
+ }
82
+
83
+ .config-about__item-row {
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: space-between;
87
+ padding: 12px 16px;
88
+ color: var(--text-color);
89
+ text-decoration: none;
90
+ border-bottom: 1px solid var(--border-color);
91
+ }
92
+
93
+ .config-about__item-left {
94
+ display: inline-flex;
95
+ align-items: center;
96
+ gap: 10px;
97
+ }
98
+
99
+ .config-about__item-icon {
100
+ font-size: 18px;
101
+ color: var(--sub-text-color);
102
+ }
103
+
104
+ .config-about__item-row:last-child {
105
+ border-bottom: none;
106
+ }
107
+
108
+ .config-about__arrow {
109
+ font-size: 18px;
110
+ color: var(--sub-text-color);
111
+ }