@liujitcn/kratos-admin-system 0.0.64 → 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.
Files changed (25) hide show
  1. package/README.md +2 -2
  2. package/dist/package/declarations/src/api/system/admin/v1/ai_provider.d.ts +3 -1
  3. package/dist/package/declarations/src/locales/generated.d.ts +68 -0
  4. package/dist/package/declarations/src/rpc/base/v1/ai_provider.d.ts +13 -6
  5. package/dist/package/declarations/src/rpc/system/admin/v1/ai_provider.d.ts +23 -0
  6. package/dist/package/declarations/src/views/ai/chat/modelDisplay.d.ts +5 -0
  7. package/dist/package/declarations/src/views/ai/chat/modelSelection.d.ts +9 -0
  8. package/dist/package/src/api/system/admin/v1/ai_provider.ts +7 -0
  9. package/dist/package/src/locales/en-US.json +18 -1
  10. package/dist/package/src/locales/ja-JP.json +18 -1
  11. package/dist/package/src/locales/zh-CN.json +18 -1
  12. package/dist/package/src/locales/zh-TW.json +18 -1
  13. package/dist/package/src/rpc/base/v1/ai_provider.ts +14 -6
  14. package/dist/package/src/rpc/system/admin/v1/ai_provider.ts +26 -0
  15. package/dist/package/src/views/ai/chat/components/ChatPanel.vue +4 -1
  16. package/dist/package/src/views/ai/chat/components/XSender.vue +266 -71
  17. package/dist/package/src/views/ai/chat/index.vue +24 -6
  18. package/dist/package/src/views/ai/chat/modelDisplay.ts +67 -0
  19. package/dist/package/src/views/ai/chat/modelSelection.ts +31 -0
  20. package/dist/package/src/views/base/ai-provider/index.vue +123 -22
  21. package/dist/package/src/views/base/api-rate-limit-policy/index.vue +4 -3
  22. package/dist/package/src/views/base/message-provider/index.vue +7 -3
  23. package/dist/package/src/views/base/message-template/index.vue +2 -2
  24. package/dist/package/src/views/base/rate-limit-rule/index.vue +8 -3
  25. package/package.json +2 -2
@@ -53,22 +53,79 @@
53
53
 
54
54
  <template #action-list>
55
55
  <div class="agent-sender-actions">
