@vibe-forge/client 0.5.0 → 0.6.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 (134) hide show
  1. package/AGENTS.md +37 -0
  2. package/dist/assets/{arc-C4ymrcSQ.js → arc-CMAHd5G3.js} +1 -1
  3. package/dist/assets/{blockDiagram-c4efeb88-CeB7-kgP.js → blockDiagram-c4efeb88-DKww-VCP.js} +1 -1
  4. package/dist/assets/{c4Diagram-c83219d4-C935Im8S.js → c4Diagram-c83219d4-DKrjVHyY.js} +1 -1
  5. package/dist/assets/channel-Bi4g8rj9.js +1 -0
  6. package/dist/assets/{classDiagram-beda092f-B9IV13KI.js → classDiagram-beda092f-BXx5rdo3.js} +1 -1
  7. package/dist/assets/{classDiagram-v2-2358418a-CXF_K4fE.js → classDiagram-v2-2358418a-CnR3WLsr.js} +1 -1
  8. package/dist/assets/clone-DPrpP2ky.js +1 -0
  9. package/dist/assets/{createText-1719965b-DwX8iC5F.js → createText-1719965b-CmOsl1W7.js} +1 -1
  10. package/dist/assets/{edges-96097737-9P1uH1RE.js → edges-96097737-CQeQgpjD.js} +1 -1
  11. package/dist/assets/{erDiagram-0228fc6a-ixeGTFvg.js → erDiagram-0228fc6a-ZUNB-ucF.js} +1 -1
  12. package/dist/assets/{flowDb-c6c81e3f-G1gSTTBI.js → flowDb-c6c81e3f-DuuKeSLX.js} +1 -1
  13. package/dist/assets/{flowDiagram-50d868cf-CzrG99nD.js → flowDiagram-50d868cf-Bc6n85yR.js} +1 -1
  14. package/dist/assets/flowDiagram-v2-4f6560a1-BZqaeqoh.js +1 -0
  15. package/dist/assets/{flowchart-elk-definition-6af322e1-sFCoysWa.js → flowchart-elk-definition-6af322e1-cAG5afW9.js} +1 -1
  16. package/dist/assets/{ganttDiagram-a2739b55-Ccsk_Lru.js → ganttDiagram-a2739b55-Dp6xhY5I.js} +1 -1
  17. package/dist/assets/{gitGraphDiagram-82fe8481-CwathJ6H.js → gitGraphDiagram-82fe8481-MlIIRBdG.js} +1 -1
  18. package/dist/assets/{graph-DRCU-8Rz.js → graph-D7Es8jZ-.js} +1 -1
  19. package/dist/assets/{index-5325376f-Bq-fg2i_.js → index-5325376f-DC18ottv.js} +1 -1
  20. package/dist/assets/index-D37AbgPQ.js +545 -0
  21. package/dist/assets/{index-CHMuZ5-1.css → index-fcJ9v94I.css} +1 -1
  22. package/dist/assets/{infoDiagram-8eee0895-JBcUkJ6T.js → infoDiagram-8eee0895-CXk21kFp.js} +1 -1
  23. package/dist/assets/{journeyDiagram-c64418c1-DsdQU-R8.js → journeyDiagram-c64418c1-899BKBHL.js} +1 -1
  24. package/dist/assets/{layout-s0slG1OL.js → layout-DLaxdy48.js} +1 -1
  25. package/dist/assets/{line-CymFqgW6.js → line-_lw5YbRM.js} +1 -1
  26. package/dist/assets/{linear-lDQVZ6aQ.js → linear-D5iu84ui.js} +1 -1
  27. package/dist/assets/{mermaid.core-Cmlqga_E.js → mermaid.core-C6sW3GFM.js} +4 -4
  28. package/dist/assets/{mindmap-definition-8da855dc-CqqTDJn_.js → mindmap-definition-8da855dc-BS9Xy9KN.js} +1 -1
  29. package/dist/assets/{pieDiagram-a8764435-BL2Ajx7Z.js → pieDiagram-a8764435-DZt9cEgs.js} +1 -1
  30. package/dist/assets/{quadrantDiagram-1e28029f-ClL_3ASt.js → quadrantDiagram-1e28029f-BTIeHOgn.js} +1 -1
  31. package/dist/assets/{requirementDiagram-08caed73-CB1RgE3K.js → requirementDiagram-08caed73-BHJAKD2g.js} +1 -1
  32. package/dist/assets/{sankeyDiagram-a04cb91d-tgleEYiD.js → sankeyDiagram-a04cb91d-DnAkVOK8.js} +1 -1
  33. package/dist/assets/{sequenceDiagram-c5b8d532-DlatQT5R.js → sequenceDiagram-c5b8d532-92tE3oFv.js} +1 -1
  34. package/dist/assets/{stateDiagram-1ecb1508-B--MLqRs.js → stateDiagram-1ecb1508-DG0ObiMg.js} +1 -1
  35. package/dist/assets/{stateDiagram-v2-c2b004d7-CRMZ6Dpx.js → stateDiagram-v2-c2b004d7-BKoJx2ci.js} +1 -1
  36. package/dist/assets/{styles-b4e223ce-CPiYHfUz.js → styles-b4e223ce-Ba6G4ri9.js} +1 -1
  37. package/dist/assets/{styles-ca3715f6-B9UKPAzX.js → styles-ca3715f6-Bn6RIIVW.js} +1 -1
  38. package/dist/assets/{styles-d45a18b0-BC1Ak1So.js → styles-d45a18b0-_dELBUI6.js} +1 -1
  39. package/dist/assets/{svgDrawCommon-b86b1483-DV8R0g-n.js → svgDrawCommon-b86b1483-CRK-ZoIs.js} +1 -1
  40. package/dist/assets/{timeline-definition-faaaa080-CiqGS5DC.js → timeline-definition-faaaa080-DvQ_RA_i.js} +1 -1
  41. package/dist/assets/{xychartDiagram-f5964ef8-h6VSD3GE.js → xychartDiagram-f5964ef8-CJxeDLfg.js} +1 -1
  42. package/dist/index.html +2 -2
  43. package/package.json +10 -8
  44. package/src/App.tsx +20 -168
  45. package/src/api/base.ts +116 -7
  46. package/src/api.ts +3 -1
  47. package/src/components/ArchiveView.tsx +5 -5
  48. package/src/components/ConfigView.tsx +3 -3
  49. package/src/components/{AutomationView → automation-view}/index.tsx +10 -9
  50. package/src/components/{BenchmarkView → benchmark-view}/index.tsx +5 -4
  51. package/src/components/chat/ChatHeader.tsx +6 -6
  52. package/src/components/chat/ChatHistoryView.tsx +64 -16
  53. package/src/components/chat/ChatTimelineView.tsx +3 -3
  54. package/src/components/chat/CurrentTodoList.scss +56 -27
  55. package/src/components/chat/{Sender → sender}/Sender.scss +201 -71
  56. package/src/components/chat/{Sender → sender}/Sender.tsx +104 -42
  57. package/src/components/chat/tools/core/ToolGroup.tsx +1 -1
  58. package/src/components/config/ConfigSectionForm.tsx +1 -1
  59. package/src/components/layout/AppShell.scss +19 -0
  60. package/src/components/layout/AppShell.tsx +45 -0
  61. package/src/hooks/chat/model-selector.ts +150 -0
  62. package/src/hooks/chat/use-chat-adapter.ts +20 -8
  63. package/src/hooks/chat/use-chat-models.tsx +79 -74
  64. package/src/hooks/chat/use-chat-permission-mode.ts +14 -10
  65. package/src/hooks/chat/use-chat-session-actions.ts +13 -10
  66. package/src/hooks/chat/use-chat-session-messages.ts +46 -6
  67. package/src/hooks/chat/use-chat-session.ts +42 -1
  68. package/src/hooks/use-app-preferences.ts +41 -0
  69. package/src/hooks/use-session-subscription.ts +101 -0
  70. package/src/hooks/use-sidebar-navigation.ts +35 -0
  71. package/src/resources/locales/en.json +6 -0
  72. package/src/resources/locales/zh.json +6 -0
  73. package/src/routes/AppRoutes.tsx +22 -0
  74. package/src/routes/ArchiveRoute.tsx +5 -0
  75. package/src/routes/AutomationRoute.tsx +5 -0
  76. package/src/routes/BenchmarkRoute.tsx +5 -0
  77. package/src/{components/Chat.scss → routes/ChatRoute.scss} +35 -0
  78. package/src/{components/Chat.tsx → routes/ChatRoute.tsx} +54 -28
  79. package/src/routes/ConfigRoute.tsx +5 -0
  80. package/src/routes/KnowledgeRoute.tsx +5 -0
  81. package/dist/assets/channel-84s1ACzD.js +0 -1
  82. package/dist/assets/clone-B2E8tddE.js +0 -1
  83. package/dist/assets/flowDiagram-v2-4f6560a1-CJfJYbME.js +0 -1
  84. package/dist/assets/index-cGZvDhhU.js +0 -542
  85. /package/src/components/{AutomationView → automation-view}/RuleFormPanel.scss +0 -0
  86. /package/src/components/{AutomationView → automation-view}/RuleFormPanel.tsx +0 -0
  87. /package/src/components/{AutomationView → automation-view}/RuleSidebar.scss +0 -0
  88. /package/src/components/{AutomationView → automation-view}/RuleSidebar.tsx +0 -0
  89. /package/src/components/{AutomationView → automation-view}/RunHistoryPanel.scss +0 -0
  90. /package/src/components/{AutomationView → automation-view}/RunHistoryPanel.tsx +0 -0
  91. /package/src/components/{AutomationView → automation-view}/TaskList.scss +0 -0
  92. /package/src/components/{AutomationView → automation-view}/TaskList.tsx +0 -0
  93. /package/src/components/{AutomationView → automation-view}/TriggerList.scss +0 -0
  94. /package/src/components/{AutomationView → automation-view}/TriggerList.tsx +0 -0
  95. /package/src/components/{AutomationView/AutomationView.scss → automation-view/index.scss} +0 -0
  96. /package/src/components/{AutomationView → automation-view}/types.ts +0 -0
  97. /package/src/components/{BenchmarkView → benchmark-view}/BenchmarkCasePanel.scss +0 -0
  98. /package/src/components/{BenchmarkView → benchmark-view}/BenchmarkCasePanel.tsx +0 -0
  99. /package/src/components/{BenchmarkView → benchmark-view}/BenchmarkSidebar.scss +0 -0
  100. /package/src/components/{BenchmarkView → benchmark-view}/BenchmarkSidebar.tsx +0 -0
  101. /package/src/components/{BenchmarkView → benchmark-view}/BenchmarkView.scss +0 -0
  102. /package/src/components/{BenchmarkView → benchmark-view}/types.ts +0 -0
  103. /package/src/components/{BenchmarkView → benchmark-view}/utils.ts +0 -0
  104. /package/src/components/chat/{Messages → messages}/MessageFooter.tsx +0 -0
  105. /package/src/components/chat/{Messages → messages}/MessageItem.scss +0 -0
  106. /package/src/components/chat/{Messages → messages}/MessageItem.tsx +0 -0
  107. /package/src/components/chat/{Messages → messages}/message-utils.ts +0 -0
  108. /package/src/components/chat/{Sender → sender}/CompletionMenu.scss +0 -0
  109. /package/src/components/chat/{Sender → sender}/CompletionMenu.tsx +0 -0
  110. /package/src/components/chat/{Sender → sender}/ThinkingStatus.scss +0 -0
  111. /package/src/components/chat/{Sender → sender}/ThinkingStatus.tsx +0 -0
  112. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/EventList.scss +0 -0
  113. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/EventList.tsx +0 -0
  114. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/gantt.ts +0 -0
  115. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/git-graph.ts +0 -0
  116. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/index.scss +0 -0
  117. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/index.tsx +0 -0
  118. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/mermaid.ts +0 -0
  119. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/types.ts +0 -0
  120. /package/src/components/chat/{SessionTimelinePanel → session-timeline-panel}/utils.ts +0 -0
  121. /package/src/components/config/{recordEditors → record-editors}/BooleanRecordEditor.scss +0 -0
  122. /package/src/components/config/{recordEditors → record-editors}/BooleanRecordEditor.tsx +0 -0
  123. /package/src/components/config/{recordEditors → record-editors}/ChannelRecordEditor.scss +0 -0
  124. /package/src/components/config/{recordEditors → record-editors}/ChannelRecordEditor.tsx +0 -0
  125. /package/src/components/config/{recordEditors → record-editors}/KeyValueEditor.scss +0 -0
  126. /package/src/components/config/{recordEditors → record-editors}/KeyValueEditor.tsx +0 -0
  127. /package/src/components/config/{recordEditors → record-editors}/McpServersRecordEditor.scss +0 -0
  128. /package/src/components/config/{recordEditors → record-editors}/McpServersRecordEditor.tsx +0 -0
  129. /package/src/components/config/{recordEditors → record-editors}/ModelServicesRecordEditor.scss +0 -0
  130. /package/src/components/config/{recordEditors → record-editors}/ModelServicesRecordEditor.tsx +0 -0
  131. /package/src/components/config/{recordEditors → record-editors}/RecordEditors.scss +0 -0
  132. /package/src/components/config/{recordEditors → record-editors}/RecordJsonEditor.scss +0 -0
  133. /package/src/components/config/{recordEditors → record-editors}/RecordJsonEditor.tsx +0 -0
  134. /package/src/components/config/{recordEditors → record-editors}/index.tsx +0 -0
