@liujitcn/kratos-taro-app-system 0.0.64 → 0.0.66

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.
package/dist/module.mjs CHANGED
@@ -167,7 +167,10 @@ var zh_CN_default = {
167
167
  "system.notification.action.delete": "\u5220\u9664",
168
168
  "system.ai.model.provider_placeholder": "\u9009\u62E9\u4F9B\u5E94\u5546",
169
169
  "system.ai.model.model_placeholder": "\u9009\u62E9\u6A21\u578B",
170
- "system.ai.model.load_failed": "\u52A0\u8F7D\u4F9B\u5E94\u5546\u548C\u6A21\u578B\u5931\u8D25"
170
+ "system.ai.model.load_failed": "\u52A0\u8F7D\u4F9B\u5E94\u5546\u548C\u6A21\u578B\u5931\u8D25",
171
+ "system.ai.model.title": "\u9009\u62E9\u6A21\u578B",
172
+ "system.ai.model.provider_model_count": "{providers} \u4E2A\u4F9B\u5E94\u5546 \xB7 {models} \u4E2A\u6A21\u578B",
173
+ "system.ai.model.empty": "\u6682\u65E0\u53EF\u9009\u6A21\u578B"
171
174
  };
172
175
 
173
176
  // src/locales/en-US.json
@@ -304,7 +307,10 @@ var en_US_default = {
304
307
  "system.notification.action.delete": "Delete",
305
308
  "system.ai.model.provider_placeholder": "Select a provider",
306
309
  "system.ai.model.model_placeholder": "Select a model",
307
- "system.ai.model.load_failed": "Failed to load providers and models"
310
+ "system.ai.model.load_failed": "Failed to load providers and models",
311
+ "system.ai.model.title": "Select Model",
312
+ "system.ai.model.provider_model_count": "{providers} providers \xB7 {models} models",
313
+ "system.ai.model.empty": "No models available"
308
314
  };
309
315
 
310
316
  // src/locales/ja-JP.json
@@ -441,7 +447,10 @@ var ja_JP_default = {
441
447
  "system.notification.action.delete": "\u524A\u9664",
442
448
  "system.ai.model.provider_placeholder": "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u3092\u9078\u629E",
443
449
  "system.ai.model.model_placeholder": "\u30E2\u30C7\u30EB\u3092\u9078\u629E",
444
- "system.ai.model.load_failed": "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u3068\u30E2\u30C7\u30EB\u3092\u8AAD\u307F\u8FBC\u3081\u307E\u305B\u3093\u3067\u3057\u305F"
450
+ "system.ai.model.load_failed": "\u30D7\u30ED\u30D0\u30A4\u30C0\u30FC\u3068\u30E2\u30C7\u30EB\u3092\u8AAD\u307F\u8FBC\u3081\u307E\u305B\u3093\u3067\u3057\u305F",
451
+ "system.ai.model.title": "\u30E2\u30C7\u30EB\u3092\u9078\u629E",
452
+ "system.ai.model.provider_model_count": "{providers} \u30D7\u30ED\u30D0\u30A4\u30C0\u30FC \xB7 {models} \u30E2\u30C7\u30EB",
453
+ "system.ai.model.empty": "\u9078\u629E\u53EF\u80FD\u306A\u30E2\u30C7\u30EB\u304C\u3042\u308A\u307E\u305B\u3093"
445
454
  };
446
455
 
447
456
  // src/locales/zh-TW.json
@@ -578,7 +587,10 @@ var zh_TW_default = {
578
587
  "system.notification.action.delete": "\u522A\u9664",
579
588
  "system.ai.model.provider_placeholder": "\u9078\u64C7\u4F9B\u61C9\u5546",
580
589
  "system.ai.model.model_placeholder": "\u9078\u64C7\u6A21\u578B",
581
- "system.ai.model.load_failed": "\u8F09\u5165\u4F9B\u61C9\u5546\u8207\u6A21\u578B\u5931\u6557"
590
+ "system.ai.model.load_failed": "\u8F09\u5165\u4F9B\u61C9\u5546\u8207\u6A21\u578B\u5931\u6557",
591
+ "system.ai.model.title": "\u9078\u64C7\u6A21\u578B",
592
+ "system.ai.model.provider_model_count": "{providers} \u500B\u4F9B\u61C9\u5546 \xB7 {models} \u500B\u6A21\u578B",
593
+ "system.ai.model.empty": "\u66AB\u7121\u53EF\u9078\u6A21\u578B"
582
594
  };