56
- <div class="agent-model-selector" :class="{ 'is-disabled': sending || !modelOptions.length }">
57
- <el-cascader
58
- :model-value="selectedModelPath"
59
- :options="modelOptions"
60
- :props="{ expandTrigger: 'click' }"
61
- :placeholder="t('system.ai.model.model_placeholder')"
62
- :disabled="sending || !modelOptions.length"
63
- :show-all-levels="true"
64
- separator="/"
65
- :clearable="true"
66
- :teleported="true"
67
- popper-class="agent-model-cascader-popper"
68
- size="small"
69
- @change="handleModelPathChange"
70
- />
71
- </div>
56
+ <el-popover
57
+ ref="modelPopoverRef"
58
+ placement="top-start"
59
+ :width="324"
60
+ trigger="click"
61
+ :show-arrow="false"
62
+ :persistent="false"
63
+ popper-class="agent-model-popper"
64
+ >
65
+ <template #reference>
66
+ <button
67
+ class="agent-model-selector"
68
+ :class="{ 'is-disabled': sending || !modelOptions.length }"
69
+ type="button"
70
+ :disabled="sending || !modelOptions.length"
71
+ :aria-label="t('system.ai.model.title')"
72
+ >
73
+ <el-icon class="agent-model-selector__spark"><MagicStick /></el-icon>
74
+ <span class="agent-model-selector__label">{{
75
+ selectedModelDisplayName || t("system.ai.model.model_placeholder")
76
+ }}</span>
77
+ <el-icon class="agent-model-selector__arrow"><ArrowDown /></el-icon>
78
+ </button>
79
+ </template>
80
+ <div v-if="modelOptions.length" class="agent-model-panel">
81
+ <div class="agent-model-panel__head">
82
+ <span class="agent-model-panel__title">{{ t("system.ai.model.title") }}</span>
83
+ <span class="agent-model-panel__meta">{{
84
+ t("system.ai.model.provider_model_count", {
85
+ providers: modelOptions.length,
86
+ models: modelOptionCount
87
+ })
88
+ }}</span>
89
+ </div>
90
+ <div class="agent-model-panel__list">
91
+ <template v-for="provider in modelOptions" :key="provider.providerId">
92
+ <div class="agent-model-panel__group">{{ provider.label }}</div>
93
+ <button
94
+ v-for="model in provider.models"
95
+ :key="model.name"
96
+ class="agent-model-row"
97
+ :class="{ 'is-selected': isModelSelected(provider.providerId, model.name) }"
98
+ type="button"
99
+ @click="handleModelSelect(provider.providerId, model.name)"
100
+ >
101
+ <span class="agent-model-row__name">{{ model.displayName }}</span>
102
+ <span v-if="model.displayName !== model.name" class="agent-model-row__raw">{{
103
+ model.name
104
+ }}</span>
105
+ <el-icon
106
+ v-if="isModelSelected(provider.providerId, model.name)"
107
+ class="agent-model-row__check"
108
+ >
109
+ <Check />
110
+ </el-icon>
111
+ </button>
112
+ </template>
113
+ </div>
114
+ <button
115
+ v-if="aiProviderRoute"
116
+ class="agent-model-panel__manage"
117
+ type="button"
118
+ @click="openModelManage"
119
+ >
120
+ <el-icon><Setting /></el-icon>
121
+ <span>{{ t("system.ai.model.manage") }}</span>
122
+ <el-icon class="agent-model-panel__manage-arrow"><ArrowRight /></el-icon>
123
+ </button>
124
+ </div>
125
+ <div v-else class="agent-model-panel agent-model-panel--empty">
126
+ {{ t("system.ai.model.empty") }}
127
+ </div>
128
+ </el-popover>
72
129
  <el-tooltip
73
130
  :content="recording ? t('system.ai.chat.action.stop_voice_input') : t('system.ai.chat.action.voice_input')"
74
131
  placement="top"
@@ -117,17 +174,30 @@
117
174
 
118
175
  <script setup lang="ts" name="XSender">
119
176
  import { computed, ref } from "vue";
177
+ import { useRouter } from "vue-router";
120
178
  import { Attachments, XSender as BaseXSender } from "vue-element-plus-x";
121
179
  import type { FilesCardProps } from "vue-element-plus-x/types/FilesCard";
122
- import { Loading, Microphone, Paperclip, Promotion } from "@element-plus/icons-vue";
123
- import { ElMessage } from "element-plus";
124
- import type { CascaderValue } from "element-plus";
180
+ import {
181
+ ArrowDown,
182
+ ArrowRight,
183
+ Check,
184
+ Loading,
185
+ MagicStick,
186
+ Microphone,
187
+ Paperclip,
188
+ Promotion,
189
+ Setting
190
+ } from "@element-plus/icons-vue";
191
+ import { ElMessage, ElPopover } from "element-plus";
125
192
  import { t } from "@liujitcn/kratos-admin-core";
193
+ import { navigateTo } from "@liujitcn/kratos-admin-core/navigation";
194
+ import { useAuthStore } from "@liujitcn/kratos-admin-core/stores/runtime";
126
195
  import { defFileService } from "@liujitcn/kratos-admin-core/api/base/v1/file";
127
196
  import type { AiAttachment } from "@liujitcn/kratos-admin-system/rpc/base/v1/ai_session";
128
197
  import type { AiProviderModelOption } from "@liujitcn/kratos-admin-system/rpc/base/v1/ai_provider";
129
198
  import type { SubmitPayload } from "../types";
130
199
  import { buildAIAttachmentFileCard } from "../attachment";
200
+ import { fallbackModelDisplayName } from "../modelDisplay";
131
201
  import { useSpeechRecognition } from "./speech-recognition";
132
202
  import type { SpeechRecognitionError } from "./speech-recognition";