@@ -2,13 +2,21 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'
2
2
  import { useTranslation } from 'react-i18next'
3
3
  import useSWR from 'swr'
4
4
 
5
- import type { AdapterBuiltinModel, ConfigResponse, ModelServiceConfig, RecommendedModelConfig } from '@vibe-forge/core'
6
5
  import { getConfig } from '#~/api.js'
6
+ import type { AdapterBuiltinModel, ConfigResponse, ModelServiceConfig, RecommendedModelConfig } from '@vibe-forge/core'
7
+ import {
8
+ buildServiceModelSelector,
9
+ listServiceModels,
10
+ resolveChatModelSelection,
11
+ resolveDefaultChatModelSelection,
12
+ resolveServiceModelSelector
13
+ } from './model-selector'
7
14
 
8
15
  export interface ModelSelectOption {
9
16
  value: string
10
17
  label: React.ReactNode
11
18
  searchText: string
19
+ displayLabel: string
12
20
  }
13
21
 
14
22
  export interface ModelSelectGroup {
@@ -71,87 +79,69 @@ export function useChatModels({
71
79
 
72
80
  const modelServiceEntries = useMemo(() => Object.entries(mergedModelServices), [mergedModelServices])
73
81
 
74
- const availableModels = useMemo(() => {
75
- const list: Array<{ model: string; serviceKey: string; serviceTitle: string }> = []
76
- for (const [serviceKey, serviceValue] of modelServiceEntries) {
77
- const service = (serviceValue != null && typeof serviceValue === 'object')
78
- ? serviceValue as ModelServiceConfig
79
- : undefined
80
- const serviceTitle = service?.title?.trim() !== '' ? service?.title ?? '' : serviceKey
81
- const models = Array.isArray(service?.models) ? service?.models.filter(item => typeof item === 'string') : []
82
- for (const model of models) {
83
- list.push({ model, serviceKey, serviceTitle })
84
- }
85
- }
86
- return list
87
- }, [modelServiceEntries])
88
-
89
- const availableModelValues = useMemo(() => availableModels.map(item => item.model), [availableModels])
90
- const availableModelKey = useMemo(() => availableModelValues.join('|'), [availableModelValues])
91
- const availableModelSet = useMemo(() => new Set(availableModelValues), [availableModelKey])
92
- const hasAvailableModels = availableModelValues.length > 0 || builtinModelSet.size > 0
82
+ const availableServiceModels = useMemo(() => listServiceModels(mergedModelServices), [mergedModelServices])
83
+ const hasAvailableModels = availableServiceModels.length > 0 || builtinModelSet.size > 0
93
84
  const modelToService = useMemo(() => {
94
85
  const map = new Map<string, { key: string; title: string }>()
95
- for (const entry of availableModels) {
86
+ for (const entry of availableServiceModels) {
87
+ const serviceValue = mergedModelServices[entry.serviceKey]
88
+ const serviceTitle = serviceValue?.title?.trim() !== '' ? serviceValue?.title ?? '' : entry.serviceKey
96
89
  if (!map.has(entry.model)) {
97
- map.set(entry.model, { key: entry.serviceKey, title: entry.serviceTitle })
90
+ map.set(entry.model, { key: entry.serviceKey, title: serviceTitle })
98
91
  }
99
92
  }
100
93
  return map
101
- }, [availableModels])
94
+ }, [availableServiceModels, mergedModelServices])
102
95
  const defaultModelService = configRes?.sources?.merged?.general?.defaultModelService
103
96
  const defaultModel = configRes?.sources?.merged?.general?.defaultModel
104
97
  const formatModelWithService = useCallback((model: string | undefined) => {
105
- const normalizedModel = typeof model === 'string' ? model.trim() : ''
106
- if (normalizedModel === '') return undefined
107
- // Builtin adapter models pass through as-is (no service prefix)
108
- if (builtinModelSet.has(normalizedModel)) return normalizedModel
109
- if (normalizedModel.includes(',')) return normalizedModel
110
- const resolvedService = modelToService.get(normalizedModel)?.key ?? defaultModelService
111
- return resolvedService ? `${resolvedService},${normalizedModel}` : normalizedModel
112
- }, [builtinModelSet, defaultModelService, modelToService])
98
+ return resolveChatModelSelection({
99
+ value: model,
100
+ builtinModels: activeBuiltinModelValues,
101
+ serviceModels: availableServiceModels,
102
+ defaultModelService
103
+ })
104
+ }, [activeBuiltinModelValues, availableServiceModels, defaultModelService])
113
105
  const resolvedDefaultModel = useMemo(() => {
114
- if (defaultModel && builtinModelSet.has(defaultModel)) return defaultModel
115
- if (activeBuiltinModelValues.length > 0) {
116
- return activeBuiltinModelValues[0]
117
- }
118
- if (defaultModel && availableModelSet.has(defaultModel)) return defaultModel
119
- if (defaultModelService && mergedModelServices[defaultModelService]) {
120
- const service = mergedModelServices[defaultModelService]
121
- const models = Array.isArray(service?.models) ? service?.models.filter(item => typeof item === 'string') : []
122
- if (models.length > 0) return models[0]
123
- }
124
- if (availableModelValues.length > 0) return availableModelValues[0]
125
- // Fall back to first builtin model from the active adapter
126
- const firstBuiltin = Object.values(activeBuiltinModels).flat()[0]
127
- return firstBuiltin?.value
106
+ return resolveDefaultChatModelSelection({
107
+ defaultModel,
108
+ defaultModelService,
109
+ builtinModels: activeBuiltinModelValues,
110
+ serviceModels: availableServiceModels
111
+ })
128
112
  }, [
129
- activeBuiltinModels,
130
113
  activeBuiltinModelValues,
131
- availableModelSet,
132
- availableModelValues,
133
- builtinModelSet,
114
+ availableServiceModels,
134
115
  defaultModel,
135
- defaultModelService,
136
- mergedModelServices
116
+ defaultModelService
137
117
  ])
