@liujitcn/kratos-uni-app-system 0.0.62 → 0.0.65

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.
@@ -1,5 +1,9 @@
1
1
  <script setup lang="ts">
2
+ import { computed, ref } from 'vue'
2
3
  import type { AiAttachment } from '../../../../rpc/base/v1/ai_session'
4
+ import type { AiProviderModelOption } from '../../../../rpc/base/v1/ai_provider'
5
+ import { fallbackModelDisplayName } from '../modelDisplay'
6
+ import ModelPicker from './ModelPicker.vue'
3
7
 
4
8
  type InputEventValue = {
5
9
  detail: {
@@ -7,9 +11,15 @@ type InputEventValue = {
7
11
  }
8
12
  }
9
13
 
10
- defineProps<{
14
+ const props = defineProps<{
11
15
  modelValue: string
12
16
  attachments: AiAttachment[]
17
+ /** 当前可供选择的供应商与模型。 */
18
+ modelProviders: AiProviderModelOption[]
19
+ /** 当前选中的供应商编号和模型名称。 */
20
+ providerId: number
21
+ modelName: string
22
+ modelPlaceholder: string
13
23
  placeholder: string
14
24
  bottom: string
15
25
  recording: boolean
@@ -23,15 +33,45 @@ const emit = defineEmits<{
23
33
  record: []
24
34
  send: []
25
35
  'remove-attachment': [attachment: AiAttachment]
36
+ 'model-change': [providerId: number, modelName: string]
26
37
  }>()
27
38
 
39
+ const showModelPicker = ref(false)
40
+
41
+ const selectedModelDisplayName = computed(() => {
42
+ const provider = props.modelProviders.find((item) => item.provider_id === props.providerId)
43
+ const matched = provider?.models.find((model) => model.model_name === props.modelName)
44
+ return matched?.display_name || fallbackModelDisplayName(props.modelName)
45
+ })
46
+
28
47
  function handleInput(event: Event) {
29
48
  emit('update:modelValue', ((event as unknown as InputEventValue).detail?.value || '').toString())
30
49
  }
50
+
51
+ function openModelPicker() {
52
+ if (props.sending || !props.modelProviders.length) {
53
+ return
54
+ }
55
+ showModelPicker.value = true
56
+ }
57
+
58
+ function handleModelSelect(providerId: number, modelName: string) {
59
+ showModelPicker.value = false
60
+ emit('model-change', providerId, modelName)
61
+ }
31
62
  </script>
32
63
 
33
64
  <template>
34
65
  <view class="composer" :style="{ paddingBottom: bottom }">
66
+ <view
67
+ class="composer-model-chip"
68
+ :class="{ 'is-disabled': sending || !modelProviders.length }"
69
+ @tap="openModelPicker"
70
+ >
71
+ <text class="composer-model-chip__spark">✦</text>
72
+ <text class="composer-model-chip__label">{{ selectedModelDisplayName || modelPlaceholder }}</text>
73
+ <uni-icons type="down" size="12" color="#8a8f99" />
74
+ </view>
35
75
  <view class="composer-main">
36
76
  <button class="attach-button" hover-class="none" @tap="emit('attach')">
37
77
  <uni-icons type="plusempty" size="30" color="#111" />
@@ -75,6 +115,15 @@ function handleInput(event: Event) {
75
115
  <uni-icons type="paperplane" size="28" :color="disabled ? '#111' : '#00a96b'" />
76
116
  </button>
77
117
  </view>
118
+
119
+ <ModelPicker
120
+ v-if="showModelPicker"
121
+ :providers="modelProviders"
122
+ :provider-id="providerId"
123
+ :model-name="modelName"
124
+ @select="handleModelSelect"
125
+ @close="showModelPicker = false"
126
+ />
78
127
  </view>
79
128
  </template>
80
129
 
@@ -114,6 +163,44 @@ function handleInput(event: Event) {
114
163
  box-sizing: border-box;
115
164
  }
116
165
 
166
+ .composer-model-chip {
167
+ display: inline-flex;
168
+ align-items: center;
169
+ gap: 10rpx;
170
+ max-width: 100%;
171
+ height: 56rpx;
172
+ padding: 0 24rpx;
173
+ margin: 0 8rpx 12rpx;
174
+ color: #1f2937;
175
+ background: #fff;
176
+ border: 2rpx solid #d6dae2;
177
+ border-radius: 28rpx;
178
+ box-shadow: 0 4rpx 16rpx rgba(15, 23, 42, 0.04);
179
+ box-sizing: border-box;
180
+ }
181
+
182
+ .composer-model-chip.is-disabled {
183
+ opacity: 0.55;
184
+ }
185
+
186
+ .composer-model-chip__spark {
187
+ flex-shrink: 0;
188
+ color: #00a96b;
189
+ font-size: 26rpx;
190
+ line-height: 26rpx;
191
+ }
192
+
193
+ .composer-model-chip__label {
194
+ max-width: 320rpx;
195
+ overflow: hidden;
196
+ color: inherit;
197
+ font-size: 24rpx;
198
+ font-weight: 500;
199
+ line-height: 34rpx;
200
+ text-overflow: ellipsis;
201
+ white-space: nowrap;
202
+ }
203
+
117
204
  .attach-button {
118
205
  flex-shrink: 0;
119
206
  display: flex;
@@ -0,0 +1,268 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue'
3
+ import { useI18n } from '@liujitcn/kratos-uni-app-core'
4
+ import type { AiProviderModelOption } from '../../../../rpc/base/v1/ai_provider'
5
+ import { fallbackModelDisplayName } from '../modelDisplay'
6
+
7
+ type PickerModel = {
8
+ name: string
9
+ displayName: string
10
+ }
11
+
12
+ type PickerProvider = {
13
+ providerId: number
14
+ label: string
15
+ models: PickerModel[]
16
+ }
17
+
18
+ const props = defineProps<{
19
+ providers: AiProviderModelOption[]
20
+ providerId: number
21
+ modelName: string
22
+ }>()
23
+
24
+ const emit = defineEmits<{
25
+ select: [providerId: number, modelName: string]
26
+ close: []
27
+ }>()
28
+
29
+ const { t } = useI18n()
30
+
31
+ const pickerProviders = computed<PickerProvider[]>(() =>
32
+ props.providers.map((provider) => ({
33
+ providerId: provider.provider_id,
34
+ label: provider.name,
35
+ models: provider.models.map((model) => ({
36
+ name: model.model_name,
37
+ displayName: model.display_name || fallbackModelDisplayName(model.model_name),
38
+ })),
39
+ })),
40
+ )
41
+
42
+ const modelCount = computed(() =>
43
+ pickerProviders.value.reduce((total, provider) => total + provider.models.length, 0),
44
+ )
45
+
46
+ function isSelected(providerId: number, modelName: string) {
47
+ return props.providerId === providerId && props.modelName === modelName
48
+ }
49
+
50
+ function handleSelect(providerId: number, modelName: string) {
51
+ emit('select', providerId, modelName)
52
+ }
53
+ </script>
54
+
55
+ <template>
56
+ <view class="model-picker">
57
+ <view class="model-picker__mask" @tap="emit('close')" />
58
+ <view class="model-picker__sheet">
59
+ <view class="model-picker__handle" />
60
+ <view class="model-picker__head">
61
+ <text class="model-picker__title">{{ t('system.ai.model.title') }}</text>
62
+ <text class="model-picker__count">{{
63
+ t('system.ai.model.provider_model_count', {
64
+ providers: pickerProviders.length,
65
+ models: modelCount,
66
+ })
67
+ }}</text>
68
+ <button class="model-picker__close" hover-class="none" @tap="emit('close')">✕</button>
69
+ </view>
70
+ <scroll-view class="model-picker__body" scroll-y :show-scrollbar="true">
71
+ <block v-for="provider in pickerProviders" :key="provider.providerId">
72
+ <view class="model-picker__group">{{ provider.label }}</view>
73
+ <view
74
+ v-for="model in provider.models"
75
+ :key="model.name"
76
+ class="model-picker__row"
77
+ :class="{ 'is-selected': isSelected(provider.providerId, model.name) }"
78
+ @tap="handleSelect(provider.providerId, model.name)"
79
+ >
80
+ <view class="model-picker__row-main">
81
+ <text class="model-picker__row-name">{{ model.displayName }}</text>
82
+ <text v-if="model.displayName !== model.name" class="model-picker__row-raw">{{
83
+ model.name
84
+ }}</text>
85
+ </view>
86
+ <text v-if="isSelected(provider.providerId, model.name)" class="model-picker__row-check"
87
+ >✓</text
88
+ >
89
+ </view>
90
+ </block>
91
+ <view v-if="!pickerProviders.length" class="model-picker__empty">{{
92
+ t('system.ai.model.empty')
93
+ }}</view>
94
+ </scroll-view>
95
+ <view class="model-picker__safe" />
96
+ </view>
97
+ </view>
98
+ </template>
99
+
100
+ <style lang="scss" scoped>
101
+ .model-picker {
102
+ position: fixed;
103
+ inset: 0;
104
+ /* 高于 KratosTabBar 的 z-index:999,避免弹层被底部导航遮挡 */
105
+ z-index: 1000;
106
+ }
107
+
108
+ .model-picker__mask {
109
+ position: absolute;
110
+ inset: 0;
111
+ background-color: rgba(15, 23, 42, 0.45);
112
+ }
113
+
114
+ .model-picker__sheet {
115
+ position: absolute;
116
+ right: 0;
117
+ bottom: 0;
118
+ left: 0;
119
+ display: flex;
120
+ flex-direction: column;
121
+ max-height: 68vh;
122
+ overflow: hidden;
123
+ background-color: #fff;
124
+ border-radius: 32rpx 32rpx 0 0;
125
+ box-shadow: 0 -24rpx 80rpx rgba(15, 23, 42, 0.16);
126
+ box-sizing: border-box;
127
+ }
128
+
129
+ .model-picker__handle {
130
+ flex-shrink: 0;
131
+ width: 72rpx;
132
+ height: 8rpx;
133
+ margin: 16rpx auto 8rpx;
134
+ background-color: #e2e5ea;
135
+ border-radius: 4rpx;
136
+ }
137
+
138
+ .model-picker__head {
139
+ position: relative;
140
+ flex-shrink: 0;
141
+ display: flex;
142
+ align-items: baseline;
143
+ justify-content: center;
144
+ gap: 12rpx;
145
+ padding: 0 96rpx 16rpx;
146
+ }
147
+
148
+ .model-picker__title {
149
+ color: #111;
150
+ font-size: 32rpx;
151
+ font-weight: 600;
152
+ }
153
+
154
+ .model-picker__count {
155
+ color: #8a8f99;
156
+ font-size: 20rpx;
157
+ }
158
+
159
+ .model-picker__close {
160
+ position: absolute;
161
+ top: -6rpx;
162
+ right: 24rpx;
163
+ display: flex;
164
+ align-items: center;
165
+ justify-content: center;
166
+ width: 56rpx;
167
+ height: 56rpx;
168
+ padding: 0;
169
+ margin: 0;
170
+ color: #8a8f99;
171
+ font-size: 28rpx;
172
+ line-height: normal;
173
+ background: transparent;
174
+ border-radius: 0;
175
+ }
176
+
177
+ .model-picker__close::after {
178
+ border: 0;
179
+ }
180
+
181
+ .model-picker__body {
182
+ flex: 1;
183
+ min-height: 0;
184
+ max-height: calc(68vh - 130rpx - env(safe-area-inset-bottom));
185
+ padding: 0 32rpx;
186
+ box-sizing: border-box;
187
+ }
188
+
189
+ /* #ifdef H5 */
190
+ .model-picker__body :deep(::-webkit-scrollbar) {
191
+ width: 8rpx;
192
+ height: 8rpx;
193
+ }
194
+
195
+ .model-picker__body :deep(::-webkit-scrollbar-thumb) {
196
+ background-color: #c9ced6;
197
+ border-radius: 4rpx;
198
+ }
199
+
200
+ .model-picker__body :deep(::-webkit-scrollbar-track) {
201
+ background-color: transparent;
202
+ }
203
+ /* #endif */
204
+
205
+ .model-picker__group {
206
+ padding: 24rpx 8rpx 12rpx;
207
+ color: #8a8f99;
208
+ font-size: 22rpx;
209
+ line-height: 32rpx;
210
+ }
211
+
212
+ .model-picker__row {
213
+ display: flex;
214
+ align-items: center;
215
+ min-height: 88rpx;
216
+ padding: 16rpx 28rpx;
217
+ margin-bottom: 12rpx;
218
+ border: 2rpx solid #eef0f4;
219
+ border-radius: 24rpx;
220
+ box-sizing: border-box;
221
+ }
222
+
223
+ .model-picker__row.is-selected {
224
+ color: #0f7a62;
225
+ font-weight: 500;
226
+ background-color: #e8f8f4;
227
+ border-color: #d5efe7;
228
+ }
229
+
230
+ .model-picker__row-main {
231
+ display: flex;
232
+ flex-direction: column;
233
+ gap: 4rpx;
234
+ min-width: 0;
235
+ }
236
+
237
+ .model-picker__row-name {
238
+ color: inherit;
239
+ font-size: 28rpx;
240
+ line-height: 40rpx;
241
+ }
242
+
243
+ .model-picker__row-raw {
244
+ color: #a7acb5;
245
+ font-size: 22rpx;
246
+ font-weight: 400;
247
+ line-height: 30rpx;
248
+ }
249
+
250
+ .model-picker__row-check {
251
+ margin-left: auto;
252
+ color: #00a96b;
253
+ font-size: 32rpx;
254
+ }
255
+
256
+ .model-picker__empty {
257
+ padding: 64rpx 0;
258
+ color: #8a8f99;
259
+ font-size: 26rpx;
260
+ text-align: center;
261
+ }
262
+
263
+ .model-picker__safe {
264
+ flex-shrink: 0;
265
+ height: calc(18rpx + constant(safe-area-inset-bottom));
266
+ height: calc(18rpx + env(safe-area-inset-bottom));
267
+ }
268
+ </style>
@@ -140,7 +140,8 @@ function formatSessionTime(session: AiSession) {
140
140
  right: 0;
141
141
  bottom: 0;
142
142
  left: 560rpx;
143
- z-index: 20;
143
+ /* 高于 KratosTabBar 的 z-index:999,避免抽屉被底部导航遮挡 */
144
+ z-index: 1000;
144
145
  background-color: rgba(0, 0, 0, 0.18);
145
146
  }
146
147
 
@@ -151,7 +152,7 @@ function formatSessionTime(session: AiSession) {
151
152
  top: 0;
152
153
  left: 0;
153
154
  bottom: 0;
154
- z-index: 21;
155
+ z-index: 1001;
155
156
  width: 560rpx;
156
157
  padding: 0 24rpx 36rpx;
157
158
  background-color: #fff;
@@ -7,13 +7,16 @@ import {
7
7
  } from '../../../api/base/v1/ai_message'
8
8
  import { defAiSessionService } from '../../../api/base/v1/ai_session'
9
9
  import { defAiToolService } from '../../../api/base/v1/ai_tool'
10
+ import { defAiModelService } from '../../../api/base/v1/ai_provider'
10
11
  import type { AiMessage } from '../../../rpc/base/v1/ai_session'
11
12
  import type { AiAttachment, AiSession } from '../../../rpc/base/v1/ai_session'
13
+ import type { AiProviderModelOption } from '../../../rpc/base/v1/ai_provider'
12
14
  import type { AiShortcut, AiToolCall } from '../../../rpc/base/v1/ai_tool'
13
15
  import { AiMessageStatus } from '../../../rpc/base/v1/ai_session'
14
16
  import { Terminal } from '../../../rpc/base/v1/ai_tool'
15
17
  import { uploadFile } from '@liujitcn/kratos-uni-app-core/utils/file'
16
18
  import { formatSrc } from '@liujitcn/kratos-uni-app-core/utils/index'
19
+ import { isAuthExpiredError } from '@liujitcn/kratos-uni-app-core/utils/http'
17
20
  import Composer from './components/Composer.vue'
18
21
  import SessionDrawer from './components/SessionDrawer.vue'
19
22
  import WelcomePanel from './components/WelcomePanel.vue'
@@ -25,6 +28,8 @@ import {
25
28
  parseAiEventStreamText,
26
29
  readAiEventStream,
27
30
  } from './stream'
31
+ import { resolveModelDisplayName } from './modelDisplay'
32
+ import { loadStoredModelSelection, saveModelSelection } from './modelSelection'
28
33
 
29
34
  type ChatRole = 'user' | 'ai'
30
35
 
@@ -92,6 +97,9 @@ const chatBottomAnchor = ref('')
92
97
  const sessions = ref<AiSession[]>([])
93
98
  const messages = ref<Record<string, ChatMessageItem[]>>({})
94
99
  const selectedAttachments = ref<AiAttachment[]>([])
100
+ const modelProviders = ref<AiProviderModelOption[]>([])
101
+ const selectedProviderId = ref(0)
102
+ const selectedModelName = ref('')
95
103
  const runningStreamTaskMap = new Map<string, StreamTask>()
96
104
  const pendingDeltaMap = new Map<string, AiStreamPayload>()
97
105
  let pendingDeltaTimer = 0
@@ -464,21 +472,54 @@ async function loadAiShortcuts() {
464
472
  }
465
473
  loadingShortcuts.value = true
466
474
  try {
467
- const response = await defAiToolService.ListAiShortcut({
468
- terminal: AI_TERMINAL,
469
- })
475
+ const [response, providerResponse] = await Promise.all([
476
+ defAiToolService.ListAiShortcut({ terminal: AI_TERMINAL }),
477
+ defAiModelService.ListAiProviderModelOptions({}),
478
+ ])
479
+ modelProviders.value = providerResponse.providers ?? []
480
+ applyModelSelection()
470
481
  const shortcuts = normalizeStarterShortcuts(response.shortcuts).filter((item) => !item.action)
471
482
  if (shortcuts.length) {
472
483
  starterShortcuts.value = shortcuts
473
484
  starterPromptGroupIndex.value = 0
474
485
  }
475
486
  } catch (error) {
487
+ modelProviders.value = []
488
+ selectedProviderId.value = 0
489
+ selectedModelName.value = ''
476
490
  showError(error, t('system.ai.load_shortcuts_failed'))
477
491
  } finally {
478
492
  loadingShortcuts.value = false
479
493
  }
480
494
  }
481
495
 
496
+ /** 优先恢复本地缓存的模型选择,缓存失效时回退到首个可用模型。 */
497
+ function applyModelSelection() {
498
+ const stored = loadStoredModelSelection()
499
+ const provider = stored
500
+ ? modelProviders.value.find(
501
+ (item) =>
502
+ item.provider_id === stored.provider_id &&
503
+ item.models.some((model) => model.model_name === stored.model_name),
504
+ )
505
+ : undefined
506
+ if (provider && stored) {
507
+ selectedProviderId.value = provider.provider_id
508
+ selectedModelName.value = stored.model_name
509
+ return
510
+ }
511
+ const firstProvider = modelProviders.value[0]
512
+ selectedProviderId.value = firstProvider?.provider_id ?? 0
513
+ selectedModelName.value = firstProvider?.models[0]?.model_name ?? ''
514
+ }
515
+
516
+ /** 更新当前聊天使用的供应商和模型并持久化到本地缓存。 */
517
+ function handleModelChange(providerId: number, modelName: string) {
518
+ selectedProviderId.value = providerId
519
+ selectedModelName.value = modelName
520
+ saveModelSelection({ provider_id: providerId, model_name: modelName })
521
+ }
522
+
482
523
  async function sendAiPayload(payload: { text: string; attachments: AiAttachment[] }) {
483
524
  const sessionID = await ensureActiveSession()
484
525
  if (!sessionID || isSessionSending(sessionID)) {
@@ -507,6 +548,8 @@ async function runAiTask(
507
548
  const request = {
508
549
  session_id: sessionID,
509
550
  content: payload.text,
551
+ provider_id: selectedProviderId.value,
552
+ model_name: selectedModelName.value,
510
553
  attachments: payload.attachments,
511
554
  action: undefined,
512
555
  }
@@ -897,6 +940,7 @@ async function waitForMessagesLoaded(sessionID: string) {
897
940
  function normalizeSession(session?: Partial<AiSession> | null): AiSession {
898
941
  return {
899
942
  id: String(session?.id ?? ''),
943
+ tenant_id: Number(session?.tenant_id ?? 0),
900
944
  title: String(session?.title ?? t('system.ai.new_session')),
901
945
  summary: String(session?.summary ?? ''),
902
946
  updated_at: session?.updated_at,
@@ -989,6 +1033,7 @@ function createLocalUserMessage(payload: { text: string; attachments: AiAttachme
989
1033
  const message = mapMessageItem(
990
1034
  {
991
1035
  id: `${LOCAL_USER_MESSAGE_PREFIX}-${now}`,
1036
+ tenant_id: 0,
992
1037
  input_content: { kind: 'text', content: payload.text },
993
1038
  output_content: undefined,
994
1039
  attachments: payload.attachments,
@@ -1017,6 +1062,7 @@ function createThinkingMessage(options?: { sessionID?: string; messageID?: strin
1017
1062
  const message = mapMessageItem(
1018
1063
  {
1019
1064
  id: streamKey || `ai-thinking-${now}`,
1065
+ tenant_id: 0,
1020
1066
  input_content: undefined,
1021
1067
  output_content: {
1022
1068
  kind: 'text',
@@ -1184,12 +1230,16 @@ function formatAttachmentMeta(attachment: AiAttachment) {
1184
1230
 
1185
1231
  function formatTools(tools: AiToolCall[]) {
1186
1232
  return tools
1187
- .map((item) => item.title || item.name)
1233
+ .map((item) =>
1234
+ item.name === 'web_search' ? t('system.ai.web_search') : item.title || item.name,
1235
+ )
1188
1236
  .filter(Boolean)
1189
1237
  .join(' · ')
1190
1238
  }
1191
1239
 
1192
1240
  function showError(error: unknown, fallback: string) {
1241
+ // 登录态失效时请求层已弹窗引导重新登录,不再叠加失败提示。
1242
+ if (isAuthExpiredError(error)) return
1193
1243
  const message = error instanceof Error ? error.message : fallback
1194
1244
  uni.showToast({ icon: 'none', title: message || fallback })
1195
1245
  }
@@ -1243,7 +1293,7 @@ function showError(error: unknown, fallback: string) {
1243
1293
  >
1244
1294
  <view v-if="item.role === 'ai' && item.model" class="reply-meta">
1245
1295
  <text class="reply-tag">{{ t('system.ai.model_reply') }}</text>
1246
- <text class="reply-model">{{ item.model }}</text>
1296
+ <text class="reply-model">{{ resolveModelDisplayName(modelProviders, item.model) }}</text>
1247
1297
  </view>
1248
1298
  <view class="bubble-content">{{ item.content }}</view>
1249
1299
  <view v-if="item.attachments.length" class="attachment-list">
@@ -1279,6 +1329,10 @@ function showError(error: unknown, fallback: string) {
1279
1329
  <Composer
1280
1330
  v-model="inputText"
1281
1331
  :attachments="selectedAttachments"
1332
+ :model-providers="modelProviders"
1333
+ :provider-id="selectedProviderId"
1334
+ :model-name="selectedModelName"
1335
+ :model-placeholder="t('system.ai.model.model_placeholder')"
1282
1336
  :placeholder="composerPlaceholder"
1283
1337
  :bottom="composerBottom"
1284
1338
  :recording="isRecording"
@@ -1287,6 +1341,7 @@ function showError(error: unknown, fallback: string) {
1287
1341
  @attach="handleAttachment"
1288
1342
  @record="handleToggleRecord"
1289
1343
  @send="handleSend"
1344
+ @model-change="handleModelChange"
1290
1345
  @remove-attachment="removeSelectedAttachment"
1291
1346
  />
1292
1347
 
@@ -0,0 +1,77 @@
1
+ import type { AiProviderModelOption } from '../../../rpc/base/v1/ai_provider'
2
+
3
+ /** 常见模型的展示名映射;未命中时按通用规则美化原始名。 */
4
+ const MODEL_DISPLAY_NAMES: Record<string, string> = {
5
+ 'deepseek-chat': 'DeepSeek Chat',
6
+ 'deepseek-reasoner': 'DeepSeek Reasoner',
7
+ 'deepseek-v3': 'DeepSeek V3',
8
+ 'deepseek-r1': 'DeepSeek R1',
9
+ 'gpt-4o': 'GPT-4o',
10
+ 'gpt-4o-mini': 'GPT-4o mini',
11
+ 'gpt-4.1': 'GPT-4.1',
12
+ 'gpt-4.1-mini': 'GPT-4.1 mini',
13
+ 'gpt-4-turbo': 'GPT-4 Turbo',
14
+ 'o3-mini': 'O3-mini',
15
+ 'claude-3-5-sonnet': 'Claude 3.5 Sonnet',
16
+ 'claude-3-7-sonnet': 'Claude 3.7 Sonnet',
17
+ 'qwen-max': 'Qwen Max',
18
+ 'qwen-plus': 'Qwen Plus',
19
+ }
20
+
21
+ /** 需要整体大写的模型名片段。 */
22
+ const UPPERCASE_TOKENS = new Set([
23
+ 'gpt',
24
+ 'llama',
25
+ 'qwen',
26
+ 'glm',
27
+ 'kimi',
28
+ 'gemini',
29
+ 'claude',
30
+ 'doubao',
31
+ 'deepseek',
32
+ 'moonshot',
33
+ 'hunyuan',
34
+ ])
35
+
36
+ /** 对未配置展示名的模型名做通用美化。 */
37
+ export function fallbackModelDisplayName(modelName: string): string {
38
+ if (!modelName) {
39
+ return ''
40
+ }
41
+ const mapped = MODEL_DISPLAY_NAMES[modelName.toLowerCase()]
42
+ if (mapped) {
43
+ return mapped
44
+ }
45
+ return modelName
46
+ .split(/[-_]/)
47
+ .filter(Boolean)
48
+ .map((token) => {
49
+ const lower = token.toLowerCase()
50
+ if (UPPERCASE_TOKENS.has(lower)) {
51
+ return lower.toUpperCase()
52
+ }
53
+ return lower.charAt(0).toUpperCase() + lower.slice(1)
54
+ })
55
+ .join(' ')
56
+ }
57
+
58
+ /** 从供应商模型选项中解析模型展示名称,兼容历史消息中「供应商 / 模型」格式的原始值。 */
59
+ export function resolveModelDisplayName(
60
+ providers: AiProviderModelOption[] | undefined,
61
+ modelName: string,
62
+ ): string {
63
+ if (!modelName) {
64
+ return ''
65
+ }
66
+ const separator = ' / '
67
+ const modelPart = modelName.includes(separator)
68
+ ? modelName.slice(modelName.lastIndexOf(separator) + separator.length)
69
+ : modelName
70
+ for (const provider of providers ?? []) {
71
+ const matched = provider.models.find((item) => item.model_name === modelPart)
72
+ if (matched?.display_name) {
73
+ return matched.display_name
74
+ }
75
+ }
76
+ return fallbackModelDisplayName(modelPart)
77
+ }
@@ -0,0 +1,38 @@
1
+ /** 用户最近一次选择的供应商与模型,仅保存在设备本地。 */
2
+ export interface AiModelSelection {
3
+ provider_id: number
4
+ model_name: string
5
+ }
6
+
7
+ const MODEL_SELECTION_STORAGE_KEY = 'kratos-uni-app:ai:model-selection'
8
+
9
+ /** 读取本地缓存的模型选择,缓存缺失或损坏时返回 undefined。 */
10
+ export function loadStoredModelSelection(): AiModelSelection | undefined {
11
+ try {
12
+ const raw = uni.getStorageSync(MODEL_SELECTION_STORAGE_KEY) as unknown
13
+ if (!raw) {
14
+ return undefined
15
+ }
16
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
17
+ const value = parsed as Partial<AiModelSelection>
18
+ if (
19
+ typeof value.provider_id === 'number' &&
20
+ typeof value.model_name === 'string' &&
21
+ value.model_name
22
+ ) {
23
+ return { provider_id: value.provider_id, model_name: value.model_name }
24
+ }
25
+ } catch {
26
+ // 缓存损坏或存储不可用时忽略,回退到默认选择。
27
+ }
28
+ return undefined
29
+ }
30
+
31
+ /** 持久化模型选择,存储不可用时静默失败,选择仍对当前会话生效。 */
32
+ export function saveModelSelection(selection: AiModelSelection): void {
33
+ try {
34
+ uni.setStorageSync(MODEL_SELECTION_STORAGE_KEY, JSON.stringify(selection))
35
+ } catch {
36
+ // 忽略存储失败,不影响当前会话内的选择。
37
+ }
38
+ }