133
203
 
@@ -151,17 +221,34 @@ const emit = defineEmits<{
151
221
 
152
222
  const senderRef = ref<InstanceType<typeof BaseXSender>>();
153
223
  const fileInputRef = ref<HTMLInputElement>();
224
+ const modelPopoverRef = ref<InstanceType<typeof ElPopover>>();
225
+ const router = useRouter();
226
+ const authStore = useAuthStore();
154
227
  const inputText = ref("");
155
228
  const selectedAttachments = ref<AiAttachment[]>([]);
156
229
  const modelOptions = computed(() =>
157
230
  props.modelProviders.map(provider => ({
158
- value: provider.provider_id,
231
+ providerId: provider.provider_id,
159
232
  label: provider.name,
160
- disabled: provider.models.length === 0,
161
- children: provider.models.map(model => ({ value: model, label: model }))
233
+ models: provider.models.map(model => ({
234
+ name: model.model_name,
235
+ displayName: model.display_name || fallbackModelDisplayName(model.model_name)
236
+ }))
162
237
  }))
163
238
  );
164
- const selectedModelPath = computed(() => (props.providerId && props.modelName ? [props.providerId, props.modelName] : []));
239
+ const modelOptionCount = computed(() =>
240
+ modelOptions.value.reduce((total, provider) => total + provider.models.length, 0)
241
+ );
242
+ const selectedModelDisplayName = computed(() => {
243
+ const provider = modelOptions.value.find(item => item.providerId === props.providerId);
244
+ return (
245
+ provider?.models.find(model => model.name === props.modelName)?.displayName ??
246
+ fallbackModelDisplayName(props.modelName)
247
+ );
248
+ });
249
+ const aiProviderRoute = computed(() =>
250
+ authStore.flatMenuListGet.find(item => item.name === "AiProvider" && item.path)
251
+ );
165
252
  const uploading = ref(false);
166
253
  const maxAttachmentCount = 6;
167
254
  const maxAttachmentSizeMB = 20;
@@ -218,10 +305,22 @@ const isSubmitDisabled = computed(() => {
218
305
  return uploading.value || (!inputText.value.trim() && selectedAttachments.value.length === 0);
219
306
  });
220
307
 
221
- /** 更新当前选中的供应商和模型路径。 */
222
- function handleModelPathChange(value: CascaderValue | null | undefined) {
223
- const path = Array.isArray(value) ? value : [];
224
- emit("model-change", Number(path[0]) || 0, typeof path[1] === "string" ? path[1] : "");
308
+ /** 判断指定供应商与模型是否为当前选择。 */
309
+ function isModelSelected(providerId: number, modelName: string) {
310
+ return props.providerId === providerId && props.modelName === modelName;
311
+ }
312
+
313
+ /** 选中模型并关闭选择面板。 */
314
+ function handleModelSelect(providerId: number, modelName: string) {
315
+ emit("model-change", providerId, modelName);
316
+ modelPopoverRef.value?.hide();
317
+ }
318
+
319
+ /** 跳转到 AI 供应商配置页。 */
320
+ async function openModelManage() {
321
+ if (!aiProviderRoute.value?.path) return;
322
+ modelPopoverRef.value?.hide();
323
+ await navigateTo(router, aiProviderRoute.value.path);
225
324
  }
226
325
 
227
326
  /** 读取输入内容并发送给父组件。 */
@@ -464,11 +563,15 @@ function resetFileInput() {
464
563
  min-height: 36px;
465
564
  }
466
565
  .agent-model-selector {
467
- display: flex;
566
+ display: inline-flex;
468
567
  flex: 0 1 auto;
469
- width: clamp(180px, 18vw, 200px);
568
+ align-items: center;
569
+ gap: 6px;
570
+ width: clamp(180px, 18vw, 220px);
470
571
  min-width: 0;
471
572
  height: 34px;
573
+ padding: 0 10px;
574
+ overflow: hidden;
472
575
  box-sizing: border-box;
473
576
  color: var(--admin-page-text-secondary);
474
577
  cursor: pointer;
@@ -479,48 +582,34 @@ function resetFileInput() {
479
582
  color 0.2s ease,
480
583
  border-color 0.2s ease,
481
584
  background-color 0.2s ease;
482
- }
483
- .agent-model-selector:hover {
484
- color: var(--el-color-primary);
485
- background: var(--el-color-primary-light-9);
486
- border-color: var(--el-color-primary-light-5);
487
- }
488
- .agent-model-selector:focus-within {
489
- border-color: var(--el-color-primary-light-5);
490
- }
491
- .agent-model-selector.is-disabled {
492
- color: var(--el-disabled-text-color);
493
- cursor: not-allowed;
494
- background: var(--el-disabled-bg-color);
495
- border-color: var(--el-disabled-border-color);
496
- }
497
- .agent-model-selector :deep(.el-cascader) {
498
- width: 100%;
499
- height: 100%;
500
- min-width: 0;
501
- }
502
- .agent-model-selector :deep(.el-input) {
503
- height: 100%;
504
- }
505
- .agent-model-selector :deep(.el-input__wrapper) {
506
- height: 100%;
507
- padding: 0 9px;
508
- background: transparent;
509
- box-shadow: none;
510
- border-radius: inherit;
511
- }
512
- .agent-model-selector :deep(.el-input__wrapper.is-focus) {
513
- box-shadow: none;
514
- }
515
- .agent-model-selector :deep(.el-input__inner) {
516
- font-size: 14px;
517
- font-weight: 500;
518
- line-height: 20px;
519
- color: currentColor;
520
- }
521
- .agent-model-selector :deep(.el-input__suffix .el-icon) {
522
- font-size: 14px;
523
- color: currentColor;
585
+ &:hover {
586
+ color: var(--el-color-primary);
587
+ background: var(--el-color-primary-light-9);
588
+ border-color: var(--el-color-primary-light-5);
589
+ }
590
+ &:disabled {
591
+ cursor: not-allowed;
592
+ opacity: 0.55;
593
+ }
594
+ &__spark {
595
+ flex-shrink: 0;
596
+ font-size: 15px;
597
+ }
598
+ &__label {
599
+ flex: 1;
600
+ min-width: 0;
601
+ overflow: hidden;
602
+ font-size: 14px;
603
+ font-weight: 500;
604
+ line-height: 20px;
605
+ text-align: left;
606
+ text-overflow: ellipsis;
607
+ white-space: nowrap;
608
+ }
609
+ &__arrow {
610
+ flex-shrink: 0;
611
+ font-size: 14px;
612
+ }
524
613
  }
525
614
  .agent-sender-actions {
526
615
  display: inline-flex;
@@ -612,6 +701,112 @@ function resetFileInput() {
612
701
  :global(.agent-sender-popover.el-popover) {
613
702
  box-shadow: 0 14px 36px rgb(15 23 42 / 10%);
614
703
  }
704
+ :global(.agent-model-popper.el-popover) {
705
+ padding: 0 !important;
706
+ border-radius: var(--admin-page-radius) !important;
707
+ box-shadow: 0 14px 36px rgb(15 23 42 / 16%) !important;
708
+ }
709
+ .agent-model-panel {
710
+ padding: 10px;
711
+ &__list {
712
+ max-height: min(52vh, 420px);
713
+ overflow-y: auto;
714
+ scrollbar-width: thin;
715
+ scrollbar-color: var(--el-border-color-darker) transparent;
716
+ }
717
+ &__head {
718
+ display: flex;
719
+ align-items: baseline;
720
+ justify-content: space-between;
721
+ padding: 4px 6px 10px;
722
+ }
723
+ &__title {
724
+ font-size: 14px;
725
+ font-weight: 700;
726
+ color: var(--admin-page-text-primary);
727
+ }
728
+ &__meta {
729
+ font-size: 11px;
730
+ color: var(--el-text-color-placeholder);
731
+ }
732
+ &__group {
733
+ padding: 8px 6px 6px;
734
+ font-size: 12px;
735
+ color: var(--admin-page-text-secondary);
736
+ }
737
+ &__manage {
738
+ display: inline-flex;
739
+ align-items: center;
740
+ gap: 6px;
741
+ width: 100%;
742
+ height: 36px;
743
+ margin-top: 6px;
744
+ padding: 0 6px;
745
+ font-size: 12.5px;
746
+ font-weight: 600;
747
+ color: var(--el-color-primary);
748
+ cursor: pointer;
749
+ background: transparent;
750
+ border: 0;
751
+ border-top: 1px solid var(--el-border-color-lighter);
752
+ border-radius: 0;
753
+ &:hover {
754
+ color: var(--el-color-primary-light-3);
755
+ }
756
+ }
757
+ &__manage-arrow {
758
+ margin-left: auto;
759
+ }
760
+ &--empty {
761
+ padding: 20px 10px;
762
+ font-size: 13px;
763
+ color: var(--admin-page-text-secondary);
764
+ text-align: center;
765
+ }
766
+ }
767
+ .agent-model-row {
768
+ display: flex;
769
+ align-items: center;
770
+ gap: 8px;
771
+ width: 100%;
772
+ height: 42px;
773
+ margin-bottom: 2px;
774
+ padding: 0 10px;
775
+ font-size: 13.5px;
776
+ color: var(--admin-page-text-primary);
777
+ text-align: left;
778
+ cursor: pointer;
779
+ background: transparent;
780
+ border: 0;
781
+ border-radius: var(--admin-page-radius);
782
+ &:hover {
783
+ background: var(--el-fill-color-light);
784
+ }
785
+ &.is-selected {
786
+ font-weight: 600;
787
+ color: var(--el-color-primary);
788
+ background: var(--el-color-primary-light-9);
789
+ }
790
+ &__name {
791
+ overflow: hidden;
792
+ text-overflow: ellipsis;
793
+ white-space: nowrap;
794
+ }
795
+ &__raw {
796
+ flex: 1;
797
+ min-width: 0;
798
+ overflow: hidden;
799
+ font-size: 12px;
800
+ font-weight: 400;
801
+ color: var(--el-text-color-placeholder);
802
+ text-overflow: ellipsis;
803
+ white-space: nowrap;
804
+ }
805
+ &__check {
806
+ flex-shrink: 0;
807
+ font-size: 15px;
808
+ }
809
+ }
615
810
 
616
811
  @media screen and (width <= 768px) {
617
812
  .agent-prefix-actions {
@@ -85,6 +85,7 @@ import {
85
85
  sortMessages
86
86
  } from "./message";
87
87
  import { readAiEventStream } from "./stream";
88
+ import { loadStoredModelSelection, saveModelSelection } from "./modelSelection";
88
89
  import type {
89
90
  AiStreamEvent,
90
91
  AiStreamPayload,
@@ -662,11 +663,7 @@ async function loadAiShortcuts() {
662
663
  ]);
663
664
  starterShortcuts.value = normalizeStarterShortcuts(response.shortcuts);
664
665
  modelProviders.value = providerResponse.providers ?? [];
665
- if (!modelProviders.value.some(item => item.provider_id === selectedProviderId.value && item.models.includes(selectedModelName.value))) {
666
- const firstProvider = modelProviders.value[0];
667
- selectedProviderId.value = firstProvider?.provider_id ?? 0;
668
- selectedModelName.value = firstProvider?.models[0] ?? "";
669
- }
666
+ applyModelSelection();
670
667
  } catch {
671
668
  starterShortcuts.value = [];
672
669
  modelProviders.value = [];
@@ -677,10 +674,31 @@ async function loadAiShortcuts() {
677
674
  }
678
675
  }
679
676
 
680
- /** 更新当前聊天使用的供应商与模型。 */
677
+ /** 优先恢复本地缓存的模型选择,缓存失效时回退到首个可用模型。 */
678
+ function applyModelSelection() {
679
+ const stored = loadStoredModelSelection();
680
+ const provider = stored
681
+ ? modelProviders.value.find(
682
+ item =>
683
+ item.provider_id === stored.provider_id &&
684
+ item.models.some(model => model.model_name === stored.model_name)
685
+ )
686
+ : undefined;
687
+ if (provider && stored) {
688
+ selectedProviderId.value = provider.provider_id;
689
+ selectedModelName.value = stored.model_name;
690
+ return;
691
+ }
692
+ const firstProvider = modelProviders.value[0];
693
+ selectedProviderId.value = firstProvider?.provider_id ?? 0;
694
+ selectedModelName.value = firstProvider?.models[0]?.model_name ?? "";
695
+ }
696
+
697
+ /** 更新当前聊天使用的供应商与模型并持久化到本地缓存。 */
681
698
  function handleModelChange(providerId: number, modelName: string) {
682
699
  selectedProviderId.value = providerId;
683
700
  selectedModelName.value = modelName;
701
+ saveModelSelection({ provider_id: providerId, model_name: modelName });
684
702
  }
685
703
 
686
704
  /** 使指定会话仍在途的消息列表请求失效。 */
@@ -0,0 +1,67 @@
1
+ import type { AiProviderModelOption } from "@liujitcn/kratos-admin-system/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) return "";
39
+ const mapped = MODEL_DISPLAY_NAMES[modelName.toLowerCase()];
40
+ if (mapped) return mapped;
41
+ return modelName
42
+ .split(/[-_]/)
43
+ .filter(Boolean)
44
+ .map(token => {
45
+ const lower = token.toLowerCase();
46
+ if (UPPERCASE_TOKENS.has(lower)) return lower.toUpperCase();
47
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
48
+ })
49
+ .join(" ");
50
+ }
51
+
52
+ /** 从供应商模型选项中解析模型展示名称,兼容历史消息中「供应商 / 模型」格式的原始值。 */
53
+ export function resolveModelDisplayName(
54
+ providers: AiProviderModelOption[] | undefined,
55
+ modelName: string
56
+ ): string {
57
+ if (!modelName) return "";
58
+ const separator = " / ";
59
+ const modelPart = modelName.includes(separator)
60
+ ? modelName.slice(modelName.lastIndexOf(separator) + separator.length)
61
+ : modelName;
62
+ for (const provider of providers ?? []) {
63
+ const matched = provider.models.find(item => item.model_name === modelPart);
64
+ if (matched?.display_name) return matched.display_name;
65
+ }
66
+ return fallbackModelDisplayName(modelPart);
67
+ }
@@ -0,0 +1,31 @@
1
+ /** 用户最近一次选择的供应商与模型,仅保存在浏览器本地。 */
2
+ export interface AiModelSelection {
3
+ provider_id: number;
4
+ model_name: string;
5
+ }
6
+
7
+ const MODEL_SELECTION_STORAGE_KEY = "kratos-admin:ai:model-selection";
8
+
9
+ /** 读取本地缓存的模型选择,缓存缺失或损坏时返回 undefined。 */
10
+ export function loadStoredModelSelection(): AiModelSelection | undefined {
11
+ try {
12
+ const raw = localStorage.getItem(MODEL_SELECTION_STORAGE_KEY);
13
+ if (!raw) return undefined;
14
+ const parsed = JSON.parse(raw) as Partial<AiModelSelection>;
15
+ if (typeof parsed.provider_id === "number" && typeof parsed.model_name === "string" && parsed.model_name) {
16
+ return { provider_id: parsed.provider_id, model_name: parsed.model_name };
17
+ }
18
+ } catch {
19
+ // 缓存损坏或存储不可用时忽略,回退到默认选择。
20
+ }
21
+ return undefined;
22
+ }
23
+
24
+ /** 持久化模型选择,存储不可用时静默失败,选择仍对当前会话生效。 */
25
+ export function saveModelSelection(selection: AiModelSelection): void {
26
+ try {
27
+ localStorage.setItem(MODEL_SELECTION_STORAGE_KEY, JSON.stringify(selection));
28
+ } catch {
29
+ // 忽略存储失败,不影响当前会话内的选择。
30
+ }
31
+ }