138
118
  const selectedModelWithService = useMemo(() => (
139
- formatModelWithService(selectedModel)
140
- ), [formatModelWithService, selectedModel])
119
+ formatModelWithService(selectedModel) ?? resolvedDefaultModel
120
+ ), [formatModelWithService, resolvedDefaultModel, selectedModel])
121
+
122
+ const resolveSelectableModel = useCallback((value?: string) => {
123
+ return resolveChatModelSelection({
124
+ value,
125
+ builtinModels: activeBuiltinModelValues,
126
+ serviceModels: availableServiceModels,
127
+ defaultModelService
128
+ }) ?? resolvedDefaultModel
129
+ }, [activeBuiltinModelValues, availableServiceModels, defaultModelService, resolvedDefaultModel])
130
+
131
+ const updateSelectedModel = useCallback((value?: string) => {
132
+ setSelectedModel((prev) => {
133
+ const nextValue = resolveSelectableModel(value)
134
+ return nextValue === prev ? prev : nextValue
135
+ })
136
+ }, [resolveSelectableModel])
141
137
 
142
138
  useEffect(() => {
143
139
  if (!hasAvailableModels) {
144
140
  setSelectedModel(undefined)
145
141
  return
146
142
  }
147
- setSelectedModel((prev) => {
148
- if (prev != null) {
149
- const isValid = availableModelSet.has(prev) || builtinModelSet.has(prev)
150
- if (isValid) return prev
151
- }
152
- return resolvedDefaultModel
153
- })
154
- }, [availableModelSet, builtinModelSet, hasAvailableModels, resolvedDefaultModel, selectedAdapter])
143
+ setSelectedModel((prev) => resolveSelectableModel(prev))
144
+ }, [hasAvailableModels, resolveSelectableModel, selectedAdapter])
155
145
 