583
595
 
584
596
  // src/locales/generated.ts
package/package.json CHANGED
@@ -7,7 +7,7 @@
7
7
  "type": "git",
8
8
  "url": "git@github.com:liujitcn/kratos-admin.git"
9
9
  },
10
- "version": "0.0.64",
10
+ "version": "0.0.66",
11
11
  "type": "module",
12
12
  "files": [
13
13
  "dist",
@@ -38,8 +38,8 @@
38
38
  "@tarojs/components": "4.2.1",
39
39
  "@tarojs/taro": "4.2.1",
40
40
  "react": "18.3.1",
41
- "@liujitcn/kratos-taro-app-ui": "^0.0.64",
42
- "@liujitcn/kratos-taro-app-core": "^0.0.64"
41
+ "@liujitcn/kratos-taro-app-core": "^0.0.66",
42
+ "@liujitcn/kratos-taro-app-ui": "^0.0.66"
43
43
  },
44
44
  "scripts": {
45
45
  "build": "pnpm run build:entries && pnpm pack --pack-destination ../../../dist/npm",
@@ -1,4 +1,5 @@
1
1
  import {
2
+ AuthPromptError,
2
3
  getRequestAccessToken,
3
4
  handleAuthExpired,
4
5
  http,
@@ -94,7 +95,7 @@ export async function SendAiMessageStream(
94
95
  // direct stream 不经过 uni.request 拦截器,需要在这里补齐登录失效处理。
95
96
  if (response.status === 401 || response.status === 403) {
96
97
  handleAuthExpired('required')
97
- throw new Error(t('core.auth.session_expired'))
98
+ throw new AuthPromptError()
98
99
  }
99
100
  if (!response.ok) {
100
101
  throw new Error(await resolveStreamErrorMessage(response))
@@ -142,7 +143,7 @@ export function StreamAiMessageByChunkedRequest(
142
143
  }
143
144
  if (res.statusCode === 401 || res.statusCode === 403) {
144
145
  handleAuthExpired('required')
145
- reject(new Error(t('core.auth.session_expired')))
146
+ reject(new AuthPromptError())
146
147
  return
147
148
  }
148
149
  if (res.statusCode < 200 || res.statusCode >= 300) {
@@ -131,5 +131,8 @@
131
131
  "system.notification.action.delete": "Delete",
132
132
  "system.ai.model.provider_placeholder": "Select a provider",
133
133
  "system.ai.model.model_placeholder": "Select a model",
134
- "system.ai.model.load_failed": "Failed to load providers and models"
134
+ "system.ai.model.load_failed": "Failed to load providers and models",
135
+ "system.ai.model.title": "Select Model",
136
+ "system.ai.model.provider_model_count": "{providers} providers · {models} models",
137
+ "system.ai.model.empty": "No models available"
135
138
  }
@@ -131,5 +131,8 @@
131
131
  "system.notification.action.delete": "削除",
132
132
  "system.ai.model.provider_placeholder": "プロバイダーを選択",
133
133
  "system.ai.model.model_placeholder": "モデルを選択",
134
- "system.ai.model.load_failed": "プロバイダーとモデルを読み込めませんでした"
134
+ "system.ai.model.load_failed": "プロバイダーとモデルを読み込めませんでした",
135
+ "system.ai.model.title": "モデルを選択",
136
+ "system.ai.model.provider_model_count": "{providers} プロバイダー · {models} モデル",
137
+ "system.ai.model.empty": "選択可能なモデルがありません"
135
138
  }
@@ -131,5 +131,8 @@
131
131
  "system.notification.action.delete": "删除",
132
132
  "system.ai.model.provider_placeholder": "选择供应商",
133
133
  "system.ai.model.model_placeholder": "选择模型",
134
- "system.ai.model.load_failed": "加载供应商和模型失败"
134
+ "system.ai.model.load_failed": "加载供应商和模型失败",
135
+ "system.ai.model.title": "选择模型",
136
+ "system.ai.model.provider_model_count": "{providers} 个供应商 · {models} 个模型",
137
+ "system.ai.model.empty": "暂无可选模型"
135
138
  }
@@ -131,5 +131,8 @@
131
131
  "system.notification.action.delete": "刪除",
132
132
  "system.ai.model.provider_placeholder": "選擇供應商",
133
133
  "system.ai.model.model_placeholder": "選擇模型",
134
- "system.ai.model.load_failed": "載入供應商與模型失敗"
134
+ "system.ai.model.load_failed": "載入供應商與模型失敗",
135
+ "system.ai.model.title": "選擇模型",
136
+ "system.ai.model.provider_model_count": "{providers} 個供應商 · {models} 個模型",
137
+ "system.ai.model.empty": "暫無可選模型"
135
138
  }
@@ -10,20 +10,28 @@
10
10
  export interface ListAiProviderModelOptionsRequest {
11
11
  }
12
12
 
13
+ /** AI Provider模型选项查询响应。 */
14
+ export interface ListAiProviderModelOptionsResponse {
15
+ /** Provider及其模型列表 */
16
+ providers: AiProviderModelOption[];
17
+ }
18
+
13
19
  /** 已启用的AI Provider及其模型选项。 */
14
20
  export interface AiProviderModelOption {
15
21
  /** Provider ID */
16
22
  provider_id: number;
17
23
  /** Provider名称 */
18
24
  name: string;
19
- /** 模型名称列表 */
20
- models: string[];
25
+ /** 模型选项列表 */
26
+ models: AiProviderModel[];
21
27
  }
22
28
 
23
- /** AI Provider模型选项查询响应。 */
24
- export interface ListAiProviderModelOptionsResponse {
25
- /** Provider及其模型列表 */
26
- providers: AiProviderModelOption[];
29
+ /** AI Provider下的单个模型选项。 */
30
+ export interface AiProviderModel {
31
+ /** 模型名称 */
32
+ model_name: string;
33
+ /** 展示名称 */
34
+ display_name: string;
27
35
  }
28
36
 
29
37
  /** AI Provider 可选模型查询服务。 */
@@ -1,7 +1,10 @@
1
- import { Button, Picker, Text, Textarea, View } from '@tarojs/components'
1
+ import { Button, Text, Textarea, View } from '@tarojs/components'
2
+ import { useMemo, useState } from 'react'
2
3
  import { UniIcon } from '@liujitcn/kratos-taro-app-ui'
3
4
  import type { AiAttachment } from '../../../../rpc/base/v1/ai_session'
4
5
  import type { AiProviderModelOption } from '../../../../rpc/base/v1/ai_provider'
6
+ import { fallbackModelDisplayName } from '../modelDisplay'
7
+ import ModelPicker from './ModelPicker'
5
8
  import './composer.scss'
6
9
 
7
10
  /** AI 助手输入框与模型选择器参数。 */
@@ -14,7 +17,6 @@ type ComposerProps = {
14
17
  providerId: number
15
18
  modelName: string
16
19
  /** 选择器未配置时显示的本地化占位文字。 */
17
- providerPlaceholder: string
18
20
  modelPlaceholder: string
19
21
  placeholder: string
20
22
  bottom: string
@@ -31,35 +33,37 @@ type ComposerProps = {
31
33
 
32
34
  /** AI 助手消息输入器。 */
33
35
  export default function Composer(props: ComposerProps) {
34
- const selectedProvider = props.modelProviders.find((provider) => provider.provider_id === props.providerId)
35
- const modelNames = selectedProvider?.models ?? []
36
- const providerIndex = Math.max(props.modelProviders.findIndex((provider) => provider.provider_id === props.providerId), 0)
37
- const modelIndex = Math.max(modelNames.indexOf(props.modelName), 0)
36
+ const [showModelPicker, setShowModelPicker] = useState(false)
37
+
38
+ const selectedModelDisplayName = useMemo(() => {
39
+ const provider = props.modelProviders.find((item) => item.provider_id === props.providerId)
40
+ const matched = provider?.models.find((model) => model.model_name === props.modelName)
41
+ return matched?.display_name || fallbackModelDisplayName(props.modelName)
42
+ }, [props.modelProviders, props.providerId, props.modelName])
43
+
44
+ const openModelPicker = () => {
45
+ if (props.sending || !props.modelProviders.length) {
46
+ return
47
+ }
48
+ setShowModelPicker(true)
49
+ }
50
+
51
+ const handleModelSelect = (providerId: number, modelName: string) => {
52
+ setShowModelPicker(false)
53
+ props.onModelChange(providerId, modelName)
54
+ }
38
55
 
39
56
  return (
40
57
  <View className='composer' style={{ paddingBottom: props.bottom }}>
41
- <View className='composer-model-selectors'>
42
- <Picker
43
- mode='selector'
44
- range={props.modelProviders.map((provider) => provider.name)}
45
- value={providerIndex}
46
- disabled={props.sending || !props.modelProviders.length}
47
- onChange={(event) => {
48
- const provider = props.modelProviders[Number(event.detail.value)]
49
- props.onModelChange(provider?.provider_id ?? 0, provider?.models[0] ?? '')
50
- }}
51
- >
52
- <View className='composer-model-select'><Text>{selectedProvider?.name || props.providerPlaceholder}</Text></View>
53
- </Picker>
54
- <Picker
55
- mode='selector'
56
- range={modelNames}
57
- value={modelIndex}
58
- disabled={props.sending || !modelNames.length}
59
- onChange={(event) => props.onModelChange(props.providerId, modelNames[Number(event.detail.value)] ?? '')}
60
- >
61
- <View className='composer-model-select'><Text>{props.modelName || props.modelPlaceholder}</Text></View>
62
- </Picker>
58
+ <View
59
+ className={`composer-model-chip${props.sending || !props.modelProviders.length ? ' is-disabled' : ''}`}
60
+ onClick={openModelPicker}
61
+ >
62
+ <Text className='composer-model-chip__spark'>✦</Text>
63
+ <Text className='composer-model-chip__label'>
64
+ {selectedModelDisplayName || props.modelPlaceholder}
65
+ </Text>
66
+ <Text className='composer-model-chip__chevron'>▾</Text>
63
67
  </View>
64
68
  <View className='composer-main'>
65
69
  <Button className='attach-button' hoverClass='none' onClick={props.onAttach}>
@@ -105,6 +109,16 @@ export default function Composer(props: ComposerProps) {
105
109
  <UniIcon type='paperplane' size={28} color={props.disabled ? '#111' : '#00a96b'} />
106
110
  </Button>
107
111
  </View>
112
+
113
+ {showModelPicker ? (
114
+ <ModelPicker
115
+ providers={props.modelProviders}
116
+ providerId={props.providerId}
117
+ modelName={props.modelName}
118
+ onSelect={handleModelSelect}
119
+ onClose={() => setShowModelPicker(false)}
120
+ />
121
+ ) : null}
108
122
  </View>
109
123
  )
110
124
  }
@@ -0,0 +1,95 @@
1
+ import { ScrollView, Text, View } from '@tarojs/components'
2
+ import { useMemo } from 'react'
3
+ import { t } from '@liujitcn/kratos-taro-app-core'
4
+ import type { AiProviderModelOption } from '../../../../rpc/base/v1/ai_provider'
5
+ import { fallbackModelDisplayName } from '../modelDisplay'
6
+ import './model-picker.scss'
7
+
8
+ type PickerModel = {
9
+ name: string
10
+ displayName: string
11
+ }
12
+
13
+ type PickerProvider = {
14
+ providerId: number
15
+ label: string
16
+ models: PickerModel[]
17
+ }
18
+
19
+ type ModelPickerProps = {
20
+ providers: AiProviderModelOption[]
21
+ providerId: number
22
+ modelName: string
23
+ onSelect: (providerId: number, modelName: string) => void
24
+ onClose: () => void
25
+ }
26
+
27
+ /** AI 模型选择底部弹层。 */
28
+ export default function ModelPicker(props: ModelPickerProps) {
29
+ const pickerProviders = useMemo<PickerProvider[]>(
30
+ () =>
31
+ props.providers.map((provider) => ({
32
+ providerId: provider.provider_id,
33
+ label: provider.name,
34
+ models: provider.models.map((model) => ({
35
+ name: model.model_name,
36
+ displayName: model.display_name || fallbackModelDisplayName(model.model_name),
37
+ })),
38
+ })),
39
+ [props.providers],
40
+ )
41
+
42
+ const modelCount = pickerProviders.reduce((total, provider) => total + provider.models.length, 0)
43
+
44
+ const isSelected = (providerId: number, modelName: string) =>
45
+ props.providerId === providerId && props.modelName === modelName
46
+
47
+ return (
48
+ <View className='model-picker' catchMove>
49
+ <View className='model-picker__mask' onClick={props.onClose} />
50
+ <View className='model-picker__sheet'>
51
+ <View className='model-picker__handle' />
52
+ <View className='model-picker__head'>
53
+ <Text className='model-picker__title'>{t('system.ai.model.title')}</Text>
54
+ <Text className='model-picker__count'>
55
+ {t('system.ai.model.provider_model_count', {
56
+ providers: pickerProviders.length,
57
+ models: modelCount,
58
+ })}
59
+ </Text>
60
+ <View className='model-picker__close' onClick={props.onClose}>
61
+ ✕
62
+ </View>
63
+ </View>
64
+ <ScrollView className='model-picker__body' scrollY>
65
+ {pickerProviders.map((provider) => (
66
+ <View key={provider.providerId}>
67
+ <View className='model-picker__group'>{provider.label}</View>
68
+ {provider.models.map((model) => (
69
+ <View
70
+ key={model.name}
71
+ className={`model-picker__row${isSelected(provider.providerId, model.name) ? ' is-selected' : ''}`}
72
+ onClick={() => props.onSelect(provider.providerId, model.name)}
73
+ >
74
+ <View className='model-picker__row-main'>
75
+ <Text className='model-picker__row-name'>{model.displayName}</Text>
76
+ {model.displayName !== model.name ? (
77
+ <Text className='model-picker__row-raw'>{model.name}</Text>
78
+ ) : null}
79
+ </View>
80
+ {isSelected(provider.providerId, model.name) ? (
81
+ <Text className='model-picker__row-check'>✓</Text>
82
+ ) : null}
83
+ </View>
84
+ ))}
85
+ </View>
86
+ ))}
87
+ {!pickerProviders.length ? (
88
+ <View className='model-picker__empty'>{t('system.ai.model.empty')}</View>
89
+ ) : null}
90
+ </ScrollView>
91
+ <View className='model-picker__safe' />
92
+ </View>
93
+ </View>
94
+ )
95
+ }
@@ -19,27 +19,49 @@
19
19
  box-sizing: border-box;
20
20
  }
21
21
 
22
- .composer-model-selectors {
23
- display: flex;
24
- justify-content: flex-end;
25
- gap: 12px;
26
- min-width: 0;
22
+ .composer-model-chip {
23
+ display: inline-flex;
24
+ align-items: center;
25
+ gap: 10px;
26
+ max-width: 100%;
27
+ height: 56px;
28
+ padding: 0 24px;
27
29
  margin: 0 8px 12px;
30
+ color: #1f2937;
31
+ background: #fff;
32
+ border: 2px solid #d6dae2;
33
+ border-radius: 28px;
34
+ box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04);
35
+ box-sizing: border-box;
36
+ }
37
+
38
+ .composer-model-chip.is-disabled {
39
+ opacity: 0.55;
40
+ }
41
+
42
+ .composer-model-chip__spark {
43
+ flex-shrink: 0;
44
+ color: #00a96b;
45
+ font-size: 26px;
46
+ line-height: 26px;
28
47
  }
29
48
 
30
- .composer-model-select {
31
- max-width: 300px;
32
- min-width: 120px;
33
- padding: 8px 16px;
49
+ .composer-model-chip__label {
50
+ max-width: 320px;
34
51
  overflow: hidden;
35
- color: #46515b;
36
- font-size: 22px;
37
- line-height: 32px;
52
+ color: inherit;
53
+ font-size: 24px;
54
+ font-weight: 500;
55
+ line-height: 34px;
38
56
  text-overflow: ellipsis;
39
57
  white-space: nowrap;
40
- background: #fff;
41
- border: 2px solid #d6dae2;
42
- border-radius: 8px;
58
+ }
59
+
60
+ .composer-model-chip__chevron {
61
+ flex-shrink: 0;
62
+ color: #8a8f99;
63
+ font-size: 22px;
64
+ line-height: 22px;
43
65
  }
44
66
 
45
67
  .composer-main {
@@ -0,0 +1,147 @@
1
+ .model-picker {
2
+ position: fixed;
3
+ top: 0;
4
+ right: 0;
5
+ bottom: 0;
6
+ left: 0;
7
+ z-index: 100;
8
+ }
9
+
10
+ .model-picker__mask {
11
+ position: absolute;
12
+ top: 0;
13
+ right: 0;
14
+ bottom: 0;
15
+ left: 0;
16
+ background-color: rgba(15, 23, 42, 0.45);
17
+ }
18
+
19
+ .model-picker__sheet {
20
+ position: absolute;
21
+ right: 0;
22
+ bottom: 0;
23
+ left: 0;
24
+ display: flex;
25
+ flex-direction: column;
26
+ max-height: 68vh;
27
+ overflow: hidden;
28
+ background-color: #fff;
29
+ border-radius: 32px 32px 0 0;
30
+ box-shadow: 0 -24px 80px rgba(15, 23, 42, 0.16);
31
+ box-sizing: border-box;
32
+ }
33
+
34
+ .model-picker__handle {
35
+ flex-shrink: 0;
36
+ width: 72px;
37
+ height: 8px;
38
+ margin: 16px auto 8px;
39
+ background-color: #e2e5ea;
40
+ border-radius: 4px;
41
+ }
42
+
43
+ .model-picker__head {
44
+ position: relative;
45
+ flex-shrink: 0;
46
+ display: flex;
47
+ align-items: baseline;
48
+ justify-content: center;
49
+ gap: 12px;
50
+ padding: 0 96px 16px;
51
+ }
52
+
53
+ .model-picker__title {
54
+ color: #111;
55
+ font-size: 32px;
56
+ font-weight: 600;
57
+ }
58
+
59
+ .model-picker__count {
60
+ color: #8a8f99;
61
+ font-size: 20px;
62
+ }
63
+
64
+ .model-picker__close {
65
+ position: absolute;
66
+ top: -6px;
67
+ right: 24px;
68
+ display: flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ width: 56px;
72
+ height: 56px;
73
+ color: #8a8f99;
74
+ font-size: 28px;
75
+ }
76
+
77
+ .model-picker__body {
78
+ flex: 1;
79
+ min-height: 0;
80
+ max-height: calc(68vh - 130px - env(safe-area-inset-bottom));
81
+ padding: 0 32px;
82
+ box-sizing: border-box;
83
+ }
84
+
85
+ .model-picker__group {
86
+ padding: 24px 8px 12px;
87
+ color: #8a8f99;
88
+ font-size: 22px;
89
+ line-height: 32px;
90
+ }
91
+
92
+ .model-picker__row {
93
+ display: flex;
94
+ align-items: center;
95
+ min-height: 88px;
96
+ padding: 16px 28px;
97
+ margin-bottom: 12px;
98
+ border: 2px solid #eef0f4;
99
+ border-radius: 24px;
100
+ box-sizing: border-box;
101
+ }
102
+
103
+ .model-picker__row.is-selected {
104
+ color: #0f7a62;
105
+ font-weight: 500;
106
+ background-color: #e8f8f4;
107
+ border-color: #d5efe7;
108
+ }
109
+
110
+ .model-picker__row-main {
111
+ display: flex;
112
+ flex-direction: column;
113
+ gap: 4px;
114
+ min-width: 0;
115
+ }
116
+
117
+ .model-picker__row-name {
118
+ color: inherit;
119
+ font-size: 28px;
120
+ line-height: 40px;
121
+ }
122
+
123
+ .model-picker__row-raw {
124
+ color: #a7acb5;
125
+ font-size: 22px;
126
+ font-weight: 400;
127
+ line-height: 30px;
128
+ }
129
+
130
+ .model-picker__row-check {
131
+ margin-left: auto;
132
+ color: #00a96b;
133
+ font-size: 32px;
134
+ }
135
+
136
+ .model-picker__empty {
137
+ padding: 64px 0;
138
+ color: #8a8f99;
139
+ font-size: 26px;
140
+ text-align: center;
141
+ }
142
+
143
+ .model-picker__safe {
144
+ flex-shrink: 0;
145
+ height: calc(18px + constant(safe-area-inset-bottom));
146
+ height: calc(18px + env(safe-area-inset-bottom));
147
+ }
@@ -10,6 +10,7 @@ import {
10
10
  useI18n,
11
11
  } from '@liujitcn/kratos-taro-app-core'
12
12
  import { UniIcon } from '@liujitcn/kratos-taro-app-ui'
13
+ import { isAuthExpiredError } from '@liujitcn/kratos-taro-app-core/utils/http'
13
14
  import { defAiMessageService, StreamAiMessageByChunkedRequest } from '../../../api/base/v1/ai_message'
14
15
  import { defAiSessionService } from '../../../api/base/v1/ai_session'
15
16
  import { defAiToolService } from '../../../api/base/v1/ai_tool'
@@ -22,6 +23,8 @@ import { Terminal } from '../../../rpc/base/v1/ai_tool'
22
23
  import Composer from './components/Composer'
23
24
  import SessionDrawer from './components/SessionDrawer'
24
25
  import WelcomePanel from './components/WelcomePanel'
26
+ import { resolveModelDisplayName } from './modelDisplay'
27
+ import { loadStoredModelSelection, saveModelSelection } from './modelSelection'
25
28
  import {
26
29
  type AiStreamEvent,
27
30
  type AiStreamPayload,
@@ -363,11 +366,7 @@ export default function AiPage() {
363
366
  const shortcuts = normalizeStarterShortcuts(response.shortcuts).filter((item) => !item.action)
364
367
  const providers = providerResponse.providers ?? []
365
368
  setModelProviders(providers)
366
- if (!providers.some((item) => item.provider_id === selectedProviderId && item.models.includes(selectedModelName))) {
367
- const firstProvider = providers[0]
368
- setSelectedProviderId(firstProvider?.provider_id ?? 0)
369
- setSelectedModelName(firstProvider?.models[0] ?? '')
370
- }
369
+ applyModelSelection(providers)
371
370
  if (shortcuts.length) {
372
371
  setStarterShortcuts(shortcuts)
373
372
  setStarterPromptGroupIndex(0)
@@ -385,6 +384,27 @@ export default function AiPage() {
385
384
  const handleModelChange = (providerId: number, modelName: string) => {
386
385
  setSelectedProviderId(providerId)
387
386
  setSelectedModelName(modelName)
387
+ saveModelSelection({ provider_id: providerId, model_name: modelName })
388
+ }
389
+
390
+ /** 优先恢复本地缓存的模型选择,缓存失效时回退到首个可用模型。 */
391
+ const applyModelSelection = (providers: AiProviderModelOption[]) => {
392
+ const stored = loadStoredModelSelection()
393
+ const provider = stored
394
+ ? providers.find(
395
+ (item) =>
396
+ item.provider_id === stored.provider_id &&
397
+ item.models.some((model) => model.model_name === stored.model_name),
398
+ )
399
+ : undefined
400
+ if (provider && stored) {
401
+ setSelectedProviderId(provider.provider_id)
402
+ setSelectedModelName(stored.model_name)
403
+ return
404
+ }
405
+ const firstProvider = providers[0]
406
+ setSelectedProviderId(firstProvider?.provider_id ?? 0)
407
+ setSelectedModelName(firstProvider?.models[0]?.model_name ?? '')
388
408
  }
389
409
 
390
410
  const runAiTask = async (
@@ -717,7 +737,7 @@ export default function AiPage() {
717
737
  onLongPress={() => void handleMessageAction(item)}
718
738
  >
719
739
  {item.role === 'ai' && item.model ? (
720
- <View className='reply-meta'><Text className='reply-tag'>{t('system.ai.model_reply')}</Text><Text className='reply-model'>{item.model}</Text></View>
740
+ <View className='reply-meta'><Text className='reply-tag'>{t('system.ai.model_reply')}</Text><Text className='reply-model'>{resolveModelDisplayName(modelProviders, item.model)}</Text></View>
721
741
  ) : null}
722
742
  <View className='bubble-content'>{item.content}</View>
723
743
  {item.attachments.length ? (
@@ -752,7 +772,6 @@ export default function AiPage() {
752
772
  modelProviders={modelProviders}
753
773
  providerId={selectedProviderId}
754
774
  modelName={selectedModelName}
755
- providerPlaceholder={t('system.ai.model.provider_placeholder')}
756
775
  modelPlaceholder={t('system.ai.model.model_placeholder')}
757
776
  placeholder={composerPlaceholder}
758
777
  bottom={composerBottom}
@@ -1040,6 +1059,8 @@ function formatTools(tools: AiToolCall[]) {
1040
1059
  }
1041
1060
 
1042
1061
  function showError(error: unknown, fallback: string) {
1062
+ // 登录态失效时请求层已弹窗引导重新登录,不再叠加失败提示。
1063
+ if (isAuthExpiredError(error)) return
1043
1064
  const message = error instanceof Error ? error.message : fallback
1044
1065
  void Taro.showToast({ icon: 'none', title: message || fallback })
1045
1066
  }
@@ -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,40 @@
1
+ import Taro from '@tarojs/taro'
2
+
3
+ /** 用户最近一次选择的供应商与模型,仅保存在设备本地。 */
4
+ export interface AiModelSelection {
5
+ provider_id: number
6
+ model_name: string
7
+ }
8
+
9
+ const MODEL_SELECTION_STORAGE_KEY = 'kratos-taro-app:ai:model-selection'
10
+
11
+ /** 读取本地缓存的模型选择,缓存缺失或损坏时返回 undefined。 */
12
+ export function loadStoredModelSelection(): AiModelSelection | undefined {
13
+ try {
14
+ const raw = Taro.getStorageSync<unknown>(MODEL_SELECTION_STORAGE_KEY)
15
+ if (!raw) {
16
+ return undefined
17
+ }
18
+ const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw
19
+ const value = parsed as Partial<AiModelSelection>
20
+ if (
21
+ typeof value.provider_id === 'number' &&
22
+ typeof value.model_name === 'string' &&
23
+ value.model_name
24
+ ) {
25
+ return { provider_id: value.provider_id, model_name: value.model_name }
26
+ }
27
+ } catch {
28
+ // 缓存损坏或存储不可用时忽略,回退到默认选择。
29
+ }
30
+ return undefined
31
+ }
32
+
33
+ /** 持久化模型选择,存储不可用时静默失败,选择仍对当前会话生效。 */
34
+ export function saveModelSelection(selection: AiModelSelection): void {
35
+ try {
36
+ Taro.setStorageSync(MODEL_SELECTION_STORAGE_KEY, JSON.stringify(selection))
37
+ } catch {
38
+ // 忽略存储失败,不影响当前会话内的选择。
39
+ }
40
+ }
@@ -9,6 +9,7 @@ import {
9
9
  useUserStore,
10
10
  } from '@liujitcn/kratos-taro-app-core'
11
11
  import { defMfaService } from '@liujitcn/kratos-taro-app-core/api/base/v1/mfa'
12
+ import { isAuthExpiredError } from '@liujitcn/kratos-taro-app-core/utils/http'
12
13
  import PasswordVerifyDialog from './components/PasswordVerifyDialog'
13
14
  import './settings.scss'
14
15
 
@@ -109,15 +110,6 @@ export default function SettingsPage() {
109
110
  void loadMfaStatus()
110
111
  })
111
112
 
112
- /** 登录态失效时请求层已弹窗引导重新登录,页面侧不再叠加失败提示。 */
113
- const isAuthExpiredError = (error: unknown) => {
114
- if (error && typeof error === 'object') {
115
- const statusCode = (error as { statusCode?: number }).statusCode
116
- if (statusCode === 401 || statusCode === 403) return true
117
- }
118
- return error instanceof Error && /auth (required|expired)/.test(error.message)
119
- }
120
-
121
113
  const onLogout = async () => {
122
114
  if (logoutLoading) return
123
115
  const result = await Taro.showModal({