@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.
- package/README.md +1 -1
- package/dist/index.mjs +40 -4
- package/dist/module.mjs +40 -4
- package/package.json +2 -2
- package/src/api/base/v1/ai_message.ts +3 -2
- package/src/api/base/v1/ai_provider.ts +25 -0
- package/src/locales/en-US.json +10 -1
- package/src/locales/ja-JP.json +10 -1
- package/src/locales/zh-CN.json +10 -1
- package/src/locales/zh-TW.json +10 -1
- package/src/rpc/base/v1/ai_message.ts +7 -1
- package/src/rpc/base/v1/ai_provider.ts +41 -0
- package/src/rpc/base/v1/ai_session.ts +4 -0
- package/src/rpc/base/v1/notification.ts +2 -0
- package/src/views/pagesMember/ai/components/Composer.vue +88 -1
- package/src/views/pagesMember/ai/components/ModelPicker.vue +268 -0
- package/src/views/pagesMember/ai/components/SessionDrawer.vue +3 -2
- package/src/views/pagesMember/ai/index.vue +60 -5
- package/src/views/pagesMember/ai/modelDisplay.ts +77 -0
- package/src/views/pagesMember/ai/modelSelection.ts +38 -0
- package/src/views/pagesMember/message/detail.vue +9 -1
|
@@ -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:
|
|
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:
|
|
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
|
|
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) =>
|
|
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
|
+
}
|