156
146
  useEffect(() => {
157
147
  try {
@@ -191,7 +181,8 @@ export function useChatModels({
191
181
  return {
192
182
  value: params.value,
193
183
  label,
194
- searchText
184
+ searchText,
185
+ displayLabel: params.title
195
186
  }
196
187
  }
197
188
 
@@ -212,14 +203,16 @@ export function useChatModels({
212
203
  const serviceTitle = service?.title?.trim() !== '' ? service?.title ?? '' : serviceKey
213
204
  const groupTitle = serviceTitle?.trim() !== '' ? serviceTitle : serviceKey
214
205
  const serviceDescription = service?.description
215
- const models = Array.isArray(service?.models) ? service?.models.filter(item => typeof item === 'string') : []
206
+ const models = Array.isArray(service?.models)
207
+ ? service.models.filter((item): item is string => typeof item === 'string')
208
+ : []
216
209
  if (models.length === 0) return null
217
- const options = models.map((model) => {
210
+ const options = models.map((model: string) => {
218
211
  const alias = resolveFirstAlias(service?.modelsAlias as Record<string, string[]> | undefined, model)
219
212
  const title = alias ?? model
220
213
  const description = alias ? model : serviceTitle
221
214
  return buildOption({
222
- value: model,
215
+ value: buildServiceModelSelector(serviceKey, model),
223
216
  title,
224
217
  description,
225
218
  serviceKey,
@@ -241,7 +234,11 @@ export function useChatModels({
241
234
  const recommendedOptions = recommendedModels
242
235
  .filter((item) => {
243
236
  if (item.placement && item.placement !== 'modelSelector') return false
244
- return availableModelSet.has(item.model)
237
+ return resolveServiceModelSelector({
238
+ value: item.service ? buildServiceModelSelector(item.service, item.model) : item.model,
239
+ serviceModels: availableServiceModels,
240
+ preferredServiceKey: item.service ?? defaultModelService
241
+ }) != null
245
242
  })
246
243
  .map((item) => {
247
244
  const serviceInfo = item.service ? mergedModelServices[item.service] : undefined
@@ -255,8 +252,13 @@ export function useChatModels({
255
252
  const description = item.description?.trim() !== ''
256
253
  ? item.description
257
254
  : serviceTitle
255
+ const value = resolveServiceModelSelector({
256
+ value: item.service ? buildServiceModelSelector(item.service, item.model) : item.model,
257
+ serviceModels: availableServiceModels,
258
+ preferredServiceKey: item.service ?? defaultModelService
259
+ }) ?? item.model
258
260
  return buildOption({
259
- value: item.model,
261
+ value,
260
262
  title,
261
263
  description,
262
264
  serviceKey: item.service ?? modelToService.get(item.model)?.key,
@@ -290,18 +292,21 @@ export function useChatModels({
290
292
  <div className='model-group-title'>{adapterTitle}</div>
291
293
  </div>
292
294
  ),
293
- options: models.map(m => buildOption({
294
- value: m.value,
295
- title: m.title,
296
- description: m.description
297
- }))
295
+ options: models.map(m =>
296
+ buildOption({
297
+ value: m.value,
298
+ title: m.title,
299
+ description: m.description
300
+ })
301
+ )
298
302
  })
299
303
  }
300
304
 
301
305
  return [...groups, ...serviceGroups]
302
306
  }, [
303
307
  activeBuiltinModels,
304
- availableModelSet,
308
+ availableServiceModels,
309
+ defaultModelService,
305
310
  modelToService,
306
311
  mergedModelServices,
307
312
  modelServiceEntries,
@@ -312,7 +317,7 @@ export function useChatModels({
312
317
  return {
313
318
  selectedModel,
314
319
  selectedModelWithService,
315
- setSelectedModel,
320
+ setSelectedModel: updateSelectedModel,
316
321
  modelOptions,
317
322
  hasAvailableModels
318
323
  }
@@ -5,22 +5,26 @@ export type PermissionMode = 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'b
5
5
 
6
6
  const PERMISSION_MODE_STORAGE_KEY = 'vf_chat_permission_mode'
7
7
 
8
- const isPermissionMode = (value: string): value is PermissionMode => {
9
- return value === 'default'
10
- || value === 'acceptEdits'
11
- || value === 'plan'
12
- || value === 'dontAsk'
13
- || value === 'bypassPermissions'
8
+ export const isPermissionMode = (value: string): value is PermissionMode => {
9
+ return value === 'default' ||
10
+ value === 'acceptEdits' ||
11
+ value === 'plan' ||
12
+ value === 'dontAsk' ||
13
+ value === 'bypassPermissions'
14
14
  }
15
15
 
16
16
  export function useChatPermissionMode() {
17
17
  const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
18
18
 
19
+ const updatePermissionMode = (value?: string) => {
20
+ setPermissionMode(isPermissionMode(value ?? '') ? value : 'default')
21
+ }
22
+
19
23
  useEffect(() => {
20
24
  try {
21
25
  const raw = localStorage.getItem(PERMISSION_MODE_STORAGE_KEY)
22
26
  if (raw && isPermissionMode(raw)) {
23
- setPermissionMode(raw)
27
+ updatePermissionMode(raw)
24
28
  }
25
29
  } catch {}
26
30
  }, [])
@@ -31,17 +35,17 @@ export function useChatPermissionMode() {
31
35
  } catch {}
32
36
  }, [permissionMode])
33
37
 
34
- const permissionModeOptions = useMemo<Array<{ value: PermissionMode; label: ReactNode }>>(() => ([
38
+ const permissionModeOptions = useMemo<Array<{ value: PermissionMode; label: ReactNode }>>(() => [
35
39
  { value: 'default', label: '默认' },
36
40
  { value: 'acceptEdits', label: '接受编辑' },
37
41
  { value: 'plan', label: '计划' },
38
42
  { value: 'dontAsk', label: '不询问' },
39
43
  { value: 'bypassPermissions', label: '跳过权限' }
40
- ]), [])
44
+ ], [])
41
45
 
42
46
  return {
43
47
  permissionMode,
44
- setPermissionMode,
48
+ setPermissionMode: updatePermissionMode,
45
49
  permissionModeOptions
46
50
  }
47
51
  }
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'
4
4
  import { useNavigate } from 'react-router-dom'
5
5
  import { useSWRConfig } from 'swr'
6
6
 
7
- import { createSession } from '#~/api.js'
7
+ import { createSession, getApiErrorMessage } from '#~/api.js'
8
8
  import { connectionManager } from '#~/connectionManager.js'
9
9
  import type { ChatMessageContent, Session } from '@vibe-forge/core'
10
10
  import type { PermissionMode } from './use-chat-permission-mode'
@@ -32,10 +32,10 @@ export function useChatSessionActions({
32
32
  const isThinking = isCreating || session?.status === 'running'
33
33
 
34
34
  const send = useCallback(async (text: string) => {
35
- if (text.trim() === '' || isThinking) return
35
+ if (text.trim() === '' || isThinking) return false
36
36
  if (!hasAvailableModels) {
37
37
  void message.warning(t('chat.modelConfigRequired'))
38
- return
38
+ return false
39
39
  }
40
40
 
41
41
  if (!session?.id) {
@@ -55,18 +55,20 @@ export function useChatSessionActions({
55
55
  }, false)
56
56
 
57
57
  void navigate(`/session/${newSession.id}`)
58
+ return true
58
59
  } catch (err) {
59
60
  console.error(err)
60
61
  setIsCreating(false)
61
- void message.error('Failed to create session')
62
+ void message.error(getApiErrorMessage(err, 'Failed to create session'))
63
+ return false
62
64
  }
63
- return
64
65
  }
65
66
 
66
67
  connectionManager.send(session.id, {
67
68
  type: 'user_message',
68
69
  text: text.trim()
69
70
  })
71
+ return true
70
72
  }, [
71
73
  adapter,
72
74
  hasAvailableModels,
@@ -81,10 +83,10 @@ export function useChatSessionActions({
81
83
  ])
82
84
 
83
85
  const sendContent = useCallback(async (content: ChatMessageContent[]) => {
84
- if (content.length === 0 || isThinking) return
86
+ if (content.length === 0 || isThinking) return false
85
87
  if (!hasAvailableModels) {
86
88
  void message.warning(t('chat.modelConfigRequired'))
87
- return
89
+ return false
88
90
  }
89
91
 
90
92
  if (!session?.id) {
@@ -104,19 +106,20 @@ export function useChatSessionActions({
104
106
  }, false)
105
107
 
106
108
  void navigate(`/session/${newSession.id}`)
107
- setIsCreating(false)
109
+ return true
108
110
  } catch (err) {
109
111
  console.error(err)
110
112
  setIsCreating(false)
111
- void message.error('Failed to create session')
113
+ void message.error(getApiErrorMessage(err, 'Failed to create session'))
114
+ return false
112
115
  }
113
- return
114
116
  }
115
117
 
116
118
  connectionManager.send(session.id, {
117
119
  type: 'user_message',
118
120
  content
119
121
  })
122
+ return true
120
123
  }, [
121
124
  adapter,
122
125
  hasAvailableModels,
@@ -1,5 +1,6 @@
1
1
  import { App } from 'antd'
2
- import { useEffect, useRef, useState } from 'react'
2
+ import { useCallback, useEffect, useRef, useState } from 'react'
3
+ import { useTranslation } from 'react-i18next'
3
4
  import { useSWRConfig } from 'swr'
4
5
 
5
6
  import { getSessionMessages } from '#~/api.js'
@@ -47,20 +48,32 @@ export function useChatSessionMessages({
47
48
  adapter?: string
48
49
  setInteractionRequest: (value: { id: string; payload: AskUserQuestionParams } | null) => void
49
50
  }) {
50
- const { message } = App.useApp()
51
+ const { t } = useTranslation()
51
52
  const { mutate } = useSWRConfig()
52
53
  const [messages, setMessages] = useState<ChatMessage[]>([])
53
54
  const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null)
54
55
  const [isReady, setIsReady] = useState(false)
56
+ const [connectionError, setConnectionError] = useState<string | null>(null)
57
+ const [retryCount, setRetryCount] = useState(0)
55
58
  const isInitialLoadRef = useRef<boolean>(true)
56
59
  const lastConnectedModelRef = useRef<string | undefined>(undefined)
57
60
  const lastConnectedPermissionModeRef = useRef<string | undefined>(undefined)
58
61
  const lastConnectedAdapterRef = useRef<string | undefined>(undefined)
62
+ const expectedCloseRef = useRef(false)
63
+
64
+ const retryConnection = useCallback(() => {
65
+ if (session?.id == null || session.id === '') return
66
+ expectedCloseRef.current = true
67
+ setConnectionError(null)
68
+ connectionManager.close(session.id)
69
+ setRetryCount((count) => count + 1)
70
+ }, [session?.id])
59
71
 
60
72
  useEffect(() => {
61
73
  setMessages([])
62
74
  setSessionInfo(null)
63
75
  setIsReady(false)
76
+ setConnectionError(null)
64
77
  setInteractionRequest(null)
65
78
  isInitialLoadRef.current = true
66
79
 
@@ -68,6 +81,7 @@ export function useChatSessionMessages({
68
81
  setIsReady(true)
69
82
  lastConnectedModelRef.current = undefined
70
83
  lastConnectedPermissionModeRef.current = undefined
84
+ lastConnectedAdapterRef.current = undefined
71
85
  return
72
86
  }
73
87
 
@@ -150,6 +164,8 @@ export function useChatSessionMessages({
150
164
  normalizedAdapter !== lastConnectedAdapterRef.current &&
151
165
  session?.status !== 'running'
152
166
  if (modelChanged || permissionModeChanged || adapterChanged) {
167
+ expectedCloseRef.current = true
168
+ setConnectionError(null)
153
169
  connectionManager.send(session.id, { type: 'terminate_session' })
154
170
  connectionManager.close(session.id)
155
171
  }
@@ -173,11 +189,13 @@ export function useChatSessionMessages({
173
189
 
174
190
  cleanup = connectionManager.connect(session.id, {
175
191
  onOpen() {
192
+ expectedCloseRef.current = false
193
+ setConnectionError(null)
176
194
  },
177
195
  onMessage(data: WSEvent) {
178
196
  if (isDisposed) return
179
197
  if (data.type === 'error') {
180
- void message.error(data.message)
198
+ setConnectionError(data.message)
181
199
  return
182
200
  }
183
201
 
@@ -241,22 +259,44 @@ export function useChatSessionMessages({
241
259
  setInteractionRequest({ id: data.id, payload: data.payload })
242
260
  }
243
261
  },
262
+ onError() {
263
+ if (isDisposed) return
264
+ setConnectionError(t('chat.connectionError'))
265
+ },
244
266
  onClose() {
267
+ if (isDisposed) return
268
+ if (expectedCloseRef.current) {
269
+ expectedCloseRef.current = false
270
+ return
271
+ }
272
+ setConnectionError((current) => current ?? t('chat.connectionClosed'))
245
273
  }
246
274
  }, Object.keys(connectionParams).length > 0 ? connectionParams : undefined)
247
- }, modelChanged ? 200 : 100)
275
+ }, (modelChanged || permissionModeChanged || adapterChanged) ? 200 : 100)
248
276
 
249
277
  return () => {
250
278
  isDisposed = true
251
279
  clearTimeout(timer)
252
280
  cleanup?.()
253
281
  }
254
- }, [adapter, message, modelForQuery, mutate, permissionMode, session?.id, session?.status, setInteractionRequest])
282
+ }, [
283
+ adapter,
284
+ modelForQuery,
285
+ mutate,
286
+ permissionMode,
287
+ retryCount,
288
+ session?.id,
289
+ session?.status,
290
+ setInteractionRequest,
291
+ t
292
+ ])
255
293
 
256
294
  return {
257
295
  messages,
258
296
  setMessages,
259
297
  sessionInfo,
260
- isReady
298
+ isReady,
299
+ connectionError,
300
+ retryConnection
261
301
  }
262
302
  }
@@ -1,3 +1,4 @@
1
+ import { useEffect, useRef } from 'react'
1
2
  import { useTranslation } from 'react-i18next'
2
3
 
3
4
  import type { Session } from '@vibe-forge/core'
@@ -27,20 +28,60 @@ export function useChatSession({
27
28
  const { interactionRequest, setInteractionRequest, handleInteractionResponse } = useChatInteraction({
28
29
  sessionId: session?.id
29
30
  })
30
- const { messages, setMessages, sessionInfo, isReady } = useChatSessionMessages({
31
+ const { messages, setMessages, sessionInfo, isReady, connectionError, retryConnection } = useChatSessionMessages({
31
32
  session,
32
33
  modelForQuery: selectedModelWithService,
33
34
  permissionMode,
34
35
  adapter: selectedAdapter,
35
36
  setInteractionRequest
36
37
  })
38
+ const lastObservedSessionRef = useRef<Pick<Session, 'id' | 'model' | 'permissionMode' | 'adapter'> | null>(null)
37
39
  const isThinking = session?.status === 'running'
38
40
 
41
+ useEffect(() => {
42
+ if (session?.id == null || session.id === '') {
43
+ lastObservedSessionRef.current = null
44
+ return
45
+ }
46
+
47
+ const previous = lastObservedSessionRef.current
48
+ const sessionChanged = previous?.id !== session.id
49
+
50
+ if (sessionChanged || previous?.model !== session.model) {
51
+ setSelectedModel(session.model)
52
+ }
53
+
54
+ if (sessionChanged || previous?.permissionMode !== session.permissionMode) {
55
+ setPermissionMode(session.permissionMode)
56
+ }
57
+
58
+ if (sessionChanged || previous?.adapter !== session.adapter) {
59
+ setSelectedAdapter(session.adapter)
60
+ }
61
+
62
+ lastObservedSessionRef.current = {
63
+ id: session.id,
64
+ model: session.model,
65
+ permissionMode: session.permissionMode,
66
+ adapter: session.adapter
67
+ }
68
+ }, [
69
+ session?.adapter,
70
+ session?.id,
71
+ session?.model,
72
+ session?.permissionMode,
73
+ setPermissionMode,
74
+ setSelectedAdapter,
75
+ setSelectedModel
76
+ ])
77
+
39
78
  return {
40
79
  messages,
41
80
  sessionInfo,
42
81
  interactionRequest,
43
82
  isReady,
83
+ connectionError,
84
+ retryConnection,
44
85
  isThinking,
45
86
  activeView,
46
87
  setActiveView,
@@ -0,0 +1,41 @@
1
+ import { theme } from 'antd'
2
+ import { useAtomValue } from 'jotai'
3
+ import { useEffect, useMemo } from 'react'
4
+ import { useTranslation } from 'react-i18next'
5
+ import useSWR from 'swr'
6
+
7
+ import type { ConfigResponse } from '@vibe-forge/core'
8
+
9
+ import { getConfig } from '#~/api'
10
+ import { themeAtom } from '#~/store'
11
+
12
+ export function useAppPreferences() {
13
+ const { i18n } = useTranslation()
14
+ const themeMode = useAtomValue(themeAtom)
15
+ const { data: configRes } = useSWR<ConfigResponse>('/api/config', getConfig)
16
+ const interfaceLanguage = configRes?.sources?.merged?.general?.interfaceLanguage
17
+ const isDarkMode = themeMode === 'dark'
18
+ || (themeMode === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
19
+
20
+ useEffect(() => {
21
+ document.documentElement.classList.toggle('dark', isDarkMode)
22
+ }, [isDarkMode])
23
+
24
+ useEffect(() => {
25
+ if (interfaceLanguage && i18n.language !== interfaceLanguage) {
26
+ void i18n.changeLanguage(interfaceLanguage)
27
+ }
28
+ }, [i18n, interfaceLanguage])
29
+
30
+ const themeConfig = useMemo(() => ({
31
+ algorithm: isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
32
+ token: {
33
+ colorPrimary: isDarkMode ? '#3b82f6' : '#000000'
34
+ }
35
+ }), [isDarkMode])
36
+
37
+ return {
38
+ isDarkMode,
39
+ themeConfig
40
+ }
41
+ }